takt: github-issue-142-intarakuteibu (#147)

This commit is contained in:
nrs 2026-02-08 17:47:22 +09:00 committed by GitHub
parent e48c267562
commit f3b8c772cb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 382 additions and 49 deletions

View File

@ -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<Record<string, unknown>>()),
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<string, unknown> = {};
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();
});
});
});

View File

@ -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();
});
});

View File

@ -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<void> {
// --- 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<void> {
}
const pieceContext = getPieceDescription(pieceId, resolvedCwd);
const result = await interactiveMode(resolvedCwd, task, pieceContext);
const result = await interactiveMode(resolvedCwd, initialInput, pieceContext);
switch (result.action) {
case 'execute':

View File

@ -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 };
}

View File

@ -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<typeof fetchIssue>[] | 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,