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'); + }); +});