diff --git a/src/__tests__/addTask.test.ts b/src/__tests__/addTask.test.ts index 063883a..60eb29d 100644 --- a/src/__tests__/addTask.test.ts +++ b/src/__tests__/addTask.test.ts @@ -54,6 +54,16 @@ vi.mock('../config/paths.js', () => ({ vi.mock('../github/issue.js', () => ({ isIssueReference: vi.fn((s: string) => /^#\d+$/.test(s)), resolveIssueTask: vi.fn(), + parseIssueNumbers: vi.fn((args: string[]) => { + const numbers: number[] = []; + for (const arg of args) { + const match = arg.match(/^#(\d+)$/); + if (match?.[1]) { + numbers.push(Number.parseInt(match[1], 10)); + } + } + return numbers; + }), })); import { interactiveMode } from '../commands/interactive.js'; @@ -348,6 +358,24 @@ describe('addTask', () => { expect(files.length).toBe(0); expect(mockGetProvider).not.toHaveBeenCalled(); }); + + it('should include issue number in task file when issue reference is used', async () => { + // Given: issue reference "#99" + const issueText = 'Issue #99: Fix login timeout'; + mockResolveIssueTask.mockReturnValue(issueText); + mockSummarizeTaskName.mockResolvedValue('fix-login-timeout'); + mockConfirm.mockResolvedValue(false); + mockListWorkflows.mockReturnValue([]); + + // When + await addTask(testDir, '#99'); + + // Then: task file contains issue field + const taskFile = path.join(testDir, '.takt', 'tasks', 'fix-login-timeout.yaml'); + expect(fs.existsSync(taskFile)).toBe(true); + const content = fs.readFileSync(taskFile, 'utf-8'); + expect(content).toContain('issue: 99'); + }); }); describe('summarizeConversation', () => { diff --git a/src/__tests__/clone.test.ts b/src/__tests__/clone.test.ts index b370bab..f3c8d3f 100644 --- a/src/__tests__/clone.test.ts +++ b/src/__tests__/clone.test.ts @@ -168,3 +168,146 @@ describe('cloneAndIsolate git config propagation', () => { expect(configSetCalls).toContainEqual({ key: 'user.email', value: 'temp@example.com' }); }); }); + +describe('branch and worktree path formatting with issue numbers', () => { + function setupMockForPathTest() { + mockExecFileSync.mockImplementation((cmd, args) => { + const argsArr = args as string[]; + + // git clone + if (argsArr[0] === 'clone') { + const clonePath = argsArr[argsArr.length - 1]; + return Buffer.from(`Cloning into '${clonePath}'...`); + } + + // git remote remove origin + if (argsArr[0] === 'remote' && argsArr[1] === 'remove') { + return Buffer.from(''); + } + + // git config + if (argsArr[0] === 'config') { + return Buffer.from(''); + } + + // git rev-parse --verify (branchExists check) + if (argsArr[0] === 'rev-parse') { + throw new Error('branch not found'); + } + + // git checkout -b (new branch) + if (argsArr[0] === 'checkout' && argsArr[1] === '-b') { + const branchName = argsArr[2]; + return Buffer.from(`Switched to a new branch '${branchName}'`); + } + + return Buffer.from(''); + }); + } + + it('should format branch as takt/#{issue}/{slug} when issue number is provided', () => { + // Given: issue number 99 with slug + setupMockForPathTest(); + + // When + const result = createSharedClone('/project', { + worktree: true, + taskSlug: 'fix-login-timeout', + issueNumber: 99, + }); + + // Then: branch should use issue format + expect(result.branch).toBe('takt/#99/fix-login-timeout'); + }); + + it('should format branch as takt/{timestamp}-{slug} when no issue number', () => { + // Given: no issue number + setupMockForPathTest(); + + // When + const result = createSharedClone('/project', { + worktree: true, + taskSlug: 'regular-task', + }); + + // Then: branch should use timestamp format (13 chars: 8 digits + T + 4 digits) + expect(result.branch).toMatch(/^takt\/\d{8}T\d{4}-regular-task$/); + }); + + it('should format worktree path as {timestamp}-{issue}-{slug} when issue number is provided', () => { + // Given: issue number 99 with slug + setupMockForPathTest(); + + // When + const result = createSharedClone('/project', { + worktree: true, + taskSlug: 'fix-bug', + issueNumber: 99, + }); + + // Then: path should include issue number (timestamp: 8 digits + T + 4 digits) + expect(result.path).toMatch(/\/\d{8}T\d{4}-99-fix-bug$/); + }); + + it('should format worktree path as {timestamp}-{slug} when no issue number', () => { + // Given: no issue number + setupMockForPathTest(); + + // When + const result = createSharedClone('/project', { + worktree: true, + taskSlug: 'regular-task', + }); + + // Then: path should NOT include issue number (timestamp: 8 digits + T + 4 digits) + expect(result.path).toMatch(/\/\d{8}T\d{4}-regular-task$/); + expect(result.path).not.toMatch(/-\d+-/); + }); + + it('should use custom branch when provided, ignoring issue number', () => { + // Given: custom branch with issue number + setupMockForPathTest(); + + // When + const result = createSharedClone('/project', { + worktree: true, + taskSlug: 'task', + issueNumber: 99, + branch: 'custom-branch-name', + }); + + // Then: custom branch takes precedence + expect(result.branch).toBe('custom-branch-name'); + }); + + it('should use custom worktree path when provided, ignoring issue formatting', () => { + // Given: custom path with issue number + setupMockForPathTest(); + + // When + const result = createSharedClone('/project', { + worktree: '/custom/path/to/worktree', + taskSlug: 'task', + issueNumber: 99, + }); + + // Then: custom path takes precedence + expect(result.path).toBe('/custom/path/to/worktree'); + }); + + it('should fall back to timestamp-only format when issue number provided but slug is empty', () => { + // Given: issue number but taskSlug produces empty string after slugify + setupMockForPathTest(); + + // When + const result = createSharedClone('/project', { + worktree: true, + taskSlug: '', // empty slug + issueNumber: 99, + }); + + // Then: falls back to timestamp format (issue number not included due to empty slug) + expect(result.branch).toMatch(/^takt\/\d{8}T\d{4}$/); + expect(result.path).toMatch(/\/\d{8}T\d{4}$/); + }); +}); diff --git a/src/commands/addTask.ts b/src/commands/addTask.ts index cb072f3..f128eb0 100644 --- a/src/commands/addTask.ts +++ b/src/commands/addTask.ts @@ -18,7 +18,7 @@ import { getErrorMessage } from '../utils/error.js'; import { listWorkflows } from '../config/workflowLoader.js'; import { getCurrentWorkflow } from '../config/paths.js'; import { interactiveMode } from './interactive.js'; -import { isIssueReference, resolveIssueTask } from '../github/issue.js'; +import { isIssueReference, resolveIssueTask, parseIssueNumbers } from '../github/issue.js'; import type { TaskFileData } from '../task/schema.js'; const log = createLogger('add-task'); @@ -81,12 +81,17 @@ export async function addTask(cwd: string, task?: string): Promise { fs.mkdirSync(tasksDir, { recursive: true }); let taskContent: string; + let issueNumber: number | undefined; if (task && isIssueReference(task)) { // Issue reference: fetch issue and use directly as task content info('Fetching GitHub Issue...'); try { taskContent = resolveIssueTask(task); + const numbers = parseIssueNumbers([task]); + if (numbers.length > 0) { + issueNumber = numbers[0]; + } } catch (e) { const msg = getErrorMessage(e); log.error('Failed to fetch GitHub Issue', { task, error: msg }); @@ -156,6 +161,9 @@ export async function addTask(cwd: string, task?: string): Promise { if (workflow) { taskData.workflow = workflow; } + if (issueNumber !== undefined) { + taskData.issue = issueNumber; + } const filePath = path.join(tasksDir, filename); const yamlContent = stringifyYaml(taskData); diff --git a/src/commands/taskExecution.ts b/src/commands/taskExecution.ts index 59e373b..29bedd7 100644 --- a/src/commands/taskExecution.ts +++ b/src/commands/taskExecution.ts @@ -234,6 +234,7 @@ export async function resolveTaskExecution( worktree: data.worktree, branch: data.branch, taskSlug, + issueNumber: data.issue, }); execCwd = result.path; branch = result.branch; diff --git a/src/task/clone.ts b/src/task/clone.ts index bda5d63..c4dac85 100644 --- a/src/task/clone.ts +++ b/src/task/clone.ts @@ -23,6 +23,8 @@ export interface WorktreeOptions { branch?: string; /** Task slug for auto-generated paths/branches */ taskSlug: string; + /** GitHub Issue number (optional, for formatting branch/path) */ + issueNumber?: number; } export interface WorktreeResult { @@ -57,11 +59,22 @@ function resolveCloneBaseDir(projectDir: string): string { * 1. Custom path in options.worktree (string) * 2. worktree_dir from config.yaml (if set) * 3. Default: ../{dir-name} + * + * Format with issue: {timestamp}-{issue}-{slug} + * Format without issue: {timestamp}-{slug} */ function resolveClonePath(projectDir: string, options: WorktreeOptions): string { const timestamp = generateTimestamp(); const slug = slugify(options.taskSlug); - const dirName = slug ? `${timestamp}-${slug}` : timestamp; + + let dirName: string; + if (options.issueNumber !== undefined && slug) { + dirName = `${timestamp}-${options.issueNumber}-${slug}`; + } else if (slug) { + dirName = `${timestamp}-${slug}`; + } else { + dirName = timestamp; + } if (typeof options.worktree === 'string') { return path.isAbsolute(options.worktree) @@ -72,12 +85,25 @@ function resolveClonePath(projectDir: string, options: WorktreeOptions): string return path.join(resolveCloneBaseDir(projectDir), dirName); } +/** + * Resolve branch name from options. + * + * Format with issue: takt/#{issue}/{slug} + * Format without issue: takt/{timestamp}-{slug} + * Custom branch: use as-is + */ function resolveBranchName(options: WorktreeOptions): string { if (options.branch) { return options.branch; } - const timestamp = generateTimestamp(); + const slug = slugify(options.taskSlug); + + if (options.issueNumber !== undefined && slug) { + return `takt/#${options.issueNumber}/${slug}`; + } + + const timestamp = generateTimestamp(); return slug ? `takt/${timestamp}-${slug}` : `takt/${timestamp}`; } diff --git a/src/task/schema.ts b/src/task/schema.ts index aec1e5b..c83169b 100644 --- a/src/task/schema.ts +++ b/src/task/schema.ts @@ -29,6 +29,7 @@ export const TaskFileSchema = z.object({ worktree: z.union([z.boolean(), z.string()]).optional(), branch: z.string().optional(), workflow: z.string().optional(), + issue: z.number().int().positive().optional(), }); export type TaskFileData = z.infer;