takt: github-issue-194-takt-add (#206)

This commit is contained in:
nrs 2026-02-10 20:10:08 +09:00 committed by GitHub
parent 6e67f864f5
commit d185039c73
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 52 additions and 107 deletions

View File

@ -8,11 +8,6 @@ vi.mock('../features/interactive/index.js', () => ({
interactiveMode: vi.fn(),
}));
vi.mock('../infra/config/global/globalConfig.js', () => ({
loadGlobalConfig: vi.fn(() => ({ provider: 'claude' })),
getBuiltinPiecesEnabled: vi.fn().mockReturnValue(true),
}));
vi.mock('../shared/prompt/index.js', () => ({
promptInput: vi.fn(),
confirm: vi.fn(),
@ -38,15 +33,6 @@ vi.mock('../features/tasks/execute/selectAndExecute.js', () => ({
determinePiece: vi.fn(),
}));
vi.mock('../infra/config/loaders/pieceResolver.js', () => ({
getPieceDescription: vi.fn(() => ({
name: 'default',
description: '',
pieceStructure: '1. implement\n2. review',
movementPreviews: [],
})),
}));
vi.mock('../infra/github/issue.js', () => ({
isIssueReference: vi.fn((s: string) => /^#\d+$/.test(s)),
resolveIssueTask: vi.fn(),
@ -65,16 +51,17 @@ vi.mock('../infra/github/issue.js', () => ({
import { interactiveMode } from '../features/interactive/index.js';
import { promptInput, confirm } from '../shared/prompt/index.js';
import { info } from '../shared/ui/index.js';
import { determinePiece } from '../features/tasks/execute/selectAndExecute.js';
import { resolveIssueTask, createIssue } from '../infra/github/issue.js';
import { resolveIssueTask } from '../infra/github/issue.js';
import { addTask } from '../features/tasks/index.js';
const mockResolveIssueTask = vi.mocked(resolveIssueTask);
const mockInteractiveMode = vi.mocked(interactiveMode);
const mockPromptInput = vi.mocked(promptInput);
const mockConfirm = vi.mocked(confirm);
const mockInfo = vi.mocked(info);
const mockDeterminePiece = vi.mocked(determinePiece);
const mockCreateIssue = vi.mocked(createIssue);
const mockResolveIssueTask = vi.mocked(resolveIssueTask);
let testDir: string;
@ -101,25 +88,38 @@ describe('addTask', () => {
return fs.readFileSync(path.join(dir, String(taskDir), 'order.md'), 'utf-8');
}
it('should create task entry from interactive result', async () => {
mockInteractiveMode.mockResolvedValue({ action: 'execute', task: '# 認証機能追加\nJWT認証を実装する' });
it('should show usage and exit when task is missing', async () => {
await addTask(testDir);
const tasks = loadTasks(testDir).tasks;
expect(tasks).toHaveLength(1);
expect(tasks[0]?.content).toBeUndefined();
expect(tasks[0]?.task_dir).toBeTypeOf('string');
expect(readOrderContent(testDir, tasks[0]?.task_dir)).toContain('JWT認証を実装する');
expect(tasks[0]?.piece).toBe('default');
expect(mockInfo).toHaveBeenCalledWith('Usage: takt add <task>');
expect(mockDeterminePiece).not.toHaveBeenCalled();
expect(fs.existsSync(path.join(testDir, '.takt', 'tasks.yaml'))).toBe(false);
});
it('should show usage and exit when task is blank', async () => {
await addTask(testDir, ' ');
expect(mockInfo).toHaveBeenCalledWith('Usage: takt add <task>');
expect(mockDeterminePiece).not.toHaveBeenCalled();
expect(fs.existsSync(path.join(testDir, '.takt', 'tasks.yaml'))).toBe(false);
});
it('should save plain text task without interactive mode', async () => {
await addTask(testDir, ' JWT認証を実装する ');
expect(mockInteractiveMode).not.toHaveBeenCalled();
const task = loadTasks(testDir).tasks[0]!;
expect(task.content).toBeUndefined();
expect(task.task_dir).toBeTypeOf('string');
expect(readOrderContent(testDir, task.task_dir)).toContain('JWT認証を実装する');
expect(task.piece).toBe('default');
});
it('should include worktree settings when enabled', async () => {
mockInteractiveMode.mockResolvedValue({ action: 'execute', task: 'Task content' });
mockConfirm.mockResolvedValue(true);
mockPromptInput.mockResolvedValueOnce('/custom/path').mockResolvedValueOnce('feat/branch');
await addTask(testDir);
await addTask(testDir, 'Task content');
const task = loadTasks(testDir).tasks[0]!;
expect(task.worktree).toBe('/custom/path');
@ -128,7 +128,6 @@ describe('addTask', () => {
it('should create task from issue reference without interactive mode', async () => {
mockResolveIssueTask.mockReturnValue('Issue #99: Fix login timeout');
mockConfirm.mockResolvedValue(false);
await addTask(testDir, '#99');
@ -142,37 +141,8 @@ describe('addTask', () => {
it('should not create task when piece selection is cancelled', async () => {
mockDeterminePiece.mockResolvedValue(null);
await addTask(testDir);
await addTask(testDir, 'Task content');
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).toBeUndefined();
expect(readOrderContent(testDir, tasks[0]?.task_dir)).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

@ -30,7 +30,7 @@ program
program
.command('add')
.description('Add a new task (interactive AI conversation)')
.description('Add a new task')
.argument('[task]', 'Task description or GitHub issue reference (e.g. "#28")')
.action(async (task?: string) => {
await addTask(resolvedCwd, task);

View File

@ -1,8 +1,7 @@
/**
* add command implementation
*
* Starts an AI conversation to refine task requirements,
* then appends a task record to .takt/tasks.yaml.
* Appends a task record to .takt/tasks.yaml.
*/
import * as path from 'node:path';
@ -10,11 +9,9 @@ import * as fs from 'node:fs';
import { promptInput, confirm } from '../../../shared/prompt/index.js';
import { success, info, error } from '../../../shared/ui/index.js';
import { TaskRunner, type TaskFileData } from '../../../infra/task/index.js';
import { getPieceDescription, loadGlobalConfig } from '../../../infra/config/index.js';
import { determinePiece } from '../execute/selectAndExecute.js';
import { createLogger, getErrorMessage, generateReportDir } from '../../../shared/utils/index.js';
import { isIssueReference, resolveIssueTask, parseIssueNumbers, createIssue } from '../../../infra/github/index.js';
import { interactiveMode } from '../../interactive/index.js';
const log = createLogger('add-task');
@ -163,66 +160,44 @@ export async function saveTaskFromInteractive(
* add command handler
*
* Flow:
* A) Issue参照の場合: issue取得 YAML作成
* B) それ以外: ピース選択 AI対話モード YAML作成
* A) 引数なし: Usage表示して終了
* B) Issue参照の場合: issue取得 YAML作成
* C) 通常入力: 引数をそのまま保存
*/
export async function addTask(cwd: string, task?: string): Promise<void> {
// ピース選択とタスク内容の決定
const rawTask = task ?? '';
const trimmedTask = rawTask.trim();
if (!trimmedTask) {
info('Usage: takt add <task>');
return;
}
let taskContent: string;
let issueNumber: number | undefined;
let piece: string | undefined;
if (task && isIssueReference(task)) {
if (isIssueReference(trimmedTask)) {
// Issue reference: fetch issue and use directly as task content
info('Fetching GitHub Issue...');
try {
taskContent = resolveIssueTask(task);
const numbers = parseIssueNumbers([task]);
taskContent = resolveIssueTask(trimmedTask);
const numbers = parseIssueNumbers([trimmedTask]);
if (numbers.length > 0) {
issueNumber = numbers[0];
}
} catch (e) {
const msg = getErrorMessage(e);
log.error('Failed to fetch GitHub Issue', { task, error: msg });
info(`Failed to fetch issue ${task}: ${msg}`);
log.error('Failed to fetch GitHub Issue', { task: trimmedTask, error: msg });
info(`Failed to fetch issue ${trimmedTask}: ${msg}`);
return;
}
// ピース選択issue取得成功後
const pieceId = await determinePiece(cwd);
if (pieceId === null) {
info('Cancelled.');
return;
}
piece = pieceId;
} else {
// ピース選択を先に行い、結果を対話モードに渡す
const pieceId = await determinePiece(cwd);
if (pieceId === null) {
info('Cancelled.');
return;
}
piece = pieceId;
taskContent = rawTask;
}
const globalConfig = loadGlobalConfig();
const previewCount = globalConfig.interactivePreviewMovements;
const pieceContext = getPieceDescription(pieceId, cwd, previewCount);
// Interactive mode: AI conversation to refine task
const result = await interactiveMode(cwd, undefined, pieceContext);
if (result.action === 'create_issue') {
await createIssueAndSaveTask(cwd, result.task, piece);
return;
}
if (result.action !== 'execute' && result.action !== 'save_task') {
info('Cancelled.');
return;
}
// interactiveMode already returns a summarized task from conversation
taskContent = result.task;
const piece = await determinePiece(cwd);
if (piece === null) {
info('Cancelled.');
return;
}
// 3. ワークツリー/ブランチ/PR設定