takt: github-issue-142-intarakuteibu (#147)
This commit is contained in:
parent
e48c267562
commit
f3b8c772cb
258
src/__tests__/cli-routing-issue-resolve.test.ts
Normal file
258
src/__tests__/cli-routing-issue-resolve.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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) {
|
||||
// Resolve issue references (--issue N or #N positional arg) before interactive mode
|
||||
let initialInput: string | undefined = task;
|
||||
|
||||
try {
|
||||
const ghStatus = checkGhCli();
|
||||
if (!ghStatus.available) {
|
||||
throw new Error(ghStatus.error);
|
||||
const issueResult = resolveIssueInput(opts.issue as number | undefined, task);
|
||||
if (issueResult) {
|
||||
selectOptions.issues = issueResult.issues;
|
||||
initialInput = issueResult.initialInput;
|
||||
}
|
||||
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);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
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':
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user