takt: github-issue-194-takt-add (#206)
This commit is contained in:
parent
6e67f864f5
commit
d185039c73
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,67 +160,45 @@ 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) {
|
||||
taskContent = rawTask;
|
||||
}
|
||||
|
||||
const piece = await determinePiece(cwd);
|
||||
if (piece === null) {
|
||||
info('Cancelled.');
|
||||
return;
|
||||
}
|
||||
piece = pieceId;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 3. ワークツリー/ブランチ/PR設定
|
||||
const settings = await promptWorktreeSettings();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user