From c044aea33f391aebea767b4d2943a0096971eef4 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Thu, 5 Feb 2026 23:37:00 +0900 Subject: [PATCH] takt: add-ai-consultation-actions --- docs/data-flow-diagrams.md | 2 +- docs/data-flow.md | 2 +- src/__tests__/addTask.test.ts | 55 ++++++- src/__tests__/createIssue.test.ts | 137 ++++++++++++++++ src/__tests__/createIssueFromTask.test.ts | 131 +++++++++++++++ src/__tests__/i18n.test.ts | 3 +- src/__tests__/interactive.test.ts | 95 ++++++++--- src/__tests__/saveTaskFile.test.ts | 186 ++++++++++++++++++++++ src/app/cli/routing.ts | 27 +++- src/features/interactive/index.ts | 7 +- src/features/interactive/interactive.ts | 62 +++++--- src/features/tasks/add/index.ts | 114 +++++++++---- src/features/tasks/index.ts | 2 +- src/infra/github/index.ts | 3 +- src/infra/github/issue.ts | 39 ++++- src/infra/github/types.ts | 17 ++ src/shared/i18n/labels_en.yaml | 7 +- src/shared/i18n/labels_ja.yaml | 7 +- 18 files changed, 801 insertions(+), 95 deletions(-) create mode 100644 src/__tests__/createIssue.test.ts create mode 100644 src/__tests__/createIssueFromTask.test.ts create mode 100644 src/__tests__/saveTaskFile.test.ts diff --git a/docs/data-flow-diagrams.md b/docs/data-flow-diagrams.md index 99936b0..f2d403f 100644 --- a/docs/data-flow-diagrams.md +++ b/docs/data-flow-diagrams.md @@ -38,7 +38,7 @@ sequenceDiagram User->>Interactive: /go コマンド Interactive->>Interactive: buildTaskFromHistory() - Interactive-->>CLI: { confirmed: true, task: string } + Interactive-->>CLI: { action: InteractiveModeAction, task: string } CLI->>Orchestration: selectAndExecuteTask(cwd, task) diff --git a/docs/data-flow.md b/docs/data-flow.md index 245dad1..9ed61c8 100644 --- a/docs/data-flow.md +++ b/docs/data-flow.md @@ -355,7 +355,7 @@ TAKTのデータフローは以下の7つの主要なレイヤーで構成され **データ出力**: - `InteractiveModeResult`: - - `confirmed: boolean` + - `action: InteractiveModeAction` (`'execute' | 'save_task' | 'create_issue' | 'cancel'`) - `task: string` (会話履歴全体を結合した文字列) --- diff --git a/src/__tests__/addTask.test.ts b/src/__tests__/addTask.test.ts index e6f74c7..49036e7 100644 --- a/src/__tests__/addTask.test.ts +++ b/src/__tests__/addTask.test.ts @@ -70,6 +70,7 @@ vi.mock('../infra/github/issue.js', () => ({ } return numbers; }), + createIssue: vi.fn(), })); import { interactiveMode } from '../features/interactive/index.js'; @@ -77,10 +78,11 @@ import { promptInput, confirm } from '../shared/prompt/index.js'; import { summarizeTaskName } from '../infra/task/summarize.js'; import { determinePiece } from '../features/tasks/execute/selectAndExecute.js'; import { getPieceDescription } from '../infra/config/loaders/pieceResolver.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); +const mockCreateIssue = vi.mocked(createIssue); const mockInteractiveMode = vi.mocked(interactiveMode); const mockPromptInput = vi.mocked(promptInput); const mockConfirm = vi.mocked(confirm); @@ -97,7 +99,7 @@ function setupFullFlowMocks(overrides?: { mockDeterminePiece.mockResolvedValue('default'); mockGetPieceDescription.mockReturnValue({ name: 'default', description: '', pieceStructure: '' }); - mockInteractiveMode.mockResolvedValue({ confirmed: true, task }); + mockInteractiveMode.mockResolvedValue({ action: 'execute', task }); mockSummarizeTaskName.mockResolvedValue(slug); mockConfirm.mockResolvedValue(false); } @@ -122,7 +124,7 @@ describe('addTask', () => { it('should cancel when interactive mode is not confirmed', async () => { // Given: user cancels interactive mode mockDeterminePiece.mockResolvedValue('default'); - mockInteractiveMode.mockResolvedValue({ confirmed: false, task: '' }); + mockInteractiveMode.mockResolvedValue({ action: 'cancel', task: '' }); // When await addTask(testDir); @@ -341,4 +343,51 @@ describe('addTask', () => { const content = fs.readFileSync(taskFile, 'utf-8'); expect(content).toContain('issue: 99'); }); + + describe('create_issue action', () => { + it('should call createIssue when create_issue action is selected', async () => { + // Given: interactive mode returns create_issue action + const task = 'Create a new feature\nWith detailed description'; + mockDeterminePiece.mockResolvedValue('default'); + mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task }); + mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/1' }); + + // When + await addTask(testDir); + + // Then: createIssue is called via createIssueFromTask + expect(mockCreateIssue).toHaveBeenCalledWith({ + title: 'Create a new feature', + body: task, + }); + }); + + it('should not create task file when create_issue action is selected', async () => { + // Given: interactive mode returns create_issue action + mockDeterminePiece.mockResolvedValue('default'); + mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'Some task' }); + mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/1' }); + + // When + await addTask(testDir); + + // Then: no task file created + const tasksDir = path.join(testDir, '.takt', 'tasks'); + const files = fs.existsSync(tasksDir) ? fs.readdirSync(tasksDir) : []; + expect(files.length).toBe(0); + }); + + it('should not prompt for worktree settings when create_issue action is selected', async () => { + // Given: interactive mode returns create_issue action + mockDeterminePiece.mockResolvedValue('default'); + mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'Some task' }); + mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/1' }); + + // When + await addTask(testDir); + + // Then: confirm (worktree prompt) is never called + expect(mockConfirm).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/__tests__/createIssue.test.ts b/src/__tests__/createIssue.test.ts new file mode 100644 index 0000000..3f1f05b --- /dev/null +++ b/src/__tests__/createIssue.test.ts @@ -0,0 +1,137 @@ +/** + * Tests for createIssue function + * + * createIssue uses `gh issue create` via execFileSync, which is an + * integration concern. Tests focus on argument construction and error handling + * by mocking child_process. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { execFileSync } from 'node:child_process'; + +vi.mock('node:child_process', () => ({ + execFileSync: vi.fn(), +})); + +vi.mock('../../shared/utils/index.js', async (importOriginal) => ({ + ...(await importOriginal>()), + createLogger: () => ({ + info: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + }), +})); + +import { createIssue, checkGhCli } from '../infra/github/issue.js'; + +const mockExecFileSync = vi.mocked(execFileSync); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('createIssue', () => { + it('should return success with URL when gh issue create succeeds', () => { + // Given: gh auth and issue creation both succeed + mockExecFileSync + .mockReturnValueOnce(Buffer.from('')) // gh auth status + .mockReturnValueOnce('https://github.com/owner/repo/issues/42\n' as unknown as Buffer); + + // When + const result = createIssue({ title: 'Test issue', body: 'Test body' }); + + // Then + expect(result.success).toBe(true); + expect(result.url).toBe('https://github.com/owner/repo/issues/42'); + }); + + it('should pass title and body as arguments', () => { + // Given + mockExecFileSync + .mockReturnValueOnce(Buffer.from('')) // gh auth status + .mockReturnValueOnce('https://github.com/owner/repo/issues/1\n' as unknown as Buffer); + + // When + createIssue({ title: 'My Title', body: 'My Body' }); + + // Then: verify the second call (issue create) has correct args + const issueCreateCall = mockExecFileSync.mock.calls[1]; + expect(issueCreateCall?.[0]).toBe('gh'); + expect(issueCreateCall?.[1]).toEqual([ + 'issue', 'create', '--title', 'My Title', '--body', 'My Body', + ]); + }); + + it('should include labels when provided', () => { + // Given + mockExecFileSync + .mockReturnValueOnce(Buffer.from('')) // gh auth status + .mockReturnValueOnce('https://github.com/owner/repo/issues/1\n' as unknown as Buffer); + + // When + createIssue({ title: 'Bug', body: 'Fix it', labels: ['bug', 'priority:high'] }); + + // Then + const issueCreateCall = mockExecFileSync.mock.calls[1]; + expect(issueCreateCall?.[1]).toEqual([ + 'issue', 'create', '--title', 'Bug', '--body', 'Fix it', + '--label', 'bug,priority:high', + ]); + }); + + it('should not include --label when labels is empty', () => { + // Given + mockExecFileSync + .mockReturnValueOnce(Buffer.from('')) // gh auth status + .mockReturnValueOnce('https://github.com/owner/repo/issues/1\n' as unknown as Buffer); + + // When + createIssue({ title: 'Title', body: 'Body', labels: [] }); + + // Then + const issueCreateCall = mockExecFileSync.mock.calls[1]; + expect(issueCreateCall?.[1]).not.toContain('--label'); + }); + + it('should return error when gh CLI is not authenticated', () => { + // Given: auth fails, version succeeds + mockExecFileSync + .mockImplementationOnce(() => { throw new Error('not authenticated'); }) + .mockReturnValueOnce(Buffer.from('gh version 2.0.0')); + + // When + const result = createIssue({ title: 'Test', body: 'Body' }); + + // Then + expect(result.success).toBe(false); + expect(result.error).toContain('not authenticated'); + }); + + it('should return error when gh CLI is not installed', () => { + // Given: both auth and version fail + mockExecFileSync + .mockImplementationOnce(() => { throw new Error('command not found'); }) + .mockImplementationOnce(() => { throw new Error('command not found'); }); + + // When + const result = createIssue({ title: 'Test', body: 'Body' }); + + // Then + expect(result.success).toBe(false); + expect(result.error).toContain('not installed'); + }); + + it('should return error when gh issue create fails', () => { + // Given: auth succeeds but issue creation fails + mockExecFileSync + .mockReturnValueOnce(Buffer.from('')) // gh auth status + .mockImplementationOnce(() => { throw new Error('repo not found'); }); + + // When + const result = createIssue({ title: 'Test', body: 'Body' }); + + // Then + expect(result.success).toBe(false); + expect(result.error).toContain('repo not found'); + }); +}); diff --git a/src/__tests__/createIssueFromTask.test.ts b/src/__tests__/createIssueFromTask.test.ts new file mode 100644 index 0000000..2d15a87 --- /dev/null +++ b/src/__tests__/createIssueFromTask.test.ts @@ -0,0 +1,131 @@ +/** + * Tests for createIssueFromTask function + * + * Verifies title truncation (100-char boundary), success/failure UI output, + * and multi-line task handling (first line → title, full text → body). + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../infra/github/issue.js', () => ({ + createIssue: vi.fn(), +})); + +vi.mock('../shared/ui/index.js', () => ({ + success: vi.fn(), + info: vi.fn(), + error: vi.fn(), +})); + +vi.mock('../shared/utils/index.js', async (importOriginal) => ({ + ...(await importOriginal>()), + createLogger: () => ({ + info: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + }), +})); + +import { createIssue } from '../infra/github/issue.js'; +import { success, error } from '../shared/ui/index.js'; +import { createIssueFromTask } from '../features/tasks/index.js'; + +const mockCreateIssue = vi.mocked(createIssue); +const mockSuccess = vi.mocked(success); +const mockError = vi.mocked(error); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('createIssueFromTask', () => { + describe('title truncation boundary', () => { + it('should use title as-is when exactly 99 characters', () => { + // Given: 99-character first line + const title99 = 'a'.repeat(99); + mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/1' }); + + // When + createIssueFromTask(title99); + + // Then: title passed without truncation + expect(mockCreateIssue).toHaveBeenCalledWith({ + title: title99, + body: title99, + }); + }); + + it('should use title as-is when exactly 100 characters', () => { + // Given: 100-character first line + const title100 = 'a'.repeat(100); + mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/1' }); + + // When + createIssueFromTask(title100); + + // Then: title passed without truncation + expect(mockCreateIssue).toHaveBeenCalledWith({ + title: title100, + body: title100, + }); + }); + + it('should truncate title to 97 chars + ellipsis when 101 characters', () => { + // Given: 101-character first line + const title101 = 'a'.repeat(101); + mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/1' }); + + // When + createIssueFromTask(title101); + + // Then: title truncated to 97 chars + "..." + const expectedTitle = `${'a'.repeat(97)}...`; + expect(expectedTitle).toHaveLength(100); + expect(mockCreateIssue).toHaveBeenCalledWith({ + title: expectedTitle, + body: title101, + }); + }); + }); + + it('should display success message with URL when issue creation succeeds', () => { + // Given + const url = 'https://github.com/owner/repo/issues/42'; + mockCreateIssue.mockReturnValue({ success: true, url }); + + // When + createIssueFromTask('Test task'); + + // Then + expect(mockSuccess).toHaveBeenCalledWith(`Issue created: ${url}`); + expect(mockError).not.toHaveBeenCalled(); + }); + + it('should display error message when issue creation fails', () => { + // Given + const errorMsg = 'repo not found'; + mockCreateIssue.mockReturnValue({ success: false, error: errorMsg }); + + // When + createIssueFromTask('Test task'); + + // Then + expect(mockError).toHaveBeenCalledWith(`Failed to create issue: ${errorMsg}`); + expect(mockSuccess).not.toHaveBeenCalled(); + }); + + 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'; + mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/1' }); + + // When + createIssueFromTask(task); + + // Then: first line → title, full text → body + expect(mockCreateIssue).toHaveBeenCalledWith({ + title: 'First line title', + body: task, + }); + }); +}); diff --git a/src/__tests__/i18n.test.ts b/src/__tests__/i18n.test.ts index 5e37919..149c530 100644 --- a/src/__tests__/i18n.test.ts +++ b/src/__tests__/i18n.test.ts @@ -96,7 +96,8 @@ describe('label integrity', () => { expect(ui).toHaveProperty('summarizeFailed'); expect(ui).toHaveProperty('continuePrompt'); expect(ui).toHaveProperty('proposed'); - expect(ui).toHaveProperty('confirm'); + expect(ui).toHaveProperty('actionPrompt'); + expect(ui).toHaveProperty('actions'); expect(ui).toHaveProperty('cancelled'); }); diff --git a/src/__tests__/interactive.test.ts b/src/__tests__/interactive.test.ts index fba65ce..ef398d3 100644 --- a/src/__tests__/interactive.test.ts +++ b/src/__tests__/interactive.test.ts @@ -123,11 +123,12 @@ function setupMockProvider(responses: string[]): void { beforeEach(() => { vi.clearAllMocks(); - mockSelectOption.mockResolvedValue('yes'); + // selectPostSummaryAction uses selectOption with action values + mockSelectOption.mockResolvedValue('execute'); }); describe('interactiveMode', () => { - it('should return confirmed=false when user types /cancel', async () => { + it('should return action=cancel when user types /cancel', async () => { // Given setupInputSequence(['/cancel']); setupMockProvider([]); @@ -136,11 +137,11 @@ describe('interactiveMode', () => { const result = await interactiveMode('/project'); // Then - expect(result.confirmed).toBe(false); + expect(result.action).toBe('cancel'); expect(result.task).toBe(''); }); - it('should return confirmed=false on EOF (Ctrl+D)', async () => { + it('should return action=cancel on EOF (Ctrl+D)', async () => { // Given setupInputSequence([null]); setupMockProvider([]); @@ -149,7 +150,7 @@ describe('interactiveMode', () => { const result = await interactiveMode('/project'); // Then - expect(result.confirmed).toBe(false); + expect(result.action).toBe('cancel'); }); it('should call provider with allowed tools for codebase exploration', async () => { @@ -172,7 +173,7 @@ describe('interactiveMode', () => { ); }); - it('should return confirmed=true with task on /go after conversation', async () => { + it('should return action=execute with task on /go after conversation', async () => { // Given setupInputSequence(['add auth feature', '/go']); setupMockProvider(['What kind of authentication?', 'Implement auth feature with chosen method.']); @@ -181,7 +182,7 @@ describe('interactiveMode', () => { const result = await interactiveMode('/project'); // Then - expect(result.confirmed).toBe(true); + expect(result.action).toBe('execute'); expect(result.task).toBe('Implement auth feature with chosen method.'); }); @@ -193,8 +194,8 @@ describe('interactiveMode', () => { // When const result = await interactiveMode('/project'); - // Then: should not confirm (fell through to /cancel) - expect(result.confirmed).toBe(false); + // Then: should cancel (fell through to /cancel) + expect(result.action).toBe('cancel'); }); it('should skip empty input', async () => { @@ -206,7 +207,7 @@ describe('interactiveMode', () => { const result = await interactiveMode('/project'); // Then - expect(result.confirmed).toBe(true); + expect(result.action).toBe('execute'); const mockProvider = mockGetProvider.mock.results[0]!.value as { call: ReturnType }; expect(mockProvider.call).toHaveBeenCalledTimes(2); }); @@ -220,7 +221,7 @@ describe('interactiveMode', () => { const result = await interactiveMode('/project'); // Then: task should be a summary and prompt should include full history - expect(result.confirmed).toBe(true); + expect(result.action).toBe('execute'); expect(result.task).toBe('Summarized task.'); const mockProvider = mockGetProvider.mock.results[0]!.value as { call: ReturnType }; const summaryPrompt = mockProvider.call.mock.calls[2]?.[1] as string; @@ -259,7 +260,7 @@ describe('interactiveMode', () => { expect(mockProvider.call.mock.calls[0]?.[1]).toBe('a'); // /go should work because initialInput already started conversation - expect(result.confirmed).toBe(true); + expect(result.action).toBe('execute'); expect(result.task).toBe('Clarify task for "a".'); }); @@ -278,12 +279,12 @@ describe('interactiveMode', () => { expect(mockProvider.call.mock.calls[1]?.[1]).toBe('fix the login page'); // Task still contains all history for downstream use - expect(result.confirmed).toBe(true); + expect(result.action).toBe('execute'); expect(result.task).toBe('Fix login page with clarified scope.'); }); describe('/play command', () => { - it('should return confirmed=true with task on /play command', async () => { + it('should return action=execute with task on /play command', async () => { // Given setupInputSequence(['/play implement login feature']); setupMockProvider([]); @@ -292,7 +293,7 @@ describe('interactiveMode', () => { const result = await interactiveMode('/project'); // Then - expect(result.confirmed).toBe(true); + expect(result.action).toBe('execute'); expect(result.task).toBe('implement login feature'); }); @@ -304,8 +305,8 @@ describe('interactiveMode', () => { // When const result = await interactiveMode('/project'); - // Then: should not confirm (fell through to /cancel) - expect(result.confirmed).toBe(false); + // Then: should cancel (fell through to /cancel) + expect(result.action).toBe('cancel'); }); it('should handle /play with leading/trailing spaces', async () => { @@ -317,7 +318,7 @@ describe('interactiveMode', () => { const result = await interactiveMode('/project'); // Then - expect(result.confirmed).toBe(true); + expect(result.action).toBe('execute'); expect(result.task).toBe('test task'); }); @@ -332,8 +333,64 @@ describe('interactiveMode', () => { // Then: provider should NOT have been called (no summary needed) const mockProvider = mockGetProvider.mock.results[0]?.value as { call: ReturnType }; expect(mockProvider.call).not.toHaveBeenCalled(); - expect(result.confirmed).toBe(true); + expect(result.action).toBe('execute'); expect(result.task).toBe('quick task'); }); }); + + describe('action selection after /go', () => { + it('should return action=create_issue when user selects create issue', async () => { + // Given + setupInputSequence(['describe task', '/go']); + setupMockProvider(['response', 'Summarized task.']); + mockSelectOption.mockResolvedValue('create_issue'); + + // When + const result = await interactiveMode('/project'); + + // Then + expect(result.action).toBe('create_issue'); + expect(result.task).toBe('Summarized task.'); + }); + + it('should return action=save_task when user selects save task', async () => { + // Given + setupInputSequence(['describe task', '/go']); + setupMockProvider(['response', 'Summarized task.']); + mockSelectOption.mockResolvedValue('save_task'); + + // When + const result = await interactiveMode('/project'); + + // Then + expect(result.action).toBe('save_task'); + expect(result.task).toBe('Summarized task.'); + }); + + it('should continue editing when user selects continue', async () => { + // Given: user selects 'continue' first, then cancels + setupInputSequence(['describe task', '/go', '/cancel']); + setupMockProvider(['response', 'Summarized task.']); + mockSelectOption.mockResolvedValueOnce('continue'); + + // When + const result = await interactiveMode('/project'); + + // Then: should fall through to /cancel + expect(result.action).toBe('cancel'); + }); + + it('should continue editing when user presses ESC (null)', async () => { + // Given: selectOption returns null (ESC), then user cancels + setupInputSequence(['describe task', '/go', '/cancel']); + setupMockProvider(['response', 'Summarized task.']); + mockSelectOption.mockResolvedValueOnce(null); + + // When + const result = await interactiveMode('/project'); + + // Then: should fall through to /cancel + expect(result.action).toBe('cancel'); + }); + }); }); diff --git a/src/__tests__/saveTaskFile.test.ts b/src/__tests__/saveTaskFile.test.ts new file mode 100644 index 0000000..6c087ad --- /dev/null +++ b/src/__tests__/saveTaskFile.test.ts @@ -0,0 +1,186 @@ +/** + * Tests for saveTaskFile and saveTaskFromInteractive + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { tmpdir } from 'node:os'; + +vi.mock('../infra/task/summarize.js', () => ({ + summarizeTaskName: vi.fn(), +})); + +vi.mock('../shared/ui/index.js', () => ({ + success: vi.fn(), + info: vi.fn(), + blankLine: vi.fn(), +})); + +vi.mock('../shared/utils/index.js', async (importOriginal) => ({ + ...(await importOriginal>()), + createLogger: () => ({ + info: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + }), +})); + +import { summarizeTaskName } from '../infra/task/summarize.js'; +import { success, info } from '../shared/ui/index.js'; +import { saveTaskFile, saveTaskFromInteractive } from '../features/tasks/add/index.js'; + +const mockSummarizeTaskName = vi.mocked(summarizeTaskName); +const mockSuccess = vi.mocked(success); +const mockInfo = vi.mocked(info); + +let testDir: string; + +beforeEach(() => { + vi.clearAllMocks(); + testDir = fs.mkdtempSync(path.join(tmpdir(), 'takt-test-save-')); + mockSummarizeTaskName.mockResolvedValue('test-task'); +}); + +afterEach(() => { + if (testDir && fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true }); + } +}); + +describe('saveTaskFile', () => { + it('should create task file with correct YAML content', async () => { + // Given + const taskContent = 'Implement feature X\nDetails here'; + + // When + const filePath = await saveTaskFile(testDir, taskContent); + + // Then + expect(fs.existsSync(filePath)).toBe(true); + const content = fs.readFileSync(filePath, 'utf-8'); + expect(content).toContain('Implement feature X'); + expect(content).toContain('Details here'); + }); + + it('should create .takt/tasks directory if it does not exist', async () => { + // Given + const tasksDir = path.join(testDir, '.takt', 'tasks'); + expect(fs.existsSync(tasksDir)).toBe(false); + + // When + await saveTaskFile(testDir, 'Task content'); + + // Then + expect(fs.existsSync(tasksDir)).toBe(true); + }); + + it('should include piece in YAML when specified', async () => { + // When + const filePath = await saveTaskFile(testDir, 'Task', { piece: 'review' }); + + // Then + const content = fs.readFileSync(filePath, 'utf-8'); + expect(content).toContain('piece: review'); + }); + + it('should include issue number in YAML when specified', async () => { + // When + const filePath = await saveTaskFile(testDir, 'Task', { issue: 42 }); + + // Then + const content = fs.readFileSync(filePath, 'utf-8'); + expect(content).toContain('issue: 42'); + }); + + it('should include worktree in YAML when specified', async () => { + // When + const filePath = await saveTaskFile(testDir, 'Task', { worktree: true }); + + // Then + const content = fs.readFileSync(filePath, 'utf-8'); + expect(content).toContain('worktree: true'); + }); + + it('should include branch in YAML when specified', async () => { + // When + const filePath = await saveTaskFile(testDir, 'Task', { branch: 'feat/my-branch' }); + + // Then + const content = fs.readFileSync(filePath, 'utf-8'); + expect(content).toContain('branch: feat/my-branch'); + }); + + it('should not include optional fields when not specified', async () => { + // When + const filePath = await saveTaskFile(testDir, 'Simple task'); + + // Then + const content = fs.readFileSync(filePath, 'utf-8'); + expect(content).not.toContain('piece:'); + expect(content).not.toContain('issue:'); + expect(content).not.toContain('worktree:'); + expect(content).not.toContain('branch:'); + }); + + it('should use first line for filename generation', async () => { + // When + await saveTaskFile(testDir, 'First line\nSecond line'); + + // Then + expect(mockSummarizeTaskName).toHaveBeenCalledWith('First line', { cwd: testDir }); + }); + + it('should handle duplicate filenames with counter', async () => { + // Given: first file already exists + await saveTaskFile(testDir, 'Task 1'); + + // When: second file with same slug + const filePath = await saveTaskFile(testDir, 'Task 2'); + + // Then + expect(path.basename(filePath)).toBe('test-task-1.yaml'); + }); +}); + +describe('saveTaskFromInteractive', () => { + it('should save task and display success message', async () => { + // When + await saveTaskFromInteractive(testDir, 'Task content'); + + // Then + expect(mockSuccess).toHaveBeenCalledWith('Task created: test-task.yaml'); + expect(mockInfo).toHaveBeenCalledWith(expect.stringContaining('Path:')); + }); + + it('should display piece info when specified', async () => { + // When + await saveTaskFromInteractive(testDir, 'Task content', 'review'); + + // Then + expect(mockInfo).toHaveBeenCalledWith(' Piece: review'); + }); + + it('should include piece in saved YAML', async () => { + // When + await saveTaskFromInteractive(testDir, 'Task content', 'custom'); + + // Then + const tasksDir = path.join(testDir, '.takt', 'tasks'); + const files = fs.readdirSync(tasksDir); + expect(files.length).toBe(1); + const content = fs.readFileSync(path.join(tasksDir, files[0]!), 'utf-8'); + expect(content).toContain('piece: custom'); + }); + + it('should not display piece info when not specified', async () => { + // When + await saveTaskFromInteractive(testDir, 'Task content'); + + // Then + const pieceInfoCalls = mockInfo.mock.calls.filter( + (call) => typeof call[0] === 'string' && call[0].includes('Piece:') + ); + expect(pieceInfoCalls.length).toBe(0); + }); +}); diff --git a/src/app/cli/routing.ts b/src/app/cli/routing.ts index 5148d6e..4731de5 100644 --- a/src/app/cli/routing.ts +++ b/src/app/cli/routing.ts @@ -8,7 +8,7 @@ import { info, error } from '../../shared/ui/index.js'; import { getErrorMessage } from '../../shared/utils/index.js'; import { resolveIssueTask } from '../../infra/github/index.js'; -import { selectAndExecuteTask, determinePiece, type SelectAndExecuteOptions } from '../../features/tasks/index.js'; +import { selectAndExecuteTask, determinePiece, saveTaskFromInteractive, createIssueFromTask, type SelectAndExecuteOptions } from '../../features/tasks/index.js'; import { executePipeline } from '../../features/pipeline/index.js'; import { interactiveMode } from '../../features/interactive/index.js'; import { getPieceDescription } from '../../infra/config/index.js'; @@ -99,14 +99,25 @@ export async function executeDefaultAction(task?: string): Promise { const pieceContext = getPieceDescription(pieceId, resolvedCwd); const result = await interactiveMode(resolvedCwd, task, pieceContext); - if (!result.confirmed) { - return; - } + switch (result.action) { + case 'execute': + selectOptions.interactiveUserInput = true; + selectOptions.piece = pieceId; + selectOptions.interactiveMetadata = { confirmed: true, task: result.task }; + await selectAndExecuteTask(resolvedCwd, result.task, selectOptions, agentOverrides); + break; - selectOptions.interactiveUserInput = true; - selectOptions.piece = pieceId; - selectOptions.interactiveMetadata = { confirmed: result.confirmed, task: result.task }; - await selectAndExecuteTask(resolvedCwd, result.task, selectOptions, agentOverrides); + case 'create_issue': + createIssueFromTask(result.task); + break; + + case 'save_task': + await saveTaskFromInteractive(resolvedCwd, result.task, pieceId); + break; + + case 'cancel': + break; + } } program diff --git a/src/features/interactive/index.ts b/src/features/interactive/index.ts index 51142e4..fc5c54b 100644 --- a/src/features/interactive/index.ts +++ b/src/features/interactive/index.ts @@ -2,4 +2,9 @@ * Interactive mode commands. */ -export { interactiveMode, type PieceContext, type InteractiveModeResult } from './interactive.js'; +export { + interactiveMode, + type PieceContext, + type InteractiveModeResult, + type InteractiveModeAction, +} from './interactive.js'; diff --git a/src/features/interactive/interactive.ts b/src/features/interactive/interactive.ts index 360238d..48fbaf9 100644 --- a/src/features/interactive/interactive.ts +++ b/src/features/interactive/interactive.ts @@ -38,7 +38,13 @@ interface InteractiveUIText { summarizeFailed: string; continuePrompt: string; proposed: string; - confirm: string; + actionPrompt: string; + actions: { + execute: string; + createIssue: string; + saveTask: string; + continue: string; + }; cancelled: string; playNoTask: string; } @@ -149,15 +155,23 @@ function buildSummaryPrompt( }); } -async function confirmTask(task: string, message: string, confirmLabel: string, yesLabel: string, noLabel: string): Promise { +type PostSummaryAction = InteractiveModeAction | 'continue'; + +async function selectPostSummaryAction( + task: string, + proposedLabel: string, + ui: InteractiveUIText, +): Promise { blankLine(); - info(message); + info(proposedLabel); console.log(task); - const decision = await selectOption(confirmLabel, [ - { label: yesLabel, value: 'yes' }, - { label: noLabel, value: 'no' }, + + return selectOption(ui.actionPrompt, [ + { label: ui.actions.execute, value: 'execute' }, + { label: ui.actions.createIssue, value: 'create_issue' }, + { label: ui.actions.saveTask, value: 'save_task' }, + { label: ui.actions.continue, value: 'continue' }, ]); - return decision === 'yes'; } /** @@ -221,10 +235,12 @@ async function callAI( return { content: response.content, sessionId: response.sessionId, success }; } +export type InteractiveModeAction = 'execute' | 'save_task' | 'create_issue' | 'cancel'; + export interface InteractiveModeResult { - /** Whether the user confirmed with /go */ - confirmed: boolean; - /** The assembled task text (only meaningful when confirmed=true) */ + /** The action selected by the user */ + action: InteractiveModeAction; + /** The assembled task text (only meaningful when action is not 'cancel') */ task: string; } @@ -338,7 +354,7 @@ export async function interactiveMode( if (!result.success) { error(result.content); blankLine(); - return { confirmed: false, task: '' }; + return { action: 'cancel', task: '' }; } history.push({ role: 'assistant', content: result.content }); blankLine(); @@ -354,7 +370,7 @@ export async function interactiveMode( if (input === null) { blankLine(); info('Cancelled'); - return { confirmed: false, task: '' }; + return { action: 'cancel', task: '' }; } const trimmed = input.trim(); @@ -372,7 +388,7 @@ export async function interactiveMode( continue; } log.info('Play command', { task }); - return { confirmed: true, task }; + return { action: 'execute', task }; } if (trimmed.startsWith('/go')) { @@ -400,27 +416,21 @@ export async function interactiveMode( if (!summaryResult.success) { error(summaryResult.content); blankLine(); - return { confirmed: false, task: '' }; + return { action: 'cancel', task: '' }; } const task = summaryResult.content.trim(); - const confirmed = await confirmTask( - task, - prompts.ui.proposed, - prompts.ui.confirm, - lang === 'ja' ? 'はい' : 'Yes', - lang === 'ja' ? 'いいえ' : 'No', - ); - if (!confirmed) { + const selectedAction = await selectPostSummaryAction(task, prompts.ui.proposed, prompts.ui); + if (selectedAction === 'continue' || selectedAction === null) { info(prompts.ui.continuePrompt); continue; } - log.info('Interactive mode confirmed', { messageCount: history.length }); - return { confirmed: true, task }; + log.info('Interactive mode action selected', { action: selectedAction, messageCount: history.length }); + return { action: selectedAction, task }; } if (trimmed === '/cancel') { info(prompts.ui.cancelled); - return { confirmed: false, task: '' }; + return { action: 'cancel', task: '' }; } // Regular input — send to AI @@ -436,7 +446,7 @@ export async function interactiveMode( error(result.content); blankLine(); history.pop(); - return { confirmed: false, task: '' }; + return { action: 'cancel', task: '' }; } history.push({ role: 'assistant', content: result.content }); blankLine(); diff --git a/src/features/tasks/add/index.ts b/src/features/tasks/add/index.ts index 910d192..d90788d 100644 --- a/src/features/tasks/add/index.ts +++ b/src/features/tasks/add/index.ts @@ -9,12 +9,12 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { stringify as stringifyYaml } from 'yaml'; import { promptInput, confirm } from '../../../shared/prompt/index.js'; -import { success, info } from '../../../shared/ui/index.js'; +import { success, info, error } from '../../../shared/ui/index.js'; import { summarizeTaskName, type TaskFileData } from '../../../infra/task/index.js'; import { getPieceDescription } from '../../../infra/config/index.js'; import { determinePiece } from '../execute/selectAndExecute.js'; import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; -import { isIssueReference, resolveIssueTask, parseIssueNumbers } from '../../../infra/github/index.js'; +import { isIssueReference, resolveIssueTask, parseIssueNumbers, createIssue } from '../../../infra/github/index.js'; import { interactiveMode } from '../../interactive/index.js'; const log = createLogger('add-task'); @@ -34,6 +34,74 @@ async function generateFilename(tasksDir: string, taskContent: string, cwd: stri return filename; } +/** + * Save a task file to .takt/tasks/ with YAML format. + * + * Common logic extracted from addTask(). Used by both addTask() + * and saveTaskFromInteractive(). + */ +export async function saveTaskFile( + cwd: string, + taskContent: string, + options?: { piece?: string; issue?: number; worktree?: boolean | string; branch?: string }, +): Promise { + const tasksDir = path.join(cwd, '.takt', 'tasks'); + fs.mkdirSync(tasksDir, { recursive: true }); + + const firstLine = taskContent.split('\n')[0] || taskContent; + const filename = await generateFilename(tasksDir, firstLine, cwd); + + const taskData: TaskFileData = { + task: taskContent, + ...(options?.worktree !== undefined && { worktree: options.worktree }), + ...(options?.branch && { branch: options.branch }), + ...(options?.piece && { piece: options.piece }), + ...(options?.issue !== undefined && { issue: options.issue }), + }; + + const filePath = path.join(tasksDir, filename); + const yamlContent = stringifyYaml(taskData); + fs.writeFileSync(filePath, yamlContent, 'utf-8'); + + log.info('Task created', { filePath, taskData }); + + return filePath; +} + +/** + * Create a GitHub Issue from a task description. + * + * 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 { + 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}`); + } else { + error(`Failed to create issue: ${issueResult.error}`); + } +} + +/** + * Save a task from interactive mode result. + * Does not prompt for worktree/branch settings. + */ +export async function saveTaskFromInteractive( + cwd: string, + task: string, + piece?: string, +): Promise { + const filePath = await saveTaskFile(cwd, task, { piece }); + const filename = path.basename(filePath); + success(`Task created: ${filename}`); + info(` Path: ${filePath}`); + if (piece) info(` Piece: ${piece}`); +} + /** * add command handler * @@ -82,7 +150,13 @@ export async function addTask(cwd: string, task?: string): Promise { // Interactive mode: AI conversation to refine task const result = await interactiveMode(cwd, undefined, pieceContext); - if (!result.confirmed) { + + if (result.action === 'create_issue') { + createIssueFromTask(result.task); + return; + } + + if (result.action !== 'execute' && result.action !== 'save_task') { info('Cancelled.'); return; } @@ -91,11 +165,7 @@ export async function addTask(cwd: string, task?: string): Promise { taskContent = result.task; } - // 3. 要約からファイル名生成 - const firstLine = taskContent.split('\n')[0] || taskContent; - const filename = await generateFilename(tasksDir, firstLine, cwd); - - // 4. ワークツリー/ブランチ設定 + // 3. ワークツリー/ブランチ設定 let worktree: boolean | string | undefined; let branch: string | undefined; @@ -110,27 +180,15 @@ export async function addTask(cwd: string, task?: string): Promise { } } - // 5. YAMLファイル作成 - const taskData: TaskFileData = { task: taskContent }; - if (worktree !== undefined) { - taskData.worktree = worktree; - } - if (branch) { - taskData.branch = branch; - } - if (piece) { - taskData.piece = piece; - } - if (issueNumber !== undefined) { - taskData.issue = issueNumber; - } - - const filePath = path.join(tasksDir, filename); - const yamlContent = stringifyYaml(taskData); - fs.writeFileSync(filePath, yamlContent, 'utf-8'); - - log.info('Task created', { filePath, taskData }); + // 4. YAMLファイル作成 + const filePath = await saveTaskFile(cwd, taskContent, { + piece, + issue: issueNumber, + worktree, + branch, + }); + const filename = path.basename(filePath); success(`Task created: ${filename}`); info(` Path: ${filePath}`); if (worktree) { diff --git a/src/features/tasks/index.ts b/src/features/tasks/index.ts index 04b8805..b647201 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 } from './add/index.js'; +export { addTask, saveTaskFile, saveTaskFromInteractive, createIssueFromTask } from './add/index.js'; export { watchTasks } from './watch/index.js'; export { listTasks, diff --git a/src/infra/github/index.ts b/src/infra/github/index.ts index d0e2fc6..b085622 100644 --- a/src/infra/github/index.ts +++ b/src/infra/github/index.ts @@ -2,7 +2,7 @@ * GitHub integration - barrel exports */ -export type { GitHubIssue, GhCliStatus, CreatePrOptions, CreatePrResult } from './types.js'; +export type { GitHubIssue, GhCliStatus, CreatePrOptions, CreatePrResult, CreateIssueOptions, CreateIssueResult } from './types.js'; export { checkGhCli, @@ -11,6 +11,7 @@ export { parseIssueNumbers, isIssueReference, resolveIssueTask, + createIssue, } from './issue.js'; export { pushBranch, createPullRequest, buildPrBody } from './pr.js'; diff --git a/src/infra/github/issue.ts b/src/infra/github/issue.ts index a8cb65a..7f55ba9 100644 --- a/src/infra/github/issue.ts +++ b/src/infra/github/issue.ts @@ -6,10 +6,10 @@ */ import { execFileSync } from 'node:child_process'; -import { createLogger } from '../../shared/utils/index.js'; -import type { GitHubIssue, GhCliStatus } from './types.js'; +import { createLogger, getErrorMessage } from '../../shared/utils/index.js'; +import type { GitHubIssue, GhCliStatus, CreateIssueOptions, CreateIssueResult } from './types.js'; -export type { GitHubIssue, GhCliStatus }; +export type { GitHubIssue, GhCliStatus, CreateIssueOptions, CreateIssueResult }; const log = createLogger('github'); @@ -172,3 +172,36 @@ export function resolveIssueTask(task: string): string { const issues = issueNumbers.map((n) => fetchIssue(n)); return issues.map(formatIssueAsTask).join('\n\n---\n\n'); } + +/** + * Create a GitHub Issue via `gh issue create`. + */ +export function createIssue(options: CreateIssueOptions): CreateIssueResult { + const ghStatus = checkGhCli(); + if (!ghStatus.available) { + return { success: false, error: ghStatus.error }; + } + + const args = ['issue', 'create', '--title', options.title, '--body', options.body]; + if (options.labels && options.labels.length > 0) { + args.push('--label', options.labels.join(',')); + } + + log.info('Creating issue', { title: options.title }); + + try { + const output = execFileSync('gh', args, { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + + const url = output.trim(); + log.info('Issue created', { url }); + + return { success: true, url }; + } catch (err) { + const errorMessage = getErrorMessage(err); + log.error('Issue creation failed', { error: errorMessage }); + return { success: false, error: errorMessage }; + } +} diff --git a/src/infra/github/types.ts b/src/infra/github/types.ts index e318d40..07e0376 100644 --- a/src/infra/github/types.ts +++ b/src/infra/github/types.ts @@ -35,3 +35,20 @@ export interface CreatePrResult { /** 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; +} diff --git a/src/shared/i18n/labels_en.yaml b/src/shared/i18n/labels_en.yaml index af0c664..00387a3 100644 --- a/src/shared/i18n/labels_en.yaml +++ b/src/shared/i18n/labels_en.yaml @@ -16,7 +16,12 @@ interactive: summarizeFailed: "Failed to summarize conversation. Please try again." continuePrompt: "Okay, continue describing your task." proposed: "Proposed task instruction:" - confirm: "Use this task instruction?" + actionPrompt: "What would you like to do?" + actions: + execute: "Execute now" + createIssue: "Create GitHub Issue" + saveTask: "Save as Task" + continue: "Continue editing" cancelled: "Cancelled" playNoTask: "Please specify task content: /play " previousTask: diff --git a/src/shared/i18n/labels_ja.yaml b/src/shared/i18n/labels_ja.yaml index efbc22f..da7c810 100644 --- a/src/shared/i18n/labels_ja.yaml +++ b/src/shared/i18n/labels_ja.yaml @@ -16,7 +16,12 @@ interactive: summarizeFailed: "会話の要約に失敗しました。再度お試しください。" continuePrompt: "続けてタスク内容を入力してください。" proposed: "提案されたタスク指示:" - confirm: "このタスク指示で進めますか?" + actionPrompt: "どうしますか?" + actions: + execute: "実行する" + createIssue: "GitHub Issueを建てる" + saveTask: "タスクにつむ" + continue: "会話を続ける" cancelled: "キャンセルしました" playNoTask: "タスク内容を指定してください: /play <タスク内容>" previousTask: