From f8a6a4a6b66e9ce0131d82d344df2059ef21d622 Mon Sep 17 00:00:00 2001 From: rgardler-msft Date: Sun, 1 Mar 2026 19:21:50 -0800 Subject: [PATCH 1/5] WL-0MM8LWWCD014HTGU: Add assignGithubIssue and assignGithubIssueAsync helpers Add new exported functions to src/github.ts that wrap `gh issue edit --add-assignee` with the existing retry/backoff infrastructure: - assignGithubIssueAsync: async variant with rate-limit retry/backoff (configurable retries, default 3). Returns { ok, error? } without throwing on failure. - assignGithubIssue: sync variant using runGhDetailed. Returns { ok, error? } without throwing on failure. - AssignGithubIssueResult interface exported for consumers. Includes 11 unit tests covering success, failure, retry on rate-limit/403, max retry exhaustion, and command construction verification. --- src/github.ts | 62 ++++++++ tests/github-assign-issue.test.ts | 225 ++++++++++++++++++++++++++++++ 2 files changed, 287 insertions(+) create mode 100644 tests/github-assign-issue.test.ts diff --git a/src/github.ts b/src/github.ts index 2721948f..8fb3a2b5 100644 --- a/src/github.ts +++ b/src/github.ts @@ -827,6 +827,68 @@ export async function getGithubIssueCommentAsync(config: GithubConfig, commentId return normalizeGithubIssueComment(data); } +// --------------------------------------------------------------------------- +// Issue assignment helpers +// --------------------------------------------------------------------------- + +export interface AssignGithubIssueResult { + ok: boolean; + error?: string; +} + +/** + * Assign a GitHub user to an issue via `gh issue edit --add-assignee`. + * + * Uses `runGhDetailedAsync` with rate-limit retry/backoff. On failure returns + * `{ ok: false, error: }` without throwing. + */ +export async function assignGithubIssueAsync( + config: GithubConfig, + issueNumber: number, + assignee: string, + retries = 3 +): Promise { + let attempt = 0; + let backoff = 500; + while (attempt <= retries) { + const res = await runGhDetailedAsync( + `gh issue edit ${issueNumber} --repo ${config.repo} --add-assignee ${JSON.stringify(assignee)}` + ); + if (res.ok) { + return { ok: true }; + } + const stderr = res.stderr || ''; + // Retry on rate-limit / 403 errors + if (/rate limit|403|API rate limit exceeded/i.test(stderr) && attempt < retries) { + await new Promise(r => setTimeout(r, backoff)); + attempt += 1; + backoff *= 2; + continue; + } + return { ok: false, error: stderr || `gh issue edit failed with unknown error` }; + } + return { ok: false, error: 'Max retries exceeded' }; +} + +/** + * Synchronous variant of `assignGithubIssueAsync`. Calls `runGhDetailed` + * directly (no retry/backoff). Returns `{ ok: false, error }` on failure + * without throwing. + */ +export function assignGithubIssue( + config: GithubConfig, + issueNumber: number, + assignee: string +): AssignGithubIssueResult { + const res = runGhDetailed( + `gh issue edit ${issueNumber} --repo ${config.repo} --add-assignee ${JSON.stringify(assignee)}` + ); + if (res.ok) { + return { ok: true }; + } + return { ok: false, error: res.stderr || `gh issue edit failed with unknown error` }; +} + /** * Legacy priority label mapping. Labels like `wl:P0`, `wl:P1`, etc. are mapped * to the current priority values for backward compatibility during import. diff --git a/tests/github-assign-issue.test.ts b/tests/github-assign-issue.test.ts new file mode 100644 index 00000000..55744470 --- /dev/null +++ b/tests/github-assign-issue.test.ts @@ -0,0 +1,225 @@ +/** + * Tests for assignGithubIssue and assignGithubIssueAsync helpers in github.ts + * + * Validates that: + * - assignGithubIssueAsync calls `gh issue edit --add-assignee` and returns { ok: true } on success + * - assignGithubIssueAsync returns { ok: false, error } on failure without throwing + * - assignGithubIssueAsync retries on rate-limit / 403 errors with backoff + * - assignGithubIssueAsync returns { ok: false, error: 'Max retries exceeded' } after exhausting retries + * - assignGithubIssue (sync) returns { ok: true } on success + * - assignGithubIssue (sync) returns { ok: false, error } on failure without throwing + * - Both functions construct the correct gh CLI command with repo, issue number, and assignee + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { EventEmitter } from 'events'; +import { Readable, Writable } from 'stream'; + +// Mock child_process.spawn (async) and child_process.execSync (sync) for +// the underlying runGhDetailedAsync / runGhDetailed wrappers. +const { mockSpawn, mockExecSync } = vi.hoisted(() => { + return { mockSpawn: vi.fn(), mockExecSync: vi.fn() }; +}); + +vi.mock('child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, spawn: mockSpawn, execSync: mockExecSync }; +}); + +import { + assignGithubIssueAsync, + assignGithubIssue, +} from '../src/github.js'; +import type { GithubConfig, AssignGithubIssueResult } from '../src/github.js'; + +const defaultConfig: GithubConfig = { repo: 'owner/repo', labelPrefix: 'wl:' }; + +function createMockSpawnImpl( + stdout: string, + exitCode: number = 0, + stderr: string = '' +) { + return (_cmd: string, _args: string[], _opts: any) => { + const proc = new EventEmitter() as any; + proc.stdin = new Writable({ write: (_c: any, _e: any, cb: () => void) => cb() }); + proc.stdout = new Readable({ + read() { + this.push(stdout); + this.push(null); + }, + }); + proc.stdout.setEncoding = () => proc.stdout; + proc.stderr = new Readable({ + read() { + this.push(stderr); + this.push(null); + }, + }); + proc.stderr.setEncoding = () => proc.stderr; + proc.exitCode = exitCode; + proc.kill = () => {}; + + // Emit close asynchronously to simulate real process + setImmediate(() => { + proc.emit('close', exitCode); + }); + + return proc; + }; +} + +describe('assignGithubIssueAsync', () => { + beforeEach(() => { + mockSpawn.mockReset(); + }); + + it('returns { ok: true } on successful assignment', async () => { + mockSpawn.mockImplementation(createMockSpawnImpl('', 0)); + + const result = await assignGithubIssueAsync(defaultConfig, 42, 'copilot'); + + expect(result).toEqual({ ok: true }); + expect(mockSpawn).toHaveBeenCalledTimes(1); + // Verify the command contains the correct issue number and assignee + const command = mockSpawn.mock.calls[0][1][1]; // spawn('/bin/sh', ['-c', command]) + expect(command).toContain('gh issue edit 42'); + expect(command).toContain('--add-assignee'); + expect(command).toContain('copilot'); + expect(command).toContain('--repo owner/repo'); + }); + + it('returns { ok: false, error } on gh failure without throwing', async () => { + mockSpawn.mockImplementation( + createMockSpawnImpl('', 1, 'user copilot is not assignable to this issue') + ); + + const result = await assignGithubIssueAsync(defaultConfig, 42, 'copilot'); + + expect(result.ok).toBe(false); + expect(result.error).toContain('copilot is not assignable'); + }); + + it('retries on rate-limit errors', async () => { + let callCount = 0; + mockSpawn.mockImplementation((_cmd: string, _args: string[], _opts: any) => { + callCount++; + if (callCount <= 2) { + return createMockSpawnImpl('', 1, 'API rate limit exceeded')(_cmd, _args, _opts); + } + return createMockSpawnImpl('', 0)(_cmd, _args, _opts); + }); + + const result = await assignGithubIssueAsync(defaultConfig, 42, 'copilot', 3); + + expect(result.ok).toBe(true); + expect(mockSpawn).toHaveBeenCalledTimes(3); + }); + + it('retries on 403 errors', async () => { + let callCount = 0; + mockSpawn.mockImplementation((_cmd: string, _args: string[], _opts: any) => { + callCount++; + if (callCount <= 1) { + return createMockSpawnImpl('', 1, '403 Forbidden')(_cmd, _args, _opts); + } + return createMockSpawnImpl('', 0)(_cmd, _args, _opts); + }); + + const result = await assignGithubIssueAsync(defaultConfig, 42, 'copilot', 3); + + expect(result.ok).toBe(true); + expect(mockSpawn).toHaveBeenCalledTimes(2); + }); + + it('returns error after exhausting retries on persistent rate limit', async () => { + mockSpawn.mockImplementation( + createMockSpawnImpl('', 1, 'API rate limit exceeded') + ); + + const result = await assignGithubIssueAsync(defaultConfig, 42, 'copilot', 2); + + expect(result.ok).toBe(false); + expect(result.error).toContain('rate limit'); + // Should have tried 3 times (initial + 2 retries) + expect(mockSpawn).toHaveBeenCalledTimes(3); + }); + + it('does not retry on non-rate-limit failures', async () => { + mockSpawn.mockImplementation( + createMockSpawnImpl('', 1, 'repository not found') + ); + + const result = await assignGithubIssueAsync(defaultConfig, 42, 'copilot', 3); + + expect(result.ok).toBe(false); + expect(result.error).toContain('repository not found'); + // Should not retry + expect(mockSpawn).toHaveBeenCalledTimes(1); + }); + + it('returns fallback error when stderr is empty', async () => { + mockSpawn.mockImplementation( + createMockSpawnImpl('', 1, '') + ); + + const result = await assignGithubIssueAsync(defaultConfig, 42, 'copilot'); + + expect(result.ok).toBe(false); + expect(result.error).toBeTruthy(); + }); +}); + +describe('assignGithubIssue (sync)', () => { + beforeEach(() => { + mockExecSync.mockReset(); + }); + + it('returns { ok: true } on successful assignment', () => { + // execSync returns stdout as string on success + mockExecSync.mockReturnValue(''); + + const result = assignGithubIssue(defaultConfig, 42, 'copilot'); + + expect(result).toEqual({ ok: true }); + expect(mockExecSync).toHaveBeenCalledTimes(1); + }); + + it('returns { ok: false, error } on gh failure without throwing', () => { + // execSync throws on non-zero exit code; runGhDetailed catches it + const err: any = new Error('Command failed'); + err.stderr = 'user copilot is not assignable to this issue'; + err.stdout = ''; + mockExecSync.mockImplementation(() => { throw err; }); + + const result = assignGithubIssue(defaultConfig, 42, 'copilot'); + + expect(result.ok).toBe(false); + expect(result.error).toContain('copilot is not assignable'); + }); + + it('returns fallback error when stderr is empty on failure', () => { + const err: any = new Error('Command failed'); + err.stderr = ''; + err.stdout = ''; + mockExecSync.mockImplementation(() => { throw err; }); + + const result = assignGithubIssue(defaultConfig, 42, 'copilot'); + + expect(result.ok).toBe(false); + expect(result.error).toBeTruthy(); + }); + + it('constructs correct gh command with repo, issue number, and assignee', () => { + mockExecSync.mockReturnValue(''); + + assignGithubIssue({ repo: 'myorg/myrepo', labelPrefix: 'wl:' }, 123, 'some-user'); + + expect(mockExecSync).toHaveBeenCalledTimes(1); + // execSync is called with (command, options) + const command = mockExecSync.mock.calls[0][0]; + expect(command).toContain('gh issue edit 123'); + expect(command).toContain('--add-assignee'); + expect(command).toContain('some-user'); + expect(command).toContain('--repo myorg/myrepo'); + }); +}); From e342eab308908eba6869a72e68e181556f5b5a38 Mon Sep 17 00:00:00 2001 From: rgardler-msft Date: Sun, 1 Mar 2026 19:31:40 -0800 Subject: [PATCH 2/5] WL-0MM8LX8RB0OVLJWB, WL-0MM8LXODU1DA2PON, WL-0MM8LXZ0M04W2YUF: Implement delegate subcommand with guard rails, push/assign flow, and output formatting - Register `wl github delegate ` subcommand with --force, --json, --prefix options - Implement do-not-delegate tag guard rail with --force bypass - Implement children warning with TTY prompt; non-interactive mode proceeds silently - Wire push (upsertIssuesFromWorkItems) + assign (assignGithubIssueAsync) + local state update - On assignment failure: skip local state update, add comment, re-push for consistency - Support both human-readable progress output and structured --json output - Add 13 unit tests covering guard rails, success/failure paths, and output formatting --- src/commands/github.ts | 171 ++++++++++++ tests/cli/delegate-guard-rails.test.ts | 365 +++++++++++++++++++++++++ 2 files changed, 536 insertions(+) create mode 100644 tests/cli/delegate-guard-rails.test.ts diff --git a/src/commands/github.ts b/src/commands/github.ts index 59b966f1..0ffcdd7e 100644 --- a/src/commands/github.ts +++ b/src/commands/github.ts @@ -435,4 +435,175 @@ export default function register(ctx: PluginContext): void { process.exit(1); } }); + + githubCommand + .command('delegate ') + .description('Delegate a work item to GitHub Copilot coding agent') + .option('--force', 'Bypass do-not-delegate tag guard rail', false) + .option('--prefix ', 'Override the default prefix') + .action(async (id: string, options: { force?: boolean; prefix?: string }) => { + utils.requireInitialized(); + const db = utils.getDatabase(options.prefix); + const isJsonMode = utils.isJsonMode(); + + // Resolve work item + const normalizedId = utils.normalizeCliId(id, options.prefix) || id; + const item = db.get(normalizedId); + if (!item) { + output.error(`Work item not found: ${normalizedId}`, { + success: false, + error: `Work item not found: ${normalizedId}`, + }); + process.exit(1); + } + + // Guard rail: do-not-delegate tag + if (Array.isArray(item.tags) && item.tags.includes('do-not-delegate')) { + if (!options.force) { + const message = `Work item ${normalizedId} has a "do-not-delegate" tag. Use --force to override.`; + output.error(message, { + success: false, + error: 'do-not-delegate', + workItemId: normalizedId, + }); + process.exit(1); + } + if (!isJsonMode) { + console.log(`Warning: Work item ${normalizedId} has a "do-not-delegate" tag. Proceeding due to --force.`); + } + } + + // Guard rail: children warning + const children = db.getChildren(normalizedId); + if (children.length > 0) { + const nonClosedChildren = children.filter( + c => c.status !== 'completed' && c.status !== 'deleted' + ); + if (nonClosedChildren.length > 0) { + // In non-interactive mode (JSON or non-TTY), proceed with single item only + const isInteractive = !isJsonMode && process.stdout.isTTY === true && process.stdin.isTTY === true; + if (isInteractive) { + const readline = await import('node:readline'); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise(resolve => { + rl.question( + `Work item ${normalizedId} has ${nonClosedChildren.length} open child item(s). ` + + `Only the specified item will be delegated. Continue? (y/N): `, + resolve + ); + }); + rl.close(); + if (answer.toLowerCase() !== 'y' && answer.toLowerCase() !== 'yes') { + if (!isJsonMode) { + console.log('Delegation cancelled.'); + } + process.exit(0); + } + } else { + // Non-interactive: proceed with single item, log warning + if (!isJsonMode) { + console.log( + `Warning: Work item ${normalizedId} has ${nonClosedChildren.length} open child item(s). ` + + `Delegating only the specified item.` + ); + } + } + } + } + + // Guard rails passed — delegate flow placeholder + // The actual push + assign + local state update is wired in WL-0MM8LXODU1DA2PON + try { + const githubConfig = resolveGithubConfig({ repo: (options as any).repo, labelPrefix: (options as any).labelPrefix }); + + // Push the work item to GitHub (smart sync) + const items = db.getAll(); + const comments = db.getAllComments(); + const { updatedItems } = await upsertIssuesFromWorkItems( + [item], + comments.filter(c => c.workItemId === item.id), + githubConfig, + () => {} // no progress rendering for single-item push + ); + if (updatedItems.length > 0) { + db.import(updatedItems); + } + + // Resolve the GitHub issue number (may have been set by the push) + const refreshedItem = db.get(normalizedId); + const issueNumber = refreshedItem?.githubIssueNumber ?? item.githubIssueNumber; + if (!issueNumber) { + const message = `Failed to resolve GitHub issue number for ${normalizedId} after push.`; + output.error(message, { + success: false, + error: message, + workItemId: normalizedId, + }); + process.exit(1); + } + + // Assign the issue to copilot + const { assignGithubIssueAsync } = await import('../github.js'); + const assignResult = await assignGithubIssueAsync(githubConfig, issueNumber, 'copilot'); + + if (!assignResult.ok) { + // Assignment failed: do NOT update local state, add comment, re-push + const failureMessage = `Failed to assign copilot to GitHub issue #${issueNumber}: ${assignResult.error}`; + db.createComment({ + workItemId: normalizedId, + author: 'wl-delegate', + comment: failureMessage, + }); + // Re-push to restore consistency after comment + const refreshedComments = db.getAllComments(); + await upsertIssuesFromWorkItems( + [db.get(normalizedId)!], + refreshedComments.filter(c => c.workItemId === normalizedId), + githubConfig, + () => {} + ); + output.error(failureMessage, { + success: false, + error: assignResult.error, + workItemId: normalizedId, + issueNumber, + issueUrl: `https://github.com/${githubConfig.repo}/issues/${issueNumber}`, + pushed: true, + assigned: false, + }); + process.exit(1); + } + + // Assignment succeeded: update local state + db.update(normalizedId, { + status: 'in-progress' as any, + assignee: '@github-copilot', + }); + + const issueUrl = `https://github.com/${githubConfig.repo}/issues/${issueNumber}`; + + if (isJsonMode) { + output.json({ + success: true, + workItemId: normalizedId, + issueNumber, + issueUrl, + pushed: true, + assigned: true, + }); + } else { + console.log(`Pushing to GitHub... done.`); + console.log(`Assigning to copilot... done.`); + console.log(`Done. Issue: ${issueUrl}`); + } + } catch (error) { + const message = `Delegation failed: ${(error as Error).message}`; + output.error(message, { + success: false, + error: (error as Error).message, + workItemId: normalizedId, + }); + process.exit(1); + } + }); } diff --git a/tests/cli/delegate-guard-rails.test.ts b/tests/cli/delegate-guard-rails.test.ts new file mode 100644 index 00000000..7d9db4d2 --- /dev/null +++ b/tests/cli/delegate-guard-rails.test.ts @@ -0,0 +1,365 @@ +/** + * Unit tests for the delegate subcommand guard rails: + * - do-not-delegate tag check + * - children warning + * - invalid/missing work item ID + * - --force bypass + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock child_process to prevent real gh CLI calls +const mockSpawn = vi.hoisted(() => vi.fn()); +vi.mock('child_process', () => ({ + spawn: mockSpawn, + execSync: vi.fn(() => ''), +})); + +// Mock the github-sync module to prevent real GitHub API calls +vi.mock('../../src/github-sync.js', () => ({ + upsertIssuesFromWorkItems: vi.fn(async (items: any[]) => ({ + updatedItems: items, + result: { created: 0, updated: 0, closed: 0, skipped: 0, errors: [], syncedItems: [], errorItems: [], commentsCreated: 0, commentsUpdated: 0 }, + timing: { totalMs: 0, upsertMs: 0, commentListMs: 0, commentUpsertMs: 0, hierarchyCheckMs: 0, hierarchyLinkMs: 0, hierarchyVerifyMs: 0 }, + })), + importIssuesToWorkItems: vi.fn(), +})); + +// Mock config and github helpers +vi.mock('../../src/config.js', () => ({ + loadConfig: () => ({ githubRepo: 'test-owner/test-repo', githubLabelPrefix: 'wl:' }), +})); + +vi.mock('../../src/github.js', async (importOriginal) => { + const original = await importOriginal() as any; + return { + ...original, + getRepoFromGitRemote: () => 'test-owner/test-repo', + assignGithubIssueAsync: vi.fn(async () => ({ ok: true })), + }; +}); + +import registerGithub from '../../src/commands/github.js'; + +/** + * Create a minimal context that supports nested subcommand registration + * (github -> delegate). This mimics the real Commander structure enough + * to invoke the delegate action handler. + */ +function createDelegateTestContext() { + let nextId = 1; + const items = new Map(); + const comments: any[] = []; + const createdComments: any[] = []; + let processExitCode: number | undefined; + const jsonOutput: any[] = []; + const errorOutput: any[] = []; + const consoleMessages: string[] = []; + + // Track registered subcommands by their chain: github -> delegate + const commandHandlers = new Map(); + let currentChain: string[] = []; + + function createCommandBuilder(parentChain: string[]) { + const meta: any = { opts: {} }; + const builder: any = { + description: (_d: string) => builder, + alias: (_a: string) => builder, + option: (spec: string, _desc?: string, defaultVal?: any) => { + // Parse option name from spec (e.g., '--force' -> 'force', '--prefix ' -> 'prefix') + const match = spec.match(/--([a-z-]+)/); + if (match) { + const camelKey = match[1].replace(/-([a-z])/g, (_: string, c: string) => c.toUpperCase()); + if (defaultVal !== undefined) meta.opts[camelKey] = defaultVal; + } + return builder; + }, + command: (spec: string) => { + const name = spec.split(' ')[0]; + return createCommandBuilder([...parentChain, name]); + }, + action: (fn: Function) => { + const key = parentChain.join('.'); + commandHandlers.set(key, { handler: fn, options: meta.opts }); + return builder; + }, + }; + return builder; + } + + const makeItem = (overrides: any = {}) => { + const id = overrides.id || `WL-TEST-${nextId++}`; + const now = new Date().toISOString(); + const item = { + id, + title: overrides.title || 'Sample', + description: '', + status: overrides.status || 'open', + priority: 'medium', + sortIndex: 0, + parentId: overrides.parentId || null, + createdAt: now, + updatedAt: now, + tags: overrides.tags || [], + assignee: overrides.assignee || '', + stage: '', + issueType: 'task', + createdBy: '', + deletedBy: '', + deleteReason: '', + risk: '', + effort: '', + needsProducerReview: false, + githubIssueNumber: overrides.githubIssueNumber, + ...overrides, + }; + items.set(id, item); + return id; + }; + + const db = { + get: (id: string) => items.get(id) || null, + getAll: () => Array.from(items.values()), + getAllComments: () => comments, + getChildren: (parentId: string) => Array.from(items.values()).filter(i => i.parentId === parentId), + getDescendants: (parentId: string) => Array.from(items.values()).filter(i => i.parentId === parentId), + import: (updatedItems: any[]) => { + for (const item of updatedItems) { + items.set(item.id, item); + } + }, + update: (id: string, updates: any) => { + const cur = items.get(id); + if (!cur) return null; + const next = { ...cur, ...updates }; + items.set(id, next); + return next; + }, + createComment: (input: any) => { + const c = { id: `WL-C${nextId++}`, ...input, createdAt: new Date().toISOString() }; + createdComments.push(c); + comments.push(c); + return c; + }, + getCommentsForWorkItem: (id: string) => comments.filter(c => c.workItemId === id), + }; + + const output = { + json: (data: any) => jsonOutput.push(data), + error: (msg: string, data?: any) => errorOutput.push({ msg, data }), + }; + + const program = { + opts: () => ({ verbose: false, format: undefined, json: false }), + command: (spec: string) => createCommandBuilder([spec.split(' ')[0]]), + }; + + const ctx = { + program, + output, + utils: { + requireInitialized: () => {}, + getDatabase: () => db, + normalizeCliId: (id: string) => id, + isJsonMode: () => false, + }, + }; + + // Replace process.exit with a throw so we can test exit paths + const origExit = process.exit; + const exitSpy = vi.fn((code?: number) => { + processExitCode = code; + throw new Error(`process.exit(${code})`); + }) as any; + + // Capture console.log + const origLog = console.log; + const logSpy = vi.fn((...args: any[]) => { + consoleMessages.push(args.join(' ')); + }); + + return { + ctx, + db, + items, + makeItem, + commandHandlers, + output, + jsonOutput, + errorOutput, + consoleMessages, + getExitCode: () => processExitCode, + createdComments, + setup: () => { + process.exit = exitSpy; + console.log = logSpy; + }, + teardown: () => { + process.exit = origExit; + console.log = origLog; + processExitCode = undefined; + jsonOutput.length = 0; + errorOutput.length = 0; + consoleMessages.length = 0; + createdComments.length = 0; + items.clear(); + comments.length = 0; + }, + /** + * Invoke the delegate handler with the given id and options. + */ + async runDelegate(id: string, options: Record = {}) { + const entry = commandHandlers.get('github.delegate'); + if (!entry) throw new Error('delegate command not registered'); + const mergedOptions = { ...entry.options, ...options }; + return entry.handler(id, mergedOptions); + }, + }; +} + +describe('delegate subcommand guard rails', () => { + let t: ReturnType; + + beforeEach(() => { + t = createDelegateTestContext(); + registerGithub(t.ctx as any); + t.setup(); + }); + + afterEach(() => { + t.teardown(); + vi.restoreAllMocks(); + }); + + it('registers the delegate subcommand', () => { + expect(t.commandHandlers.has('github.delegate')).toBe(true); + }); + + it('exits with error when work item is not found', async () => { + await expect(t.runDelegate('WL-NONEXISTENT')).rejects.toThrow('process.exit(1)'); + expect(t.errorOutput).toHaveLength(1); + expect(t.errorOutput[0].msg).toContain('Work item not found'); + expect(t.errorOutput[0].data.success).toBe(false); + }); + + it('exits with error when work item has do-not-delegate tag and no --force', async () => { + const id = t.makeItem({ tags: ['do-not-delegate'] }); + await expect(t.runDelegate(id)).rejects.toThrow('process.exit(1)'); + expect(t.errorOutput).toHaveLength(1); + expect(t.errorOutput[0].msg).toContain('do-not-delegate'); + expect(t.errorOutput[0].data.error).toBe('do-not-delegate'); + }); + + it('proceeds when work item has do-not-delegate tag with --force', async () => { + const id = t.makeItem({ tags: ['do-not-delegate'], githubIssueNumber: 42 }); + // Should not throw for the do-not-delegate guard; may still proceed to push+assign + await t.runDelegate(id, { force: true }); + expect(t.consoleMessages.some(m => m.includes('--force'))).toBe(true); + // Should not have the do-not-delegate error + expect(t.errorOutput.filter(e => e.data?.error === 'do-not-delegate')).toHaveLength(0); + }); + + it('warns about children in non-interactive mode and proceeds', async () => { + const parentId = t.makeItem({ id: 'WL-PARENT-1', githubIssueNumber: 10 }); + t.makeItem({ id: 'WL-CHILD-1', parentId: 'WL-PARENT-1', status: 'open' }); + t.makeItem({ id: 'WL-CHILD-2', parentId: 'WL-PARENT-1', status: 'open' }); + + // non-interactive mode (stdout is not TTY in test environment) + await t.runDelegate('WL-PARENT-1'); + // Should warn about children but proceed + expect(t.consoleMessages.some(m => m.includes('child item(s)'))).toBe(true); + }); + + it('does not warn about children when all children are closed', async () => { + t.makeItem({ id: 'WL-PARENT-2', githubIssueNumber: 20 }); + t.makeItem({ id: 'WL-CHILD-3', parentId: 'WL-PARENT-2', status: 'completed' }); + t.makeItem({ id: 'WL-CHILD-4', parentId: 'WL-PARENT-2', status: 'deleted' }); + + await t.runDelegate('WL-PARENT-2'); + // Should not warn about children since they're all closed/deleted + expect(t.consoleMessages.filter(m => m.includes('child item(s)'))).toHaveLength(0); + }); + + it('does not warn about children when item has no children', async () => { + t.makeItem({ id: 'WL-LEAF-1', githubIssueNumber: 30 }); + + await t.runDelegate('WL-LEAF-1'); + expect(t.consoleMessages.filter(m => m.includes('child item(s)'))).toHaveLength(0); + }); + + it('outputs success in JSON mode', async () => { + t.makeItem({ id: 'WL-JSON-1', githubIssueNumber: 50 }); + // Enable JSON mode + t.ctx.utils.isJsonMode = () => true; + + await t.runDelegate('WL-JSON-1'); + expect(t.jsonOutput).toHaveLength(1); + expect(t.jsonOutput[0].success).toBe(true); + expect(t.jsonOutput[0].workItemId).toBe('WL-JSON-1'); + expect(t.jsonOutput[0].issueNumber).toBe(50); + expect(t.jsonOutput[0].issueUrl).toContain('test-owner/test-repo'); + expect(t.jsonOutput[0].pushed).toBe(true); + expect(t.jsonOutput[0].assigned).toBe(true); + }); + + it('updates local state on successful delegation', async () => { + const id = t.makeItem({ id: 'WL-STATE-1', githubIssueNumber: 60, status: 'open', assignee: '' }); + + await t.runDelegate('WL-STATE-1'); + const updated = t.db.get('WL-STATE-1'); + expect(updated.status).toBe('in-progress'); + expect(updated.assignee).toBe('@github-copilot'); + }); + + it('outputs human-readable success messages', async () => { + t.makeItem({ id: 'WL-HUMAN-1', githubIssueNumber: 70 }); + + await t.runDelegate('WL-HUMAN-1'); + expect(t.consoleMessages.some(m => m.includes('Pushing to GitHub'))).toBe(true); + expect(t.consoleMessages.some(m => m.includes('Assigning to copilot'))).toBe(true); + expect(t.consoleMessages.some(m => m.includes('Done. Issue:'))).toBe(true); + }); + + it('handles assignment failure: does not update local state', async () => { + t.makeItem({ id: 'WL-FAIL-1', githubIssueNumber: 80, status: 'open', assignee: '' }); + + // Make assign fail + const { assignGithubIssueAsync } = await import('../../src/github.js'); + vi.mocked(assignGithubIssueAsync).mockResolvedValueOnce({ ok: false, error: 'copilot user not found' }); + + await expect(t.runDelegate('WL-FAIL-1')).rejects.toThrow('process.exit(1)'); + const item = t.db.get('WL-FAIL-1'); + // Local state should NOT be updated + expect(item.status).toBe('open'); + expect(item.assignee).toBe(''); + }); + + it('adds comment on assignment failure', async () => { + t.makeItem({ id: 'WL-FAIL-2', githubIssueNumber: 90, status: 'open', assignee: '' }); + + const { assignGithubIssueAsync } = await import('../../src/github.js'); + vi.mocked(assignGithubIssueAsync).mockResolvedValueOnce({ ok: false, error: 'rate limited' }); + + await expect(t.runDelegate('WL-FAIL-2')).rejects.toThrow('process.exit(1)'); + expect(t.createdComments).toHaveLength(1); + expect(t.createdComments[0].comment).toContain('Failed to assign copilot'); + expect(t.createdComments[0].comment).toContain('rate limited'); + expect(t.createdComments[0].author).toBe('wl-delegate'); + }); + + it('outputs structured error JSON on assignment failure', async () => { + t.makeItem({ id: 'WL-FAIL-3', githubIssueNumber: 100 }); + t.ctx.utils.isJsonMode = () => true; + + const { assignGithubIssueAsync } = await import('../../src/github.js'); + vi.mocked(assignGithubIssueAsync).mockResolvedValueOnce({ ok: false, error: 'forbidden' }); + + await expect(t.runDelegate('WL-FAIL-3')).rejects.toThrow('process.exit(1)'); + // Find the error with the assignment failure data (ignore any earlier errors) + const assignError = t.errorOutput.find(e => e.data?.assigned === false); + expect(assignError).toBeDefined(); + expect(assignError!.data.success).toBe(false); + expect(assignError!.data.pushed).toBe(true); + expect(assignError!.data.assigned).toBe(false); + expect(assignError!.data.error).toBe('forbidden'); + }); +}); From dab4b1b4a7730144a4dfef356a80a05809aa1a03 Mon Sep 17 00:00:00 2001 From: rgardler-msft Date: Sun, 1 Mar 2026 20:04:33 -0800 Subject: [PATCH 3/5] WL-0MM8NN4S71WUBRFT: Fix @copilot assignee handle in delegate command The delegate command was passing 'copilot' to gh issue edit --add-assignee, but GitHub requires '@copilot' for Copilot assignment. Updated the assignee argument, error messages, console output, and all related tests. --- src/commands/github.ts | 6 +++--- tests/cli/delegate-guard-rails.test.ts | 6 +++--- tests/github-assign-issue.test.ts | 30 +++++++++++++------------- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/commands/github.ts b/src/commands/github.ts index 0ffcdd7e..e9718728 100644 --- a/src/commands/github.ts +++ b/src/commands/github.ts @@ -544,11 +544,11 @@ export default function register(ctx: PluginContext): void { // Assign the issue to copilot const { assignGithubIssueAsync } = await import('../github.js'); - const assignResult = await assignGithubIssueAsync(githubConfig, issueNumber, 'copilot'); + const assignResult = await assignGithubIssueAsync(githubConfig, issueNumber, '@copilot'); if (!assignResult.ok) { // Assignment failed: do NOT update local state, add comment, re-push - const failureMessage = `Failed to assign copilot to GitHub issue #${issueNumber}: ${assignResult.error}`; + const failureMessage = `Failed to assign @copilot to GitHub issue #${issueNumber}: ${assignResult.error}`; db.createComment({ workItemId: normalizedId, author: 'wl-delegate', @@ -593,7 +593,7 @@ export default function register(ctx: PluginContext): void { }); } else { console.log(`Pushing to GitHub... done.`); - console.log(`Assigning to copilot... done.`); + console.log(`Assigning to @copilot... done.`); console.log(`Done. Issue: ${issueUrl}`); } } catch (error) { diff --git a/tests/cli/delegate-guard-rails.test.ts b/tests/cli/delegate-guard-rails.test.ts index 7d9db4d2..82f63f18 100644 --- a/tests/cli/delegate-guard-rails.test.ts +++ b/tests/cli/delegate-guard-rails.test.ts @@ -315,7 +315,7 @@ describe('delegate subcommand guard rails', () => { await t.runDelegate('WL-HUMAN-1'); expect(t.consoleMessages.some(m => m.includes('Pushing to GitHub'))).toBe(true); - expect(t.consoleMessages.some(m => m.includes('Assigning to copilot'))).toBe(true); + expect(t.consoleMessages.some(m => m.includes('Assigning to @copilot'))).toBe(true); expect(t.consoleMessages.some(m => m.includes('Done. Issue:'))).toBe(true); }); @@ -324,7 +324,7 @@ describe('delegate subcommand guard rails', () => { // Make assign fail const { assignGithubIssueAsync } = await import('../../src/github.js'); - vi.mocked(assignGithubIssueAsync).mockResolvedValueOnce({ ok: false, error: 'copilot user not found' }); + vi.mocked(assignGithubIssueAsync).mockResolvedValueOnce({ ok: false, error: '@copilot user not found' }); await expect(t.runDelegate('WL-FAIL-1')).rejects.toThrow('process.exit(1)'); const item = t.db.get('WL-FAIL-1'); @@ -341,7 +341,7 @@ describe('delegate subcommand guard rails', () => { await expect(t.runDelegate('WL-FAIL-2')).rejects.toThrow('process.exit(1)'); expect(t.createdComments).toHaveLength(1); - expect(t.createdComments[0].comment).toContain('Failed to assign copilot'); + expect(t.createdComments[0].comment).toContain('Failed to assign @copilot'); expect(t.createdComments[0].comment).toContain('rate limited'); expect(t.createdComments[0].author).toBe('wl-delegate'); }); diff --git a/tests/github-assign-issue.test.ts b/tests/github-assign-issue.test.ts index 55744470..59523e38 100644 --- a/tests/github-assign-issue.test.ts +++ b/tests/github-assign-issue.test.ts @@ -76,7 +76,7 @@ describe('assignGithubIssueAsync', () => { it('returns { ok: true } on successful assignment', async () => { mockSpawn.mockImplementation(createMockSpawnImpl('', 0)); - const result = await assignGithubIssueAsync(defaultConfig, 42, 'copilot'); + const result = await assignGithubIssueAsync(defaultConfig, 42, '@copilot'); expect(result).toEqual({ ok: true }); expect(mockSpawn).toHaveBeenCalledTimes(1); @@ -84,19 +84,19 @@ describe('assignGithubIssueAsync', () => { const command = mockSpawn.mock.calls[0][1][1]; // spawn('/bin/sh', ['-c', command]) expect(command).toContain('gh issue edit 42'); expect(command).toContain('--add-assignee'); - expect(command).toContain('copilot'); + expect(command).toContain('@copilot'); expect(command).toContain('--repo owner/repo'); }); it('returns { ok: false, error } on gh failure without throwing', async () => { mockSpawn.mockImplementation( - createMockSpawnImpl('', 1, 'user copilot is not assignable to this issue') + createMockSpawnImpl('', 1, 'user @copilot is not assignable to this issue') ); - const result = await assignGithubIssueAsync(defaultConfig, 42, 'copilot'); + const result = await assignGithubIssueAsync(defaultConfig, 42, '@copilot'); expect(result.ok).toBe(false); - expect(result.error).toContain('copilot is not assignable'); + expect(result.error).toContain('@copilot is not assignable'); }); it('retries on rate-limit errors', async () => { @@ -109,7 +109,7 @@ describe('assignGithubIssueAsync', () => { return createMockSpawnImpl('', 0)(_cmd, _args, _opts); }); - const result = await assignGithubIssueAsync(defaultConfig, 42, 'copilot', 3); + const result = await assignGithubIssueAsync(defaultConfig, 42, '@copilot', 3); expect(result.ok).toBe(true); expect(mockSpawn).toHaveBeenCalledTimes(3); @@ -125,7 +125,7 @@ describe('assignGithubIssueAsync', () => { return createMockSpawnImpl('', 0)(_cmd, _args, _opts); }); - const result = await assignGithubIssueAsync(defaultConfig, 42, 'copilot', 3); + const result = await assignGithubIssueAsync(defaultConfig, 42, '@copilot', 3); expect(result.ok).toBe(true); expect(mockSpawn).toHaveBeenCalledTimes(2); @@ -136,7 +136,7 @@ describe('assignGithubIssueAsync', () => { createMockSpawnImpl('', 1, 'API rate limit exceeded') ); - const result = await assignGithubIssueAsync(defaultConfig, 42, 'copilot', 2); + const result = await assignGithubIssueAsync(defaultConfig, 42, '@copilot', 2); expect(result.ok).toBe(false); expect(result.error).toContain('rate limit'); @@ -149,7 +149,7 @@ describe('assignGithubIssueAsync', () => { createMockSpawnImpl('', 1, 'repository not found') ); - const result = await assignGithubIssueAsync(defaultConfig, 42, 'copilot', 3); + const result = await assignGithubIssueAsync(defaultConfig, 42, '@copilot', 3); expect(result.ok).toBe(false); expect(result.error).toContain('repository not found'); @@ -162,7 +162,7 @@ describe('assignGithubIssueAsync', () => { createMockSpawnImpl('', 1, '') ); - const result = await assignGithubIssueAsync(defaultConfig, 42, 'copilot'); + const result = await assignGithubIssueAsync(defaultConfig, 42, '@copilot'); expect(result.ok).toBe(false); expect(result.error).toBeTruthy(); @@ -178,7 +178,7 @@ describe('assignGithubIssue (sync)', () => { // execSync returns stdout as string on success mockExecSync.mockReturnValue(''); - const result = assignGithubIssue(defaultConfig, 42, 'copilot'); + const result = assignGithubIssue(defaultConfig, 42, '@copilot'); expect(result).toEqual({ ok: true }); expect(mockExecSync).toHaveBeenCalledTimes(1); @@ -187,14 +187,14 @@ describe('assignGithubIssue (sync)', () => { it('returns { ok: false, error } on gh failure without throwing', () => { // execSync throws on non-zero exit code; runGhDetailed catches it const err: any = new Error('Command failed'); - err.stderr = 'user copilot is not assignable to this issue'; + err.stderr = 'user @copilot is not assignable to this issue'; err.stdout = ''; mockExecSync.mockImplementation(() => { throw err; }); - const result = assignGithubIssue(defaultConfig, 42, 'copilot'); + const result = assignGithubIssue(defaultConfig, 42, '@copilot'); expect(result.ok).toBe(false); - expect(result.error).toContain('copilot is not assignable'); + expect(result.error).toContain('@copilot is not assignable'); }); it('returns fallback error when stderr is empty on failure', () => { @@ -203,7 +203,7 @@ describe('assignGithubIssue (sync)', () => { err.stdout = ''; mockExecSync.mockImplementation(() => { throw err; }); - const result = assignGithubIssue(defaultConfig, 42, 'copilot'); + const result = assignGithubIssue(defaultConfig, 42, '@copilot'); expect(result.ok).toBe(false); expect(result.error).toBeTruthy(); From 819af783bb30231f33d838ff1403b6a7bdff9328 Mon Sep 17 00:00:00 2001 From: rgardler-msft Date: Sun, 1 Mar 2026 20:19:17 -0800 Subject: [PATCH 4/5] WL-0MM8LXODU1DA2PON: Set stage to in_progress and re-push on successful delegation On successful delegation, the local state update now includes stage='in_progress' alongside status='in-progress' and assignee='@github-copilot'. A re-push is performed after the local update so the GitHub issue immediately reflects the updated wl:status:in-progress and wl:stage:in_progress labels. --- src/commands/github.ts | 10 ++++++++++ tests/cli/delegate-guard-rails.test.ts | 1 + 2 files changed, 11 insertions(+) diff --git a/src/commands/github.ts b/src/commands/github.ts index e9718728..c3d822e6 100644 --- a/src/commands/github.ts +++ b/src/commands/github.ts @@ -578,8 +578,18 @@ export default function register(ctx: PluginContext): void { db.update(normalizedId, { status: 'in-progress' as any, assignee: '@github-copilot', + stage: 'in_progress', }); + // Re-push to sync updated status/stage labels to GitHub + const postAssignComments = db.getAllComments(); + await upsertIssuesFromWorkItems( + [db.get(normalizedId)!], + postAssignComments.filter(c => c.workItemId === normalizedId), + githubConfig, + () => {} + ); + const issueUrl = `https://github.com/${githubConfig.repo}/issues/${issueNumber}`; if (isJsonMode) { diff --git a/tests/cli/delegate-guard-rails.test.ts b/tests/cli/delegate-guard-rails.test.ts index 82f63f18..d44f5783 100644 --- a/tests/cli/delegate-guard-rails.test.ts +++ b/tests/cli/delegate-guard-rails.test.ts @@ -308,6 +308,7 @@ describe('delegate subcommand guard rails', () => { const updated = t.db.get('WL-STATE-1'); expect(updated.status).toBe('in-progress'); expect(updated.assignee).toBe('@github-copilot'); + expect(updated.stage).toBe('in_progress'); }); it('outputs human-readable success messages', async () => { From c02a79f334e5bc36ba361afdf592314329f9b5de Mon Sep 17 00:00:00 2001 From: rgardler-msft Date: Sun, 1 Mar 2026 20:29:43 -0800 Subject: [PATCH 5/5] WL-0MM8LXZ0M04W2YUF, WL-0MM8LY8LU1PDY487: Add "Local state was not updated." to failure output and first-push test - Fix AC #2: failure message now includes "Local state was not updated." to clearly indicate local state was preserved on assignment failure - Add test verifying failure output contains the phrase - Add test for first-push path (no githubIssueNumber) that creates the GitHub issue before assigning to @copilot - All 1174 tests pass across 92 test files --- src/commands/github.ts | 2 +- tests/cli/delegate-guard-rails.test.ts | 43 ++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/commands/github.ts b/src/commands/github.ts index c3d822e6..7e8b5e3d 100644 --- a/src/commands/github.ts +++ b/src/commands/github.ts @@ -548,7 +548,7 @@ export default function register(ctx: PluginContext): void { if (!assignResult.ok) { // Assignment failed: do NOT update local state, add comment, re-push - const failureMessage = `Failed to assign @copilot to GitHub issue #${issueNumber}: ${assignResult.error}`; + const failureMessage = `Failed to assign @copilot to GitHub issue #${issueNumber}: ${assignResult.error}. Local state was not updated.`; db.createComment({ workItemId: normalizedId, author: 'wl-delegate', diff --git a/tests/cli/delegate-guard-rails.test.ts b/tests/cli/delegate-guard-rails.test.ts index d44f5783..e92f149e 100644 --- a/tests/cli/delegate-guard-rails.test.ts +++ b/tests/cli/delegate-guard-rails.test.ts @@ -347,6 +347,49 @@ describe('delegate subcommand guard rails', () => { expect(t.createdComments[0].author).toBe('wl-delegate'); }); + it('includes "Local state was not updated." in human failure output', async () => { + t.makeItem({ id: 'WL-FAIL-MSG', githubIssueNumber: 95, status: 'open', assignee: '' }); + + const { assignGithubIssueAsync } = await import('../../src/github.js'); + vi.mocked(assignGithubIssueAsync).mockResolvedValueOnce({ ok: false, error: 'not found' }); + + await expect(t.runDelegate('WL-FAIL-MSG')).rejects.toThrow('process.exit(1)'); + // Find the assignment failure error (there may be additional errors from re-push) + const assignError = t.errorOutput.find(e => e.msg.includes('Failed to assign @copilot')); + expect(assignError).toBeDefined(); + expect(assignError!.msg).toContain('Local state was not updated.'); + expect(assignError!.msg).toContain('Failed to assign @copilot'); + }); + + it('delegates item without githubIssueNumber (first push creates issue)', async () => { + // Item with no githubIssueNumber — the push should create the issue + const id = t.makeItem({ id: 'WL-FIRST-PUSH', status: 'open', assignee: '' }); + // The mock upsertIssuesFromWorkItems returns the items as-is, so we need + // to simulate that the push sets githubIssueNumber on the item + const { upsertIssuesFromWorkItems } = await import('../../src/github-sync.js'); + vi.mocked(upsertIssuesFromWorkItems).mockImplementationOnce(async (items: any[]) => { + // Simulate push assigning a GitHub issue number + const updated = items.map((it: any) => ({ ...it, githubIssueNumber: 999 })); + // Also update the item in the test DB so the refreshed lookup finds it + for (const u of updated) { + t.db.update(u.id, { githubIssueNumber: u.githubIssueNumber }); + } + return { + updatedItems: updated, + result: { created: 1, updated: 0, closed: 0, skipped: 0, errors: [], syncedItems: [], errorItems: [], commentsCreated: 0, commentsUpdated: 0 }, + timing: { totalMs: 0, upsertMs: 0, commentListMs: 0, commentUpsertMs: 0, hierarchyCheckMs: 0, hierarchyLinkMs: 0, hierarchyVerifyMs: 0 }, + }; + }); + + await t.runDelegate('WL-FIRST-PUSH'); + const updated = t.db.get('WL-FIRST-PUSH'); + expect(updated.status).toBe('in-progress'); + expect(updated.assignee).toBe('@github-copilot'); + expect(updated.githubIssueNumber).toBe(999); + // Human output should indicate success + expect(t.consoleMessages.some(m => m.includes('Done. Issue:'))).toBe(true); + }); + it('outputs structured error JSON on assignment failure', async () => { t.makeItem({ id: 'WL-FAIL-3', githubIssueNumber: 100 }); t.ctx.utils.isJsonMode = () => true;