takt: implement-task-base-branch (#455)
This commit is contained in:
parent
ed16c05160
commit
290d085f5e
@ -40,6 +40,11 @@ vi.mock('../features/tasks/execute/selectAndExecute.js', () => ({
|
||||
vi.mock('../infra/task/index.js', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
summarizeTaskName: vi.fn().mockResolvedValue('test-task'),
|
||||
getCurrentBranch: vi.fn().mockReturnValue('main'),
|
||||
}));
|
||||
|
||||
vi.mock('../infra/task/clone-base-branch.js', () => ({
|
||||
branchExists: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../infra/git/index.js', () => ({
|
||||
@ -76,6 +81,8 @@ import { promptInput, confirm } from '../shared/prompt/index.js';
|
||||
import { error, info } from '../shared/ui/index.js';
|
||||
import { determinePiece } from '../features/tasks/execute/selectAndExecute.js';
|
||||
import { addTask } from '../features/tasks/index.js';
|
||||
import { getCurrentBranch } from '../infra/task/index.js';
|
||||
import { branchExists } from '../infra/task/clone-base-branch.js';
|
||||
import type { PrReviewData } from '../infra/git/index.js';
|
||||
|
||||
const mockInteractiveMode = vi.mocked(interactiveMode);
|
||||
@ -84,6 +91,8 @@ const mockConfirm = vi.mocked(confirm);
|
||||
const mockInfo = vi.mocked(info);
|
||||
const mockError = vi.mocked(error);
|
||||
const mockDeterminePiece = vi.mocked(determinePiece);
|
||||
const mockGetCurrentBranch = vi.mocked(getCurrentBranch);
|
||||
const mockBranchExists = vi.mocked(branchExists);
|
||||
|
||||
let testDir: string;
|
||||
|
||||
@ -96,7 +105,7 @@ function addTaskWithPrOption(cwd: string, task: string, prNumber: number): Promi
|
||||
return addTask(cwd, task, { prNumber });
|
||||
}
|
||||
|
||||
function createMockPrReview(overrides: Partial<PrReviewData> = {}): PrReviewData {
|
||||
function createMockPrReview(overrides: Partial<PrReviewData & { baseRefName?: string }> = {}): PrReviewData {
|
||||
return {
|
||||
number: 456,
|
||||
title: 'Fix auth bug',
|
||||
@ -107,7 +116,7 @@ function createMockPrReview(overrides: Partial<PrReviewData> = {}): PrReviewData
|
||||
reviews: [{ author: 'reviewer', body: 'Fix null check' }],
|
||||
files: ['src/auth.ts'],
|
||||
...overrides,
|
||||
};
|
||||
} as PrReviewData;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@ -115,6 +124,8 @@ beforeEach(() => {
|
||||
testDir = fs.mkdtempSync(path.join(tmpdir(), 'takt-test-'));
|
||||
mockDeterminePiece.mockResolvedValue('default');
|
||||
mockConfirm.mockResolvedValue(false);
|
||||
mockGetCurrentBranch.mockReturnValue('main');
|
||||
mockBranchExists.mockReturnValue(true);
|
||||
mockCheckCliStatus.mockReturnValue({ available: true });
|
||||
});
|
||||
|
||||
@ -169,6 +180,61 @@ describe('addTask', () => {
|
||||
expect(task.auto_pr).toBe(true);
|
||||
});
|
||||
|
||||
it('should set base_branch when current branch is not main/master and user confirms', async () => {
|
||||
mockGetCurrentBranch.mockReturnValue('feat/awesome');
|
||||
mockConfirm.mockResolvedValueOnce(true);
|
||||
mockPromptInput.mockResolvedValueOnce('').mockResolvedValueOnce('');
|
||||
mockConfirm.mockResolvedValueOnce(false);
|
||||
|
||||
await addTask(testDir, 'Task content');
|
||||
|
||||
const task = loadTasks(testDir).tasks[0]!;
|
||||
expect(task.base_branch).toBe('feat/awesome');
|
||||
});
|
||||
|
||||
it('should not set base_branch when current branch prompt is declined', async () => {
|
||||
mockGetCurrentBranch.mockReturnValue('feat/awesome');
|
||||
mockConfirm.mockResolvedValueOnce(false);
|
||||
mockPromptInput.mockResolvedValueOnce('').mockResolvedValueOnce('');
|
||||
|
||||
await addTask(testDir, 'Task content');
|
||||
|
||||
const task = loadTasks(testDir).tasks[0]!;
|
||||
expect(task.base_branch).toBeUndefined();
|
||||
expect(mockBranchExists).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip base branch prompt when current branch detection fails', async () => {
|
||||
mockGetCurrentBranch.mockImplementationOnce(() => {
|
||||
throw new Error('not a git repository');
|
||||
});
|
||||
|
||||
await addTask(testDir, 'Task content');
|
||||
|
||||
expect(mockConfirm).toHaveBeenCalledTimes(1);
|
||||
expect(mockConfirm).toHaveBeenCalledWith('Auto-create PR?', true);
|
||||
expect(mockConfirm).not.toHaveBeenCalledWith(expect.stringContaining('現在のブランチ'));
|
||||
const task = loadTasks(testDir).tasks[0]!;
|
||||
expect(task.base_branch).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reprompt when base branch does not exist', async () => {
|
||||
mockGetCurrentBranch.mockReturnValue('feat/missing');
|
||||
mockConfirm.mockResolvedValueOnce(true);
|
||||
mockBranchExists.mockReturnValueOnce(false).mockReturnValueOnce(true);
|
||||
mockPromptInput
|
||||
.mockResolvedValueOnce('develop')
|
||||
.mockResolvedValueOnce('')
|
||||
.mockResolvedValueOnce('');
|
||||
mockConfirm.mockResolvedValueOnce(false);
|
||||
|
||||
await addTask(testDir, 'Task content');
|
||||
|
||||
const task = loadTasks(testDir).tasks[0]!;
|
||||
expect(task.base_branch).toBe('develop');
|
||||
expect(mockError).toHaveBeenCalledWith('Base branch does not exist: feat/missing');
|
||||
});
|
||||
|
||||
it('should create task from issue reference without interactive mode', async () => {
|
||||
mockResolveIssueTask.mockReturnValue('Issue #99: Fix login timeout');
|
||||
|
||||
@ -214,6 +280,18 @@ describe('addTask', () => {
|
||||
expect(readOrderContent(testDir, task.task_dir)).toContain(formattedTask);
|
||||
});
|
||||
|
||||
it('should store PR base_ref as base_branch when adding with --pr', async () => {
|
||||
const prReview = createMockPrReview({ baseRefName: 'release/main' });
|
||||
const formattedTask = '## PR #456 Review Comments: Fix auth bug';
|
||||
mockFetchPrReviewComments.mockReturnValue(prReview);
|
||||
mockFormatPrReviewAsTask.mockReturnValue(formattedTask);
|
||||
|
||||
await addTaskWithPrOption(testDir, 'placeholder', 456);
|
||||
|
||||
const task = loadTasks(testDir).tasks[0]!;
|
||||
expect(task.base_branch).toBe('release/main');
|
||||
});
|
||||
|
||||
it('should not create a PR task when PR has no review comments', async () => {
|
||||
const prReview = createMockPrReview({ comments: [], reviews: [] });
|
||||
mockFetchPrReviewComments.mockReturnValue(prReview);
|
||||
|
||||
@ -1,10 +1,3 @@
|
||||
/**
|
||||
* Tests for PR resolution in routing module.
|
||||
*
|
||||
* Verifies that --pr option fetches review comments
|
||||
* and passes formatted task to interactive mode.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../shared/ui/index.js', () => ({
|
||||
@ -117,11 +110,12 @@ vi.mock('../app/cli/helpers.js', () => ({
|
||||
isDirectTask: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
import { selectAndExecuteTask, determinePiece } from '../features/tasks/index.js';
|
||||
import { selectAndExecuteTask, determinePiece, saveTaskFromInteractive } from '../features/tasks/index.js';
|
||||
import { interactiveMode } from '../features/interactive/index.js';
|
||||
import { executePipeline } from '../features/pipeline/index.js';
|
||||
import { executeDefaultAction } from '../app/cli/routing.js';
|
||||
import { error as logError } from '../shared/ui/index.js';
|
||||
import type { InteractiveModeResult } from '../features/interactive/index.js';
|
||||
import type { PrReviewData } from '../infra/git/index.js';
|
||||
|
||||
const mockSelectAndExecuteTask = vi.mocked(selectAndExecuteTask);
|
||||
@ -129,8 +123,9 @@ const mockDeterminePiece = vi.mocked(determinePiece);
|
||||
const mockInteractiveMode = vi.mocked(interactiveMode);
|
||||
const mockExecutePipeline = vi.mocked(executePipeline);
|
||||
const mockLogError = vi.mocked(logError);
|
||||
const mockSaveTaskFromInteractive = vi.mocked(saveTaskFromInteractive);
|
||||
|
||||
function createMockPrReview(overrides: Partial<PrReviewData> = {}): PrReviewData {
|
||||
function createMockPrReview(overrides: Partial<PrReviewData & { baseRefName?: string }> = {}): PrReviewData {
|
||||
return {
|
||||
number: 456,
|
||||
title: 'Fix auth bug',
|
||||
@ -179,6 +174,37 @@ describe('PR resolution in routing', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass PR base branch as baseBranch when interactive save_task is selected', async () => {
|
||||
// Given
|
||||
mockOpts.pr = 456;
|
||||
const actionResult: InteractiveModeResult = {
|
||||
action: 'save_task',
|
||||
task: 'Saved PR task',
|
||||
};
|
||||
mockInteractiveMode.mockResolvedValue(actionResult);
|
||||
const prReview = createMockPrReview({ baseRefName: 'release/main', headRefName: 'feat/my-pr-branch' });
|
||||
mockCheckCliStatus.mockReturnValue({ available: true });
|
||||
mockFetchPrReviewComments.mockReturnValue(prReview);
|
||||
|
||||
// When
|
||||
await executeDefaultAction();
|
||||
|
||||
// Then
|
||||
expect(mockSaveTaskFromInteractive).toHaveBeenCalledWith(
|
||||
'/test/cwd',
|
||||
'Saved PR task',
|
||||
'default',
|
||||
expect.objectContaining({
|
||||
presetSettings: expect.objectContaining({
|
||||
worktree: true,
|
||||
branch: 'feat/my-pr-branch',
|
||||
autoPr: false,
|
||||
baseBranch: 'release/main',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should execute task after resolving PR review comments', async () => {
|
||||
// Given
|
||||
mockOpts.pr = 456;
|
||||
|
||||
@ -1,7 +1,3 @@
|
||||
/**
|
||||
* Tests for clone module (cloneAndIsolate git config propagation)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { mockLogInfo, mockLogDebug, mockLogError } = vi.hoisted(() => ({
|
||||
@ -425,6 +421,98 @@ describe('resolveBaseBranch', () => {
|
||||
expect(cloneCalls[0]).toContain('develop');
|
||||
});
|
||||
|
||||
it('should use explicit baseBranch from options when provided', () => {
|
||||
const cloneCalls: string[][] = [];
|
||||
|
||||
mockExecFileSync.mockImplementation((_cmd, args) => {
|
||||
const argsArr = args as string[];
|
||||
|
||||
if (argsArr[0] === 'rev-parse' && argsArr[1] === '--abbrev-ref') {
|
||||
return 'main\n';
|
||||
}
|
||||
if (argsArr[0] === 'symbolic-ref' && argsArr[1] === 'refs/remotes/origin/HEAD') {
|
||||
return 'refs/remotes/origin/develop\n';
|
||||
}
|
||||
if (argsArr[0] === 'clone') {
|
||||
cloneCalls.push(argsArr);
|
||||
return Buffer.from('');
|
||||
}
|
||||
if (argsArr[0] === 'remote') {
|
||||
return Buffer.from('');
|
||||
}
|
||||
if (argsArr[0] === 'config') {
|
||||
if (argsArr[1] === '--local') {
|
||||
throw new Error('not set');
|
||||
}
|
||||
return Buffer.from('');
|
||||
}
|
||||
if (argsArr[0] === 'rev-parse' && argsArr[1] === '--verify') {
|
||||
const ref = argsArr[2] === '--' ? argsArr[3] : argsArr[2];
|
||||
if (ref === 'release/main' || ref === 'origin/release/main') {
|
||||
return Buffer.from('');
|
||||
}
|
||||
throw new Error('branch not found');
|
||||
}
|
||||
if (argsArr[0] === 'checkout') {
|
||||
return Buffer.from('');
|
||||
}
|
||||
|
||||
return Buffer.from('');
|
||||
});
|
||||
|
||||
createSharedClone('/project', ({
|
||||
worktree: true,
|
||||
taskSlug: 'explicit-base-branch',
|
||||
baseBranch: 'release/main',
|
||||
} as unknown) as { worktree: true; taskSlug: string; baseBranch: string });
|
||||
|
||||
expect(cloneCalls).toHaveLength(1);
|
||||
expect(cloneCalls[0]).toContain('--branch');
|
||||
expect(cloneCalls[0]).toContain('release/main');
|
||||
});
|
||||
|
||||
it('should throw when explicit baseBranch is whitespace', () => {
|
||||
expect(() => createSharedClone('/project', {
|
||||
worktree: true,
|
||||
taskSlug: 'whitespace-base-branch',
|
||||
baseBranch: ' ',
|
||||
})).toThrow('Base branch override must not be empty.');
|
||||
});
|
||||
|
||||
it('should throw when explicit baseBranch is invalid ref', () => {
|
||||
mockExecFileSync.mockImplementation((_cmd, args) => {
|
||||
const argsArr = args as string[];
|
||||
if (argsArr[0] === 'check-ref-format') {
|
||||
throw new Error('invalid ref');
|
||||
}
|
||||
return Buffer.from('');
|
||||
});
|
||||
|
||||
expect(() => createSharedClone('/project', {
|
||||
worktree: true,
|
||||
taskSlug: 'invalid-base-branch',
|
||||
baseBranch: 'invalid..name',
|
||||
})).toThrow('Invalid base branch: invalid..name');
|
||||
});
|
||||
|
||||
it('should throw when explicit baseBranch does not exist locally or on origin', () => {
|
||||
mockExecFileSync.mockImplementation((_cmd, args) => {
|
||||
const argsArr = args as string[];
|
||||
|
||||
if (argsArr[0] === 'rev-parse' && argsArr[1] === '--verify') {
|
||||
throw new Error('branch not found');
|
||||
}
|
||||
|
||||
return Buffer.from('');
|
||||
});
|
||||
|
||||
expect(() => createSharedClone('/project', {
|
||||
worktree: true,
|
||||
taskSlug: 'missing-base-branch',
|
||||
baseBranch: 'missing/branch',
|
||||
})).toThrow('Base branch does not exist: missing/branch');
|
||||
});
|
||||
|
||||
it('should continue clone creation when fetch fails (network error)', () => {
|
||||
// Given: fetch throws (no network)
|
||||
mockExecFileSync.mockImplementation((_cmd, args) => {
|
||||
@ -593,7 +681,7 @@ describe('branchExists remote tracking branch fallback', () => {
|
||||
|
||||
// branchExists: git rev-parse --verify <branch>
|
||||
if (argsArr[0] === 'rev-parse' && argsArr[1] === '--verify') {
|
||||
const ref = argsArr[2];
|
||||
const ref = argsArr[2] === '--' ? argsArr[3] : argsArr[2];
|
||||
if (typeof ref === 'string' && ref.startsWith('origin/')) {
|
||||
// Remote tracking branch exists
|
||||
return Buffer.from('abc123');
|
||||
@ -966,6 +1054,7 @@ describe('cleanupOrphanedClone path traversal protection', () => {
|
||||
vi.mocked(fs.readFileSync).mockReturnValueOnce(
|
||||
JSON.stringify({ clonePath: '/etc/malicious' })
|
||||
);
|
||||
vi.mocked(fs.existsSync).mockReturnValueOnce(true);
|
||||
|
||||
cleanupOrphanedClone(PROJECT_DIR, BRANCH);
|
||||
|
||||
@ -982,7 +1071,7 @@ describe('cleanupOrphanedClone path traversal protection', () => {
|
||||
vi.mocked(fs.readFileSync).mockReturnValueOnce(
|
||||
JSON.stringify({ clonePath: validClonePath })
|
||||
);
|
||||
vi.mocked(fs.existsSync).mockReturnValueOnce(true);
|
||||
vi.mocked(fs.existsSync).mockReturnValueOnce(true).mockReturnValueOnce(true);
|
||||
|
||||
cleanupOrphanedClone(PROJECT_DIR, BRANCH);
|
||||
|
||||
|
||||
@ -1,10 +1,3 @@
|
||||
/**
|
||||
* Tests for github/pr module
|
||||
*
|
||||
* Tests buildPrBody formatting and findExistingPr logic.
|
||||
* createPullRequest/commentOnPr call `gh` CLI, not unit-tested here.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const mockExecFileSync = vi.fn();
|
||||
@ -183,13 +176,14 @@ describe('fetchPrReviewComments', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should parse gh pr view JSON and return PrReviewData', () => {
|
||||
it('should return PrReviewData when gh pr view JSON is valid', () => {
|
||||
// Given
|
||||
const ghResponse = {
|
||||
number: 456,
|
||||
title: 'Fix auth bug',
|
||||
body: 'PR description',
|
||||
url: 'https://github.com/org/repo/pull/456',
|
||||
baseRefName: 'release/main',
|
||||
headRefName: 'fix/auth-bug',
|
||||
comments: [
|
||||
{ author: { login: 'commenter1' }, body: 'Please update tests' },
|
||||
@ -221,11 +215,12 @@ describe('fetchPrReviewComments', () => {
|
||||
// Then
|
||||
expect(mockExecFileSync).toHaveBeenCalledWith(
|
||||
'gh',
|
||||
['pr', 'view', '456', '--json', 'number,title,body,url,headRefName,comments,reviews,files'],
|
||||
['pr', 'view', '456', '--json', 'number,title,body,url,headRefName,baseRefName,comments,reviews,files'],
|
||||
expect.objectContaining({ encoding: 'utf-8' }),
|
||||
);
|
||||
expect(result.number).toBe(456);
|
||||
expect(result.title).toBe('Fix auth bug');
|
||||
expect((result as { baseRefName?: string }).baseRefName).toBe('release/main');
|
||||
expect(result.headRefName).toBe('fix/auth-bug');
|
||||
expect(result.comments).toEqual([{ author: 'commenter1', body: 'Please update tests' }]);
|
||||
expect(result.reviews).toEqual([
|
||||
|
||||
@ -1,12 +1,5 @@
|
||||
/**
|
||||
* Tests for pipeline execution
|
||||
*
|
||||
* Tests the orchestration logic with mocked dependencies.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock all external dependencies
|
||||
const mockFetchIssue = vi.fn();
|
||||
const mockCheckGhCli = vi.fn().mockReturnValue({ available: true });
|
||||
vi.mock('../infra/github/issue.js', () => ({
|
||||
@ -49,13 +42,11 @@ vi.mock('../infra/config/index.js', () => ({
|
||||
resolveConfigValue: vi.fn(() => undefined),
|
||||
}));
|
||||
|
||||
// Mock execFileSync for git operations
|
||||
const mockExecFileSync = vi.fn();
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFileSync: mockExecFileSync,
|
||||
}));
|
||||
|
||||
// Mock UI
|
||||
vi.mock('../shared/ui/index.js', () => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
@ -67,7 +58,6 @@ vi.mock('../shared/ui/index.js', () => ({
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}));
|
||||
// Mock Slack + utils
|
||||
const mockGetSlackWebhookUrl = vi.fn<() => string | undefined>(() => undefined);
|
||||
const mockSendSlackNotification = vi.fn<(url: string, message: string) => Promise<void>>();
|
||||
const mockBuildSlackRunSummary = vi.fn<(params: unknown) => string>(() => 'TAKT Run Summary');
|
||||
@ -241,7 +231,7 @@ describe('executePipeline', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('draftPr: true の場合、createPullRequest に draft: true が渡される', async () => {
|
||||
it('should pass draft: true to createPullRequest when draftPr is true', async () => {
|
||||
mockExecuteTask.mockResolvedValueOnce(true);
|
||||
mockCreatePullRequest.mockReturnValueOnce({ success: true, url: 'https://github.com/test/pr/1' });
|
||||
|
||||
@ -261,7 +251,7 @@ describe('executePipeline', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('draftPr: false の場合、createPullRequest に draft: false が渡される', async () => {
|
||||
it('should pass draft: false to createPullRequest when draftPr is false', async () => {
|
||||
mockExecuteTask.mockResolvedValueOnce(true);
|
||||
mockCreatePullRequest.mockReturnValueOnce({ success: true, url: 'https://github.com/test/pr/1' });
|
||||
|
||||
@ -281,7 +271,7 @@ describe('executePipeline', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass baseBranch as base to createPullRequest', async () => {
|
||||
it('should pass baseBranch as base to createPullRequest when autoPr is true', async () => {
|
||||
// Given: detectDefaultBranch returns 'develop' (via symbolic-ref)
|
||||
mockExecFileSync.mockImplementation((_cmd: string, args: string[]) => {
|
||||
if (args[0] === 'symbolic-ref' && args[1] === 'refs/remotes/origin/HEAD') {
|
||||
@ -303,13 +293,11 @@ describe('executePipeline', () => {
|
||||
|
||||
// Then
|
||||
expect(exitCode).toBe(0);
|
||||
expect(mockCreatePullRequest).toHaveBeenCalledWith(
|
||||
'/tmp/test',
|
||||
expect.objectContaining({
|
||||
branch: 'fix/my-branch',
|
||||
base: 'develop',
|
||||
}),
|
||||
);
|
||||
expect(mockCreatePullRequest).toHaveBeenCalledTimes(1);
|
||||
const prOptions = mockCreatePullRequest.mock.calls[0]?.[1] as { branch?: string; base?: string };
|
||||
expect(prOptions.branch).toBe('fix/my-branch');
|
||||
expect(prOptions.base).toBe('develop');
|
||||
expect(prOptions.base).not.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should use --task when both --task and positional task are provided', async () => {
|
||||
@ -534,7 +522,13 @@ describe('executePipeline', () => {
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(mockConfirmAndCreateWorktree).toHaveBeenCalledWith('/tmp/test', 'Fix the bug', true, undefined);
|
||||
expect(mockConfirmAndCreateWorktree).toHaveBeenCalledWith(
|
||||
'/tmp/test',
|
||||
'Fix the bug',
|
||||
true,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(mockExecuteTask).toHaveBeenCalledWith({
|
||||
task: 'Fix the bug',
|
||||
cwd: '/tmp/test-worktree',
|
||||
@ -631,6 +625,28 @@ describe('executePipeline', () => {
|
||||
expect(exitCode).toBe(4);
|
||||
});
|
||||
|
||||
it('should return exit code 4 when worktree baseBranch is unresolved', async () => {
|
||||
mockConfirmAndCreateWorktree.mockResolvedValueOnce({
|
||||
execCwd: '/tmp/test-worktree',
|
||||
isWorktree: true,
|
||||
branch: 'fix/the-bug',
|
||||
baseBranch: undefined,
|
||||
taskSlug: 'fix-the-bug',
|
||||
});
|
||||
|
||||
const exitCode = await executePipeline({
|
||||
task: 'Fix the bug',
|
||||
piece: 'default',
|
||||
autoPr: false,
|
||||
cwd: '/tmp/test',
|
||||
createWorktree: true,
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(4);
|
||||
expect(mockExecuteTask).not.toHaveBeenCalled();
|
||||
expect(mockCreatePullRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should commit in worktree and push via clone→project→origin', async () => {
|
||||
mockConfirmAndCreateWorktree.mockResolvedValueOnce({
|
||||
execCwd: '/tmp/test-worktree',
|
||||
@ -700,6 +716,74 @@ describe('executePipeline', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use worktree base branch for PR creation', async () => {
|
||||
mockConfirmAndCreateWorktree.mockResolvedValueOnce({
|
||||
execCwd: '/tmp/test-worktree',
|
||||
isWorktree: true,
|
||||
branch: 'fix/the-bug',
|
||||
baseBranch: 'release/main',
|
||||
taskSlug: 'fix-the-bug',
|
||||
});
|
||||
mockExecuteTask.mockResolvedValueOnce(true);
|
||||
mockCreatePullRequest.mockReturnValueOnce({ success: true, url: 'https://github.com/test/pr/1' });
|
||||
|
||||
const exitCode = await executePipeline({
|
||||
task: 'Fix the bug',
|
||||
piece: 'default',
|
||||
autoPr: true,
|
||||
cwd: '/tmp/test',
|
||||
createWorktree: true,
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(mockCreatePullRequest).toHaveBeenCalledWith(
|
||||
'/tmp/test',
|
||||
expect.objectContaining({
|
||||
branch: 'fix/the-bug',
|
||||
base: 'release/main',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass PR base branch to worktree creation when createWorktree is true', async () => {
|
||||
mockFetchPrReviewComments.mockReturnValueOnce({
|
||||
number: 456,
|
||||
title: 'Fix auth bug',
|
||||
body: 'PR description',
|
||||
url: 'https://github.com/org/repo/pull/456',
|
||||
baseRefName: 'release/main',
|
||||
headRefName: 'fix/auth-bug',
|
||||
comments: [{ author: 'reviewer1', body: 'Fix null check' }],
|
||||
reviews: [],
|
||||
files: ['src/auth.ts'],
|
||||
});
|
||||
mockConfirmAndCreateWorktree.mockResolvedValueOnce({
|
||||
execCwd: '/tmp/test-worktree',
|
||||
isWorktree: true,
|
||||
branch: 'fix/auth-bug',
|
||||
baseBranch: 'release/main',
|
||||
taskSlug: 'fix-auth-bug',
|
||||
});
|
||||
mockExecuteTask.mockResolvedValueOnce(true);
|
||||
|
||||
const exitCode = await executePipeline({
|
||||
prNumber: 456,
|
||||
piece: 'default',
|
||||
autoPr: false,
|
||||
cwd: '/tmp/test',
|
||||
createWorktree: true,
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(mockConfirmAndCreateWorktree).toHaveBeenCalledWith(
|
||||
'/tmp/test',
|
||||
expect.any(String),
|
||||
true,
|
||||
'fix/auth-bug',
|
||||
'release/main',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return exit code 4 when git commit/push fails', async () => {
|
||||
@ -934,5 +1018,79 @@ describe('executePipeline', () => {
|
||||
);
|
||||
expect(checkoutPrBranch).toBeDefined();
|
||||
});
|
||||
|
||||
it('should pass PR base branch to PR creation when baseRefName is provided', async () => {
|
||||
// Given: remote default branch is develop, PR base is release/main
|
||||
mockExecFileSync.mockImplementation((_cmd: string, args: string[]) => {
|
||||
if (args[0] === 'symbolic-ref' && args[1] === 'refs/remotes/origin/HEAD') {
|
||||
return 'refs/remotes/origin/develop\n';
|
||||
}
|
||||
return 'abc1234\n';
|
||||
});
|
||||
mockFetchPrReviewComments.mockReturnValueOnce({
|
||||
number: 456,
|
||||
title: 'Fix auth bug',
|
||||
body: 'PR description',
|
||||
url: 'https://github.com/org/repo/pull/456',
|
||||
baseRefName: 'release/main',
|
||||
headRefName: 'fix/auth-bug',
|
||||
comments: [{ author: 'commenter1', body: 'Update tests' }],
|
||||
reviews: [{ author: 'reviewer1', body: 'Fix null check' }],
|
||||
files: ['src/auth.ts'],
|
||||
});
|
||||
mockExecuteTask.mockResolvedValueOnce(true);
|
||||
mockCreatePullRequest.mockReturnValueOnce({ success: true, url: 'https://github.com/org/repo/pull/1' });
|
||||
|
||||
// When
|
||||
const exitCode = await executePipeline({
|
||||
prNumber: 456,
|
||||
piece: 'default',
|
||||
autoPr: true,
|
||||
cwd: '/tmp/test',
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(exitCode).toBe(0);
|
||||
expect(mockCreatePullRequest).toHaveBeenCalledTimes(1);
|
||||
const prOptions = mockCreatePullRequest.mock.calls[0]?.[1] as { base?: string };
|
||||
expect(prOptions.base).toBe('release/main');
|
||||
expect(prOptions.base).not.toBeUndefined();
|
||||
expect(prOptions.base).not.toBe('develop');
|
||||
});
|
||||
|
||||
it('should resolve default base branch for PR creation when baseRefName is undefined', async () => {
|
||||
mockExecFileSync.mockImplementation((_cmd: string, args: string[]) => {
|
||||
if (args[0] === 'symbolic-ref' && args[1] === 'refs/remotes/origin/HEAD') {
|
||||
return 'refs/remotes/origin/develop\n';
|
||||
}
|
||||
return 'abc1234\n';
|
||||
});
|
||||
mockFetchPrReviewComments.mockReturnValueOnce({
|
||||
number: 456,
|
||||
title: 'Fix auth bug',
|
||||
body: 'PR description',
|
||||
url: 'https://github.com/org/repo/pull/456',
|
||||
baseRefName: undefined,
|
||||
headRefName: 'fix/auth-bug',
|
||||
comments: [{ author: 'commenter1', body: 'Update tests' }],
|
||||
reviews: [{ author: 'reviewer1', body: 'Fix null check' }],
|
||||
files: ['src/auth.ts'],
|
||||
});
|
||||
mockExecuteTask.mockResolvedValueOnce(true);
|
||||
mockCreatePullRequest.mockReturnValueOnce({ success: true, url: 'https://github.com/org/repo/pull/1' });
|
||||
|
||||
const exitCode = await executePipeline({
|
||||
prNumber: 456,
|
||||
piece: 'default',
|
||||
autoPr: true,
|
||||
cwd: '/tmp/test',
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(mockCreatePullRequest).toHaveBeenCalledTimes(1);
|
||||
const prOptions = mockCreatePullRequest.mock.calls[0]?.[1] as { base?: string };
|
||||
expect(prOptions.base).toBe('develop');
|
||||
expect(prOptions.base).not.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,12 +1,9 @@
|
||||
/**
|
||||
* Tests for task execution resolution.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import type { TaskInfo } from '../infra/task/index.js';
|
||||
import * as infraTask from '../infra/task/index.js';
|
||||
import { resolveTaskExecution } from '../features/tasks/execute/resolveTask.js';
|
||||
|
||||
const tempRoots = new Set<string>();
|
||||
@ -86,6 +83,235 @@ describe('resolveTaskExecution', () => {
|
||||
expect(fs.readFileSync(expectedReportOrderPath, 'utf-8')).toBe('# task instruction');
|
||||
});
|
||||
|
||||
it('should pass base_branch to shared clone options when worktree task has base_branch', async () => {
|
||||
const root = createTempProjectDir();
|
||||
const taskData = {
|
||||
task: 'Run task with base branch',
|
||||
worktree: true,
|
||||
branch: 'feature/base-branch',
|
||||
base_branch: 'release/main',
|
||||
};
|
||||
const task = createTask({
|
||||
data: ({
|
||||
...taskData,
|
||||
} as unknown) as NonNullable<TaskInfo['data']>,
|
||||
worktreePath: undefined,
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
const mockResolveBaseBranch = vi.spyOn(infraTask, 'resolveBaseBranch').mockReturnValue({
|
||||
branch: 'release/main',
|
||||
});
|
||||
const mockCreateSharedClone = vi.spyOn(infraTask, 'createSharedClone').mockReturnValue({
|
||||
path: '/tmp/shared-clone',
|
||||
branch: 'feature/base-branch',
|
||||
});
|
||||
|
||||
const result = await resolveTaskExecution(task, root, 'default');
|
||||
|
||||
expect(mockResolveBaseBranch).toHaveBeenCalledWith(root, 'release/main');
|
||||
expect(mockCreateSharedClone).toHaveBeenCalledWith(
|
||||
root,
|
||||
expect.objectContaining({
|
||||
worktree: true,
|
||||
branch: 'feature/base-branch',
|
||||
baseBranch: 'release/main',
|
||||
}),
|
||||
);
|
||||
expect(result.baseBranch).toBe('release/main');
|
||||
|
||||
mockCreateSharedClone.mockRestore();
|
||||
mockResolveBaseBranch.mockRestore();
|
||||
});
|
||||
|
||||
it('should prefer base_branch over legacy baseBranch when both are present', async () => {
|
||||
const root = createTempProjectDir();
|
||||
const task = createTask({
|
||||
slug: 'prefer-base-branch',
|
||||
data: ({
|
||||
task: 'Run task with both base branch fields',
|
||||
worktree: true,
|
||||
branch: 'feature/base-branch',
|
||||
base_branch: 'release/main',
|
||||
baseBranch: 'legacy/main',
|
||||
} as unknown) as NonNullable<TaskInfo['data']>,
|
||||
worktreePath: undefined,
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
const mockResolveBaseBranch = vi.spyOn(infraTask, 'resolveBaseBranch').mockReturnValue({
|
||||
branch: 'release/main',
|
||||
});
|
||||
const mockCreateSharedClone = vi.spyOn(infraTask, 'createSharedClone').mockReturnValue({
|
||||
path: '/tmp/shared-clone',
|
||||
branch: 'feature/base-branch',
|
||||
});
|
||||
|
||||
const result = await resolveTaskExecution(task, root, 'default');
|
||||
const cloneOptions = mockCreateSharedClone.mock.calls[0]?.[1] as Record<string, unknown> | undefined;
|
||||
|
||||
expect(mockResolveBaseBranch).toHaveBeenCalledWith(root, 'release/main');
|
||||
expect(cloneOptions).toBeDefined();
|
||||
expect(cloneOptions).toMatchObject({
|
||||
worktree: true,
|
||||
branch: 'feature/base-branch',
|
||||
taskSlug: 'prefer-base-branch',
|
||||
baseBranch: 'release/main',
|
||||
});
|
||||
expect(cloneOptions).not.toMatchObject({ baseBranch: 'legacy/main' });
|
||||
expect(result.baseBranch).toBe('release/main');
|
||||
|
||||
mockCreateSharedClone.mockRestore();
|
||||
mockResolveBaseBranch.mockRestore();
|
||||
});
|
||||
|
||||
it('should ignore legacy baseBranch field when base_branch is not set', async () => {
|
||||
const root = createTempProjectDir();
|
||||
const task = createTask({
|
||||
slug: 'legacy-base-branch',
|
||||
data: ({
|
||||
task: 'Run task with legacy baseBranch',
|
||||
worktree: true,
|
||||
branch: 'feature/base-branch',
|
||||
baseBranch: 'legacy/main',
|
||||
} as unknown) as NonNullable<TaskInfo['data']>,
|
||||
worktreePath: undefined,
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
const mockResolveBaseBranch = vi.spyOn(infraTask, 'resolveBaseBranch').mockReturnValue({
|
||||
branch: 'develop',
|
||||
});
|
||||
const mockCreateSharedClone = vi.spyOn(infraTask, 'createSharedClone').mockReturnValue({
|
||||
path: '/tmp/shared-clone',
|
||||
branch: 'feature/base-branch',
|
||||
});
|
||||
|
||||
const result = await resolveTaskExecution(task, root, 'default');
|
||||
const cloneOptions = mockCreateSharedClone.mock.calls[0]?.[1] as Record<string, unknown> | undefined;
|
||||
|
||||
expect(mockResolveBaseBranch).toHaveBeenCalledWith(root, undefined);
|
||||
expect(cloneOptions).toBeDefined();
|
||||
expect(cloneOptions).toMatchObject({
|
||||
worktree: true,
|
||||
branch: 'feature/base-branch',
|
||||
taskSlug: 'legacy-base-branch',
|
||||
});
|
||||
expect(cloneOptions).not.toHaveProperty('baseBranch');
|
||||
expect(result.baseBranch).toBe('develop');
|
||||
|
||||
mockCreateSharedClone.mockRestore();
|
||||
mockResolveBaseBranch.mockRestore();
|
||||
});
|
||||
|
||||
it('should preserve base_branch when reusing an existing worktree path', async () => {
|
||||
const root = createTempProjectDir();
|
||||
const worktreePath = path.join(root, 'existing-worktree');
|
||||
fs.mkdirSync(worktreePath, { recursive: true });
|
||||
|
||||
const task = createTask({
|
||||
data: ({
|
||||
task: 'Run task with base branch',
|
||||
worktree: true,
|
||||
branch: 'feature/base-branch',
|
||||
base_branch: 'release/main',
|
||||
} as unknown) as NonNullable<TaskInfo['data']>,
|
||||
worktreePath,
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
const mockResolveBaseBranch = vi.spyOn(infraTask, 'resolveBaseBranch').mockReturnValue({
|
||||
branch: 'release/main',
|
||||
});
|
||||
const mockCreateSharedClone = vi.spyOn(infraTask, 'createSharedClone').mockReturnValue({
|
||||
path: worktreePath,
|
||||
branch: 'feature/base-branch',
|
||||
});
|
||||
|
||||
const result = await resolveTaskExecution(task, root, 'default');
|
||||
|
||||
expect(result.execCwd).toBe(worktreePath);
|
||||
expect(result.isWorktree).toBe(true);
|
||||
expect(result.baseBranch).toBe('release/main');
|
||||
expect(mockCreateSharedClone).not.toHaveBeenCalled();
|
||||
|
||||
mockCreateSharedClone.mockRestore();
|
||||
mockResolveBaseBranch.mockRestore();
|
||||
});
|
||||
|
||||
it('should prefer base_branch over legacy baseBranch when reusing an existing worktree path', async () => {
|
||||
const root = createTempProjectDir();
|
||||
const worktreePath = path.join(root, 'existing-worktree');
|
||||
fs.mkdirSync(worktreePath, { recursive: true });
|
||||
|
||||
const task = createTask({
|
||||
data: ({
|
||||
task: 'Run task with both base branch fields',
|
||||
worktree: true,
|
||||
branch: 'feature/base-branch',
|
||||
base_branch: 'release/main',
|
||||
baseBranch: 'legacy/main',
|
||||
} as unknown) as NonNullable<TaskInfo['data']>,
|
||||
worktreePath,
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
const mockResolveBaseBranch = vi.spyOn(infraTask, 'resolveBaseBranch').mockReturnValue({
|
||||
branch: 'release/main',
|
||||
});
|
||||
const mockCreateSharedClone = vi.spyOn(infraTask, 'createSharedClone').mockReturnValue({
|
||||
path: worktreePath,
|
||||
branch: 'feature/base-branch',
|
||||
});
|
||||
|
||||
const result = await resolveTaskExecution(task, root, 'default');
|
||||
|
||||
expect(mockResolveBaseBranch).toHaveBeenCalledWith(root, 'release/main');
|
||||
expect(mockCreateSharedClone).not.toHaveBeenCalled();
|
||||
expect(result.execCwd).toBe(worktreePath);
|
||||
expect(result.isWorktree).toBe(true);
|
||||
expect(result.baseBranch).toBe('release/main');
|
||||
|
||||
mockCreateSharedClone.mockRestore();
|
||||
mockResolveBaseBranch.mockRestore();
|
||||
});
|
||||
|
||||
it('should ignore legacy baseBranch when reusing an existing worktree path', async () => {
|
||||
const root = createTempProjectDir();
|
||||
const worktreePath = path.join(root, 'existing-worktree');
|
||||
fs.mkdirSync(worktreePath, { recursive: true });
|
||||
|
||||
const task = createTask({
|
||||
data: ({
|
||||
task: 'Run task with legacy base branch',
|
||||
worktree: true,
|
||||
branch: 'feature/base-branch',
|
||||
baseBranch: 'legacy/main',
|
||||
} as unknown) as NonNullable<TaskInfo['data']>,
|
||||
worktreePath,
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
const mockResolveBaseBranch = vi.spyOn(infraTask, 'resolveBaseBranch').mockReturnValue({
|
||||
branch: 'develop',
|
||||
});
|
||||
const mockCreateSharedClone = vi.spyOn(infraTask, 'createSharedClone').mockReturnValue({
|
||||
path: worktreePath,
|
||||
branch: 'feature/base-branch',
|
||||
});
|
||||
|
||||
const result = await resolveTaskExecution(task, root, 'default');
|
||||
|
||||
expect(mockResolveBaseBranch).toHaveBeenCalledWith(root, undefined);
|
||||
expect(mockCreateSharedClone).not.toHaveBeenCalled();
|
||||
expect(result.execCwd).toBe(worktreePath);
|
||||
expect(result.isWorktree).toBe(true);
|
||||
expect(result.baseBranch).toBe('develop');
|
||||
|
||||
mockCreateSharedClone.mockRestore();
|
||||
mockResolveBaseBranch.mockRestore();
|
||||
});
|
||||
|
||||
it('draft_pr: true が draftPr: true として解決される', async () => {
|
||||
const root = createTempProjectDir();
|
||||
const task = createTask({
|
||||
|
||||
@ -4,7 +4,8 @@ import * as path from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
|
||||
vi.mock('../infra/task/summarize.js', () => ({
|
||||
vi.mock('../infra/task/summarize.js', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
summarizeTaskName: vi.fn().mockImplementation((content: string) => {
|
||||
const slug = content.split('\n')[0]!
|
||||
.toLowerCase()
|
||||
@ -36,14 +37,23 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../infra/task/index.js', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
getCurrentBranch: vi.fn().mockReturnValue('main'),
|
||||
branchExists: vi.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
import { success, info } from '../shared/ui/index.js';
|
||||
import { confirm, promptInput } from '../shared/prompt/index.js';
|
||||
import { saveTaskFile, saveTaskFromInteractive } from '../features/tasks/add/index.js';
|
||||
import { getCurrentBranch, branchExists } from '../infra/task/index.js';
|
||||
|
||||
const mockSuccess = vi.mocked(success);
|
||||
const mockInfo = vi.mocked(info);
|
||||
const mockConfirm = vi.mocked(confirm);
|
||||
const mockPromptInput = vi.mocked(promptInput);
|
||||
const mockGetCurrentBranch = vi.mocked(getCurrentBranch);
|
||||
const mockBranchExists = vi.mocked(branchExists);
|
||||
|
||||
let testDir: string;
|
||||
|
||||
@ -57,6 +67,8 @@ beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-02-10T04:40:00.000Z'));
|
||||
testDir = fs.mkdtempSync(path.join(tmpdir(), 'takt-test-save-'));
|
||||
mockGetCurrentBranch.mockReturnValue('main');
|
||||
mockBranchExists.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@ -103,7 +115,20 @@ describe('saveTaskFile', () => {
|
||||
expect(task.task_dir).toBeTypeOf('string');
|
||||
});
|
||||
|
||||
it('draftPr: true が draft_pr: true として保存される', async () => {
|
||||
it('should persist base_branch when it is provided', async () => {
|
||||
await saveTaskFile(testDir, 'Task', {
|
||||
piece: 'review',
|
||||
issue: 42,
|
||||
worktree: true,
|
||||
branch: 'feature/bugfix',
|
||||
baseBranch: 'release/main',
|
||||
});
|
||||
|
||||
const task = loadTasks(testDir).tasks[0]!;
|
||||
expect(task.base_branch).toBe('release/main');
|
||||
});
|
||||
|
||||
it('should persist draft_pr when draftPr is true', async () => {
|
||||
await saveTaskFile(testDir, 'Draft task', {
|
||||
autoPr: true,
|
||||
draftPr: true,
|
||||
@ -209,4 +234,17 @@ describe('saveTaskFromInteractive', () => {
|
||||
expect(task.worktree).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should save base_branch when current branch is not main/master and user confirms', async () => {
|
||||
mockGetCurrentBranch.mockReturnValue('feature/custom-base');
|
||||
mockConfirm.mockResolvedValueOnce(true);
|
||||
mockPromptInput.mockResolvedValueOnce('');
|
||||
mockPromptInput.mockResolvedValueOnce('');
|
||||
mockConfirm.mockResolvedValueOnce(false);
|
||||
|
||||
await saveTaskFromInteractive(testDir, 'Task content');
|
||||
|
||||
const task = loadTasks(testDir).tasks[0]!;
|
||||
expect(task.base_branch).toBe('feature/custom-base');
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,9 +1,3 @@
|
||||
/**
|
||||
* Unit tests for task schema validation
|
||||
*
|
||||
* Tests TaskRecordSchema cross-field validation rules (status-dependent constraints).
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
TaskRecordSchema,
|
||||
@ -85,6 +79,10 @@ describe('TaskExecutionConfigSchema', () => {
|
||||
it('should reject non-integer issue number', () => {
|
||||
expect(() => TaskExecutionConfigSchema.parse({ issue: 1.5 })).toThrow();
|
||||
});
|
||||
|
||||
it('should accept base_branch when provided in config', () => {
|
||||
expect(() => TaskExecutionConfigSchema.parse({ base_branch: 'feature/base' })).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TaskFileSchema', () => {
|
||||
@ -251,4 +249,13 @@ describe('TaskRecordSchema', () => {
|
||||
expect(() => TaskRecordSchema.parse(record)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it('should accept base_branch when task record uses config-only fields', () => {
|
||||
expect(() => TaskRecordSchema.parse({
|
||||
...makePendingRecord(),
|
||||
content: undefined,
|
||||
task_dir: '.takt/tasks/feat-bugfix',
|
||||
base_branch: 'release/main',
|
||||
})).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
63
src/app/cli/routing-inputs.ts
Normal file
63
src/app/cli/routing-inputs.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { withProgress } from '../../shared/ui/index.js';
|
||||
import { formatIssueAsTask, parseIssueNumbers, formatPrReviewAsTask } from '../../infra/github/index.js';
|
||||
import { getGitProvider } from '../../infra/git/index.js';
|
||||
import type { PrReviewData } from '../../infra/git/index.js';
|
||||
import { isDirectTask } from './helpers.js';
|
||||
export async function resolveIssueInput(
|
||||
issueOption: number | undefined,
|
||||
task: string | undefined,
|
||||
): Promise<{ initialInput: string } | null> {
|
||||
if (issueOption) {
|
||||
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 () => getGitProvider().fetchIssue(issueOption),
|
||||
);
|
||||
return { initialInput: formatIssueAsTask(issue) };
|
||||
}
|
||||
|
||||
if (task && isDirectTask(task)) {
|
||||
const ghStatus = getGitProvider().checkCliStatus();
|
||||
if (!ghStatus.available) {
|
||||
throw new Error(ghStatus.error);
|
||||
}
|
||||
const tokens = task.trim().split(/\s+/);
|
||||
const issueNumbers = parseIssueNumbers(tokens);
|
||||
if (issueNumbers.length === 0) {
|
||||
throw new Error(`Invalid issue reference: ${task}`);
|
||||
}
|
||||
const issues = await withProgress(
|
||||
'Fetching GitHub Issue...',
|
||||
(fetchedIssues) => `GitHub Issues fetched: ${fetchedIssues.map((issue) => `#${issue.number}`).join(', ')}`,
|
||||
async () => issueNumbers.map((n) => getGitProvider().fetchIssue(n)),
|
||||
);
|
||||
return { initialInput: issues.map(formatIssueAsTask).join('\n\n---\n\n') };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function resolvePrInput(
|
||||
prNumber: number,
|
||||
): Promise<{ initialInput: string; prBranch: string; baseBranch?: string }> {
|
||||
const ghStatus = getGitProvider().checkCliStatus();
|
||||
if (!ghStatus.available) {
|
||||
throw new Error(ghStatus.error);
|
||||
}
|
||||
|
||||
const prReview = await withProgress(
|
||||
'Fetching PR review comments...',
|
||||
(pr: PrReviewData) => `PR fetched: #${pr.number} ${pr.title}`,
|
||||
async () => getGitProvider().fetchPrReviewComments(prNumber),
|
||||
);
|
||||
|
||||
return {
|
||||
initialInput: formatPrReviewAsTask(prReview),
|
||||
prBranch: prReview.headRefName,
|
||||
baseBranch: prReview.baseRefName,
|
||||
};
|
||||
}
|
||||
@ -1,16 +1,6 @@
|
||||
/**
|
||||
* Default action routing
|
||||
*
|
||||
* Handles the default (no subcommand) action: task execution,
|
||||
* pipeline mode, or interactive mode.
|
||||
*/
|
||||
|
||||
import { info, success, error as logError, withProgress } from '../../shared/ui/index.js';
|
||||
import { info, success, error as logError } from '../../shared/ui/index.js';
|
||||
import { getErrorMessage } from '../../shared/utils/index.js';
|
||||
import { getLabel } from '../../shared/i18n/index.js';
|
||||
import { formatIssueAsTask, parseIssueNumbers, formatPrReviewAsTask } from '../../infra/github/index.js';
|
||||
import { getGitProvider } from '../../infra/git/index.js';
|
||||
import type { PrReviewData } from '../../infra/git/index.js';
|
||||
import { checkoutBranch } from '../../infra/task/index.js';
|
||||
import { selectAndExecuteTask, determinePiece, saveTaskFromInteractive, createIssueAndSaveTask, promptLabelSelection, type SelectAndExecuteOptions } from '../../features/tasks/index.js';
|
||||
import { executePipeline } from '../../features/pipeline/index.js';
|
||||
@ -26,85 +16,9 @@ import {
|
||||
} from '../../features/interactive/index.js';
|
||||
import { getPieceDescription, resolveConfigValue, resolveConfigValues, loadPersonaSessions } from '../../infra/config/index.js';
|
||||
import { program, resolvedCwd, pipelineMode } from './program.js';
|
||||
import { resolveAgentOverrides, isDirectTask } from './helpers.js';
|
||||
import { resolveAgentOverrides } from './helpers.js';
|
||||
import { loadTaskHistory } from './taskHistory.js';
|
||||
|
||||
/**
|
||||
* Resolve issue references from CLI input.
|
||||
*
|
||||
* Handles two sources:
|
||||
* - --issue N option (numeric issue number)
|
||||
* - Positional argument containing issue references (#N or "#1 #2")
|
||||
*
|
||||
* Returns the formatted task text for interactive mode.
|
||||
* Throws on gh CLI unavailability or fetch failure.
|
||||
*/
|
||||
async function resolveIssueInput(
|
||||
issueOption: number | undefined,
|
||||
task: string | undefined,
|
||||
): Promise<{ initialInput: string } | null> {
|
||||
if (issueOption) {
|
||||
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 () => getGitProvider().fetchIssue(issueOption),
|
||||
);
|
||||
return { initialInput: formatIssueAsTask(issue) };
|
||||
}
|
||||
|
||||
if (task && isDirectTask(task)) {
|
||||
const ghStatus = getGitProvider().checkCliStatus();
|
||||
if (!ghStatus.available) {
|
||||
throw new Error(ghStatus.error);
|
||||
}
|
||||
const tokens = task.trim().split(/\s+/);
|
||||
const issueNumbers = parseIssueNumbers(tokens);
|
||||
if (issueNumbers.length === 0) {
|
||||
throw new Error(`Invalid issue reference: ${task}`);
|
||||
}
|
||||
const issues = await withProgress(
|
||||
'Fetching GitHub Issue...',
|
||||
(fetchedIssues) => `GitHub Issues fetched: ${fetchedIssues.map((issue) => `#${issue.number}`).join(', ')}`,
|
||||
async () => issueNumbers.map((n) => getGitProvider().fetchIssue(n)),
|
||||
);
|
||||
return { initialInput: issues.map(formatIssueAsTask).join('\n\n---\n\n') };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve PR review comments from `--pr` option.
|
||||
*
|
||||
* Fetches review comments and metadata, formats as task text.
|
||||
* Returns the formatted task text for interactive mode.
|
||||
* Throws on gh CLI unavailability or fetch failure.
|
||||
*/
|
||||
async function resolvePrInput(
|
||||
prNumber: number,
|
||||
): Promise<{ initialInput: string; prBranch: string }> {
|
||||
const ghStatus = getGitProvider().checkCliStatus();
|
||||
if (!ghStatus.available) {
|
||||
throw new Error(ghStatus.error);
|
||||
}
|
||||
|
||||
const prReview = await withProgress(
|
||||
'Fetching PR review comments...',
|
||||
(pr: PrReviewData) => `PR fetched: #${pr.number} ${pr.title}`,
|
||||
async () => getGitProvider().fetchPrReviewComments(prNumber),
|
||||
);
|
||||
|
||||
return { initialInput: formatPrReviewAsTask(prReview), prBranch: prReview.headRefName };
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute default action: handle task execution, pipeline mode, or interactive mode.
|
||||
* Exported for use in slash-command fallback logic.
|
||||
*/
|
||||
import { resolveIssueInput, resolvePrInput } from './routing-inputs.js';
|
||||
export async function executeDefaultAction(task?: string): Promise<void> {
|
||||
const opts = program.opts();
|
||||
if (!pipelineMode && (opts.autoPr === true || opts.draft === true)) {
|
||||
@ -135,7 +49,6 @@ export async function executeDefaultAction(task?: string): Promise<void> {
|
||||
piece: opts.piece as string | undefined,
|
||||
};
|
||||
|
||||
// --- Pipeline mode (non-interactive): triggered by --pipeline ---
|
||||
if (pipelineMode) {
|
||||
const exitCode = await executePipeline({
|
||||
issueNumber,
|
||||
@ -158,9 +71,6 @@ export async function executeDefaultAction(task?: string): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Normal (interactive) mode ---
|
||||
|
||||
// Resolve --task option to task text (direct execution, no interactive mode)
|
||||
const taskFromOption = opts.task as string | undefined;
|
||||
if (taskFromOption) {
|
||||
selectOptions.skipTaskList = true;
|
||||
@ -168,21 +78,21 @@ export async function executeDefaultAction(task?: string): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve PR review comments (--pr N) before interactive mode
|
||||
let initialInput: string | undefined = task;
|
||||
let prBranch: string | undefined;
|
||||
let prBaseBranch: string | undefined;
|
||||
|
||||
if (prNumber) {
|
||||
try {
|
||||
const prResult = await resolvePrInput(prNumber);
|
||||
initialInput = prResult.initialInput;
|
||||
prBranch = prResult.prBranch;
|
||||
prBaseBranch = prResult.baseBranch;
|
||||
} catch (e) {
|
||||
logError(getErrorMessage(e));
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
// Resolve issue references (--issue N or #N positional arg) before interactive mode
|
||||
try {
|
||||
const issueResult = await resolveIssueInput(issueNumber, task);
|
||||
if (issueResult) {
|
||||
@ -194,7 +104,6 @@ export async function executeDefaultAction(task?: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// All paths below go through interactive mode
|
||||
const globalConfig = resolveConfigValues(resolvedCwd, ['language', 'interactivePreviewMovements', 'provider']);
|
||||
const lang = resolveLanguage(globalConfig.language);
|
||||
|
||||
@ -207,7 +116,6 @@ export async function executeDefaultAction(task?: string): Promise<void> {
|
||||
const previewCount = globalConfig.interactivePreviewMovements;
|
||||
const pieceDesc = getPieceDescription(pieceId, resolvedCwd, previewCount);
|
||||
|
||||
// Mode selection after piece selection
|
||||
const selectedMode = await selectInteractiveMode(lang, pieceDesc.interactiveMode);
|
||||
if (selectedMode === null) {
|
||||
info(getLabel('interactive.ui.cancelled', lang));
|
||||
@ -283,7 +191,12 @@ export async function executeDefaultAction(task?: string): Promise<void> {
|
||||
},
|
||||
save_task: async ({ task: confirmedTask }) => {
|
||||
const presetSettings = prBranch
|
||||
? { worktree: true as const, branch: prBranch, autoPr: false }
|
||||
? {
|
||||
worktree: true as const,
|
||||
branch: prBranch,
|
||||
autoPr: false,
|
||||
...(prBaseBranch ? { baseBranch: prBaseBranch } : {}),
|
||||
}
|
||||
: undefined;
|
||||
await saveTaskFromInteractive(resolvedCwd, confirmedTask, pieceId, { presetSettings });
|
||||
},
|
||||
|
||||
@ -1,16 +1,3 @@
|
||||
/**
|
||||
* Pipeline orchestration
|
||||
*
|
||||
* Thin orchestrator that coordinates pipeline steps:
|
||||
* 1. Resolve task content
|
||||
* 2. Prepare execution environment
|
||||
* 3. Run piece
|
||||
* 4. Commit & push
|
||||
* 5. Create PR
|
||||
*
|
||||
* Each step is implemented in steps.ts.
|
||||
*/
|
||||
|
||||
import { resolveConfigValues } from '../../infra/config/index.js';
|
||||
import { info, error, status, blankLine } from '../../shared/ui/index.js';
|
||||
import { createLogger, getErrorMessage, getSlackWebhookUrl, sendSlackNotification, buildSlackRunSummary } from '../../shared/utils/index.js';
|
||||
@ -37,8 +24,6 @@ export type { PipelineExecutionOptions };
|
||||
|
||||
const log = createLogger('pipeline');
|
||||
|
||||
// ---- Pipeline orchestration ----
|
||||
|
||||
interface PipelineOutcome {
|
||||
exitCode: number;
|
||||
result: PipelineResult;
|
||||
@ -52,25 +37,28 @@ async function runPipeline(options: PipelineExecutionOptions): Promise<PipelineO
|
||||
success: false, piece, issueNumber: options.issueNumber, ...overrides,
|
||||
});
|
||||
|
||||
// Step 1: Resolve task content
|
||||
const taskContent = resolveTaskContent(options);
|
||||
if (!taskContent) return { exitCode: EXIT_ISSUE_FETCH_FAILED, result: buildResult() };
|
||||
|
||||
// Step 2: Prepare execution environment
|
||||
let context: ExecutionContext;
|
||||
try {
|
||||
context = await resolveExecutionContext(cwd, taskContent.task, options, pipelineConfig, taskContent.prBranch);
|
||||
context = await resolveExecutionContext(
|
||||
cwd,
|
||||
taskContent.task,
|
||||
options,
|
||||
pipelineConfig,
|
||||
taskContent.prBranch,
|
||||
taskContent.prBaseBranch,
|
||||
);
|
||||
} catch (err) {
|
||||
error(`Failed to prepare execution environment: ${getErrorMessage(err)}`);
|
||||
return { exitCode: EXIT_GIT_OPERATION_FAILED, result: buildResult() };
|
||||
}
|
||||
|
||||
// Step 3: Run piece
|
||||
log.info('Pipeline piece execution starting', { piece, branch: context.branch, skipGit, issueNumber: options.issueNumber });
|
||||
const pieceOk = await runPiece(cwd, piece, taskContent.task, context.execCwd, options);
|
||||
if (!pieceOk) return { exitCode: EXIT_PIECE_FAILED, result: buildResult({ branch: context.branch }) };
|
||||
|
||||
// Step 4: Commit & push
|
||||
if (!skipGit && context.branch) {
|
||||
const commitMessage = buildCommitMessage(pipelineConfig, taskContent.issue, options.task);
|
||||
if (!commitAndPush(context.execCwd, cwd, context.branch, commitMessage, context.isWorktree)) {
|
||||
@ -78,7 +66,6 @@ async function runPipeline(options: PipelineExecutionOptions): Promise<PipelineO
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Create PR
|
||||
let prUrl: string | undefined;
|
||||
if (autoPr && !skipGit && context.branch) {
|
||||
prUrl = submitPullRequest(cwd, context.branch, context.baseBranch, taskContent, piece, pipelineConfig, options);
|
||||
@ -87,7 +74,6 @@ async function runPipeline(options: PipelineExecutionOptions): Promise<PipelineO
|
||||
info('--auto-pr is ignored when --skip-git is specified (no push was performed)');
|
||||
}
|
||||
|
||||
// Summary
|
||||
blankLine();
|
||||
status('Issue', taskContent.issue ? `#${taskContent.issue.number} "${taskContent.issue.title}"` : 'N/A');
|
||||
status('Branch', context.branch ?? '(current)');
|
||||
@ -96,14 +82,6 @@ async function runPipeline(options: PipelineExecutionOptions): Promise<PipelineO
|
||||
|
||||
return { exitCode: 0, result: buildResult({ success: true, branch: context.branch, prUrl }) };
|
||||
}
|
||||
|
||||
// ---- Public API ----
|
||||
|
||||
/**
|
||||
* Execute the full pipeline.
|
||||
*
|
||||
* Returns a process exit code (0 on success, 2-5 on specific failures).
|
||||
*/
|
||||
export async function executePipeline(options: PipelineExecutionOptions): Promise<number> {
|
||||
const startTime = Date.now();
|
||||
const runId = generateRunId();
|
||||
@ -118,8 +96,6 @@ export async function executePipeline(options: PipelineExecutionOptions): Promis
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Slack notification ----
|
||||
|
||||
interface PipelineResult {
|
||||
success: boolean;
|
||||
piece: string;
|
||||
@ -128,7 +104,6 @@ interface PipelineResult {
|
||||
prUrl?: string;
|
||||
}
|
||||
|
||||
/** Send Slack notification if webhook is configured. Never throws. */
|
||||
async function notifySlack(runId: string, startTime: number, result: PipelineResult): Promise<void> {
|
||||
const webhookUrl = getSlackWebhookUrl();
|
||||
if (!webhookUrl) return;
|
||||
|
||||
@ -1,10 +1,3 @@
|
||||
/**
|
||||
* Pipeline step implementations
|
||||
*
|
||||
* Each function encapsulates one step of the pipeline,
|
||||
* keeping the orchestrator at a consistent abstraction level.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { formatIssueAsTask, buildPrBody, formatPrReviewAsTask } from '../../infra/github/index.js';
|
||||
import { getGitProvider, type Issue } from '../../infra/git/index.js';
|
||||
@ -14,23 +7,42 @@ import { info, error, success } from '../../shared/ui/index.js';
|
||||
import { getErrorMessage } from '../../shared/utils/index.js';
|
||||
import type { PipelineConfig } from '../../core/models/index.js';
|
||||
|
||||
// ---- Types ----
|
||||
|
||||
export interface TaskContent {
|
||||
task: string;
|
||||
issue?: Issue;
|
||||
/** PR head branch name (set when using --pr) */
|
||||
prBranch?: string;
|
||||
prBaseBranch?: string;
|
||||
}
|
||||
|
||||
export interface ExecutionContext {
|
||||
export interface GitExecutionContext {
|
||||
execCwd: string;
|
||||
branch?: string;
|
||||
baseBranch?: string;
|
||||
isWorktree: boolean;
|
||||
branch: string;
|
||||
baseBranch: string;
|
||||
}
|
||||
|
||||
// ---- Template helpers ----
|
||||
export interface SkipGitExecutionContext {
|
||||
execCwd: string;
|
||||
isWorktree: false;
|
||||
branch: undefined;
|
||||
baseBranch: undefined;
|
||||
}
|
||||
|
||||
export type ExecutionContext = GitExecutionContext | SkipGitExecutionContext;
|
||||
|
||||
function requireBaseBranch(baseBranch: string | undefined, context: string): string {
|
||||
if (!baseBranch) {
|
||||
throw new Error(`Base branch is required (${context})`);
|
||||
}
|
||||
return baseBranch;
|
||||
}
|
||||
|
||||
function requireBranch(branch: string | undefined, context: string): string {
|
||||
if (!branch) {
|
||||
throw new Error(`Branch is required (${context})`);
|
||||
}
|
||||
return branch;
|
||||
}
|
||||
|
||||
function expandTemplate(template: string, vars: Record<string, string>): string {
|
||||
return template.replace(/\{(\w+)\}/g, (match, key: string) => vars[key] ?? match);
|
||||
@ -61,6 +73,11 @@ export function buildCommitMessage(
|
||||
: `takt: ${taskText ?? 'pipeline task'}`;
|
||||
}
|
||||
|
||||
function resolveExecutionBaseBranch(cwd: string, preferredBaseBranch?: string): string {
|
||||
const { branch } = resolveBaseBranch(cwd, preferredBaseBranch);
|
||||
return requireBaseBranch(branch, 'execution context');
|
||||
}
|
||||
|
||||
function buildPipelinePrBody(
|
||||
pipelineConfig: PipelineConfig | undefined,
|
||||
issue: Issue | undefined,
|
||||
@ -78,9 +95,6 @@ function buildPipelinePrBody(
|
||||
return buildPrBody(issue ? [issue] : undefined, report);
|
||||
}
|
||||
|
||||
// ---- Step 1: Resolve task content ----
|
||||
|
||||
/** Fetch a GitHub resource with CLI availability check and error handling. */
|
||||
function fetchGitHubResource<T>(
|
||||
label: string,
|
||||
fetch: (provider: ReturnType<typeof getGitProvider>) => T,
|
||||
@ -109,7 +123,11 @@ export function resolveTaskContent(options: PipelineExecutionOptions): TaskConte
|
||||
if (!prReview) return undefined;
|
||||
const task = formatPrReviewAsTask(prReview);
|
||||
success(`PR #${options.prNumber} fetched: "${prReview.title}"`);
|
||||
return { task, prBranch: prReview.headRefName };
|
||||
return {
|
||||
task,
|
||||
prBranch: prReview.headRefName,
|
||||
prBaseBranch: prReview.baseRefName,
|
||||
};
|
||||
}
|
||||
if (options.issueNumber) {
|
||||
info(`Fetching issue #${options.issueNumber}...`);
|
||||
@ -129,41 +147,56 @@ export function resolveTaskContent(options: PipelineExecutionOptions): TaskConte
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ---- Step 2: Resolve execution context ----
|
||||
|
||||
export async function resolveExecutionContext(
|
||||
cwd: string,
|
||||
task: string,
|
||||
options: Pick<PipelineExecutionOptions, 'createWorktree' | 'skipGit' | 'branch' | 'issueNumber'>,
|
||||
pipelineConfig: PipelineConfig | undefined,
|
||||
prBranch?: string,
|
||||
prBaseBranch?: string,
|
||||
): Promise<ExecutionContext> {
|
||||
if (options.createWorktree) {
|
||||
const result = await confirmAndCreateWorktree(cwd, task, options.createWorktree, prBranch);
|
||||
const result = await confirmAndCreateWorktree(cwd, task, options.createWorktree, prBranch, prBaseBranch);
|
||||
const branch = requireBranch(result.branch, 'worktree execution');
|
||||
const baseBranch = requireBaseBranch(result.baseBranch, 'worktree execution');
|
||||
if (result.isWorktree) {
|
||||
success(`Worktree created: ${result.execCwd}`);
|
||||
}
|
||||
return { execCwd: result.execCwd, branch: result.branch, baseBranch: result.baseBranch, isWorktree: result.isWorktree };
|
||||
return {
|
||||
execCwd: result.execCwd,
|
||||
branch,
|
||||
baseBranch,
|
||||
isWorktree: result.isWorktree,
|
||||
};
|
||||
}
|
||||
if (options.skipGit) {
|
||||
return { execCwd: cwd, isWorktree: false };
|
||||
return {
|
||||
execCwd: cwd,
|
||||
isWorktree: false,
|
||||
branch: undefined,
|
||||
baseBranch: undefined,
|
||||
};
|
||||
}
|
||||
if (prBranch) {
|
||||
info(`Fetching and checking out PR branch: ${prBranch}`);
|
||||
checkoutBranch(cwd, prBranch);
|
||||
success(`Checked out PR branch: ${prBranch}`);
|
||||
return { execCwd: cwd, branch: prBranch, baseBranch: resolveBaseBranch(cwd).branch, isWorktree: false };
|
||||
const baseBranch = resolveExecutionBaseBranch(cwd, prBaseBranch);
|
||||
return {
|
||||
execCwd: cwd,
|
||||
branch: prBranch,
|
||||
baseBranch,
|
||||
isWorktree: false,
|
||||
};
|
||||
}
|
||||
const resolved = resolveBaseBranch(cwd);
|
||||
const baseBranch = resolveExecutionBaseBranch(cwd);
|
||||
const branch = options.branch ?? generatePipelineBranchName(pipelineConfig, options.issueNumber);
|
||||
info(`Creating branch: ${branch}`);
|
||||
execFileSync('git', ['checkout', '-b', branch], { cwd, stdio: 'pipe' });
|
||||
success(`Branch created: ${branch}`);
|
||||
return { execCwd: cwd, branch, baseBranch: resolved.branch, isWorktree: false };
|
||||
return { execCwd: cwd, branch, baseBranch, isWorktree: false };
|
||||
}
|
||||
|
||||
// ---- Step 3: Run piece ----
|
||||
|
||||
export async function runPiece(
|
||||
projectCwd: string,
|
||||
piece: string,
|
||||
@ -192,8 +225,6 @@ export async function runPiece(
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- Step 4: Commit & push ----
|
||||
|
||||
export function commitAndPush(
|
||||
execCwd: string,
|
||||
projectCwd: string,
|
||||
@ -211,7 +242,6 @@ export function commitAndPush(
|
||||
}
|
||||
|
||||
if (isWorktree) {
|
||||
// Clone has no origin — push to main project via path, then project pushes to origin
|
||||
execFileSync('git', ['push', projectCwd, 'HEAD'], { cwd: execCwd, stdio: 'pipe' });
|
||||
}
|
||||
|
||||
@ -225,18 +255,17 @@ export function commitAndPush(
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Step 5: Submit pull request ----
|
||||
|
||||
export function submitPullRequest(
|
||||
projectCwd: string,
|
||||
branch: string,
|
||||
baseBranch: string | undefined,
|
||||
baseBranch: string,
|
||||
taskContent: TaskContent,
|
||||
piece: string,
|
||||
pipelineConfig: PipelineConfig | undefined,
|
||||
options: Pick<PipelineExecutionOptions, 'task' | 'repo' | 'draftPr'>,
|
||||
): string | undefined {
|
||||
info('Creating pull request...');
|
||||
const resolvedBaseBranch = requireBaseBranch(baseBranch, 'pull request creation');
|
||||
const prTitle = taskContent.issue ? `[#${taskContent.issue.number}] ${taskContent.issue.title}` : (options.task ?? 'Pipeline task');
|
||||
const report = `Piece \`${piece}\` completed successfully.`;
|
||||
const prBody = buildPipelinePrBody(pipelineConfig, taskContent.issue, report);
|
||||
@ -245,7 +274,7 @@ export function submitPullRequest(
|
||||
branch,
|
||||
title: prTitle,
|
||||
body: prBody,
|
||||
base: baseBranch,
|
||||
base: resolvedBaseBranch,
|
||||
repo: options.repo,
|
||||
draft: options.draftPr,
|
||||
});
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs';
|
||||
import { promptInput, confirm, selectOption } from '../../../shared/prompt/index.js';
|
||||
import { success, info, error, withProgress } from '../../../shared/ui/index.js';
|
||||
import { info, error, withProgress } from '../../../shared/ui/index.js';
|
||||
import { getLabel } from '../../../shared/i18n/index.js';
|
||||
import type { Language } from '../../../core/models/types.js';
|
||||
import { TaskRunner, type TaskFileData, summarizeTaskName } from '../../../infra/task/index.js';
|
||||
@ -17,6 +17,7 @@ import { isIssueReference, resolveIssueTask, parseIssueNumbers, formatPrReviewAs
|
||||
import { getGitProvider, type PrReviewData } from '../../../infra/git/index.js';
|
||||
import { firstLine } from '../../../infra/task/naming.js';
|
||||
import { extractTitle, createIssueFromTask } from './issueTask.js';
|
||||
import { displayTaskCreationResult, promptWorktreeSettings, type WorktreeSettings } from './worktree-settings.js';
|
||||
export { extractTitle, createIssueFromTask };
|
||||
|
||||
const log = createLogger('add-task');
|
||||
@ -42,7 +43,15 @@ function resolveUniqueTaskSlug(cwd: string, baseSlug: string): string {
|
||||
export async function saveTaskFile(
|
||||
cwd: string,
|
||||
taskContent: string,
|
||||
options?: { piece?: string; issue?: number; worktree?: boolean | string; branch?: string; autoPr?: boolean; draftPr?: boolean },
|
||||
options?: {
|
||||
piece?: string;
|
||||
issue?: number;
|
||||
worktree?: boolean | string;
|
||||
branch?: string;
|
||||
baseBranch?: string;
|
||||
autoPr?: boolean;
|
||||
draftPr?: boolean;
|
||||
},
|
||||
): Promise<{ taskName: string; tasksFile: string }> {
|
||||
const runner = new TaskRunner(cwd);
|
||||
const slug = await summarizeTaskName(taskContent, { cwd });
|
||||
@ -56,6 +65,7 @@ export async function saveTaskFile(
|
||||
const config: Omit<TaskFileData, 'task'> = {
|
||||
...(options?.worktree !== undefined && { worktree: options.worktree }),
|
||||
...(options?.branch && { branch: options.branch }),
|
||||
...(options?.baseBranch && { base_branch: options.baseBranch }),
|
||||
...(options?.piece && { piece: options.piece }),
|
||||
...(options?.issue !== undefined && { issue: options.issue }),
|
||||
...(options?.autoPr !== undefined && { auto_pr: options.autoPr }),
|
||||
@ -72,34 +82,6 @@ export async function saveTaskFile(
|
||||
return { taskName: created.name, tasksFile };
|
||||
}
|
||||
|
||||
interface WorktreeSettings {
|
||||
worktree?: boolean | string;
|
||||
branch?: string;
|
||||
autoPr?: boolean;
|
||||
draftPr?: boolean;
|
||||
}
|
||||
|
||||
function displayTaskCreationResult(
|
||||
created: { taskName: string; tasksFile: string },
|
||||
settings: WorktreeSettings,
|
||||
piece?: string,
|
||||
): void {
|
||||
success(`Task created: ${created.taskName}`);
|
||||
info(` File: ${created.tasksFile}`);
|
||||
if (settings.worktree) {
|
||||
info(` Worktree: ${typeof settings.worktree === 'string' ? settings.worktree : 'auto'}`);
|
||||
}
|
||||
if (settings.branch) {
|
||||
info(` Branch: ${settings.branch}`);
|
||||
}
|
||||
if (settings.autoPr) {
|
||||
info(` Auto-PR: yes`);
|
||||
}
|
||||
if (settings.draftPr) {
|
||||
info(` Draft PR: yes`);
|
||||
}
|
||||
if (piece) info(` Piece: ${piece}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt user to select a label for the GitHub Issue.
|
||||
@ -126,18 +108,6 @@ export async function promptLabelSelection(lang: Language): Promise<string[]> {
|
||||
return [selected];
|
||||
}
|
||||
|
||||
async function promptWorktreeSettings(): Promise<WorktreeSettings> {
|
||||
const customPath = await promptInput('Worktree path (Enter for auto)');
|
||||
const worktree: boolean | string = customPath || true;
|
||||
|
||||
const customBranch = await promptInput('Branch name (Enter for auto)');
|
||||
const branch = customBranch || undefined;
|
||||
|
||||
const autoPr = await confirm('Auto-create PR?', true);
|
||||
const draftPr = autoPr ? await confirm('Create as draft?', true) : false;
|
||||
|
||||
return { worktree, branch, autoPr, draftPr };
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a task from interactive mode result.
|
||||
@ -156,7 +126,7 @@ export async function saveTaskFromInteractive(
|
||||
return;
|
||||
}
|
||||
}
|
||||
const settings = options?.presetSettings ?? await promptWorktreeSettings();
|
||||
const settings = options?.presetSettings ?? await promptWorktreeSettings(cwd);
|
||||
const created = await saveTaskFile(cwd, task, { piece, issue: options?.issue, ...settings });
|
||||
displayTaskCreationResult(created, settings, piece);
|
||||
}
|
||||
@ -231,6 +201,7 @@ export async function addTask(
|
||||
const settings = {
|
||||
worktree: true,
|
||||
branch: prReview.headRefName,
|
||||
baseBranch: prReview.baseRefName,
|
||||
autoPr: false,
|
||||
};
|
||||
const created = await saveTaskFile(cwd, taskContent, { piece, ...settings });
|
||||
@ -274,7 +245,7 @@ export async function addTask(
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = await promptWorktreeSettings();
|
||||
const settings = await promptWorktreeSettings(cwd);
|
||||
|
||||
const created = await saveTaskFile(cwd, taskContent, {
|
||||
piece,
|
||||
|
||||
83
src/features/tasks/add/worktree-settings.ts
Normal file
83
src/features/tasks/add/worktree-settings.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import { confirm, promptInput } from '../../../shared/prompt/index.js';
|
||||
import { info, success, error } from '../../../shared/ui/index.js';
|
||||
import { getErrorMessage } from '../../../shared/utils/index.js';
|
||||
import { getCurrentBranch, branchExists } from '../../../infra/task/index.js';
|
||||
|
||||
export interface WorktreeSettings {
|
||||
worktree?: boolean | string;
|
||||
branch?: string;
|
||||
baseBranch?: string;
|
||||
autoPr?: boolean;
|
||||
draftPr?: boolean;
|
||||
}
|
||||
|
||||
export function displayTaskCreationResult(
|
||||
created: { taskName: string; tasksFile: string },
|
||||
settings: WorktreeSettings,
|
||||
piece?: string,
|
||||
): void {
|
||||
success(`Task created: ${created.taskName}`);
|
||||
info(` File: ${created.tasksFile}`);
|
||||
if (settings.worktree) {
|
||||
info(` Worktree: ${typeof settings.worktree === 'string' ? settings.worktree : 'auto'}`);
|
||||
}
|
||||
if (settings.branch) {
|
||||
info(` Branch: ${settings.branch}`);
|
||||
}
|
||||
if (settings.baseBranch) {
|
||||
info(` Base branch: ${settings.baseBranch}`);
|
||||
}
|
||||
if (settings.autoPr) {
|
||||
info(` Auto-PR: yes`);
|
||||
}
|
||||
if (settings.draftPr) {
|
||||
info(` Draft PR: yes`);
|
||||
}
|
||||
if (piece) info(` Piece: ${piece}`);
|
||||
}
|
||||
|
||||
export async function promptWorktreeSettings(cwd: string): Promise<WorktreeSettings> {
|
||||
let currentBranch: string | undefined;
|
||||
try {
|
||||
currentBranch = getCurrentBranch(cwd);
|
||||
} catch (err) {
|
||||
error(`Failed to detect current branch: ${getErrorMessage(err)}`);
|
||||
}
|
||||
let baseBranch: string | undefined;
|
||||
|
||||
if (currentBranch && currentBranch !== 'main' && currentBranch !== 'master') {
|
||||
const useCurrentAsBase = await confirm(
|
||||
`現在のブランチ: ${currentBranch}\nBase branch として ${currentBranch} を使いますか?`,
|
||||
true,
|
||||
);
|
||||
if (useCurrentAsBase) {
|
||||
baseBranch = await resolveExistingBaseBranch(cwd, currentBranch);
|
||||
}
|
||||
}
|
||||
|
||||
const customPath = await promptInput('Worktree path (Enter for auto)');
|
||||
const worktree: boolean | string = customPath || true;
|
||||
|
||||
const customBranch = await promptInput('Branch name (Enter for auto)');
|
||||
const branch = customBranch || undefined;
|
||||
|
||||
const autoPr = await confirm('Auto-create PR?', true);
|
||||
const draftPr = autoPr ? await confirm('Create as draft?', true) : false;
|
||||
|
||||
return { worktree, branch, baseBranch, autoPr, draftPr };
|
||||
}
|
||||
|
||||
async function resolveExistingBaseBranch(cwd: string, initialBranch: string): Promise<string | undefined> {
|
||||
let candidate: string | undefined = initialBranch;
|
||||
|
||||
while (candidate) {
|
||||
if (branchExists(cwd, candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
error(`Base branch does not exist: ${candidate}`);
|
||||
const nextInput = await promptInput('Base branch (Enter for default)');
|
||||
candidate = nextInput ?? undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@ -1,11 +1,7 @@
|
||||
/**
|
||||
* Resolve execution directory and piece from task data.
|
||||
*/
|
||||
|
||||
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 { type TaskInfo, buildTaskInstruction, createSharedClone, resolveBaseBranch, summarizeTaskName } from '../../../infra/task/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';
|
||||
@ -13,6 +9,15 @@ import { getTaskSlugFromTaskDir } from '../../../shared/utils/taskPaths.js';
|
||||
|
||||
const log = createLogger('task');
|
||||
|
||||
function resolveTaskDataBaseBranch(taskData: TaskInfo['data']): string | undefined {
|
||||
return taskData?.base_branch;
|
||||
}
|
||||
|
||||
function resolveTaskBaseBranch(projectDir: string, taskData: TaskInfo['data']): string {
|
||||
const preferredBaseBranch = resolveTaskDataBaseBranch(taskData);
|
||||
return resolveBaseBranch(projectDir, preferredBaseBranch).branch;
|
||||
}
|
||||
|
||||
export interface ResolvedTaskExecution {
|
||||
execCwd: string;
|
||||
execPiece: string;
|
||||
@ -31,17 +36,6 @@ export interface ResolvedTaskExecution {
|
||||
initialIterationOverride?: number;
|
||||
}
|
||||
|
||||
function buildRunTaskDirInstruction(reportDirName: string): string {
|
||||
const runTaskDir = `.takt/runs/${reportDirName}/context/task`;
|
||||
const orderFile = `${runTaskDir}/order.md`;
|
||||
return [
|
||||
`Implement using only the files in \`${runTaskDir}\`.`,
|
||||
`Primary spec: \`${orderFile}\`.`,
|
||||
'Use report files in Report Directory as primary execution history.',
|
||||
'Do not rely on previous response or conversation summary.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function stageTaskSpecForExecution(
|
||||
projectCwd: string,
|
||||
execCwd: string,
|
||||
@ -58,7 +52,9 @@ function stageTaskSpecForExecution(
|
||||
fs.mkdirSync(targetTaskDir, { recursive: true });
|
||||
fs.copyFileSync(sourceOrderPath, targetOrderPath);
|
||||
|
||||
return buildRunTaskDirInstruction(reportDirName);
|
||||
const runTaskDir = `.takt/runs/${reportDirName}/context/task`;
|
||||
const orderFile = `${runTaskDir}/order.md`;
|
||||
return buildTaskInstruction(runTaskDir, orderFile);
|
||||
}
|
||||
|
||||
function throwIfAborted(signal?: AbortSignal): void {
|
||||
@ -67,10 +63,6 @@ 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): Issue[] | undefined {
|
||||
if (issueNumber === undefined) {
|
||||
return undefined;
|
||||
@ -92,11 +84,6 @@ export function resolveTaskIssue(issueNumber: number | undefined): Issue[] | und
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve execution directory and piece from task data.
|
||||
* If the task has worktree settings, create a shared clone and use it as cwd.
|
||||
* Task name is summarized to English by AI for use in branch/clone names.
|
||||
*/
|
||||
export async function resolveTaskExecution(
|
||||
task: TaskInfo,
|
||||
defaultCwd: string,
|
||||
@ -117,6 +104,7 @@ export async function resolveTaskExecution(
|
||||
let branch: string | undefined;
|
||||
let worktreePath: string | undefined;
|
||||
let baseBranch: string | undefined;
|
||||
const preferredBaseBranch = resolveTaskDataBaseBranch(data);
|
||||
if (task.taskDir) {
|
||||
const taskSlug = getTaskSlugFromTaskDir(task.taskDir);
|
||||
if (!taskSlug) {
|
||||
@ -127,10 +115,9 @@ export async function resolveTaskExecution(
|
||||
|
||||
if (data.worktree) {
|
||||
throwIfAborted(abortSignal);
|
||||
baseBranch = detectDefaultBranch(defaultCwd);
|
||||
baseBranch = resolveTaskBaseBranch(defaultCwd, data);
|
||||
|
||||
if (task.worktreePath && fs.existsSync(task.worktreePath)) {
|
||||
// Reuse existing worktree (clone still on disk from previous execution)
|
||||
execCwd = task.worktreePath;
|
||||
branch = data.branch;
|
||||
worktreePath = task.worktreePath;
|
||||
@ -149,6 +136,7 @@ export async function resolveTaskExecution(
|
||||
async () => createSharedClone(defaultCwd, {
|
||||
worktree: data.worktree!,
|
||||
branch: data.branch,
|
||||
...(preferredBaseBranch ? { baseBranch: preferredBaseBranch } : {}),
|
||||
taskSlug,
|
||||
issueNumber: data.issue,
|
||||
}),
|
||||
|
||||
@ -1,10 +1,3 @@
|
||||
/**
|
||||
* Task execution orchestration.
|
||||
*
|
||||
* Coordinates piece selection and in-place task execution.
|
||||
* Extracted from cli.ts to avoid mixing CLI parsing with business logic.
|
||||
*/
|
||||
|
||||
import {
|
||||
loadPieceByIdentifier,
|
||||
isPiecePath,
|
||||
@ -42,6 +35,7 @@ export async function confirmAndCreateWorktree(
|
||||
task: string,
|
||||
createWorktreeOverride?: boolean | undefined,
|
||||
branchOverride?: string,
|
||||
baseBranchOverride?: string,
|
||||
): Promise<WorktreeConfirmationResult> {
|
||||
const useWorktree =
|
||||
typeof createWorktreeOverride === 'boolean'
|
||||
@ -52,7 +46,7 @@ export async function confirmAndCreateWorktree(
|
||||
return { execCwd: cwd, isWorktree: false };
|
||||
}
|
||||
|
||||
const baseBranch = resolveBaseBranch(cwd).branch;
|
||||
const baseBranch = resolveBaseBranch(cwd, baseBranchOverride).branch;
|
||||
|
||||
const taskSlug = await withProgress(
|
||||
'Generating branch name...',
|
||||
@ -66,6 +60,7 @@ export async function confirmAndCreateWorktree(
|
||||
async () => createSharedClone(cwd, {
|
||||
worktree: true,
|
||||
taskSlug,
|
||||
...(baseBranchOverride ? { baseBranch: baseBranchOverride } : {}),
|
||||
...(branchOverride ? { branch: branchOverride } : {}),
|
||||
}),
|
||||
);
|
||||
@ -73,10 +68,6 @@ export async function confirmAndCreateWorktree(
|
||||
return { execCwd: result.path, isWorktree: true, branch: result.branch, baseBranch, taskSlug };
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a task with piece selection.
|
||||
* Shared by direct task execution and interactive mode.
|
||||
*/
|
||||
export async function selectAndExecuteTask(
|
||||
cwd: string,
|
||||
task: string,
|
||||
@ -90,7 +81,6 @@ export async function selectAndExecuteTask(
|
||||
return;
|
||||
}
|
||||
|
||||
// execute action always runs in-place (no worktree prompt/creation).
|
||||
const execCwd = cwd;
|
||||
log.info('Starting task execution', { piece: pieceIdentifier, worktree: false });
|
||||
const taskRunner = new TaskRunner(cwd);
|
||||
|
||||
@ -1,10 +1,3 @@
|
||||
/**
|
||||
* 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;
|
||||
@ -24,89 +17,65 @@ export interface ExistingPr {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** PR review comment (conversation or inline) */
|
||||
export interface PrReviewComment {
|
||||
author: string;
|
||||
body: string;
|
||||
/** File path for inline comments (undefined for conversation comments) */
|
||||
path?: string;
|
||||
/** Line number for inline comments */
|
||||
line?: number;
|
||||
}
|
||||
|
||||
/** PR review data including metadata and review comments */
|
||||
export interface PrReviewData {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string;
|
||||
url: string;
|
||||
/** Branch name of the PR head */
|
||||
headRefName: string;
|
||||
/** Conversation comments (non-review) */
|
||||
baseRefName?: string;
|
||||
comments: PrReviewComment[];
|
||||
/** Review comments (from reviews) */
|
||||
reviews: PrReviewComment[];
|
||||
/** Changed file paths */
|
||||
files: string[];
|
||||
}
|
||||
|
||||
export interface GitProvider {
|
||||
/** Check CLI tool availability and authentication status */
|
||||
checkCliStatus(): CliStatus;
|
||||
|
||||
fetchIssue(issueNumber: number): Issue;
|
||||
|
||||
createIssue(options: CreateIssueOptions): CreateIssueResult;
|
||||
|
||||
/** Fetch PR review comments and metadata */
|
||||
fetchPrReviewComments(prNumber: number): PrReviewData;
|
||||
|
||||
/** 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;
|
||||
|
||||
@ -53,7 +53,7 @@ export function commentOnPr(cwd: string, prNumber: number, body: string): Commen
|
||||
}
|
||||
|
||||
/** JSON fields requested from `gh pr view` for review data */
|
||||
const PR_REVIEW_JSON_FIELDS = 'number,title,body,url,headRefName,comments,reviews,files';
|
||||
const PR_REVIEW_JSON_FIELDS = 'number,title,body,url,headRefName,baseRefName,comments,reviews,files';
|
||||
|
||||
/** Raw shape returned by `gh pr view --json` for review data */
|
||||
interface GhPrViewReviewResponse {
|
||||
@ -62,6 +62,7 @@ interface GhPrViewReviewResponse {
|
||||
body: string;
|
||||
url: string;
|
||||
headRefName: string;
|
||||
baseRefName?: string;
|
||||
comments: Array<{ author: { login: string }; body: string }>;
|
||||
reviews: Array<{
|
||||
author: { login: string };
|
||||
@ -112,6 +113,7 @@ export function fetchPrReviewComments(prNumber: number): PrReviewData {
|
||||
body: data.body,
|
||||
url: data.url,
|
||||
headRefName: data.headRefName,
|
||||
baseRefName: data.baseRefName,
|
||||
comments,
|
||||
reviews,
|
||||
files: data.files.map((f) => f.path),
|
||||
|
||||
88
src/infra/task/clone-base-branch.ts
Normal file
88
src/infra/task/clone-base-branch.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createLogger } from '../../shared/utils/index.js';
|
||||
import { resolveConfigValue } from '../config/index.js';
|
||||
import { detectDefaultBranch } from './branchList.js';
|
||||
|
||||
const log = createLogger('clone');
|
||||
|
||||
function gitRefExists(projectDir: string, ref: string): boolean {
|
||||
try {
|
||||
execFileSync('git', ['check-ref-format', '--branch', '--', ref], {
|
||||
cwd: projectDir,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
execFileSync('git', ['rev-parse', '--verify', '--', ref], {
|
||||
cwd: projectDir,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function branchExists(projectDir: string, branch: string): boolean {
|
||||
return gitRefExists(projectDir, branch) || gitRefExists(projectDir, `origin/${branch}`);
|
||||
}
|
||||
|
||||
function resolveConfiguredBaseBranch(projectDir: string, explicitBaseBranch?: string): string | undefined {
|
||||
if (explicitBaseBranch !== undefined) {
|
||||
const normalized = explicitBaseBranch.trim();
|
||||
if (normalized.length === 0) {
|
||||
throw new Error('Base branch override must not be empty.');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
return resolveConfigValue(projectDir, 'baseBranch');
|
||||
}
|
||||
|
||||
function assertValidBranchRef(projectDir: string, ref: string): void {
|
||||
try {
|
||||
execFileSync('git', ['check-ref-format', '--branch', '--', ref], {
|
||||
cwd: projectDir,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
} catch {
|
||||
throw new Error(`Invalid base branch: ${ref}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveBaseBranch(
|
||||
projectDir: string,
|
||||
explicitBaseBranch?: string,
|
||||
): { branch: string; fetchedCommit?: string } {
|
||||
const configBaseBranch = resolveConfiguredBaseBranch(projectDir, explicitBaseBranch);
|
||||
const autoFetch = resolveConfigValue(projectDir, 'autoFetch');
|
||||
|
||||
const baseBranch = configBaseBranch ?? detectDefaultBranch(projectDir);
|
||||
|
||||
if (explicitBaseBranch !== undefined) {
|
||||
assertValidBranchRef(projectDir, baseBranch);
|
||||
}
|
||||
|
||||
if (explicitBaseBranch !== undefined && !branchExists(projectDir, baseBranch)) {
|
||||
throw new Error(`Base branch does not exist: ${baseBranch}`);
|
||||
}
|
||||
|
||||
if (!autoFetch) {
|
||||
return { branch: baseBranch };
|
||||
}
|
||||
|
||||
try {
|
||||
execFileSync('git', ['fetch', 'origin'], {
|
||||
cwd: projectDir,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
const fetchedCommit = execFileSync(
|
||||
'git', ['rev-parse', `origin/${baseBranch}`],
|
||||
{ cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' },
|
||||
).trim();
|
||||
|
||||
log.info('Fetched remote and resolved base branch', { baseBranch, fetchedCommit });
|
||||
return { branch: baseBranch, fetchedCommit };
|
||||
} catch (err) {
|
||||
log.info('Failed to fetch from remote, continuing with local state', { baseBranch, error: String(err) });
|
||||
return { branch: baseBranch };
|
||||
}
|
||||
}
|
||||
116
src/infra/task/clone-exec.ts
Normal file
116
src/infra/task/clone-exec.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createLogger } from '../../shared/utils/index.js';
|
||||
import { loadProjectConfig } from '../config/index.js';
|
||||
|
||||
const log = createLogger('clone');
|
||||
|
||||
export function resolveCloneSubmoduleOptions(projectDir: string): { args: string[]; label: string; targets: string } {
|
||||
const config = loadProjectConfig(projectDir);
|
||||
const resolvedSubmodules = config.submodules ?? (config.withSubmodules === true ? 'all' : undefined);
|
||||
|
||||
if (resolvedSubmodules === 'all') {
|
||||
return {
|
||||
args: ['--recurse-submodules'],
|
||||
label: 'with submodule',
|
||||
targets: 'all',
|
||||
};
|
||||
}
|
||||
|
||||
if (Array.isArray(resolvedSubmodules) && resolvedSubmodules.length > 0) {
|
||||
return {
|
||||
args: resolvedSubmodules.map((submodulePath) => `--recurse-submodules=${submodulePath}`),
|
||||
label: 'with submodule',
|
||||
targets: resolvedSubmodules.join(', '),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
args: [],
|
||||
label: 'without submodule',
|
||||
targets: 'none',
|
||||
};
|
||||
}
|
||||
|
||||
function resolveMainRepo(projectDir: string): string {
|
||||
const gitPath = path.join(projectDir, '.git');
|
||||
|
||||
try {
|
||||
const stats = fs.statSync(gitPath);
|
||||
if (stats.isFile()) {
|
||||
const content = fs.readFileSync(gitPath, 'utf-8');
|
||||
const match = content.match(/^gitdir:\s*(.+)$/m);
|
||||
if (match && match[1]) {
|
||||
const worktreePath = match[1].trim();
|
||||
const gitDir = path.resolve(worktreePath, '..', '..');
|
||||
const mainRepoPath = path.dirname(gitDir);
|
||||
log.info('Detected worktree, using main repo', { worktree: projectDir, mainRepo: mainRepoPath });
|
||||
return mainRepoPath;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.debug('Failed to resolve main repo, using projectDir as-is', { error: String(err) });
|
||||
}
|
||||
|
||||
return projectDir;
|
||||
}
|
||||
|
||||
export function cloneAndIsolate(projectDir: string, clonePath: string, branch?: string): void {
|
||||
const referenceRepo = resolveMainRepo(projectDir);
|
||||
const cloneSubmoduleOptions = resolveCloneSubmoduleOptions(projectDir);
|
||||
|
||||
fs.mkdirSync(path.dirname(clonePath), { recursive: true });
|
||||
|
||||
const branchArgs = branch ? ['--branch', branch] : [];
|
||||
const commonArgs: string[] = [
|
||||
...cloneSubmoduleOptions.args,
|
||||
...branchArgs,
|
||||
projectDir,
|
||||
clonePath,
|
||||
];
|
||||
|
||||
const referenceCloneArgs = ['clone', '--reference', referenceRepo, '--dissociate', ...commonArgs];
|
||||
const fallbackCloneArgs = ['clone', ...commonArgs];
|
||||
|
||||
try {
|
||||
execFileSync('git', referenceCloneArgs, {
|
||||
cwd: projectDir,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
} catch (err) {
|
||||
const stderr = ((err as { stderr?: Buffer }).stderr ?? Buffer.alloc(0)).toString();
|
||||
if (stderr.includes('reference repository is shallow')) {
|
||||
log.info('Reference repository is shallow, retrying clone without --reference', { referenceRepo });
|
||||
try { fs.rmSync(clonePath, { recursive: true, force: true }); } catch (e) { log.debug('Failed to cleanup partial clone before retry', { clonePath, error: String(e) }); }
|
||||
execFileSync('git', fallbackCloneArgs, {
|
||||
cwd: projectDir,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
execFileSync('git', ['remote', 'remove', 'origin'], {
|
||||
cwd: clonePath,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
for (const key of ['user.name', 'user.email']) {
|
||||
try {
|
||||
const value = execFileSync('git', ['config', '--local', key], {
|
||||
cwd: projectDir,
|
||||
stdio: 'pipe',
|
||||
}).toString().trim();
|
||||
if (value) {
|
||||
execFileSync('git', ['config', key, value], {
|
||||
cwd: clonePath,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
log.debug('Local git config not found', { key, error: String(err) });
|
||||
}
|
||||
}
|
||||
}
|
||||
45
src/infra/task/clone-meta.ts
Normal file
45
src/infra/task/clone-meta.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { createLogger } from '../../shared/utils/index.js';
|
||||
|
||||
const log = createLogger('clone');
|
||||
|
||||
const CLONE_META_DIR = 'clone-meta';
|
||||
|
||||
function encodeBranchName(branch: string): string {
|
||||
return branch.replace(/\//g, '--');
|
||||
}
|
||||
|
||||
export function getCloneMetaPath(projectDir: string, branch: string): string {
|
||||
return path.join(projectDir, '.takt', CLONE_META_DIR, `${encodeBranchName(branch)}.json`);
|
||||
}
|
||||
|
||||
export function saveCloneMeta(projectDir: string, branch: string, clonePath: string): void {
|
||||
const filePath = getCloneMetaPath(projectDir, branch);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify({ branch, clonePath }));
|
||||
log.info('Clone meta saved', { branch, clonePath });
|
||||
}
|
||||
|
||||
export function removeCloneMeta(projectDir: string, branch: string): void {
|
||||
const filePath = getCloneMetaPath(projectDir, branch);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return;
|
||||
}
|
||||
fs.unlinkSync(filePath);
|
||||
log.info('Clone meta removed', { branch });
|
||||
}
|
||||
|
||||
export function loadCloneMeta(projectDir: string, branch: string): { clonePath: string } | null {
|
||||
const filePath = getCloneMetaPath(projectDir, branch);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf-8');
|
||||
return JSON.parse(raw) as { clonePath: string };
|
||||
} catch (err) {
|
||||
log.debug('Failed to load clone meta', { branch, error: String(err) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -1,69 +1,23 @@
|
||||
/**
|
||||
* Git clone lifecycle management
|
||||
*
|
||||
* Creates, removes, and tracks git clones for task isolation.
|
||||
* Uses `git clone --reference --dissociate` so each clone has a fully
|
||||
* independent .git directory, then removes the origin remote to prevent
|
||||
* Claude Code SDK from traversing back to the main repository.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createLogger } from '../../shared/utils/index.js';
|
||||
import { loadProjectConfig, resolveConfigValue } from '../config/index.js';
|
||||
import { detectDefaultBranch } from './branchList.js';
|
||||
import { resolveConfigValue } from '../config/index.js';
|
||||
import type { WorktreeOptions, WorktreeResult } from './types.js';
|
||||
import { branchExists, resolveBaseBranch as resolveBaseBranchInternal } from './clone-base-branch.js';
|
||||
import { cloneAndIsolate, resolveCloneSubmoduleOptions } from './clone-exec.js';
|
||||
import { loadCloneMeta, removeCloneMeta as removeCloneMetaFile, saveCloneMeta as saveCloneMetaFile } from './clone-meta.js';
|
||||
|
||||
export type { WorktreeOptions, WorktreeResult };
|
||||
export { branchExists } from './clone-base-branch.js';
|
||||
|
||||
const log = createLogger('clone');
|
||||
|
||||
const CLONE_META_DIR = 'clone-meta';
|
||||
|
||||
function resolveCloneSubmoduleOptions(projectDir: string): { args: string[]; label: string; targets: string } {
|
||||
const config = loadProjectConfig(projectDir);
|
||||
const resolvedSubmodules = config.submodules ?? (config.withSubmodules === true ? 'all' : undefined);
|
||||
|
||||
if (resolvedSubmodules === 'all') {
|
||||
return {
|
||||
args: ['--recurse-submodules'],
|
||||
label: 'with submodule',
|
||||
targets: 'all',
|
||||
};
|
||||
}
|
||||
|
||||
if (Array.isArray(resolvedSubmodules) && resolvedSubmodules.length > 0) {
|
||||
return {
|
||||
args: resolvedSubmodules.map((submodulePath) => `--recurse-submodules=${submodulePath}`),
|
||||
label: 'with submodule',
|
||||
targets: resolvedSubmodules.join(', '),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
args: [],
|
||||
label: 'without submodule',
|
||||
targets: 'none',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages git clone lifecycle for task isolation.
|
||||
*
|
||||
* Handles creation, removal, and metadata tracking of clones
|
||||
* used for parallel task execution.
|
||||
*/
|
||||
export class CloneManager {
|
||||
private static generateTimestamp(): string {
|
||||
return new Date().toISOString().replace(/[-:.]/g, '').slice(0, 13);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the base directory for clones from global config.
|
||||
* Returns the configured worktree_dir (resolved to absolute), or
|
||||
* the default 'takt-worktrees' (plural).
|
||||
*/
|
||||
private static resolveCloneBaseDir(projectDir: string): string {
|
||||
const worktreeDir = resolveConfigValue(projectDir, 'worktreeDir');
|
||||
if (worktreeDir) {
|
||||
@ -74,7 +28,6 @@ export class CloneManager {
|
||||
return path.join(projectDir, '..', 'takt-worktrees');
|
||||
}
|
||||
|
||||
/** Resolve the clone path based on options and global config */
|
||||
private static resolveClonePath(projectDir: string, options: WorktreeOptions): string {
|
||||
const timestamp = CloneManager.generateTimestamp();
|
||||
const slug = options.taskSlug;
|
||||
@ -97,7 +50,6 @@ export class CloneManager {
|
||||
return path.join(CloneManager.resolveCloneBaseDir(projectDir), dirName);
|
||||
}
|
||||
|
||||
/** Resolve branch name from options */
|
||||
private static resolveBranchName(options: WorktreeOptions): string {
|
||||
if (options.branch) {
|
||||
return options.branch;
|
||||
@ -113,178 +65,16 @@ export class CloneManager {
|
||||
return slug ? `takt/${timestamp}-${slug}` : `takt/${timestamp}`;
|
||||
}
|
||||
|
||||
private static branchExists(projectDir: string, branch: string): boolean {
|
||||
// Local branch
|
||||
try {
|
||||
execFileSync('git', ['rev-parse', '--verify', branch], {
|
||||
cwd: projectDir,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
// not found locally — fall through to remote check
|
||||
}
|
||||
// Remote tracking branch
|
||||
try {
|
||||
execFileSync('git', ['rev-parse', '--verify', `origin/${branch}`], {
|
||||
cwd: projectDir,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
static resolveBaseBranch(
|
||||
projectDir: string,
|
||||
explicitBaseBranch?: string,
|
||||
): { branch: string; fetchedCommit?: string } {
|
||||
return resolveBaseBranchInternal(projectDir, explicitBaseBranch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the main repository path (handles git worktree case).
|
||||
* If projectDir is a worktree, returns the main repo path.
|
||||
* Otherwise, returns projectDir as-is.
|
||||
*/
|
||||
private static resolveMainRepo(projectDir: string): string {
|
||||
const gitPath = path.join(projectDir, '.git');
|
||||
|
||||
try {
|
||||
const stats = fs.statSync(gitPath);
|
||||
if (stats.isFile()) {
|
||||
const content = fs.readFileSync(gitPath, 'utf-8');
|
||||
const match = content.match(/^gitdir:\s*(.+)$/m);
|
||||
if (match && match[1]) {
|
||||
const worktreePath = match[1].trim();
|
||||
const gitDir = path.resolve(worktreePath, '..', '..');
|
||||
const mainRepoPath = path.dirname(gitDir);
|
||||
log.info('Detected worktree, using main repo', { worktree: projectDir, mainRepo: mainRepoPath });
|
||||
return mainRepoPath;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.debug('Failed to resolve main repo, using projectDir as-is', { error: String(err) });
|
||||
}
|
||||
|
||||
return projectDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the base branch for cloning and optionally fetch from remote.
|
||||
*
|
||||
* When `auto_fetch` config is true:
|
||||
* 1. Runs `git fetch origin` (without modifying local branches)
|
||||
* 2. Resolves base branch from config `base_branch` → remote default branch fallback
|
||||
* 3. Returns the branch name and the fetched commit hash of `origin/<baseBranch>`
|
||||
*
|
||||
* When `auto_fetch` is false (default):
|
||||
* Returns only the branch name (config `base_branch` → remote default branch fallback)
|
||||
*
|
||||
* Any failure (network, no remote, etc.) is non-fatal.
|
||||
*/
|
||||
static resolveBaseBranch(projectDir: string): { branch: string; fetchedCommit?: string } {
|
||||
const configBaseBranch = resolveConfigValue(projectDir, 'baseBranch');
|
||||
const autoFetch = resolveConfigValue(projectDir, 'autoFetch');
|
||||
|
||||
// Determine base branch: config base_branch → remote default branch
|
||||
const baseBranch = configBaseBranch ?? detectDefaultBranch(projectDir);
|
||||
|
||||
if (!autoFetch) {
|
||||
return { branch: baseBranch };
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch only — do not modify any local branch refs
|
||||
execFileSync('git', ['fetch', 'origin'], {
|
||||
cwd: projectDir,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
// Get the latest commit hash from the remote-tracking ref
|
||||
const fetchedCommit = execFileSync(
|
||||
'git', ['rev-parse', `origin/${baseBranch}`],
|
||||
{ cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' },
|
||||
).trim();
|
||||
|
||||
log.info('Fetched remote and resolved base branch', { baseBranch, fetchedCommit });
|
||||
return { branch: baseBranch, fetchedCommit };
|
||||
} catch (err) {
|
||||
// Network errors, no remote, no tracking ref — all non-fatal
|
||||
log.info('Failed to fetch from remote, continuing with local state', { baseBranch, error: String(err) });
|
||||
return { branch: baseBranch };
|
||||
}
|
||||
}
|
||||
|
||||
/** Clone a repository and remove origin to isolate from the main repo.
|
||||
* When `branch` is specified, `--branch` is passed to `git clone` so the
|
||||
* branch is checked out as a local branch *before* origin is removed.
|
||||
* Without this, non-default branches are lost when `git remote remove origin`
|
||||
* deletes the remote-tracking refs.
|
||||
*/
|
||||
private static cloneAndIsolate(projectDir: string, clonePath: string, branch?: string): void {
|
||||
const referenceRepo = CloneManager.resolveMainRepo(projectDir);
|
||||
const cloneSubmoduleOptions = resolveCloneSubmoduleOptions(projectDir);
|
||||
|
||||
fs.mkdirSync(path.dirname(clonePath), { recursive: true });
|
||||
|
||||
const commonArgs: string[] = [...cloneSubmoduleOptions.args];
|
||||
if (branch) {
|
||||
commonArgs.push('--branch', branch);
|
||||
}
|
||||
commonArgs.push(projectDir, clonePath);
|
||||
|
||||
const referenceCloneArgs = ['clone', '--reference', referenceRepo, '--dissociate', ...commonArgs];
|
||||
const fallbackCloneArgs = ['clone', ...commonArgs];
|
||||
|
||||
try {
|
||||
execFileSync('git', referenceCloneArgs, {
|
||||
cwd: projectDir,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
} catch (err) {
|
||||
const stderr = ((err as { stderr?: Buffer }).stderr ?? Buffer.alloc(0)).toString();
|
||||
if (stderr.includes('reference repository is shallow')) {
|
||||
log.info('Reference repository is shallow, retrying clone without --reference', { referenceRepo });
|
||||
try { fs.rmSync(clonePath, { recursive: true, force: true }); } catch (e) { log.debug('Failed to cleanup partial clone before retry', { clonePath, error: String(e) }); }
|
||||
execFileSync('git', fallbackCloneArgs, {
|
||||
cwd: projectDir,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
execFileSync('git', ['remote', 'remove', 'origin'], {
|
||||
cwd: clonePath,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
// Propagate local git user config from source repo to clone
|
||||
for (const key of ['user.name', 'user.email']) {
|
||||
try {
|
||||
const value = execFileSync('git', ['config', '--local', key], {
|
||||
cwd: projectDir,
|
||||
stdio: 'pipe',
|
||||
}).toString().trim();
|
||||
if (value) {
|
||||
execFileSync('git', ['config', key, value], {
|
||||
cwd: clonePath,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// not set locally — skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static encodeBranchName(branch: string): string {
|
||||
return branch.replace(/\//g, '--');
|
||||
}
|
||||
|
||||
private static getCloneMetaPath(projectDir: string, branch: string): string {
|
||||
return path.join(projectDir, '.takt', CLONE_META_DIR, `${CloneManager.encodeBranchName(branch)}.json`);
|
||||
}
|
||||
|
||||
/** Create a git clone for a task */
|
||||
createSharedClone(projectDir: string, options: WorktreeOptions): WorktreeResult {
|
||||
const { branch: baseBranch, fetchedCommit } = CloneManager.resolveBaseBranch(projectDir);
|
||||
const requestedBaseBranch = options.baseBranch;
|
||||
const { branch: baseBranch, fetchedCommit } = CloneManager.resolveBaseBranch(projectDir, requestedBaseBranch);
|
||||
|
||||
const clonePath = CloneManager.resolveClonePath(projectDir, options);
|
||||
const branch = CloneManager.resolveBranchName(options);
|
||||
@ -295,12 +85,10 @@ export class CloneManager {
|
||||
{ path: clonePath, branch }
|
||||
);
|
||||
|
||||
if (CloneManager.branchExists(projectDir, branch)) {
|
||||
CloneManager.cloneAndIsolate(projectDir, clonePath, branch);
|
||||
if (branchExists(projectDir, branch)) {
|
||||
cloneAndIsolate(projectDir, clonePath, branch);
|
||||
} else {
|
||||
// Clone from the base branch so the task starts from latest state
|
||||
CloneManager.cloneAndIsolate(projectDir, clonePath, baseBranch);
|
||||
// If we fetched a newer commit from remote, reset to it
|
||||
cloneAndIsolate(projectDir, clonePath, baseBranch);
|
||||
if (fetchedCommit) {
|
||||
execFileSync('git', ['reset', '--hard', fetchedCommit], { cwd: clonePath, stdio: 'pipe' });
|
||||
}
|
||||
@ -313,9 +101,7 @@ export class CloneManager {
|
||||
return { path: clonePath, branch };
|
||||
}
|
||||
|
||||
/** Create a temporary clone for an existing branch */
|
||||
createTempCloneForBranch(projectDir: string, branch: string): WorktreeResult {
|
||||
// fetch の副作用(リモートの最新状態への同期)のために呼び出す
|
||||
CloneManager.resolveBaseBranch(projectDir);
|
||||
|
||||
const timestamp = CloneManager.generateTimestamp();
|
||||
@ -323,7 +109,7 @@ export class CloneManager {
|
||||
|
||||
log.info('Creating temp clone for branch', { path: clonePath, branch });
|
||||
|
||||
CloneManager.cloneAndIsolate(projectDir, clonePath, branch);
|
||||
cloneAndIsolate(projectDir, clonePath, branch);
|
||||
|
||||
this.saveCloneMeta(projectDir, branch, clonePath);
|
||||
log.info('Temp clone created', { path: clonePath, branch });
|
||||
@ -331,7 +117,6 @@ export class CloneManager {
|
||||
return { path: clonePath, branch };
|
||||
}
|
||||
|
||||
/** Remove a clone directory */
|
||||
removeClone(clonePath: string): void {
|
||||
log.info('Removing clone', { path: clonePath });
|
||||
try {
|
||||
@ -342,30 +127,20 @@ export class CloneManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** Save clone metadata (branch → clonePath mapping) */
|
||||
saveCloneMeta(projectDir: string, branch: string, clonePath: string): void {
|
||||
const filePath = CloneManager.getCloneMetaPath(projectDir, branch);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify({ branch, clonePath }));
|
||||
log.info('Clone meta saved', { branch, clonePath });
|
||||
saveCloneMetaFile(projectDir, branch, clonePath);
|
||||
}
|
||||
|
||||
/** Remove clone metadata for a branch */
|
||||
removeCloneMeta(projectDir: string, branch: string): void {
|
||||
try {
|
||||
fs.unlinkSync(CloneManager.getCloneMetaPath(projectDir, branch));
|
||||
log.info('Clone meta removed', { branch });
|
||||
} catch {
|
||||
// File may not exist — ignore
|
||||
}
|
||||
removeCloneMetaFile(projectDir, branch);
|
||||
}
|
||||
|
||||
/** Clean up an orphaned clone directory associated with a branch */
|
||||
cleanupOrphanedClone(projectDir: string, branch: string): void {
|
||||
try {
|
||||
const raw = fs.readFileSync(CloneManager.getCloneMetaPath(projectDir, branch), 'utf-8');
|
||||
const meta = JSON.parse(raw) as { clonePath: string };
|
||||
// Validate clonePath is within the expected clone base directory to prevent path traversal.
|
||||
const meta = loadCloneMeta(projectDir, branch);
|
||||
if (!meta) {
|
||||
this.removeCloneMeta(projectDir, branch);
|
||||
return;
|
||||
}
|
||||
const cloneBaseDir = path.resolve(CloneManager.resolveCloneBaseDir(projectDir));
|
||||
const resolvedClonePath = path.resolve(meta.clonePath);
|
||||
if (!resolvedClonePath.startsWith(cloneBaseDir + path.sep)) {
|
||||
@ -380,15 +155,10 @@ export class CloneManager {
|
||||
this.removeClone(resolvedClonePath);
|
||||
log.info('Orphaned clone cleaned up', { branch, clonePath: resolvedClonePath });
|
||||
}
|
||||
} catch {
|
||||
// No metadata or parse error — nothing to clean up
|
||||
}
|
||||
this.removeCloneMeta(projectDir, branch);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Module-level functions ----
|
||||
|
||||
const defaultManager = new CloneManager();
|
||||
|
||||
export function createSharedClone(projectDir: string, options: WorktreeOptions): WorktreeResult {
|
||||
@ -415,6 +185,9 @@ export function cleanupOrphanedClone(projectDir: string, branch: string): void {
|
||||
defaultManager.cleanupOrphanedClone(projectDir, branch);
|
||||
}
|
||||
|
||||
export function resolveBaseBranch(projectDir: string): { branch: string; fetchedCommit?: string } {
|
||||
return CloneManager.resolveBaseBranch(projectDir);
|
||||
export function resolveBaseBranch(
|
||||
projectDir: string,
|
||||
explicitBaseBranch?: string,
|
||||
): { branch: string; fetchedCommit?: string } {
|
||||
return CloneManager.resolveBaseBranch(projectDir, explicitBaseBranch);
|
||||
}
|
||||
|
||||
@ -45,6 +45,7 @@ export {
|
||||
removeCloneMeta,
|
||||
cleanupOrphanedClone,
|
||||
resolveBaseBranch,
|
||||
branchExists,
|
||||
} from './clone.js';
|
||||
export {
|
||||
detectDefaultBranch,
|
||||
@ -56,6 +57,7 @@ export {
|
||||
buildListItems,
|
||||
} from './branchList.js';
|
||||
export { stageAndCommit, getCurrentBranch, pushBranch, checkoutBranch } from './git.js';
|
||||
export { buildTaskInstruction } from './instruction.js';
|
||||
export { autoCommitAndPush, type AutoCommitResult } from './autoCommit.js';
|
||||
export { summarizeTaskName } from './summarize.js';
|
||||
export { TaskWatcher, type TaskWatcherOptions } from './watcher.js';
|
||||
|
||||
8
src/infra/task/instruction.ts
Normal file
8
src/infra/task/instruction.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export function buildTaskInstruction(taskDir: string, orderFile: string): string {
|
||||
return [
|
||||
`Implement using only the files in \`${taskDir}\`.`,
|
||||
`Primary spec: \`${orderFile}\`.`,
|
||||
'Use report files in Report Directory as primary execution history.',
|
||||
'Do not rely on previous response or conversation summary.',
|
||||
].join('\n');
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { TaskFileSchema, type TaskFileData, type TaskRecord } from './schema.js';
|
||||
import { buildTaskInstruction } from './instruction.js';
|
||||
import { firstLine } from './naming.js';
|
||||
import type { TaskInfo, TaskListItem } from './types.js';
|
||||
|
||||
@ -12,17 +13,6 @@ function toDisplayPath(projectDir: string, targetPath: string): string {
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
function buildTaskDirInstruction(projectDir: string, taskDirPath: string, orderFilePath: string): string {
|
||||
const displayTaskDir = toDisplayPath(projectDir, taskDirPath);
|
||||
const displayOrderFile = toDisplayPath(projectDir, orderFilePath);
|
||||
return [
|
||||
`Implement using only the files in \`${displayTaskDir}\`.`,
|
||||
`Primary spec: \`${displayOrderFile}\`.`,
|
||||
'Use report files in Report Directory as primary execution history.',
|
||||
'Do not rely on previous response or conversation summary.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function resolveTaskContent(projectDir: string, task: TaskRecord): string {
|
||||
if (task.content) {
|
||||
return task.content;
|
||||
@ -33,7 +23,10 @@ export function resolveTaskContent(projectDir: string, task: TaskRecord): string
|
||||
if (!fs.existsSync(orderFilePath)) {
|
||||
throw new Error(`Task spec file is missing: ${orderFilePath}`);
|
||||
}
|
||||
return buildTaskDirInstruction(projectDir, taskDirPath, orderFilePath);
|
||||
return buildTaskInstruction(
|
||||
toDisplayPath(projectDir, taskDirPath),
|
||||
toDisplayPath(projectDir, orderFilePath),
|
||||
);
|
||||
}
|
||||
if (!task.content_file) {
|
||||
throw new Error(`Task content is missing: ${task.name}`);
|
||||
@ -50,6 +43,7 @@ function buildTaskFileData(task: TaskRecord, content: string): TaskFileData {
|
||||
task: content,
|
||||
worktree: task.worktree,
|
||||
branch: task.branch,
|
||||
base_branch: task.base_branch,
|
||||
piece: task.piece,
|
||||
issue: task.issue,
|
||||
start_movement: task.start_movement,
|
||||
|
||||
@ -12,6 +12,7 @@ import { isValidTaskDir } from '../../shared/utils/taskPaths.js';
|
||||
export const TaskExecutionConfigSchema = z.object({
|
||||
worktree: z.union([z.boolean(), z.string()]).optional(),
|
||||
branch: z.string().optional(),
|
||||
base_branch: z.string().optional(),
|
||||
piece: z.string().optional(),
|
||||
issue: z.number().int().positive().optional(),
|
||||
start_movement: z.string().optional(),
|
||||
|
||||
@ -1,11 +1,6 @@
|
||||
/**
|
||||
* Task module type definitions
|
||||
*/
|
||||
|
||||
import type { TaskFileData } from './schema.js';
|
||||
import type { TaskFailure, TaskStatus } from './schema.js';
|
||||
|
||||
/** タスク情報 */
|
||||
export interface TaskInfo {
|
||||
filePath: string;
|
||||
name: string;
|
||||
@ -18,7 +13,6 @@ export interface TaskInfo {
|
||||
data: TaskFileData | null;
|
||||
}
|
||||
|
||||
/** タスク実行結果 */
|
||||
export interface TaskResult {
|
||||
task: TaskInfo;
|
||||
success: boolean;
|
||||
@ -34,49 +28,37 @@ export interface TaskResult {
|
||||
}
|
||||
|
||||
export interface WorktreeOptions {
|
||||
/** worktree setting: true = auto path, string = custom path */
|
||||
worktree: boolean | string;
|
||||
/** Branch name (optional, auto-generated if omitted) */
|
||||
branch?: string;
|
||||
/** Task slug for auto-generated paths/branches */
|
||||
baseBranch?: string;
|
||||
taskSlug: string;
|
||||
/** GitHub Issue number (optional, for formatting branch/path) */
|
||||
issueNumber?: number;
|
||||
}
|
||||
|
||||
export interface WorktreeResult {
|
||||
/** Absolute path to the clone */
|
||||
path: string;
|
||||
/** Branch name used */
|
||||
branch: string;
|
||||
}
|
||||
|
||||
/** Branch info from `git branch --list` */
|
||||
export interface BranchInfo {
|
||||
branch: string;
|
||||
commit: string;
|
||||
worktreePath?: string; // Path to worktree directory (for worktree-sessions branches)
|
||||
worktreePath?: string;
|
||||
}
|
||||
|
||||
/** Branch with list metadata */
|
||||
export interface BranchListItem {
|
||||
info: BranchInfo;
|
||||
filesChanged: number;
|
||||
taskSlug: string;
|
||||
/** Original task instruction extracted from first commit message */
|
||||
originalInstruction: string;
|
||||
}
|
||||
|
||||
export interface SummarizeOptions {
|
||||
/** Working directory for Claude execution */
|
||||
cwd: string;
|
||||
/** Model to use (optional, defaults to config or haiku) */
|
||||
model?: string;
|
||||
/** Use LLM for summarization. Defaults to config.branchNameStrategy === 'ai'. If false, uses romanization. */
|
||||
useLLM?: boolean;
|
||||
}
|
||||
|
||||
/** pending/failedタスクのリストアイテム */
|
||||
export interface TaskListItem {
|
||||
kind: 'pending' | 'running' | 'completed' | 'failed' | 'exceeded';
|
||||
name: string;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user