takt: github-issue-193-takt-add-issue (#199)

This commit is contained in:
nrs 2026-02-10 07:50:56 +09:00 committed by GitHub
parent c7c50db46a
commit f8b9d4607f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 157 additions and 35 deletions

View File

@ -66,7 +66,7 @@ vi.mock('../infra/github/issue.js', () => ({
import { interactiveMode } from '../features/interactive/index.js';
import { promptInput, confirm } from '../shared/prompt/index.js';
import { determinePiece } from '../features/tasks/execute/selectAndExecute.js';
import { resolveIssueTask } from '../infra/github/issue.js';
import { resolveIssueTask, createIssue } from '../infra/github/issue.js';
import { addTask } from '../features/tasks/index.js';
const mockResolveIssueTask = vi.mocked(resolveIssueTask);
@ -74,6 +74,7 @@ const mockInteractiveMode = vi.mocked(interactiveMode);
const mockPromptInput = vi.mocked(promptInput);
const mockConfirm = vi.mocked(confirm);
const mockDeterminePiece = vi.mocked(determinePiece);
const mockCreateIssue = vi.mocked(createIssue);
let testDir: string;
@ -138,4 +139,32 @@ describe('addTask', () => {
expect(fs.existsSync(path.join(testDir, '.takt', 'tasks.yaml'))).toBe(false);
});
it('should create issue and save task when create_issue action is chosen', async () => {
// Given
mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'New feature' });
mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/55' });
mockConfirm.mockResolvedValue(false);
// When
await addTask(testDir);
// Then
const tasks = loadTasks(testDir).tasks;
expect(tasks).toHaveLength(1);
expect(tasks[0]?.issue).toBe(55);
expect(tasks[0]?.content).toContain('New feature');
});
it('should not save task when issue creation fails in create_issue action', async () => {
// Given
mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'New feature' });
mockCreateIssue.mockReturnValue({ success: false, error: 'auth failed' });
// When
await addTask(testDir);
// Then
expect(fs.existsSync(path.join(testDir, '.takt', 'tasks.yaml'))).toBe(false);
});
});

View File

@ -36,7 +36,7 @@ vi.mock('../features/tasks/index.js', () => ({
selectAndExecuteTask: vi.fn(),
determinePiece: vi.fn(),
saveTaskFromInteractive: vi.fn(),
createIssueFromTask: vi.fn(),
createIssueAndSaveTask: vi.fn(),
}));
vi.mock('../features/pipeline/index.js', () => ({
@ -83,7 +83,7 @@ vi.mock('../app/cli/helpers.js', () => ({
}));
import { checkGhCli, fetchIssue, formatIssueAsTask, parseIssueNumbers } from '../infra/github/issue.js';
import { selectAndExecuteTask, determinePiece } from '../features/tasks/index.js';
import { selectAndExecuteTask, determinePiece, createIssueAndSaveTask } from '../features/tasks/index.js';
import { interactiveMode } from '../features/interactive/index.js';
import { isDirectTask } from '../app/cli/helpers.js';
import { executeDefaultAction } from '../app/cli/routing.js';
@ -95,6 +95,7 @@ const mockFormatIssueAsTask = vi.mocked(formatIssueAsTask);
const mockParseIssueNumbers = vi.mocked(parseIssueNumbers);
const mockSelectAndExecuteTask = vi.mocked(selectAndExecuteTask);
const mockDeterminePiece = vi.mocked(determinePiece);
const mockCreateIssueAndSaveTask = vi.mocked(createIssueAndSaveTask);
const mockInteractiveMode = vi.mocked(interactiveMode);
const mockIsDirectTask = vi.mocked(isDirectTask);
@ -261,4 +262,32 @@ describe('Issue resolution in routing', () => {
expect(mockSelectAndExecuteTask).not.toHaveBeenCalled();
});
});
describe('create_issue action', () => {
it('should delegate to createIssueAndSaveTask with cwd, task, and pieceId', async () => {
// Given
mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'New feature request' });
// When
await executeDefaultAction();
// Then: createIssueAndSaveTask should be called with correct args
expect(mockCreateIssueAndSaveTask).toHaveBeenCalledWith(
'/test/cwd',
'New feature request',
'default',
);
});
it('should not call selectAndExecuteTask when create_issue action is chosen', async () => {
// Given
mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'New feature request' });
// When
await executeDefaultAction();
// Then: selectAndExecuteTask should NOT be called
expect(mockSelectAndExecuteTask).not.toHaveBeenCalled();
});
});
});

View File

@ -114,6 +114,42 @@ describe('createIssueFromTask', () => {
expect(mockSuccess).not.toHaveBeenCalled();
});
describe('return value', () => {
it('should return issue number when creation succeeds', () => {
// Given
mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/42' });
// When
const result = createIssueFromTask('Test task');
// Then
expect(result).toBe(42);
});
it('should return undefined when creation fails', () => {
// Given
mockCreateIssue.mockReturnValue({ success: false, error: 'auth failed' });
// When
const result = createIssueFromTask('Test task');
// Then
expect(result).toBeUndefined();
});
it('should return undefined and display error when URL has non-numeric suffix', () => {
// Given
mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/abc' });
// When
const result = createIssueFromTask('Test task');
// Then
expect(result).toBeUndefined();
expect(mockError).toHaveBeenCalledWith('Failed to extract issue number from URL');
});
});
it('should use first line as title and full text as body for multi-line task', () => {
// Given: multi-line task
const task = 'First line title\nSecond line details\nThird line more info';

View File

@ -122,4 +122,16 @@ describe('saveTaskFromInteractive', () => {
expect(mockInfo).toHaveBeenCalledWith(' Piece: review');
});
it('should record issue number in tasks.yaml when issue option is provided', async () => {
// Given: user declines worktree
mockConfirm.mockResolvedValueOnce(false);
// When
await saveTaskFromInteractive(testDir, 'Fix login bug', 'default', { issue: 42 });
// Then
const task = loadTasks(testDir).tasks[0]!;
expect(task.issue).toBe(42);
});
});

View File

@ -9,7 +9,7 @@ import { info, error } from '../../shared/ui/index.js';
import { getErrorMessage } from '../../shared/utils/index.js';
import { getLabel } from '../../shared/i18n/index.js';
import { fetchIssue, formatIssueAsTask, checkGhCli, parseIssueNumbers, type GitHubIssue } from '../../infra/github/index.js';
import { selectAndExecuteTask, determinePiece, saveTaskFromInteractive, createIssueFromTask, type SelectAndExecuteOptions } from '../../features/tasks/index.js';
import { selectAndExecuteTask, determinePiece, saveTaskFromInteractive, createIssueAndSaveTask, type SelectAndExecuteOptions } from '../../features/tasks/index.js';
import { executePipeline } from '../../features/pipeline/index.js';
import {
interactiveMode,
@ -188,7 +188,7 @@ export async function executeDefaultAction(task?: string): Promise<void> {
break;
case 'create_issue':
createIssueFromTask(result.task);
await createIssueAndSaveTask(resolvedCwd, result.task, pieceId);
break;
case 'save_task':

View File

@ -48,15 +48,22 @@ export async function saveTaskFile(
* Extracts the first line as the issue title (truncated to 100 chars),
* uses the full task as the body, and displays success/error messages.
*/
export function createIssueFromTask(task: string): void {
export function createIssueFromTask(task: string): number | undefined {
info('Creating GitHub Issue...');
const firstLine = task.split('\n')[0] || task;
const title = firstLine.length > 100 ? `${firstLine.slice(0, 97)}...` : firstLine;
const issueResult = createIssue({ title, body: task });
if (issueResult.success) {
success(`Issue created: ${issueResult.url}`);
const num = Number(issueResult.url!.split('/').pop());
if (Number.isNaN(num)) {
error('Failed to extract issue number from URL');
return undefined;
}
return num;
} else {
error(`Failed to create issue: ${issueResult.error}`);
return undefined;
}
}
@ -66,6 +73,38 @@ interface WorktreeSettings {
autoPr?: boolean;
}
function displayTaskCreationResult(
created: { taskName: string; tasksFile: string },
settings: WorktreeSettings,
piece?: string,
): void {
success(`Task created: ${created.taskName}`);
info(` File: ${created.tasksFile}`);
if (settings.worktree) {
info(` Worktree: ${typeof settings.worktree === 'string' ? settings.worktree : 'auto'}`);
}
if (settings.branch) {
info(` Branch: ${settings.branch}`);
}
if (settings.autoPr) {
info(` Auto-PR: yes`);
}
if (piece) info(` Piece: ${piece}`);
}
/**
* Create a GitHub Issue and save the task to .takt/tasks.yaml.
*
* Combines issue creation and task saving into a single workflow.
* If issue creation fails, no task is saved.
*/
export async function createIssueAndSaveTask(cwd: string, task: string, piece?: string): Promise<void> {
const issueNumber = createIssueFromTask(task);
if (issueNumber !== undefined) {
await saveTaskFromInteractive(cwd, task, piece, { issue: issueNumber });
}
}
async function promptWorktreeSettings(): Promise<WorktreeSettings> {
const useWorktree = await confirm('Create worktree?', true);
if (!useWorktree) {
@ -91,21 +130,11 @@ export async function saveTaskFromInteractive(
cwd: string,
task: string,
piece?: string,
options?: { issue?: number },
): Promise<void> {
const settings = await promptWorktreeSettings();
const created = await saveTaskFile(cwd, task, { piece, ...settings });
success(`Task created: ${created.taskName}`);
info(` File: ${created.tasksFile}`);
if (settings.worktree) {
info(` Worktree: ${typeof settings.worktree === 'string' ? settings.worktree : 'auto'}`);
}
if (settings.branch) {
info(` Branch: ${settings.branch}`);
}
if (settings.autoPr) {
info(` Auto-PR: yes`);
}
if (piece) info(` Piece: ${piece}`);
const created = await saveTaskFile(cwd, task, { piece, issue: options?.issue, ...settings });
displayTaskCreationResult(created, settings, piece);
}
/**
@ -161,7 +190,7 @@ export async function addTask(cwd: string, task?: string): Promise<void> {
const result = await interactiveMode(cwd, undefined, pieceContext);
if (result.action === 'create_issue') {
createIssueFromTask(result.task);
await createIssueAndSaveTask(cwd, result.task, piece);
return;
}
@ -184,18 +213,5 @@ export async function addTask(cwd: string, task?: string): Promise<void> {
...settings,
});
success(`Task created: ${created.taskName}`);
info(` File: ${created.tasksFile}`);
if (settings.worktree) {
info(` Worktree: ${typeof settings.worktree === 'string' ? settings.worktree : 'auto'}`);
}
if (settings.branch) {
info(` Branch: ${settings.branch}`);
}
if (settings.autoPr) {
info(` Auto-PR: yes`);
}
if (piece) {
info(` Piece: ${piece}`);
}
displayTaskCreationResult(created, settings, piece);
}

View File

@ -14,7 +14,7 @@ export {
type SelectAndExecuteOptions,
type WorktreeConfirmationResult,
} from './execute/selectAndExecute.js';
export { addTask, saveTaskFile, saveTaskFromInteractive, createIssueFromTask } from './add/index.js';
export { addTask, saveTaskFile, saveTaskFromInteractive, createIssueFromTask, createIssueAndSaveTask } from './add/index.js';
export { watchTasks } from './watch/index.js';
export {
listTasks,