From f3b8c772cb6154629579682b1d1b71d1ddddcffd Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Sun, 8 Feb 2026 17:47:22 +0900 Subject: [PATCH] takt: github-issue-142-intarakuteibu (#147) --- .../cli-routing-issue-resolve.test.ts | 258 ++++++++++++++++++ src/__tests__/taskExecution.test.ts | 37 +++ src/app/cli/routing.ts | 102 ++++--- src/features/tasks/execute/resolveTask.ts | 3 +- src/features/tasks/execute/taskExecution.ts | 31 ++- 5 files changed, 382 insertions(+), 49 deletions(-) create mode 100644 src/__tests__/cli-routing-issue-resolve.test.ts diff --git a/src/__tests__/cli-routing-issue-resolve.test.ts b/src/__tests__/cli-routing-issue-resolve.test.ts new file mode 100644 index 0000000..b1cfc41 --- /dev/null +++ b/src/__tests__/cli-routing-issue-resolve.test.ts @@ -0,0 +1,258 @@ +/** + * Tests for issue resolution in routing module. + * + * Verifies that issue references (--issue N or #N positional arg) + * are resolved before interactive mode and passed to selectAndExecuteTask + * via selectOptions.issues. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../shared/ui/index.js', () => ({ + 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(), + }), +})); + +vi.mock('../infra/github/issue.js', () => ({ + parseIssueNumbers: vi.fn(() => []), + checkGhCli: vi.fn(), + fetchIssue: vi.fn(), + formatIssueAsTask: vi.fn(), + isIssueReference: vi.fn(), + resolveIssueTask: vi.fn(), + createIssue: vi.fn(), +})); + +vi.mock('../features/tasks/index.js', () => ({ + selectAndExecuteTask: vi.fn(), + determinePiece: vi.fn(), + saveTaskFromInteractive: vi.fn(), + createIssueFromTask: vi.fn(), +})); + +vi.mock('../features/pipeline/index.js', () => ({ + executePipeline: vi.fn(), +})); + +vi.mock('../features/interactive/index.js', () => ({ + interactiveMode: vi.fn(), +})); + +vi.mock('../infra/config/index.js', () => ({ + getPieceDescription: vi.fn(() => ({ name: 'default', description: 'test piece', pieceStructure: '' })), +})); + +vi.mock('../shared/constants.js', () => ({ + DEFAULT_PIECE_NAME: 'default', +})); + +const mockOpts: Record = {}; + +vi.mock('../app/cli/program.js', () => { + const chainable = { + opts: vi.fn(() => mockOpts), + argument: vi.fn().mockReturnThis(), + action: vi.fn().mockReturnThis(), + }; + return { + program: chainable, + resolvedCwd: '/test/cwd', + pipelineMode: false, + }; +}); + +vi.mock('../app/cli/helpers.js', () => ({ + resolveAgentOverrides: vi.fn(), + parseCreateWorktreeOption: vi.fn(), + isDirectTask: vi.fn(() => false), +})); + +import { checkGhCli, fetchIssue, formatIssueAsTask, parseIssueNumbers } from '../infra/github/issue.js'; +import { selectAndExecuteTask, determinePiece } 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'; +import type { GitHubIssue } from '../infra/github/types.js'; + +const mockCheckGhCli = vi.mocked(checkGhCli); +const mockFetchIssue = vi.mocked(fetchIssue); +const mockFormatIssueAsTask = vi.mocked(formatIssueAsTask); +const mockParseIssueNumbers = vi.mocked(parseIssueNumbers); +const mockSelectAndExecuteTask = vi.mocked(selectAndExecuteTask); +const mockDeterminePiece = vi.mocked(determinePiece); +const mockInteractiveMode = vi.mocked(interactiveMode); +const mockIsDirectTask = vi.mocked(isDirectTask); + +function createMockIssue(number: number): GitHubIssue { + return { + number, + title: `Issue #${number}`, + body: `Body of issue #${number}`, + labels: [], + comments: [], + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + // Reset opts + for (const key of Object.keys(mockOpts)) { + delete mockOpts[key]; + } + // Default setup + mockDeterminePiece.mockResolvedValue('default'); + mockInteractiveMode.mockResolvedValue({ action: 'execute', task: 'summarized task' }); + mockIsDirectTask.mockReturnValue(false); + mockParseIssueNumbers.mockReturnValue([]); +}); + +describe('Issue resolution in routing', () => { + describe('--issue option', () => { + it('should resolve issue and pass to interactive mode when --issue is specified', async () => { + // Given + mockOpts.issue = 131; + const issue131 = createMockIssue(131); + mockCheckGhCli.mockReturnValue({ available: true }); + mockFetchIssue.mockReturnValue(issue131); + mockFormatIssueAsTask.mockReturnValue('## GitHub Issue #131: Issue #131'); + + // When + await executeDefaultAction(); + + // Then: issue should be fetched + expect(mockFetchIssue).toHaveBeenCalledWith(131); + + // Then: interactive mode should receive the formatted issue as initial input + expect(mockInteractiveMode).toHaveBeenCalledWith( + '/test/cwd', + '## GitHub Issue #131: Issue #131', + expect.anything(), + ); + + // Then: selectAndExecuteTask should receive issues in options + expect(mockSelectAndExecuteTask).toHaveBeenCalledWith( + '/test/cwd', + 'summarized task', + expect.objectContaining({ + issues: [issue131], + }), + undefined, + ); + }); + + it('should exit with error when gh CLI is unavailable for --issue', async () => { + // Given + mockOpts.issue = 131; + mockCheckGhCli.mockReturnValue({ + available: false, + error: 'gh CLI is not installed', + }); + + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + // When / Then + await expect(executeDefaultAction()).rejects.toThrow('process.exit called'); + expect(mockExit).toHaveBeenCalledWith(1); + expect(mockInteractiveMode).not.toHaveBeenCalled(); + + mockExit.mockRestore(); + }); + }); + + describe('#N positional argument', () => { + it('should resolve issue reference and pass to interactive mode', async () => { + // Given + const issue131 = createMockIssue(131); + mockIsDirectTask.mockReturnValue(true); + mockCheckGhCli.mockReturnValue({ available: true }); + mockFetchIssue.mockReturnValue(issue131); + mockFormatIssueAsTask.mockReturnValue('## GitHub Issue #131: Issue #131'); + mockParseIssueNumbers.mockReturnValue([131]); + + // When + await executeDefaultAction('#131'); + + // Then: interactive mode should be entered with formatted issue + expect(mockInteractiveMode).toHaveBeenCalledWith( + '/test/cwd', + '## GitHub Issue #131: Issue #131', + expect.anything(), + ); + + // Then: selectAndExecuteTask should receive issues + expect(mockSelectAndExecuteTask).toHaveBeenCalledWith( + '/test/cwd', + 'summarized task', + expect.objectContaining({ + issues: [issue131], + }), + undefined, + ); + }); + }); + + describe('non-issue input', () => { + it('should pass regular text input to interactive mode without issues', async () => { + // When + await executeDefaultAction('refactor the code'); + + // Then: interactive mode should receive the original text + expect(mockInteractiveMode).toHaveBeenCalledWith( + '/test/cwd', + 'refactor the code', + expect.anything(), + ); + + // Then: no issue fetching should occur + expect(mockFetchIssue).not.toHaveBeenCalled(); + + // Then: selectAndExecuteTask should be called without issues + const callArgs = mockSelectAndExecuteTask.mock.calls[0]; + expect(callArgs?.[2]?.issues).toBeUndefined(); + }); + + it('should enter interactive mode with no input when no args provided', async () => { + // When + await executeDefaultAction(); + + // Then: interactive mode should be entered with undefined input + expect(mockInteractiveMode).toHaveBeenCalledWith( + '/test/cwd', + undefined, + expect.anything(), + ); + + // Then: no issue fetching should occur + expect(mockFetchIssue).not.toHaveBeenCalled(); + }); + }); + + describe('interactive mode cancel', () => { + it('should not call selectAndExecuteTask when interactive mode is cancelled', async () => { + // Given + mockOpts.issue = 131; + const issue131 = createMockIssue(131); + mockCheckGhCli.mockReturnValue({ available: true }); + mockFetchIssue.mockReturnValue(issue131); + mockFormatIssueAsTask.mockReturnValue('## GitHub Issue #131'); + mockInteractiveMode.mockResolvedValue({ action: 'cancel', task: '' }); + + // When + await executeDefaultAction(); + + // Then + expect(mockSelectAndExecuteTask).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/__tests__/taskExecution.test.ts b/src/__tests__/taskExecution.test.ts index bec867f..6734c06 100644 --- a/src/__tests__/taskExecution.test.ts +++ b/src/__tests__/taskExecution.test.ts @@ -451,4 +451,41 @@ describe('resolveTaskExecution', () => { expect(mockGetCurrentBranch).not.toHaveBeenCalled(); expect(result.baseBranch).toBeUndefined(); }); + + it('should return issueNumber from task data when specified', async () => { + // Given: Task with issue number + const task: TaskInfo = { + name: 'task-with-issue', + content: 'Fix authentication bug', + filePath: '/tasks/task.yaml', + data: { + task: 'Fix authentication bug', + issue: 131, + }, + }; + + // When + const result = await resolveTaskExecution(task, '/project', 'default'); + + // Then + expect(result.issueNumber).toBe(131); + }); + + it('should return undefined issueNumber when task data has no issue', async () => { + // Given: Task without issue + const task: TaskInfo = { + name: 'task-no-issue', + content: 'Task content', + filePath: '/tasks/task.yaml', + data: { + task: 'Task content', + }, + }; + + // When + const result = await resolveTaskExecution(task, '/project', 'default'); + + // Then + expect(result.issueNumber).toBeUndefined(); + }); }); diff --git a/src/app/cli/routing.ts b/src/app/cli/routing.ts index 1a463ac..7480fe9 100644 --- a/src/app/cli/routing.ts +++ b/src/app/cli/routing.ts @@ -7,7 +7,7 @@ import { info, error } from '../../shared/ui/index.js'; import { getErrorMessage } from '../../shared/utils/index.js'; -import { fetchIssue, formatIssueAsTask, checkGhCli, parseIssueNumbers } from '../../infra/github/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 { executePipeline } from '../../features/pipeline/index.js'; import { interactiveMode } from '../../features/interactive/index.js'; @@ -16,6 +16,48 @@ import { DEFAULT_PIECE_NAME } from '../../shared/constants.js'; import { program, resolvedCwd, pipelineMode } from './program.js'; import { resolveAgentOverrides, parseCreateWorktreeOption, isDirectTask } from './helpers.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 resolved issues and the formatted task text for interactive mode. + * Throws on gh CLI unavailability or fetch failure. + */ +function resolveIssueInput( + issueOption: number | undefined, + task: string | undefined, +): { issues: GitHubIssue[]; initialInput: string } | null { + if (issueOption) { + info('Fetching GitHub Issue...'); + const ghStatus = checkGhCli(); + if (!ghStatus.available) { + throw new Error(ghStatus.error); + } + const issue = fetchIssue(issueOption); + return { issues: [issue], initialInput: formatIssueAsTask(issue) }; + } + + if (task && isDirectTask(task)) { + info('Fetching GitHub Issue...'); + const ghStatus = checkGhCli(); + 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 = issueNumbers.map((n) => fetchIssue(n)); + return { issues, initialInput: issues.map(formatIssueAsTask).join('\n\n---\n\n') }; + } + + return null; +} + /** * Execute default action: handle task execution, pipeline mode, or interactive mode. * Exported for use in slash-command fallback logic. @@ -54,58 +96,28 @@ export async function executeDefaultAction(task?: string): Promise { // --- Normal (interactive) mode --- - // Resolve --task option to task text + // Resolve --task option to task text (direct execution, no interactive mode) const taskFromOption = opts.task as string | undefined; if (taskFromOption) { await selectAndExecuteTask(resolvedCwd, taskFromOption, selectOptions, agentOverrides); return; } - // Resolve --issue N to task text (same as #N) - const issueFromOption = opts.issue as number | undefined; - if (issueFromOption) { - try { - const ghStatus = checkGhCli(); - if (!ghStatus.available) { - throw new Error(ghStatus.error); - } - const issue = fetchIssue(issueFromOption); - const resolvedTask = formatIssueAsTask(issue); - selectOptions.issues = [issue]; - await selectAndExecuteTask(resolvedCwd, resolvedTask, selectOptions, agentOverrides); - } catch (e) { - error(getErrorMessage(e)); - process.exit(1); + // Resolve issue references (--issue N or #N positional arg) before interactive mode + let initialInput: string | undefined = task; + + try { + const issueResult = resolveIssueInput(opts.issue as number | undefined, task); + if (issueResult) { + selectOptions.issues = issueResult.issues; + initialInput = issueResult.initialInput; } - return; + } catch (e) { + error(getErrorMessage(e)); + process.exit(1); } - if (task && isDirectTask(task)) { - // isDirectTask() returns true only for issue references (e.g., "#6" or "#1 #2") - try { - info('Fetching GitHub Issue...'); - const ghStatus = checkGhCli(); - if (!ghStatus.available) { - throw new Error(ghStatus.error); - } - // Parse all issue numbers from task (supports "#6" and "#1 #2") - const tokens = task.trim().split(/\s+/); - const issueNumbers = parseIssueNumbers(tokens); - if (issueNumbers.length === 0) { - throw new Error(`Invalid issue reference: ${task}`); - } - const issues = issueNumbers.map((n) => fetchIssue(n)); - const resolvedTask = issues.map(formatIssueAsTask).join('\n\n---\n\n'); - selectOptions.issues = issues; - await selectAndExecuteTask(resolvedCwd, resolvedTask, selectOptions, agentOverrides); - } catch (e) { - error(getErrorMessage(e)); - process.exit(1); - } - return; - } - - // Non-issue inputs → interactive mode (with optional initial input) + // All paths below go through interactive mode const pieceId = await determinePiece(resolvedCwd, selectOptions.piece); if (pieceId === null) { info('Cancelled'); @@ -113,7 +125,7 @@ export async function executeDefaultAction(task?: string): Promise { } const pieceContext = getPieceDescription(pieceId, resolvedCwd); - const result = await interactiveMode(resolvedCwd, task, pieceContext); + const result = await interactiveMode(resolvedCwd, initialInput, pieceContext); switch (result.action) { case 'execute': diff --git a/src/features/tasks/execute/resolveTask.ts b/src/features/tasks/execute/resolveTask.ts index 3ae6e93..db88636 100644 --- a/src/features/tasks/execute/resolveTask.ts +++ b/src/features/tasks/execute/resolveTask.ts @@ -15,6 +15,7 @@ export interface ResolvedTaskExecution { startMovement?: string; retryNote?: string; autoPr?: boolean; + issueNumber?: number; } /** @@ -68,5 +69,5 @@ export async function resolveTaskExecution( autoPr = globalConfig.autoPr; } - return { execCwd, execPiece, isWorktree, branch, baseBranch, startMovement, retryNote, autoPr }; + return { execCwd, execPiece, isWorktree, branch, baseBranch, startMovement, retryNote, autoPr, issueNumber: data.issue }; } diff --git a/src/features/tasks/execute/taskExecution.ts b/src/features/tasks/execute/taskExecution.ts index 1dcd2bd..b385a5a 100644 --- a/src/features/tasks/execute/taskExecution.ts +++ b/src/features/tasks/execute/taskExecution.ts @@ -16,7 +16,7 @@ import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; import { executePiece } from './pieceExecution.js'; import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js'; import type { TaskExecutionOptions, ExecuteTaskOptions } from './types.js'; -import { createPullRequest, buildPrBody, pushBranch } from '../../../infra/github/index.js'; +import { createPullRequest, buildPrBody, pushBranch, fetchIssue, checkGhCli } from '../../../infra/github/index.js'; import { runWithWorkerPool } from './parallelExecution.js'; import { resolveTaskExecution } from './resolveTask.js'; @@ -24,6 +24,30 @@ export type { TaskExecutionOptions, ExecuteTaskOptions }; const log = createLogger('task'); +/** + * Resolve a GitHub issue from task data's issue number. + * Returns issue array for buildPrBody, or undefined if no issue or gh CLI unavailable. + */ +function resolveTaskIssue(issueNumber: number | undefined): ReturnType[] | undefined { + if (issueNumber === undefined) { + return undefined; + } + + const ghStatus = checkGhCli(); + if (!ghStatus.available) { + log.info('gh CLI unavailable, skipping issue resolution for PR body', { issueNumber }); + return undefined; + } + + try { + const issue = fetchIssue(issueNumber); + return [issue]; + } catch (e) { + log.info('Failed to fetch issue for PR body, continuing without issue info', { issueNumber, error: getErrorMessage(e) }); + return undefined; + } +} + /** * Execute a single task with piece. */ @@ -83,7 +107,7 @@ export async function executeAndCompleteTask( const executionLog: string[] = []; try { - const { execCwd, execPiece, isWorktree, branch, baseBranch, startMovement, retryNote, autoPr } = await resolveTaskExecution(task, cwd, pieceName); + const { execCwd, execPiece, isWorktree, branch, baseBranch, startMovement, retryNote, autoPr, issueNumber } = await resolveTaskExecution(task, cwd, pieceName); // cwd is always the project root; pass it as projectCwd so reports/sessions go there const taskSuccess = await executeTask({ @@ -117,7 +141,8 @@ export async function executeAndCompleteTask( // Branch may already be pushed, continue to PR creation log.info('Branch push from project cwd failed (may already exist)', { error: pushError }); } - const prBody = buildPrBody(undefined, `Task "${task.name}" completed successfully.`); + const issues = resolveTaskIssue(issueNumber); + const prBody = buildPrBody(issues, `Piece \`${execPiece}\` completed successfully.`); const prResult = createPullRequest(cwd, { branch, title: task.name.length > 100 ? `${task.name.slice(0, 97)}...` : task.name,