diff --git a/src/commands/github.ts b/src/commands/github.ts index 59b966f1..7e8b5e3d 100644 --- a/src/commands/github.ts +++ b/src/commands/github.ts @@ -435,4 +435,185 @@ 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}. Local state was not updated.`; + 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', + 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) { + 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/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/cli/delegate-guard-rails.test.ts b/tests/cli/delegate-guard-rails.test.ts new file mode 100644 index 00000000..e92f149e --- /dev/null +++ b/tests/cli/delegate-guard-rails.test.ts @@ -0,0 +1,409 @@ +/** + * 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'); + expect(updated.stage).toBe('in_progress'); + }); + + 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('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; + + 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'); + }); +}); diff --git a/tests/github-assign-issue.test.ts b/tests/github-assign-issue.test.ts new file mode 100644 index 00000000..59523e38 --- /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'); + }); +});