From 6d0bac9d07fb01d13d0a33954105995c2dbca95d Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Thu, 26 Feb 2026 01:09:29 +0900 Subject: [PATCH] [#367] abstract-git-provider (#375) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * takt: abstract-git-provider * takt: abstract-git-provider * takt: abstract-git-provider * fix: pushBranch のインポートパスを infra/task に修正 Git provider 抽象化により pushBranch が infra/github から infra/task に 移動したため、taskSyncAction とテストのインポートパスを更新。 --- src/__tests__/addTask.test.ts | 7 +- .../cli-routing-issue-resolve.test.ts | 31 ++- src/__tests__/createIssueFromTask.test.ts | 12 +- src/__tests__/github-pr.test.ts | 3 +- src/__tests__/github-provider.test.ts | 233 ++++++++++++++++++ src/__tests__/it-pipeline-modes.test.ts | 2 +- src/__tests__/it-pipeline.test.ts | 1 - src/__tests__/pipelineExecution.test.ts | 6 +- src/__tests__/postExecution.test.ts | 17 +- src/__tests__/runAllTasks-concurrency.test.ts | 2 - src/__tests__/selectAndExecute-autoPr.test.ts | 2 - .../selectAndExecute-skipTaskList.test.ts | 4 - src/__tests__/taskGit.test.ts | 56 +++++ src/__tests__/taskSyncAction.test.ts | 5 +- src/app/cli/routing.ts | 14 +- src/features/pipeline/steps.ts | 30 +-- src/features/tasks/add/index.ts | 5 +- src/features/tasks/execute/postExecution.ts | 18 +- src/features/tasks/execute/resolveTask.ts | 11 +- src/features/tasks/execute/types.ts | 4 +- src/features/tasks/list/taskSyncAction.ts | 2 +- src/infra/git/index.ts | 19 ++ src/infra/git/types.ts | 86 +++++++ src/infra/github/GitHubProvider.ts | 37 +++ src/infra/github/index.ts | 8 +- src/infra/github/pr.ts | 35 +-- src/infra/github/types.ts | 53 +--- src/infra/task/git.ts | 18 +- src/infra/task/index.ts | 2 +- 29 files changed, 550 insertions(+), 173 deletions(-) create mode 100644 src/__tests__/github-provider.test.ts create mode 100644 src/__tests__/taskGit.test.ts create mode 100644 src/infra/git/index.ts create mode 100644 src/infra/git/types.ts create mode 100644 src/infra/github/GitHubProvider.ts diff --git a/src/__tests__/addTask.test.ts b/src/__tests__/addTask.test.ts index 889b362..03e9c5a 100644 --- a/src/__tests__/addTask.test.ts +++ b/src/__tests__/addTask.test.ts @@ -39,6 +39,12 @@ vi.mock('../infra/task/index.js', async (importOriginal) => ({ summarizeTaskName: vi.fn().mockResolvedValue('test-task'), })); +vi.mock('../infra/git/index.js', () => ({ + getGitProvider: () => ({ + createIssue: vi.fn(), + }), +})); + vi.mock('../infra/github/issue.js', () => ({ isIssueReference: vi.fn((s: string) => /^#\d+$/.test(s)), resolveIssueTask: vi.fn(), @@ -52,7 +58,6 @@ vi.mock('../infra/github/issue.js', () => ({ } return numbers; }), - createIssue: vi.fn(), })); import { interactiveMode } from '../features/interactive/index.js'; diff --git a/src/__tests__/cli-routing-issue-resolve.test.ts b/src/__tests__/cli-routing-issue-resolve.test.ts index ecacde6..b5b3296 100644 --- a/src/__tests__/cli-routing-issue-resolve.test.ts +++ b/src/__tests__/cli-routing-issue-resolve.test.ts @@ -26,14 +26,23 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({ }), })); +const { mockCheckCliStatus, mockFetchIssue } = vi.hoisted(() => ({ + mockCheckCliStatus: vi.fn(), + mockFetchIssue: vi.fn(), +})); + +vi.mock('../infra/git/index.js', () => ({ + getGitProvider: () => ({ + checkCliStatus: (...args: unknown[]) => mockCheckCliStatus(...args), + fetchIssue: (...args: unknown[]) => mockFetchIssue(...args), + }), +})); + vi.mock('../infra/github/issue.js', () => ({ parseIssueNumbers: vi.fn(() => []), - checkGhCli: vi.fn(), - fetchIssue: vi.fn(), formatIssueAsTask: vi.fn(), isIssueReference: vi.fn(), resolveIssueTask: vi.fn(), - createIssue: vi.fn(), })); vi.mock('../features/tasks/index.js', () => ({ @@ -105,17 +114,15 @@ vi.mock('../app/cli/helpers.js', () => ({ isDirectTask: vi.fn(() => false), })); -import { checkGhCli, fetchIssue, formatIssueAsTask, parseIssueNumbers } from '../infra/github/issue.js'; +import { formatIssueAsTask, parseIssueNumbers } from '../infra/github/issue.js'; import { selectAndExecuteTask, determinePiece, createIssueAndSaveTask } from '../features/tasks/index.js'; import { interactiveMode } from '../features/interactive/index.js'; import { resolveConfigValues, loadPersonaSessions } from '../infra/config/index.js'; import { isDirectTask } from '../app/cli/helpers.js'; import { executeDefaultAction } from '../app/cli/routing.js'; import { info } from '../shared/ui/index.js'; -import type { GitHubIssue } from '../infra/github/types.js'; +import type { Issue } from '../infra/git/index.js'; -const mockCheckGhCli = vi.mocked(checkGhCli); -const mockFetchIssue = vi.mocked(fetchIssue); const mockFormatIssueAsTask = vi.mocked(formatIssueAsTask); const mockParseIssueNumbers = vi.mocked(parseIssueNumbers); const mockSelectAndExecuteTask = vi.mocked(selectAndExecuteTask); @@ -128,7 +135,7 @@ const mockIsDirectTask = vi.mocked(isDirectTask); const mockInfo = vi.mocked(info); const mockTaskRunnerListAllTaskItems = vi.mocked(mockListAllTaskItems); -function createMockIssue(number: number): GitHubIssue { +function createMockIssue(number: number): Issue { return { number, title: `Issue #${number}`, @@ -159,7 +166,7 @@ describe('Issue resolution in routing', () => { // Given mockOpts.issue = 131; const issue131 = createMockIssue(131); - mockCheckGhCli.mockReturnValue({ available: true }); + mockCheckCliStatus.mockReturnValue({ available: true }); mockFetchIssue.mockReturnValue(issue131); mockFormatIssueAsTask.mockReturnValue('## GitHub Issue #131: Issue #131'); @@ -191,7 +198,7 @@ describe('Issue resolution in routing', () => { it('should exit with error when gh CLI is unavailable for --issue', async () => { // Given mockOpts.issue = 131; - mockCheckGhCli.mockReturnValue({ + mockCheckCliStatus.mockReturnValue({ available: false, error: 'gh CLI is not installed', }); @@ -214,7 +221,7 @@ describe('Issue resolution in routing', () => { // Given const issue131 = createMockIssue(131); mockIsDirectTask.mockReturnValue(true); - mockCheckGhCli.mockReturnValue({ available: true }); + mockCheckCliStatus.mockReturnValue({ available: true }); mockFetchIssue.mockReturnValue(issue131); mockFormatIssueAsTask.mockReturnValue('## GitHub Issue #131: Issue #131'); mockParseIssueNumbers.mockReturnValue([131]); @@ -421,7 +428,7 @@ describe('Issue resolution in routing', () => { // Given mockOpts.issue = 131; const issue131 = createMockIssue(131); - mockCheckGhCli.mockReturnValue({ available: true }); + mockCheckCliStatus.mockReturnValue({ available: true }); mockFetchIssue.mockReturnValue(issue131); mockFormatIssueAsTask.mockReturnValue('## GitHub Issue #131'); mockInteractiveMode.mockResolvedValue({ action: 'cancel', task: '' }); diff --git a/src/__tests__/createIssueFromTask.test.ts b/src/__tests__/createIssueFromTask.test.ts index d8fb7de..7f022be 100644 --- a/src/__tests__/createIssueFromTask.test.ts +++ b/src/__tests__/createIssueFromTask.test.ts @@ -7,8 +7,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -vi.mock('../infra/github/issue.js', () => ({ - createIssue: vi.fn(), +const { mockCreateIssue } = vi.hoisted(() => ({ + mockCreateIssue: vi.fn(), +})); + +vi.mock('../infra/git/index.js', () => ({ + getGitProvider: () => ({ + createIssue: (...args: unknown[]) => mockCreateIssue(...args), + }), })); vi.mock('../shared/ui/index.js', () => ({ @@ -26,11 +32,9 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({ }), })); -import { createIssue } from '../infra/github/issue.js'; import { success, error } from '../shared/ui/index.js'; import { createIssueFromTask } from '../features/tasks/index.js'; -const mockCreateIssue = vi.mocked(createIssue); const mockSuccess = vi.mocked(success); const mockError = vi.mocked(error); diff --git a/src/__tests__/github-pr.test.ts b/src/__tests__/github-pr.test.ts index 1be0e18..3449713 100644 --- a/src/__tests__/github-pr.test.ts +++ b/src/__tests__/github-pr.test.ts @@ -2,7 +2,7 @@ * Tests for github/pr module * * Tests buildPrBody formatting and findExistingPr logic. - * createPullRequest/pushBranch/commentOnPr call `gh`/`git` CLI, not unit-tested here. + * createPullRequest/commentOnPr call `gh` CLI, not unit-tested here. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -174,4 +174,5 @@ describe('buildPrBody', () => { expect(result).toContain('Closes #1'); expect(result).toContain('Closes #2'); }); + }); diff --git a/src/__tests__/github-provider.test.ts b/src/__tests__/github-provider.test.ts new file mode 100644 index 0000000..5c1301e --- /dev/null +++ b/src/__tests__/github-provider.test.ts @@ -0,0 +1,233 @@ +/** + * Tests for GitHubProvider and getGitProvider factory. + * + * GitHubProvider should delegate each method to the corresponding function + * in github/issue.ts and github/pr.ts. + * getGitProvider() should return a singleton GitProvider instance. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { + mockCheckGhCli, + mockFetchIssue, + mockCreateIssue, + mockFindExistingPr, + mockCommentOnPr, + mockCreatePullRequest, +} = vi.hoisted(() => ({ + mockCheckGhCli: vi.fn(), + mockFetchIssue: vi.fn(), + mockCreateIssue: vi.fn(), + mockFindExistingPr: vi.fn(), + mockCommentOnPr: vi.fn(), + mockCreatePullRequest: vi.fn(), +})); + +vi.mock('../infra/github/issue.js', () => ({ + checkGhCli: (...args: unknown[]) => mockCheckGhCli(...args), + fetchIssue: (...args: unknown[]) => mockFetchIssue(...args), + createIssue: (...args: unknown[]) => mockCreateIssue(...args), +})); + +vi.mock('../infra/github/pr.js', () => ({ + findExistingPr: (...args: unknown[]) => mockFindExistingPr(...args), + commentOnPr: (...args: unknown[]) => mockCommentOnPr(...args), + createPullRequest: (...args: unknown[]) => mockCreatePullRequest(...args), +})); + +import { GitHubProvider } from '../infra/github/GitHubProvider.js'; +import { getGitProvider } from '../infra/git/index.js'; +import type { CommentResult } from '../infra/git/index.js'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('GitHubProvider', () => { + describe('checkCliStatus', () => { + it('checkGhCli() の結果をそのまま返す', () => { + // Given + const status = { available: true }; + mockCheckGhCli.mockReturnValue(status); + const provider = new GitHubProvider(); + + // When + const result = provider.checkCliStatus(); + + // Then + expect(mockCheckGhCli).toHaveBeenCalledTimes(1); + expect(result).toBe(status); + }); + + it('gh CLI が利用不可の場合は available: false を返す', () => { + // Given + mockCheckGhCli.mockReturnValue({ available: false, error: 'gh is not installed' }); + const provider = new GitHubProvider(); + + // When + const result = provider.checkCliStatus(); + + // Then + expect(result.available).toBe(false); + expect(result.error).toBe('gh is not installed'); + }); + }); + + describe('fetchIssue', () => { + it('fetchIssue(n) に委譲し結果を返す', () => { + // Given + const issue = { number: 42, title: 'Test issue', body: 'Body', labels: [], comments: [] }; + mockFetchIssue.mockReturnValue(issue); + const provider = new GitHubProvider(); + + // When + const result = provider.fetchIssue(42); + + // Then + expect(mockFetchIssue).toHaveBeenCalledWith(42); + expect(result).toBe(issue); + }); + }); + + describe('createIssue', () => { + it('createIssue(opts) に委譲し結果を返す', () => { + // Given + const opts = { title: 'New issue', body: 'Description' }; + const issueResult = { success: true, url: 'https://github.com/org/repo/issues/1' }; + mockCreateIssue.mockReturnValue(issueResult); + const provider = new GitHubProvider(); + + // When + const result = provider.createIssue(opts); + + // Then + expect(mockCreateIssue).toHaveBeenCalledWith(opts); + expect(result).toBe(issueResult); + }); + + it('ラベルを含む場合、opts をそのまま委譲する', () => { + // Given + const opts = { title: 'Bug', body: 'Details', labels: ['bug', 'urgent'] }; + mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/org/repo/issues/2' }); + const provider = new GitHubProvider(); + + // When + provider.createIssue(opts); + + // Then + expect(mockCreateIssue).toHaveBeenCalledWith(opts); + }); + }); + + describe('findExistingPr', () => { + it('findExistingPr(cwd, branch) に委譲し PR を返す', () => { + // Given + const pr = { number: 10, url: 'https://github.com/org/repo/pull/10' }; + mockFindExistingPr.mockReturnValue(pr); + const provider = new GitHubProvider(); + + // When + const result = provider.findExistingPr('/project', 'feat/my-feature'); + + // Then + expect(mockFindExistingPr).toHaveBeenCalledWith('/project', 'feat/my-feature'); + expect(result).toBe(pr); + }); + + it('PR が存在しない場合は undefined を返す', () => { + // Given + mockFindExistingPr.mockReturnValue(undefined); + const provider = new GitHubProvider(); + + // When + const result = provider.findExistingPr('/project', 'feat/no-pr'); + + // Then + expect(result).toBeUndefined(); + }); + }); + + describe('createPullRequest', () => { + it('createPullRequest(cwd, opts) に委譲し結果を返す', () => { + // Given + const opts = { branch: 'feat/new', title: 'My PR', body: 'PR body', draft: false }; + const prResult = { success: true, url: 'https://github.com/org/repo/pull/5' }; + mockCreatePullRequest.mockReturnValue(prResult); + const provider = new GitHubProvider(); + + // When + const result = provider.createPullRequest('/project', opts); + + // Then + expect(mockCreatePullRequest).toHaveBeenCalledWith('/project', opts); + expect(result).toBe(prResult); + }); + + it('draft: true の場合、opts をそのまま委譲する', () => { + // Given + const opts = { branch: 'feat/draft', title: 'Draft PR', body: 'body', draft: true }; + mockCreatePullRequest.mockReturnValue({ success: true, url: 'https://github.com/org/repo/pull/6' }); + const provider = new GitHubProvider(); + + // When + provider.createPullRequest('/project', opts); + + // Then + expect(mockCreatePullRequest).toHaveBeenCalledWith('/project', expect.objectContaining({ draft: true })); + }); + }); + + describe('commentOnPr', () => { + it('commentOnPr(cwd, prNumber, body) に委譲し CommentResult を返す', () => { + const commentResult: CommentResult = { success: true }; + mockCommentOnPr.mockReturnValue(commentResult); + const provider = new GitHubProvider(); + + // When + const result = provider.commentOnPr('/project', 42, 'Updated!'); + + // Then + expect(mockCommentOnPr).toHaveBeenCalledWith('/project', 42, 'Updated!'); + expect(result).toBe(commentResult); + }); + + it('コメント失敗時はエラー結果を委譲して返す', () => { + // Given + const commentResult: CommentResult = { success: false, error: 'Permission denied' }; + mockCommentOnPr.mockReturnValue(commentResult); + const provider = new GitHubProvider(); + + // When + const result = provider.commentOnPr('/project', 42, 'comment'); + + // Then + expect(result.success).toBe(false); + expect(result.error).toBe('Permission denied'); + }); + }); +}); + +describe('getGitProvider', () => { + it('GitProvider インターフェースを実装するインスタンスを返す', () => { + // When + const provider = getGitProvider(); + + // Then + expect(typeof provider.checkCliStatus).toBe('function'); + expect(typeof provider.fetchIssue).toBe('function'); + expect(typeof provider.createIssue).toBe('function'); + expect(typeof provider.findExistingPr).toBe('function'); + expect(typeof provider.createPullRequest).toBe('function'); + expect(typeof provider.commentOnPr).toBe('function'); + }); + + it('呼び出しのたびに同じインスタンスを返す(シングルトン)', () => { + // When + const provider1 = getGitProvider(); + const provider2 = getGitProvider(); + + // Then + expect(provider1).toBe(provider2); + }); +}); diff --git a/src/__tests__/it-pipeline-modes.test.ts b/src/__tests__/it-pipeline-modes.test.ts index 810e47d..1e3a66b 100644 --- a/src/__tests__/it-pipeline-modes.test.ts +++ b/src/__tests__/it-pipeline-modes.test.ts @@ -59,13 +59,13 @@ vi.mock('../infra/github/issue.js', () => ({ vi.mock('../infra/github/pr.js', () => ({ createPullRequest: mockCreatePullRequest, - pushBranch: mockPushBranch, buildPrBody: vi.fn().mockReturnValue('PR body'), })); vi.mock('../infra/task/git.js', () => ({ stageAndCommit: vi.fn().mockReturnValue('abc1234'), getCurrentBranch: vi.fn().mockReturnValue('main'), + pushBranch: (...args: unknown[]) => mockPushBranch(...args), })); vi.mock('../shared/ui/index.js', () => ({ diff --git a/src/__tests__/it-pipeline.test.ts b/src/__tests__/it-pipeline.test.ts index 702d342..264a879 100644 --- a/src/__tests__/it-pipeline.test.ts +++ b/src/__tests__/it-pipeline.test.ts @@ -46,7 +46,6 @@ vi.mock('../infra/github/issue.js', () => ({ vi.mock('../infra/github/pr.js', () => ({ createPullRequest: vi.fn(), - pushBranch: vi.fn(), buildPrBody: vi.fn().mockReturnValue('PR body'), })); diff --git a/src/__tests__/pipelineExecution.test.ts b/src/__tests__/pipelineExecution.test.ts index 1c2f099..221f780 100644 --- a/src/__tests__/pipelineExecution.test.ts +++ b/src/__tests__/pipelineExecution.test.ts @@ -22,10 +22,14 @@ const mockPushBranch = vi.fn(); const mockBuildPrBody = vi.fn(() => 'Default PR body'); vi.mock('../infra/github/pr.js', () => ({ createPullRequest: mockCreatePullRequest, - pushBranch: mockPushBranch, buildPrBody: mockBuildPrBody, })); +vi.mock('../infra/task/git.js', async (importOriginal) => ({ + ...(await importOriginal>()), + pushBranch: (...args: unknown[]) => mockPushBranch(...args), +})); + const mockExecuteTask = vi.fn(); const mockConfirmAndCreateWorktree = vi.fn(); vi.mock('../features/tasks/index.js', () => ({ diff --git a/src/__tests__/postExecution.test.ts b/src/__tests__/postExecution.test.ts index dbb5718..738c2c4 100644 --- a/src/__tests__/postExecution.test.ts +++ b/src/__tests__/postExecution.test.ts @@ -18,13 +18,18 @@ const { mockAutoCommitAndPush, mockPushBranch, mockFindExistingPr, mockCommentOn vi.mock('../infra/task/index.js', () => ({ autoCommitAndPush: (...args: unknown[]) => mockAutoCommitAndPush(...args), + pushBranch: (...args: unknown[]) => mockPushBranch(...args), +})); + +vi.mock('../infra/git/index.js', () => ({ + getGitProvider: () => ({ + findExistingPr: (...args: unknown[]) => mockFindExistingPr(...args), + commentOnPr: (...args: unknown[]) => mockCommentOnPr(...args), + createPullRequest: (...args: unknown[]) => mockCreatePullRequest(...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), })); @@ -180,7 +185,7 @@ describe('postExecutionFlow', () => { await postExecutionFlow({ ...baseOptions, task: 'Fix the bug', - issues: [{ number: 123, title: 'This title should not appear in PR', body: '', labels: [], comments: 0 }], + issues: [{ number: 123, title: 'This title should not appear in PR', body: '', labels: [], comments: [] }], }); expect(mockCreatePullRequest).toHaveBeenCalledWith( @@ -222,7 +227,7 @@ describe('postExecutionFlow', () => { await postExecutionFlow({ ...baseOptions, task: longTask, - issues: [{ number: 123, title: 'Long issue', body: '', labels: [], comments: 0 }], + issues: [{ number: 123, title: 'Long issue', body: '', labels: [], comments: [] }], }); expect(mockCreatePullRequest).toHaveBeenCalledWith( diff --git a/src/__tests__/runAllTasks-concurrency.test.ts b/src/__tests__/runAllTasks-concurrency.test.ts index be9634a..9f9b364 100644 --- a/src/__tests__/runAllTasks-concurrency.test.ts +++ b/src/__tests__/runAllTasks-concurrency.test.ts @@ -148,9 +148,7 @@ vi.mock('../shared/constants.js', () => ({ })); vi.mock('../infra/github/index.js', () => ({ - createPullRequest: vi.fn(), buildPrBody: vi.fn(), - pushBranch: vi.fn(), })); vi.mock('../infra/claude/query-manager.js', () => ({ diff --git a/src/__tests__/selectAndExecute-autoPr.test.ts b/src/__tests__/selectAndExecute-autoPr.test.ts index f0b2a98..0418f8b 100644 --- a/src/__tests__/selectAndExecute-autoPr.test.ts +++ b/src/__tests__/selectAndExecute-autoPr.test.ts @@ -72,9 +72,7 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({ })); vi.mock('../infra/github/index.js', () => ({ - createPullRequest: vi.fn(), buildPrBody: vi.fn(), - pushBranch: vi.fn(), })); vi.mock('../features/tasks/execute/taskExecution.js', () => ({ diff --git a/src/__tests__/selectAndExecute-skipTaskList.test.ts b/src/__tests__/selectAndExecute-skipTaskList.test.ts index 44d6913..3e994f9 100644 --- a/src/__tests__/selectAndExecute-skipTaskList.test.ts +++ b/src/__tests__/selectAndExecute-skipTaskList.test.ts @@ -68,11 +68,7 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({ })); vi.mock('../infra/github/index.js', () => ({ - createPullRequest: vi.fn(), buildPrBody: vi.fn(), - pushBranch: vi.fn(), - findExistingPr: vi.fn(), - commentOnPr: vi.fn(), })); vi.mock('../features/tasks/execute/taskExecution.js', () => ({ diff --git a/src/__tests__/taskGit.test.ts b/src/__tests__/taskGit.test.ts new file mode 100644 index 0000000..7fb1985 --- /dev/null +++ b/src/__tests__/taskGit.test.ts @@ -0,0 +1,56 @@ +/** + * Tests for pushBranch in infra/task/git.ts + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('node:child_process', () => ({ + execFileSync: vi.fn(), +})); + +vi.mock('../shared/utils/index.js', async (importOriginal) => ({ + ...(await importOriginal>()), + createLogger: () => ({ + info: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + }), +})); + +import { execFileSync } from 'node:child_process'; +const mockExecFileSync = vi.mocked(execFileSync); + +import { pushBranch } from '../infra/task/git.js'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('pushBranch', () => { + it('should call git push origin ', () => { + // Given + mockExecFileSync.mockReturnValue(Buffer.from('')); + + // When + pushBranch('/project', 'feature/my-branch'); + + // Then + expect(mockExecFileSync).toHaveBeenCalledWith( + 'git', + ['push', 'origin', 'feature/my-branch'], + { cwd: '/project', stdio: 'pipe' }, + ); + }); + + it('should throw when git push fails', () => { + // Given + mockExecFileSync.mockImplementation(() => { + throw new Error('error: failed to push some refs'); + }); + + // When / Then + expect(() => pushBranch('/project', 'feature/my-branch')).toThrow( + 'error: failed to push some refs', + ); + }); +}); diff --git a/src/__tests__/taskSyncAction.test.ts b/src/__tests__/taskSyncAction.test.ts index ea45049..b244713 100644 --- a/src/__tests__/taskSyncAction.test.ts +++ b/src/__tests__/taskSyncAction.test.ts @@ -38,7 +38,8 @@ vi.mock('../infra/config/index.js', () => ({ resolveConfigValues: vi.fn(() => ({ provider: 'claude', model: 'sonnet' })), })); -vi.mock('../infra/github/index.js', () => ({ +vi.mock('../infra/task/index.js', async (importOriginal) => ({ + ...(await importOriginal>()), pushBranch: vi.fn(), })); @@ -53,7 +54,7 @@ vi.mock('../shared/prompts/index.js', () => ({ import * as fs from 'node:fs'; import { execFileSync } from 'node:child_process'; import { error as logError, success } from '../shared/ui/index.js'; -import { pushBranch } from '../infra/github/index.js'; +import { pushBranch } from '../infra/task/index.js'; import { getProvider } from '../infra/providers/index.js'; import { syncBranchWithRoot } from '../features/tasks/list/taskSyncAction.js'; import type { TaskListItem } from '../infra/task/index.js'; diff --git a/src/app/cli/routing.ts b/src/app/cli/routing.ts index 0a44b38..30b8780 100644 --- a/src/app/cli/routing.ts +++ b/src/app/cli/routing.ts @@ -8,7 +8,9 @@ import { info, error as logError, withProgress } from '../../shared/ui/index.js'; import { getErrorMessage } from '../../shared/utils/index.js'; import { getLabel } from '../../shared/i18n/index.js'; -import { fetchIssue, formatIssueAsTask, checkGhCli, parseIssueNumbers, type GitHubIssue } from '../../infra/github/index.js'; +import { formatIssueAsTask, parseIssueNumbers } from '../../infra/github/index.js'; +import { getGitProvider } from '../../infra/git/index.js'; +import type { Issue } from '../../infra/git/index.js'; import { selectAndExecuteTask, determinePiece, saveTaskFromInteractive, createIssueAndSaveTask, promptLabelSelection, type SelectAndExecuteOptions } from '../../features/tasks/index.js'; import { executePipeline } from '../../features/pipeline/index.js'; import { @@ -39,22 +41,22 @@ import { loadTaskHistory } from './taskHistory.js'; async function resolveIssueInput( issueOption: number | undefined, task: string | undefined, -): Promise<{ issues: GitHubIssue[]; initialInput: string } | null> { +): Promise<{ issues: Issue[]; initialInput: string } | null> { if (issueOption) { - const ghStatus = checkGhCli(); + const ghStatus = getGitProvider().checkCliStatus(); if (!ghStatus.available) { throw new Error(ghStatus.error); } const issue = await withProgress( 'Fetching GitHub Issue...', (fetchedIssue) => `GitHub Issue fetched: #${fetchedIssue.number} ${fetchedIssue.title}`, - async () => fetchIssue(issueOption), + async () => getGitProvider().fetchIssue(issueOption), ); return { issues: [issue], initialInput: formatIssueAsTask(issue) }; } if (task && isDirectTask(task)) { - const ghStatus = checkGhCli(); + const ghStatus = getGitProvider().checkCliStatus(); if (!ghStatus.available) { throw new Error(ghStatus.error); } @@ -66,7 +68,7 @@ async function resolveIssueInput( const issues = await withProgress( 'Fetching GitHub Issue...', (fetchedIssues) => `GitHub Issues fetched: ${fetchedIssues.map((issue) => `#${issue.number}`).join(', ')}`, - async () => issueNumbers.map((n) => fetchIssue(n)), + async () => issueNumbers.map((n) => getGitProvider().fetchIssue(n)), ); return { issues, initialInput: issues.map(formatIssueAsTask).join('\n\n---\n\n') }; } diff --git a/src/features/pipeline/steps.ts b/src/features/pipeline/steps.ts index bdf02a4..67ea95e 100644 --- a/src/features/pipeline/steps.ts +++ b/src/features/pipeline/steps.ts @@ -6,16 +6,9 @@ */ import { execFileSync } from 'node:child_process'; -import { - fetchIssue, - formatIssueAsTask, - checkGhCli, - createPullRequest, - pushBranch, - buildPrBody, - type GitHubIssue, -} from '../../infra/github/index.js'; -import { stageAndCommit, resolveBaseBranch } from '../../infra/task/index.js'; +import { formatIssueAsTask, buildPrBody } from '../../infra/github/index.js'; +import { getGitProvider, type Issue } from '../../infra/git/index.js'; +import { stageAndCommit, resolveBaseBranch, pushBranch } from '../../infra/task/index.js'; import { executeTask, confirmAndCreateWorktree, type TaskExecutionOptions, type PipelineExecutionOptions } from '../tasks/index.js'; import { info, error, success } from '../../shared/ui/index.js'; import { getErrorMessage } from '../../shared/utils/index.js'; @@ -25,7 +18,7 @@ import type { PipelineConfig } from '../../core/models/index.js'; export interface TaskContent { task: string; - issue?: GitHubIssue; + issue?: Issue; } export interface ExecutionContext { @@ -51,7 +44,7 @@ function generatePipelineBranchName(pipelineConfig: PipelineConfig | undefined, export function buildCommitMessage( pipelineConfig: PipelineConfig | undefined, - issue: GitHubIssue | undefined, + issue: Issue | undefined, taskText: string | undefined, ): string { const template = pipelineConfig?.commitMessageTemplate; @@ -68,7 +61,7 @@ export function buildCommitMessage( function buildPipelinePrBody( pipelineConfig: PipelineConfig | undefined, - issue: GitHubIssue | undefined, + issue: Issue | undefined, report: string, ): string { const template = pipelineConfig?.prBodyTemplate; @@ -88,13 +81,14 @@ function buildPipelinePrBody( export function resolveTaskContent(options: PipelineExecutionOptions): TaskContent | undefined { if (options.issueNumber) { info(`Fetching issue #${options.issueNumber}...`); - const ghStatus = checkGhCli(); - if (!ghStatus.available) { - error(ghStatus.error ?? 'gh CLI is not available'); + const gitProvider = getGitProvider(); + const cliStatus = gitProvider.checkCliStatus(); + if (!cliStatus.available) { + error(cliStatus.error ?? 'gh CLI is not available'); return undefined; } try { - const issue = fetchIssue(options.issueNumber); + const issue = gitProvider.fetchIssue(options.issueNumber); const task = formatIssueAsTask(issue); success(`Issue #${options.issueNumber} fetched: "${issue.title}"`); return { task, issue }; @@ -215,7 +209,7 @@ export function submitPullRequest( const report = `Piece \`${piece}\` completed successfully.`; const prBody = buildPipelinePrBody(pipelineConfig, taskContent.issue, report); - const prResult = createPullRequest(projectCwd, { + const prResult = getGitProvider().createPullRequest(projectCwd, { branch, title: prTitle, body: prBody, diff --git a/src/features/tasks/add/index.ts b/src/features/tasks/add/index.ts index 1e71176..8ca639d 100644 --- a/src/features/tasks/add/index.ts +++ b/src/features/tasks/add/index.ts @@ -13,7 +13,8 @@ import type { Language } from '../../../core/models/types.js'; import { TaskRunner, type TaskFileData, summarizeTaskName } from '../../../infra/task/index.js'; import { determinePiece } from '../execute/selectAndExecute.js'; import { createLogger, getErrorMessage, generateReportDir } from '../../../shared/utils/index.js'; -import { isIssueReference, resolveIssueTask, parseIssueNumbers, createIssue } from '../../../infra/github/index.js'; +import { isIssueReference, resolveIssueTask, parseIssueNumbers } from '../../../infra/github/index.js'; +import { getGitProvider } from '../../../infra/git/index.js'; import { firstLine } from '../../../infra/task/naming.js'; const log = createLogger('add-task'); @@ -82,7 +83,7 @@ export function createIssueFromTask(task: string, options?: { labels?: string[] const effectiveLabels = options?.labels?.filter((l) => l.length > 0) ?? []; const labels = effectiveLabels.length > 0 ? effectiveLabels : undefined; - const issueResult = createIssue({ title, body: task, labels }); + const issueResult = getGitProvider().createIssue({ title, body: task, labels }); if (issueResult.success) { if (!issueResult.url) { error('Failed to extract issue number from URL'); diff --git a/src/features/tasks/execute/postExecution.ts b/src/features/tasks/execute/postExecution.ts index 00f99ed..7ce2364 100644 --- a/src/features/tasks/execute/postExecution.ts +++ b/src/features/tasks/execute/postExecution.ts @@ -7,11 +7,12 @@ import { resolvePieceConfigValue } from '../../../infra/config/index.js'; import { confirm } from '../../../shared/prompt/index.js'; -import { autoCommitAndPush } from '../../../infra/task/index.js'; +import { autoCommitAndPush, pushBranch } 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, findExistingPr, commentOnPr } from '../../../infra/github/index.js'; -import type { GitHubIssue } from '../../../infra/github/index.js'; +import { buildPrBody } from '../../../infra/github/index.js'; +import { getGitProvider } from '../../../infra/git/index.js'; +import type { Issue } from '../../../infra/git/index.js'; const log = createLogger('postExecution'); @@ -58,7 +59,7 @@ export interface PostExecutionOptions { shouldCreatePr: boolean; draftPr: boolean; pieceIdentifier?: string; - issues?: GitHubIssue[]; + issues?: Issue[]; repo?: string; } @@ -87,12 +88,13 @@ export async function postExecutionFlow(options: PostExecutionOptions): Promise< } catch (pushError) { log.info('Branch push from project cwd failed (may already exist)', { error: pushError }); } + const gitProvider = getGitProvider(); const report = pieceIdentifier ? `Piece \`${pieceIdentifier}\` completed successfully.` : 'Task completed successfully.'; - const existingPr = findExistingPr(projectCwd, branch); + const existingPr = gitProvider.findExistingPr(projectCwd, branch); if (existingPr) { - // PRが既に存在する場合はコメントを追加(push済みなので新コミットはPRに自動反映) + // push済みなので、新コミットはPRに自動反映される const commentBody = buildPrBody(issues, report); - const commentResult = commentOnPr(projectCwd, existingPr.number, commentBody); + const commentResult = gitProvider.commentOnPr(projectCwd, existingPr.number, commentBody); if (commentResult.success) { success(`PR updated with comment: ${existingPr.url}`); return { prUrl: existingPr.url }; @@ -107,7 +109,7 @@ export async function postExecutionFlow(options: PostExecutionOptions): Promise< const issuePrefix = firstIssue ? `[#${firstIssue.number}] ` : ''; const truncatedTask = task.length > 100 - issuePrefix.length ? `${task.slice(0, 100 - issuePrefix.length - 3)}...` : task; const prTitle = issuePrefix + truncatedTask; - const prResult = createPullRequest(projectCwd, { + const prResult = gitProvider.createPullRequest(projectCwd, { branch, title: prTitle, body: prBody, diff --git a/src/features/tasks/execute/resolveTask.ts b/src/features/tasks/execute/resolveTask.ts index 3e29dd9..1046158 100644 --- a/src/features/tasks/execute/resolveTask.ts +++ b/src/features/tasks/execute/resolveTask.ts @@ -6,7 +6,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { resolvePieceConfigValue } from '../../../infra/config/index.js'; import { type TaskInfo, createSharedClone, summarizeTaskName, detectDefaultBranch } from '../../../infra/task/index.js'; -import { fetchIssue, checkGhCli } from '../../../infra/github/index.js'; +import { getGitProvider, type Issue } from '../../../infra/git/index.js'; import { withProgress } from '../../../shared/ui/index.js'; import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; import { getTaskSlugFromTaskDir } from '../../../shared/utils/taskPaths.js'; @@ -69,19 +69,20 @@ function throwIfAborted(signal?: AbortSignal): void { * Resolve a GitHub issue from task data's issue number. * Returns issue array for buildPrBody, or undefined if no issue or gh CLI unavailable. */ -export function resolveTaskIssue(issueNumber: number | undefined): ReturnType[] | undefined { +export function resolveTaskIssue(issueNumber: number | undefined): Issue[] | undefined { if (issueNumber === undefined) { return undefined; } - const ghStatus = checkGhCli(); - if (!ghStatus.available) { + const gitProvider = getGitProvider(); + const cliStatus = gitProvider.checkCliStatus(); + if (!cliStatus.available) { log.info('gh CLI unavailable, skipping issue resolution for PR body', { issueNumber }); return undefined; } try { - const issue = fetchIssue(issueNumber); + const issue = gitProvider.fetchIssue(issueNumber); return [issue]; } catch (e) { log.info('Failed to fetch issue for PR body, continuing without issue info', { issueNumber, error: getErrorMessage(e) }); diff --git a/src/features/tasks/execute/types.ts b/src/features/tasks/execute/types.ts index fe6282a..97b8664 100644 --- a/src/features/tasks/execute/types.ts +++ b/src/features/tasks/execute/types.ts @@ -7,7 +7,7 @@ import type { PersonaProviderEntry } from '../../../core/models/persisted-global import type { ProviderPermissionProfiles } from '../../../core/models/provider-profiles.js'; import type { MovementProviderOptions } from '../../../core/models/piece-types.js'; import type { ProviderType } from '../../../infra/providers/index.js'; -import type { GitHubIssue } from '../../../infra/github/index.js'; +import type { Issue } from '../../../infra/git/index.js'; import type { ProviderOptionsSource } from '../../../core/piece/types.js'; /** Result of piece execution */ @@ -144,7 +144,7 @@ export interface SelectAndExecuteOptions { /** Interactive mode result metadata for NDJSON logging */ interactiveMetadata?: InteractiveMetadata; /** GitHub Issues to associate with the PR (adds "Closes #N" for each issue) */ - issues?: GitHubIssue[]; + issues?: Issue[]; /** Skip adding task to tasks.yaml */ skipTaskList?: boolean; } diff --git a/src/features/tasks/list/taskSyncAction.ts b/src/features/tasks/list/taskSyncAction.ts index 6225c7f..0c0eb0c 100644 --- a/src/features/tasks/list/taskSyncAction.ts +++ b/src/features/tasks/list/taskSyncAction.ts @@ -4,7 +4,7 @@ import { success, error as logError, StreamDisplay } from '../../../shared/ui/in import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; import { getProvider, type ProviderType } from '../../../infra/providers/index.js'; import { resolveConfigValues } from '../../../infra/config/index.js'; -import { pushBranch } from '../../../infra/github/index.js'; +import { pushBranch } from '../../../infra/task/index.js'; import { loadTemplate } from '../../../shared/prompts/index.js'; import { getLanguage } from '../../../infra/config/index.js'; import { type BranchActionTarget, resolveTargetBranch, resolveTargetInstruction } from './taskActionTarget.js'; diff --git a/src/infra/git/index.ts b/src/infra/git/index.ts new file mode 100644 index 0000000..a23f5a8 --- /dev/null +++ b/src/infra/git/index.ts @@ -0,0 +1,19 @@ +/** + * Git provider factory + * + * Returns the singleton GitProvider instance. + */ + +import { GitHubProvider } from '../github/GitHubProvider.js'; +import type { GitProvider } from './types.js'; + +export type { GitProvider, Issue, CliStatus, ExistingPr, CreatePrOptions, CreatePrResult, CommentResult, CreateIssueOptions, CreateIssueResult } from './types.js'; + +let provider: GitProvider | undefined; + +export function getGitProvider(): GitProvider { + if (!provider) { + provider = new GitHubProvider(); + } + return provider; +} diff --git a/src/infra/git/types.ts b/src/infra/git/types.ts new file mode 100644 index 0000000..3ff58c0 --- /dev/null +++ b/src/infra/git/types.ts @@ -0,0 +1,86 @@ +/** + * Git provider abstraction types + * + * Defines the GitProvider interface and its supporting types, + * decoupled from any specific provider implementation. + */ + +export interface CliStatus { + available: boolean; + error?: string; +} + +export interface Issue { + number: number; + title: string; + body: string; + labels: string[]; + comments: Array<{ author: string; body: string }>; +} + +export interface ExistingPr { + number: number; + url: string; +} + +export interface CreatePrOptions { + /** Branch to create PR from */ + branch: string; + /** PR title */ + title: string; + /** PR body (markdown) */ + body: string; + /** Base branch (default: repo default branch) */ + base?: string; + /** Repository in owner/repo format (optional, uses current repo if omitted) */ + repo?: string; + /** Create PR as draft */ + draft?: boolean; +} + +export interface CreatePrResult { + success: boolean; + /** PR URL on success */ + url?: string; + /** Error message on failure */ + error?: string; +} + +export interface CommentResult { + success: boolean; + /** Error message on failure */ + error?: string; +} + +export interface CreateIssueOptions { + /** Issue title */ + title: string; + /** Issue body (markdown) */ + body: string; + /** Labels to apply */ + labels?: string[]; +} + +export interface CreateIssueResult { + success: boolean; + /** Issue URL on success */ + url?: string; + /** Error message on failure */ + error?: string; +} + +export interface GitProvider { + /** Check CLI tool availability and authentication status */ + checkCliStatus(): CliStatus; + + fetchIssue(issueNumber: number): Issue; + + createIssue(options: CreateIssueOptions): CreateIssueResult; + + /** Find an open PR for the given branch. Returns undefined if no PR exists. */ + findExistingPr(cwd: string, branch: string): ExistingPr | undefined; + + createPullRequest(cwd: string, options: CreatePrOptions): CreatePrResult; + + commentOnPr(cwd: string, prNumber: number, body: string): CommentResult; +} diff --git a/src/infra/github/GitHubProvider.ts b/src/infra/github/GitHubProvider.ts new file mode 100644 index 0000000..9af2b6d --- /dev/null +++ b/src/infra/github/GitHubProvider.ts @@ -0,0 +1,37 @@ +/** + * GitHub implementation of GitProvider + * + * Delegates each operation to the corresponding function in + * issue.ts and pr.ts. This class is the single place that binds + * the GitProvider contract to the GitHub/gh-CLI implementation. + */ + +import { checkGhCli, fetchIssue, createIssue } from './issue.js'; +import { findExistingPr, commentOnPr, createPullRequest } from './pr.js'; +import type { GitProvider, CliStatus, Issue, ExistingPr, CreateIssueOptions, CreateIssueResult, CreatePrOptions, CreatePrResult, CommentResult } from '../git/types.js'; + +export class GitHubProvider implements GitProvider { + checkCliStatus(): CliStatus { + return checkGhCli(); + } + + fetchIssue(issueNumber: number): Issue { + return fetchIssue(issueNumber); + } + + createIssue(options: CreateIssueOptions): CreateIssueResult { + return createIssue(options); + } + + findExistingPr(cwd: string, branch: string): ExistingPr | undefined { + return findExistingPr(cwd, branch); + } + + createPullRequest(cwd: string, options: CreatePrOptions): CreatePrResult { + return createPullRequest(cwd, options); + } + + commentOnPr(cwd: string, prNumber: number, body: string): CommentResult { + return commentOnPr(cwd, prNumber, body); + } +} diff --git a/src/infra/github/index.ts b/src/infra/github/index.ts index 21e59e2..865352f 100644 --- a/src/infra/github/index.ts +++ b/src/infra/github/index.ts @@ -2,17 +2,11 @@ * GitHub integration - barrel exports */ -export type { GitHubIssue, GhCliStatus, CreatePrOptions, CreatePrResult, CreateIssueOptions, CreateIssueResult } from './types.js'; - export { - checkGhCli, - fetchIssue, formatIssueAsTask, parseIssueNumbers, isIssueReference, resolveIssueTask, - createIssue, } from './issue.js'; -export type { ExistingPr } from './pr.js'; -export { pushBranch, createPullRequest, buildPrBody, findExistingPr, commentOnPr } from './pr.js'; +export { buildPrBody } from './pr.js'; diff --git a/src/infra/github/pr.ts b/src/infra/github/pr.ts index 33e52ff..5754309 100644 --- a/src/infra/github/pr.ts +++ b/src/infra/github/pr.ts @@ -7,17 +7,10 @@ import { execFileSync } from 'node:child_process'; import { createLogger, getErrorMessage } from '../../shared/utils/index.js'; import { checkGhCli } from './issue.js'; -import type { GitHubIssue, CreatePrOptions, CreatePrResult } from './types.js'; - -export type { CreatePrOptions, CreatePrResult }; +import type { Issue, CreatePrOptions, CreatePrResult, ExistingPr, CommentResult } from '../git/types.js'; 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. @@ -33,15 +26,13 @@ export function findExistingPr(cwd: string, branch: string): ExistingPr | undefi ); const prs = JSON.parse(output) as ExistingPr[]; return prs[0]; - } catch { + } catch (e) { + log.debug('gh pr list failed, treating as no PR', { error: getErrorMessage(e) }); return undefined; } } -/** - * Add a comment to an existing PR. - */ -export function commentOnPr(cwd: string, prNumber: number, body: string): CreatePrResult { +export function commentOnPr(cwd: string, prNumber: number, body: string): CommentResult { const ghStatus = checkGhCli(); if (!ghStatus.available) { return { success: false, error: ghStatus.error ?? 'gh CLI is not available' }; @@ -61,21 +52,6 @@ export function commentOnPr(cwd: string, prNumber: number, body: string): Create } } -/** - * Push a branch to origin. - * Throws on failure. - */ -export function pushBranch(cwd: string, branch: string): void { - log.info('Pushing branch to origin', { branch }); - execFileSync('git', ['push', 'origin', branch], { - cwd, - stdio: 'pipe', - }); -} - -/** - * Create a Pull Request via `gh pr create`. - */ export function createPullRequest(cwd: string, options: CreatePrOptions): CreatePrResult { const ghStatus = checkGhCli(); if (!ghStatus.available) { @@ -125,13 +101,12 @@ export function createPullRequest(cwd: string, options: CreatePrOptions): Create * Build PR body from issues and execution report. * Supports multiple issues (adds "Closes #N" for each). */ -export function buildPrBody(issues: GitHubIssue[] | undefined, report: string): string { +export function buildPrBody(issues: Issue[] | undefined, report: string): string { const parts: string[] = []; parts.push('## Summary'); if (issues && issues.length > 0) { parts.push(''); - // Use the first issue's body/title for summary parts.push(issues[0]!.body || issues[0]!.title); } diff --git a/src/infra/github/types.ts b/src/infra/github/types.ts index 9c5da39..99258d6 100644 --- a/src/infra/github/types.ts +++ b/src/infra/github/types.ts @@ -2,55 +2,4 @@ * GitHub module type definitions */ -export interface GitHubIssue { - number: number; - title: string; - body: string; - labels: string[]; - comments: Array<{ author: string; body: string }>; -} - -export interface GhCliStatus { - available: boolean; - error?: string; -} - -export interface CreatePrOptions { - /** Branch to create PR from */ - branch: string; - /** PR title */ - title: string; - /** PR body (markdown) */ - body: string; - /** Base branch (default: repo default branch) */ - base?: string; - /** Repository in owner/repo format (optional, uses current repo if omitted) */ - repo?: string; - /** Create PR as draft */ - draft?: boolean; -} - -export interface CreatePrResult { - success: boolean; - /** PR URL on success */ - url?: string; - /** Error message on failure */ - error?: string; -} - -export interface CreateIssueOptions { - /** Issue title */ - title: string; - /** Issue body (markdown) */ - body: string; - /** Labels to apply */ - labels?: string[]; -} - -export interface CreateIssueResult { - success: boolean; - /** Issue URL on success */ - url?: string; - /** Error message on failure */ - error?: string; -} +export type { Issue as GitHubIssue, CliStatus as GhCliStatus, CreateIssueOptions, CreateIssueResult } from '../git/types.js'; diff --git a/src/infra/task/git.ts b/src/infra/task/git.ts index 63b56d6..fd53bc3 100644 --- a/src/infra/task/git.ts +++ b/src/infra/task/git.ts @@ -3,10 +3,10 @@ */ import { execFileSync } from 'node:child_process'; +import { createLogger } from '../../shared/utils/index.js'; + +const log = createLogger('git'); -/** - * Get the current branch name. - */ export function getCurrentBranch(cwd: string): string { return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd, @@ -16,7 +16,6 @@ export function getCurrentBranch(cwd: string): string { } /** - * Stage all changes and create a commit. * Returns the short commit hash if changes were committed, undefined if no changes. */ export function stageAndCommit(cwd: string, message: string): string | undefined { @@ -40,3 +39,14 @@ export function stageAndCommit(cwd: string, message: string): string | undefined encoding: 'utf-8', }).trim(); } + +/** + * Throws on failure. + */ +export function pushBranch(cwd: string, branch: string): void { + log.info('Pushing branch to origin', { branch }); + execFileSync('git', ['push', 'origin', branch], { + cwd, + stdio: 'pipe', + }); +} diff --git a/src/infra/task/index.ts b/src/infra/task/index.ts index 4c7ed33..5ea0a1e 100644 --- a/src/infra/task/index.ts +++ b/src/infra/task/index.ts @@ -55,7 +55,7 @@ export { getOriginalInstruction, buildListItems, } from './branchList.js'; -export { stageAndCommit, getCurrentBranch } from './git.js'; +export { stageAndCommit, getCurrentBranch, pushBranch } from './git.js'; export { autoCommitAndPush, type AutoCommitResult } from './autoCommit.js'; export { summarizeTaskName } from './summarize.js'; export { TaskWatcher, type TaskWatcherOptions } from './watcher.js';