[#367] abstract-git-provider (#375)

* takt: abstract-git-provider

* takt: abstract-git-provider

* takt: abstract-git-provider

* fix: pushBranch のインポートパスを infra/task に修正

Git provider 抽象化により pushBranch が infra/github から infra/task に
移動したため、taskSyncAction とテストのインポートパスを更新。
This commit is contained in:
nrs 2026-02-26 01:09:29 +09:00 committed by GitHub
parent f6334b8e75
commit 6d0bac9d07
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 550 additions and 173 deletions

View File

@ -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';

View File

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

View File

@ -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);

View File

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

View File

@ -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);
});
});

View File

@ -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', () => ({

View File

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

View File

@ -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<Record<string, unknown>>()),
pushBranch: (...args: unknown[]) => mockPushBranch(...args),
}));
const mockExecuteTask = vi.fn();
const mockConfirmAndCreateWorktree = vi.fn();
vi.mock('../features/tasks/index.js', () => ({

View File

@ -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(

View File

@ -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', () => ({

View File

@ -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', () => ({

View File

@ -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', () => ({

View File

@ -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<Record<string, unknown>>()),
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 <branch>', () => {
// 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',
);
});
});

View File

@ -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<Record<string, unknown>>()),
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';

View File

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

View File

@ -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,

View File

@ -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');

View File

@ -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,

View File

@ -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<typeof fetchIssue>[] | 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) });

View File

@ -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;
}

View File

@ -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';

19
src/infra/git/index.ts Normal file
View File

@ -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;
}

86
src/infra/git/types.ts Normal file
View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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';

View File

@ -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);
}

View File

@ -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';

View File

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

View File

@ -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';