diff --git a/src/__tests__/addTask.test.ts b/src/__tests__/addTask.test.ts index 8c645a3..5bba9ba 100644 --- a/src/__tests__/addTask.test.ts +++ b/src/__tests__/addTask.test.ts @@ -66,7 +66,7 @@ vi.mock('../infra/github/issue.js', () => ({ import { interactiveMode } from '../features/interactive/index.js'; import { promptInput, confirm } from '../shared/prompt/index.js'; import { determinePiece } from '../features/tasks/execute/selectAndExecute.js'; -import { resolveIssueTask } from '../infra/github/issue.js'; +import { resolveIssueTask, createIssue } from '../infra/github/issue.js'; import { addTask } from '../features/tasks/index.js'; const mockResolveIssueTask = vi.mocked(resolveIssueTask); @@ -74,6 +74,7 @@ const mockInteractiveMode = vi.mocked(interactiveMode); const mockPromptInput = vi.mocked(promptInput); const mockConfirm = vi.mocked(confirm); const mockDeterminePiece = vi.mocked(determinePiece); +const mockCreateIssue = vi.mocked(createIssue); let testDir: string; @@ -138,4 +139,32 @@ describe('addTask', () => { expect(fs.existsSync(path.join(testDir, '.takt', 'tasks.yaml'))).toBe(false); }); + + it('should create issue and save task when create_issue action is chosen', async () => { + // Given + mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'New feature' }); + mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/55' }); + mockConfirm.mockResolvedValue(false); + + // When + await addTask(testDir); + + // Then + const tasks = loadTasks(testDir).tasks; + expect(tasks).toHaveLength(1); + expect(tasks[0]?.issue).toBe(55); + expect(tasks[0]?.content).toContain('New feature'); + }); + + it('should not save task when issue creation fails in create_issue action', async () => { + // Given + mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'New feature' }); + mockCreateIssue.mockReturnValue({ success: false, error: 'auth failed' }); + + // When + await addTask(testDir); + + // Then + expect(fs.existsSync(path.join(testDir, '.takt', 'tasks.yaml'))).toBe(false); + }); }); diff --git a/src/__tests__/cli-routing-issue-resolve.test.ts b/src/__tests__/cli-routing-issue-resolve.test.ts index 41c53b1..b7ed5d6 100644 --- a/src/__tests__/cli-routing-issue-resolve.test.ts +++ b/src/__tests__/cli-routing-issue-resolve.test.ts @@ -36,7 +36,7 @@ vi.mock('../features/tasks/index.js', () => ({ selectAndExecuteTask: vi.fn(), determinePiece: vi.fn(), saveTaskFromInteractive: vi.fn(), - createIssueFromTask: vi.fn(), + createIssueAndSaveTask: vi.fn(), })); vi.mock('../features/pipeline/index.js', () => ({ @@ -83,7 +83,7 @@ vi.mock('../app/cli/helpers.js', () => ({ })); import { checkGhCli, fetchIssue, formatIssueAsTask, parseIssueNumbers } from '../infra/github/issue.js'; -import { selectAndExecuteTask, determinePiece } from '../features/tasks/index.js'; +import { selectAndExecuteTask, determinePiece, createIssueAndSaveTask } from '../features/tasks/index.js'; import { interactiveMode } from '../features/interactive/index.js'; import { isDirectTask } from '../app/cli/helpers.js'; import { executeDefaultAction } from '../app/cli/routing.js'; @@ -95,6 +95,7 @@ const mockFormatIssueAsTask = vi.mocked(formatIssueAsTask); const mockParseIssueNumbers = vi.mocked(parseIssueNumbers); const mockSelectAndExecuteTask = vi.mocked(selectAndExecuteTask); const mockDeterminePiece = vi.mocked(determinePiece); +const mockCreateIssueAndSaveTask = vi.mocked(createIssueAndSaveTask); const mockInteractiveMode = vi.mocked(interactiveMode); const mockIsDirectTask = vi.mocked(isDirectTask); @@ -261,4 +262,32 @@ describe('Issue resolution in routing', () => { expect(mockSelectAndExecuteTask).not.toHaveBeenCalled(); }); }); + + describe('create_issue action', () => { + it('should delegate to createIssueAndSaveTask with cwd, task, and pieceId', async () => { + // Given + mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'New feature request' }); + + // When + await executeDefaultAction(); + + // Then: createIssueAndSaveTask should be called with correct args + expect(mockCreateIssueAndSaveTask).toHaveBeenCalledWith( + '/test/cwd', + 'New feature request', + 'default', + ); + }); + + it('should not call selectAndExecuteTask when create_issue action is chosen', async () => { + // Given + mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'New feature request' }); + + // When + await executeDefaultAction(); + + // Then: selectAndExecuteTask should NOT be called + expect(mockSelectAndExecuteTask).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/__tests__/createIssueFromTask.test.ts b/src/__tests__/createIssueFromTask.test.ts index 2d15a87..e31d3ed 100644 --- a/src/__tests__/createIssueFromTask.test.ts +++ b/src/__tests__/createIssueFromTask.test.ts @@ -114,6 +114,42 @@ describe('createIssueFromTask', () => { expect(mockSuccess).not.toHaveBeenCalled(); }); + describe('return value', () => { + it('should return issue number when creation succeeds', () => { + // Given + mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/42' }); + + // When + const result = createIssueFromTask('Test task'); + + // Then + expect(result).toBe(42); + }); + + it('should return undefined when creation fails', () => { + // Given + mockCreateIssue.mockReturnValue({ success: false, error: 'auth failed' }); + + // When + const result = createIssueFromTask('Test task'); + + // Then + expect(result).toBeUndefined(); + }); + + it('should return undefined and display error when URL has non-numeric suffix', () => { + // Given + mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/abc' }); + + // When + const result = createIssueFromTask('Test task'); + + // Then + expect(result).toBeUndefined(); + expect(mockError).toHaveBeenCalledWith('Failed to extract issue number from URL'); + }); + }); + it('should use first line as title and full text as body for multi-line task', () => { // Given: multi-line task const task = 'First line title\nSecond line details\nThird line more info'; diff --git a/src/__tests__/saveTaskFile.test.ts b/src/__tests__/saveTaskFile.test.ts index 51078ae..0e179e3 100644 --- a/src/__tests__/saveTaskFile.test.ts +++ b/src/__tests__/saveTaskFile.test.ts @@ -122,4 +122,16 @@ describe('saveTaskFromInteractive', () => { expect(mockInfo).toHaveBeenCalledWith(' Piece: review'); }); + + it('should record issue number in tasks.yaml when issue option is provided', async () => { + // Given: user declines worktree + mockConfirm.mockResolvedValueOnce(false); + + // When + await saveTaskFromInteractive(testDir, 'Fix login bug', 'default', { issue: 42 }); + + // Then + const task = loadTasks(testDir).tasks[0]!; + expect(task.issue).toBe(42); + }); }); diff --git a/src/app/cli/routing.ts b/src/app/cli/routing.ts index 53dff76..5d49ffa 100644 --- a/src/app/cli/routing.ts +++ b/src/app/cli/routing.ts @@ -9,7 +9,7 @@ import { info, error } from '../../shared/ui/index.js'; import { getErrorMessage } from '../../shared/utils/index.js'; import { getLabel } from '../../shared/i18n/index.js'; import { fetchIssue, formatIssueAsTask, checkGhCli, parseIssueNumbers, type GitHubIssue } from '../../infra/github/index.js'; -import { selectAndExecuteTask, determinePiece, saveTaskFromInteractive, createIssueFromTask, type SelectAndExecuteOptions } from '../../features/tasks/index.js'; +import { selectAndExecuteTask, determinePiece, saveTaskFromInteractive, createIssueAndSaveTask, type SelectAndExecuteOptions } from '../../features/tasks/index.js'; import { executePipeline } from '../../features/pipeline/index.js'; import { interactiveMode, @@ -188,7 +188,7 @@ export async function executeDefaultAction(task?: string): Promise { break; case 'create_issue': - createIssueFromTask(result.task); + await createIssueAndSaveTask(resolvedCwd, result.task, pieceId); break; case 'save_task': diff --git a/src/features/tasks/add/index.ts b/src/features/tasks/add/index.ts index e7a4c94..6d3ef39 100644 --- a/src/features/tasks/add/index.ts +++ b/src/features/tasks/add/index.ts @@ -48,15 +48,22 @@ export async function saveTaskFile( * Extracts the first line as the issue title (truncated to 100 chars), * uses the full task as the body, and displays success/error messages. */ -export function createIssueFromTask(task: string): void { +export function createIssueFromTask(task: string): number | undefined { info('Creating GitHub Issue...'); const firstLine = task.split('\n')[0] || task; const title = firstLine.length > 100 ? `${firstLine.slice(0, 97)}...` : firstLine; const issueResult = createIssue({ title, body: task }); if (issueResult.success) { success(`Issue created: ${issueResult.url}`); + const num = Number(issueResult.url!.split('/').pop()); + if (Number.isNaN(num)) { + error('Failed to extract issue number from URL'); + return undefined; + } + return num; } else { error(`Failed to create issue: ${issueResult.error}`); + return undefined; } } @@ -66,6 +73,38 @@ interface WorktreeSettings { autoPr?: 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 (piece) info(` Piece: ${piece}`); +} + +/** + * Create a GitHub Issue and save the task to .takt/tasks.yaml. + * + * Combines issue creation and task saving into a single workflow. + * If issue creation fails, no task is saved. + */ +export async function createIssueAndSaveTask(cwd: string, task: string, piece?: string): Promise { + const issueNumber = createIssueFromTask(task); + if (issueNumber !== undefined) { + await saveTaskFromInteractive(cwd, task, piece, { issue: issueNumber }); + } +} + async function promptWorktreeSettings(): Promise { const useWorktree = await confirm('Create worktree?', true); if (!useWorktree) { @@ -91,21 +130,11 @@ export async function saveTaskFromInteractive( cwd: string, task: string, piece?: string, + options?: { issue?: number }, ): Promise { const settings = await promptWorktreeSettings(); - const created = await saveTaskFile(cwd, task, { piece, ...settings }); - 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 (piece) info(` Piece: ${piece}`); + const created = await saveTaskFile(cwd, task, { piece, issue: options?.issue, ...settings }); + displayTaskCreationResult(created, settings, piece); } /** @@ -161,7 +190,7 @@ export async function addTask(cwd: string, task?: string): Promise { const result = await interactiveMode(cwd, undefined, pieceContext); if (result.action === 'create_issue') { - createIssueFromTask(result.task); + await createIssueAndSaveTask(cwd, result.task, piece); return; } @@ -184,18 +213,5 @@ export async function addTask(cwd: string, task?: string): Promise { ...settings, }); - 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 (piece) { - info(` Piece: ${piece}`); - } + displayTaskCreationResult(created, settings, piece); } diff --git a/src/features/tasks/index.ts b/src/features/tasks/index.ts index e276807..f413860 100644 --- a/src/features/tasks/index.ts +++ b/src/features/tasks/index.ts @@ -14,7 +14,7 @@ export { type SelectAndExecuteOptions, type WorktreeConfirmationResult, } from './execute/selectAndExecute.js'; -export { addTask, saveTaskFile, saveTaskFromInteractive, createIssueFromTask } from './add/index.js'; +export { addTask, saveTaskFile, saveTaskFromInteractive, createIssueFromTask, createIssueAndSaveTask } from './add/index.js'; export { watchTasks } from './watch/index.js'; export { listTasks,