diff --git a/src/__tests__/github-pr.test.ts b/src/__tests__/github-pr.test.ts index 2e640d4..895dd76 100644 --- a/src/__tests__/github-pr.test.ts +++ b/src/__tests__/github-pr.test.ts @@ -1,14 +1,64 @@ /** * Tests for github/pr module * - * Tests buildPrBody formatting. - * createPullRequest/pushBranch call `gh`/`git` CLI, not unit-tested here. + * Tests buildPrBody formatting and findExistingPr logic. + * createPullRequest/pushBranch/commentOnPr call `gh`/`git` CLI, not unit-tested here. */ -import { describe, it, expect } from 'vitest'; -import { buildPrBody } from '../infra/github/pr.js'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockExecFileSync = vi.fn(); +vi.mock('node:child_process', () => ({ + execFileSync: (...args: unknown[]) => mockExecFileSync(...args), +})); + +vi.mock('../infra/github/issue.js', () => ({ + checkGhCli: vi.fn().mockReturnValue({ available: true }), +})); + +vi.mock('../shared/utils/index.js', async (importOriginal) => ({ + ...(await importOriginal>()), + createLogger: () => ({ + info: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + }), + getErrorMessage: (e: unknown) => String(e), +})); + +import { buildPrBody, findExistingPr } from '../infra/github/pr.js'; import type { GitHubIssue } from '../infra/github/types.js'; +describe('findExistingPr', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('オープンな PR がある場合はその PR を返す', () => { + mockExecFileSync.mockReturnValue(JSON.stringify([{ number: 42, url: 'https://github.com/org/repo/pull/42' }])); + + const result = findExistingPr('/project', 'task/fix-bug'); + + expect(result).toEqual({ number: 42, url: 'https://github.com/org/repo/pull/42' }); + }); + + it('PR がない場合は undefined を返す', () => { + mockExecFileSync.mockReturnValue(JSON.stringify([])); + + const result = findExistingPr('/project', 'task/fix-bug'); + + expect(result).toBeUndefined(); + }); + + it('gh CLI が失敗した場合は undefined を返す', () => { + mockExecFileSync.mockImplementation(() => { throw new Error('gh: command not found'); }); + + const result = findExistingPr('/project', 'task/fix-bug'); + + expect(result).toBeUndefined(); + }); +}); + describe('buildPrBody', () => { it('should build body with single issue and report', () => { const issue: GitHubIssue = { diff --git a/src/__tests__/postExecution.test.ts b/src/__tests__/postExecution.test.ts new file mode 100644 index 0000000..d1d6adf --- /dev/null +++ b/src/__tests__/postExecution.test.ts @@ -0,0 +1,116 @@ +/** + * Tests for postExecution.ts + * + * Verifies branching logic: existing PR → comment, no PR → create. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { mockAutoCommitAndPush, mockPushBranch, mockFindExistingPr, mockCommentOnPr, mockCreatePullRequest, mockBuildPrBody } = + vi.hoisted(() => ({ + mockAutoCommitAndPush: vi.fn(), + mockPushBranch: vi.fn(), + mockFindExistingPr: vi.fn(), + mockCommentOnPr: vi.fn(), + mockCreatePullRequest: vi.fn(), + mockBuildPrBody: vi.fn(() => 'pr-body'), + })); + +vi.mock('../infra/task/index.js', () => ({ + autoCommitAndPush: (...args: unknown[]) => mockAutoCommitAndPush(...args), +})); + +vi.mock('../infra/github/index.js', () => ({ + pushBranch: (...args: unknown[]) => mockPushBranch(...args), + findExistingPr: (...args: unknown[]) => mockFindExistingPr(...args), + commentOnPr: (...args: unknown[]) => mockCommentOnPr(...args), + createPullRequest: (...args: unknown[]) => mockCreatePullRequest(...args), + buildPrBody: (...args: unknown[]) => mockBuildPrBody(...args), +})); + +vi.mock('../infra/config/index.js', () => ({ + resolvePieceConfigValue: vi.fn(), +})); + +vi.mock('../shared/prompt/index.js', () => ({ + confirm: vi.fn(), +})); + +vi.mock('../shared/ui/index.js', () => ({ + info: vi.fn(), + error: vi.fn(), + success: vi.fn(), +})); + +vi.mock('../shared/utils/index.js', async (importOriginal) => ({ + ...(await importOriginal>()), + createLogger: () => ({ + info: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + }), +})); + +import { postExecutionFlow } from '../features/tasks/execute/postExecution.js'; + +const baseOptions = { + execCwd: '/clone', + projectCwd: '/project', + task: 'Fix the bug', + branch: 'task/fix-the-bug', + baseBranch: 'main', + shouldCreatePr: true, + pieceIdentifier: 'default', +}; + +describe('postExecutionFlow', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockAutoCommitAndPush.mockReturnValue({ success: true, commitHash: 'abc123' }); + mockPushBranch.mockReturnValue(undefined); + mockCommentOnPr.mockReturnValue({ success: true }); + mockCreatePullRequest.mockReturnValue({ success: true, url: 'https://github.com/org/repo/pull/1' }); + }); + + it('既存PRがない場合は createPullRequest を呼ぶ', async () => { + mockFindExistingPr.mockReturnValue(undefined); + + await postExecutionFlow(baseOptions); + + expect(mockCreatePullRequest).toHaveBeenCalledTimes(1); + expect(mockCommentOnPr).not.toHaveBeenCalled(); + }); + + it('既存PRがある場合は commentOnPr を呼び createPullRequest は呼ばない', async () => { + mockFindExistingPr.mockReturnValue({ number: 42, url: 'https://github.com/org/repo/pull/42' }); + + await postExecutionFlow(baseOptions); + + expect(mockCommentOnPr).toHaveBeenCalledWith('/project', 42, 'pr-body'); + expect(mockCreatePullRequest).not.toHaveBeenCalled(); + }); + + it('shouldCreatePr が false の場合は PR 関連処理をスキップする', async () => { + await postExecutionFlow({ ...baseOptions, shouldCreatePr: false }); + + expect(mockFindExistingPr).not.toHaveBeenCalled(); + expect(mockCommentOnPr).not.toHaveBeenCalled(); + expect(mockCreatePullRequest).not.toHaveBeenCalled(); + }); + + it('commit がない場合は PR 関連処理をスキップする', async () => { + mockAutoCommitAndPush.mockReturnValue({ success: true, commitHash: undefined }); + + await postExecutionFlow(baseOptions); + + expect(mockFindExistingPr).not.toHaveBeenCalled(); + expect(mockCreatePullRequest).not.toHaveBeenCalled(); + }); + + it('branch がない場合は PR 関連処理をスキップする', async () => { + await postExecutionFlow({ ...baseOptions, branch: undefined }); + + expect(mockFindExistingPr).not.toHaveBeenCalled(); + expect(mockCreatePullRequest).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/tasks/execute/postExecution.ts b/src/features/tasks/execute/postExecution.ts index f20ae61..6df53cb 100644 --- a/src/features/tasks/execute/postExecution.ts +++ b/src/features/tasks/execute/postExecution.ts @@ -10,7 +10,7 @@ import { confirm } from '../../../shared/prompt/index.js'; import { autoCommitAndPush } from '../../../infra/task/index.js'; import { info, error, success } from '../../../shared/ui/index.js'; import { createLogger } from '../../../shared/utils/index.js'; -import { createPullRequest, buildPrBody, pushBranch } from '../../../infra/github/index.js'; +import { createPullRequest, buildPrBody, pushBranch, findExistingPr, commentOnPr } from '../../../infra/github/index.js'; import type { GitHubIssue } from '../../../infra/github/index.js'; const log = createLogger('postExecution'); @@ -56,25 +56,37 @@ export async function postExecutionFlow(options: PostExecutionOptions): Promise< } if (commitResult.success && commitResult.commitHash && branch && shouldCreatePr) { - info('Creating pull request...'); try { pushBranch(projectCwd, branch); } catch (pushError) { log.info('Branch push from project cwd failed (may already exist)', { error: pushError }); } const report = pieceIdentifier ? `Piece \`${pieceIdentifier}\` completed successfully.` : 'Task completed successfully.'; - const prBody = buildPrBody(issues, report); - const prResult = createPullRequest(projectCwd, { - branch, - title: task.length > 100 ? `${task.slice(0, 97)}...` : task, - body: prBody, - base: baseBranch, - repo, - }); - if (prResult.success) { - success(`PR created: ${prResult.url}`); + const existingPr = findExistingPr(projectCwd, branch); + if (existingPr) { + // PRが既に存在する場合はコメントを追加(push済みなので新コミットはPRに自動反映) + const commentBody = buildPrBody(issues, report); + const commentResult = commentOnPr(projectCwd, existingPr.number, commentBody); + if (commentResult.success) { + success(`PR updated with comment: ${existingPr.url}`); + } else { + error(`PR comment failed: ${commentResult.error}`); + } } else { - error(`PR creation failed: ${prResult.error}`); + info('Creating pull request...'); + const prBody = buildPrBody(issues, report); + const prResult = createPullRequest(projectCwd, { + branch, + title: task.length > 100 ? `${task.slice(0, 97)}...` : task, + body: prBody, + base: baseBranch, + repo, + }); + if (prResult.success) { + success(`PR created: ${prResult.url}`); + } else { + error(`PR creation failed: ${prResult.error}`); + } } } } diff --git a/src/infra/github/index.ts b/src/infra/github/index.ts index b085622..21e59e2 100644 --- a/src/infra/github/index.ts +++ b/src/infra/github/index.ts @@ -14,4 +14,5 @@ export { createIssue, } from './issue.js'; -export { pushBranch, createPullRequest, buildPrBody } from './pr.js'; +export type { ExistingPr } from './pr.js'; +export { pushBranch, createPullRequest, buildPrBody, findExistingPr, commentOnPr } from './pr.js'; diff --git a/src/infra/github/pr.ts b/src/infra/github/pr.ts index 3b366bd..08b1668 100644 --- a/src/infra/github/pr.ts +++ b/src/infra/github/pr.ts @@ -13,6 +13,54 @@ export type { CreatePrOptions, CreatePrResult }; const log = createLogger('github-pr'); +export interface ExistingPr { + number: number; + url: string; +} + +/** + * Find an open PR for the given branch. + * Returns undefined if no PR exists. + */ +export function findExistingPr(cwd: string, branch: string): ExistingPr | undefined { + const ghStatus = checkGhCli(); + if (!ghStatus.available) return undefined; + + try { + const output = execFileSync( + 'gh', ['pr', 'list', '--head', branch, '--state', 'open', '--json', 'number,url', '--limit', '1'], + { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }, + ); + const prs = JSON.parse(output) as ExistingPr[]; + return prs[0]; + } catch { + return undefined; + } +} + +/** + * Add a comment to an existing PR. + */ +export function commentOnPr(cwd: string, prNumber: number, body: string): CreatePrResult { + const ghStatus = checkGhCli(); + if (!ghStatus.available) { + return { success: false, error: ghStatus.error ?? 'gh CLI is not available' }; + } + + try { + execFileSync('gh', ['pr', 'comment', String(prNumber), '--body', body], { + cwd, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + return { success: true }; + } catch (err) { + const errorMessage = getErrorMessage(err); + log.error('PR comment failed', { error: errorMessage }); + return { success: false, error: errorMessage }; + } +} + /** * Push a branch to origin. * Throws on failure.