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(),
|
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', () => ({
|
vi.mock('../shared/prompt/index.js', () => ({
|
||||||
promptInput: vi.fn(),
|
promptInput: vi.fn(),
|
||||||
confirm: vi.fn(),
|
confirm: vi.fn(),
|
||||||
@ -38,15 +33,6 @@ vi.mock('../features/tasks/execute/selectAndExecute.js', () => ({
|
|||||||
determinePiece: vi.fn(),
|
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', () => ({
|
vi.mock('../infra/github/issue.js', () => ({
|
||||||
isIssueReference: vi.fn((s: string) => /^#\d+$/.test(s)),
|
isIssueReference: vi.fn((s: string) => /^#\d+$/.test(s)),
|
||||||
resolveIssueTask: vi.fn(),
|
resolveIssueTask: vi.fn(),
|
||||||
@ -65,16 +51,17 @@ vi.mock('../infra/github/issue.js', () => ({
|
|||||||
|
|
||||||
import { interactiveMode } from '../features/interactive/index.js';
|
import { interactiveMode } from '../features/interactive/index.js';
|
||||||
import { promptInput, confirm } from '../shared/prompt/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 { 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';
|
import { addTask } from '../features/tasks/index.js';
|
||||||
|
|
||||||
const mockResolveIssueTask = vi.mocked(resolveIssueTask);
|
|
||||||
const mockInteractiveMode = vi.mocked(interactiveMode);
|
const mockInteractiveMode = vi.mocked(interactiveMode);
|
||||||
const mockPromptInput = vi.mocked(promptInput);
|
const mockPromptInput = vi.mocked(promptInput);
|
||||||
const mockConfirm = vi.mocked(confirm);
|
const mockConfirm = vi.mocked(confirm);
|
||||||
|
const mockInfo = vi.mocked(info);
|
||||||
const mockDeterminePiece = vi.mocked(determinePiece);
|
const mockDeterminePiece = vi.mocked(determinePiece);
|
||||||
const mockCreateIssue = vi.mocked(createIssue);
|
const mockResolveIssueTask = vi.mocked(resolveIssueTask);
|
||||||
|
|
||||||
let testDir: string;
|
let testDir: string;
|
||||||
|
|
||||||
@ -101,25 +88,38 @@ describe('addTask', () => {
|
|||||||
return fs.readFileSync(path.join(dir, String(taskDir), 'order.md'), 'utf-8');
|
return fs.readFileSync(path.join(dir, String(taskDir), 'order.md'), 'utf-8');
|
||||||
}
|
}
|
||||||
|
|
||||||
it('should create task entry from interactive result', async () => {
|
it('should show usage and exit when task is missing', async () => {
|
||||||
mockInteractiveMode.mockResolvedValue({ action: 'execute', task: '# 認証機能追加\nJWT認証を実装する' });
|
|
||||||
|
|
||||||
await addTask(testDir);
|
await addTask(testDir);
|
||||||
|
|
||||||
const tasks = loadTasks(testDir).tasks;
|
expect(mockInfo).toHaveBeenCalledWith('Usage: takt add <task>');
|
||||||
expect(tasks).toHaveLength(1);
|
expect(mockDeterminePiece).not.toHaveBeenCalled();
|
||||||
expect(tasks[0]?.content).toBeUndefined();
|
expect(fs.existsSync(path.join(testDir, '.takt', 'tasks.yaml'))).toBe(false);
|
||||||
expect(tasks[0]?.task_dir).toBeTypeOf('string');
|
});
|
||||||
expect(readOrderContent(testDir, tasks[0]?.task_dir)).toContain('JWT認証を実装する');
|
|
||||||
expect(tasks[0]?.piece).toBe('default');
|
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 () => {
|
it('should include worktree settings when enabled', async () => {
|
||||||
mockInteractiveMode.mockResolvedValue({ action: 'execute', task: 'Task content' });
|
|
||||||
mockConfirm.mockResolvedValue(true);
|
mockConfirm.mockResolvedValue(true);
|
||||||
mockPromptInput.mockResolvedValueOnce('/custom/path').mockResolvedValueOnce('feat/branch');
|
mockPromptInput.mockResolvedValueOnce('/custom/path').mockResolvedValueOnce('feat/branch');
|
||||||
|
|
||||||
await addTask(testDir);
|
await addTask(testDir, 'Task content');
|
||||||
|
|
||||||
const task = loadTasks(testDir).tasks[0]!;
|
const task = loadTasks(testDir).tasks[0]!;
|
||||||
expect(task.worktree).toBe('/custom/path');
|
expect(task.worktree).toBe('/custom/path');
|
||||||
@ -128,7 +128,6 @@ describe('addTask', () => {
|
|||||||
|
|
||||||
it('should create task from issue reference without interactive mode', async () => {
|
it('should create task from issue reference without interactive mode', async () => {
|
||||||
mockResolveIssueTask.mockReturnValue('Issue #99: Fix login timeout');
|
mockResolveIssueTask.mockReturnValue('Issue #99: Fix login timeout');
|
||||||
mockConfirm.mockResolvedValue(false);
|
|
||||||
|
|
||||||
await addTask(testDir, '#99');
|
await addTask(testDir, '#99');
|
||||||
|
|
||||||
@ -142,37 +141,8 @@ describe('addTask', () => {
|
|||||||
it('should not create task when piece selection is cancelled', async () => {
|
it('should not create task when piece selection is cancelled', async () => {
|
||||||
mockDeterminePiece.mockResolvedValue(null);
|
mockDeterminePiece.mockResolvedValue(null);
|
||||||
|
|
||||||
await addTask(testDir);
|
await addTask(testDir, 'Task content');
|
||||||
|
|
||||||
expect(fs.existsSync(path.join(testDir, '.takt', 'tasks.yaml'))).toBe(false);
|
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
|
program
|
||||||
.command('add')
|
.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")')
|
.argument('[task]', 'Task description or GitHub issue reference (e.g. "#28")')
|
||||||
.action(async (task?: string) => {
|
.action(async (task?: string) => {
|
||||||
await addTask(resolvedCwd, task);
|
await addTask(resolvedCwd, task);
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* add command implementation
|
* add command implementation
|
||||||
*
|
*
|
||||||
* Starts an AI conversation to refine task requirements,
|
* Appends a task record to .takt/tasks.yaml.
|
||||||
* then appends a task record to .takt/tasks.yaml.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as path from 'node:path';
|
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 { promptInput, confirm } from '../../../shared/prompt/index.js';
|
||||||
import { success, info, error } from '../../../shared/ui/index.js';
|
import { success, info, error } from '../../../shared/ui/index.js';
|
||||||
import { TaskRunner, type TaskFileData } from '../../../infra/task/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 { determinePiece } from '../execute/selectAndExecute.js';
|
||||||
import { createLogger, getErrorMessage, generateReportDir } from '../../../shared/utils/index.js';
|
import { createLogger, getErrorMessage, generateReportDir } from '../../../shared/utils/index.js';
|
||||||
import { isIssueReference, resolveIssueTask, parseIssueNumbers, createIssue } from '../../../infra/github/index.js';
|
import { isIssueReference, resolveIssueTask, parseIssueNumbers, createIssue } from '../../../infra/github/index.js';
|
||||||
import { interactiveMode } from '../../interactive/index.js';
|
|
||||||
|
|
||||||
const log = createLogger('add-task');
|
const log = createLogger('add-task');
|
||||||
|
|
||||||
@ -163,66 +160,44 @@ export async function saveTaskFromInteractive(
|
|||||||
* add command handler
|
* add command handler
|
||||||
*
|
*
|
||||||
* Flow:
|
* Flow:
|
||||||
* A) Issue参照の場合: issue取得 → ピース選択 → ワークツリー設定 → YAML作成
|
* A) 引数なし: Usage表示して終了
|
||||||
* B) それ以外: ピース選択 → AI対話モード → ワークツリー設定 → YAML作成
|
* B) Issue参照の場合: issue取得 → ピース選択 → ワークツリー設定 → YAML作成
|
||||||
|
* C) 通常入力: 引数をそのまま保存
|
||||||
*/
|
*/
|
||||||
export async function addTask(cwd: string, task?: string): Promise<void> {
|
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 taskContent: string;
|
||||||
let issueNumber: number | undefined;
|
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
|
// Issue reference: fetch issue and use directly as task content
|
||||||
info('Fetching GitHub Issue...');
|
info('Fetching GitHub Issue...');
|
||||||
try {
|
try {
|
||||||
taskContent = resolveIssueTask(task);
|
taskContent = resolveIssueTask(trimmedTask);
|
||||||
const numbers = parseIssueNumbers([task]);
|
const numbers = parseIssueNumbers([trimmedTask]);
|
||||||
if (numbers.length > 0) {
|
if (numbers.length > 0) {
|
||||||
issueNumber = numbers[0];
|
issueNumber = numbers[0];
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = getErrorMessage(e);
|
const msg = getErrorMessage(e);
|
||||||
log.error('Failed to fetch GitHub Issue', { task, error: msg });
|
log.error('Failed to fetch GitHub Issue', { task: trimmedTask, error: msg });
|
||||||
info(`Failed to fetch issue ${task}: ${msg}`);
|
info(`Failed to fetch issue ${trimmedTask}: ${msg}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ピース選択(issue取得成功後)
|
|
||||||
const pieceId = await determinePiece(cwd);
|
|
||||||
if (pieceId === null) {
|
|
||||||
info('Cancelled.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
piece = pieceId;
|
|
||||||
} else {
|
} else {
|
||||||
// ピース選択を先に行い、結果を対話モードに渡す
|
taskContent = rawTask;
|
||||||
const pieceId = await determinePiece(cwd);
|
}
|
||||||
if (pieceId === null) {
|
|
||||||
info('Cancelled.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
piece = pieceId;
|
|
||||||
|
|
||||||
const globalConfig = loadGlobalConfig();
|
const piece = await determinePiece(cwd);
|
||||||
const previewCount = globalConfig.interactivePreviewMovements;
|
if (piece === null) {
|
||||||
const pieceContext = getPieceDescription(pieceId, cwd, previewCount);
|
info('Cancelled.');
|
||||||
|
return;
|
||||||
// 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設定
|
// 3. ワークツリー/ブランチ/PR設定
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user