* 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:
parent
f6334b8e75
commit
6d0bac9d07
@ -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';
|
||||
|
||||
@ -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: '' });
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
233
src/__tests__/github-provider.test.ts
Normal file
233
src/__tests__/github-provider.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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', () => ({
|
||||
|
||||
@ -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'),
|
||||
}));
|
||||
|
||||
|
||||
@ -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', () => ({
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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', () => ({
|
||||
|
||||
@ -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', () => ({
|
||||
|
||||
@ -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', () => ({
|
||||
|
||||
56
src/__tests__/taskGit.test.ts
Normal file
56
src/__tests__/taskGit.test.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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';
|
||||
|
||||
@ -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') };
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) });
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
19
src/infra/git/index.ts
Normal 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
86
src/infra/git/types.ts
Normal 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;
|
||||
}
|
||||
37
src/infra/github/GitHubProvider.ts
Normal file
37
src/infra/github/GitHubProvider.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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';
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user