/** * add command implementation * * Appends a task record to .takt/tasks.yaml. */ import * as path from 'node:path'; import * as fs from 'node:fs'; import { promptInput, confirm, selectOption } from '../../../shared/prompt/index.js'; import { success, info, error, withProgress } from '../../../shared/ui/index.js'; import { getLabel } from '../../../shared/i18n/index.js'; import type { Language } from '../../../core/models/types.js'; import { TaskRunner, type TaskFileData, summarizeTaskName } from '../../../infra/task/index.js'; import { determinePiece } from '../execute/selectAndExecute.js'; import { createLogger, getErrorMessage, generateReportDir } from '../../../shared/utils/index.js'; import { isIssueReference, resolveIssueTask, parseIssueNumbers } from '../../../infra/github/index.js'; import { getGitProvider } from '../../../infra/git/index.js'; import { firstLine } from '../../../infra/task/naming.js'; const log = createLogger('add-task'); function resolveUniqueTaskSlug(cwd: string, baseSlug: string): string { let sequence = 1; let slug = baseSlug; let taskDir = path.join(cwd, '.takt', 'tasks', slug); while (fs.existsSync(taskDir)) { sequence += 1; slug = `${baseSlug}-${sequence}`; taskDir = path.join(cwd, '.takt', 'tasks', slug); } return slug; } /** * Save a task entry to .takt/tasks.yaml. * * Common logic extracted from addTask(). Used by both addTask() * and saveTaskFromInteractive(). */ export async function saveTaskFile( cwd: string, taskContent: string, options?: { piece?: string; issue?: number; worktree?: boolean | string; branch?: string; autoPr?: boolean; draftPr?: boolean }, ): Promise<{ taskName: string; tasksFile: string }> { const runner = new TaskRunner(cwd); const slug = await summarizeTaskName(taskContent, { cwd }); const summary = firstLine(taskContent); const taskDirSlug = resolveUniqueTaskSlug(cwd, generateReportDir(taskContent)); const taskDir = path.join(cwd, '.takt', 'tasks', taskDirSlug); const taskDirRelative = `.takt/tasks/${taskDirSlug}`; const orderPath = path.join(taskDir, 'order.md'); fs.mkdirSync(taskDir, { recursive: true }); fs.writeFileSync(orderPath, taskContent, 'utf-8'); const config: Omit = { ...(options?.worktree !== undefined && { worktree: options.worktree }), ...(options?.branch && { branch: options.branch }), ...(options?.piece && { piece: options.piece }), ...(options?.issue !== undefined && { issue: options.issue }), ...(options?.autoPr !== undefined && { auto_pr: options.autoPr }), ...(options?.draftPr !== undefined && { draft_pr: options.draftPr }), }; const created = runner.addTask(taskContent, { ...config, task_dir: taskDirRelative, slug, summary, }); const tasksFile = path.join(cwd, '.takt', 'tasks.yaml'); log.info('Task created', { taskName: created.name, tasksFile, config }); return { taskName: created.name, tasksFile }; } const TITLE_MAX_LENGTH = 100; const TITLE_TRUNCATE_LENGTH = 97; const MARKDOWN_HEADING_PATTERN = /^#{1,3}\s+\S/; /** * Extract a clean title from a task description. * * Prefers the first Markdown heading (h1-h3) if present. * Falls back to the first non-empty line otherwise. * Truncates to 100 characters (97 + "...") when exceeded. */ export function extractTitle(task: string): string { const lines = task.split('\n'); const headingLine = lines.find((l) => MARKDOWN_HEADING_PATTERN.test(l)); const titleLine = headingLine ? headingLine.replace(/^#{1,3}\s+/, '') : (lines.find((l) => l.trim().length > 0) ?? task); return titleLine.length > TITLE_MAX_LENGTH ? `${titleLine.slice(0, TITLE_TRUNCATE_LENGTH)}...` : titleLine; } /** * Create a GitHub Issue from a task description. * * Extracts the first Markdown heading (h1-h3) as the issue title, * falling back to the first non-empty line. Truncates to 100 chars. * Uses the full task as the body, and displays success/error messages. */ export function createIssueFromTask(task: string, options?: { labels?: string[] }): number | undefined { info('Creating GitHub Issue...'); const title = extractTitle(task); const effectiveLabels = options?.labels?.filter((l) => l.length > 0) ?? []; const labels = effectiveLabels.length > 0 ? effectiveLabels : undefined; const issueResult = getGitProvider().createIssue({ title, body: task, labels }); if (issueResult.success) { if (!issueResult.url) { error('Failed to extract issue number from URL'); return undefined; } 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; } } interface WorktreeSettings { worktree?: boolean | string; branch?: string; autoPr?: boolean; draftPr?: 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 (settings.draftPr) { info(` Draft 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, options?: { confirmAtEndMessage?: string; labels?: string[] }, ): Promise { const issueNumber = createIssueFromTask(task, { labels: options?.labels }); if (issueNumber !== undefined) { await saveTaskFromInteractive(cwd, task, piece, { issue: issueNumber, confirmAtEndMessage: options?.confirmAtEndMessage, }); } } /** * Prompt user to select a label for the GitHub Issue. * * Presents 4 fixed options: None, bug, enhancement, custom input. * Returns an array of selected labels (empty if none selected). */ export async function promptLabelSelection(lang: Language): Promise { const selected = await selectOption( getLabel('issue.labelSelection.prompt', lang), [ { label: getLabel('issue.labelSelection.none', lang), value: 'none' }, { label: 'bug', value: 'bug' }, { label: 'enhancement', value: 'enhancement' }, { label: getLabel('issue.labelSelection.custom', lang), value: 'custom' }, ], ); if (selected === null || selected === 'none') return []; if (selected === 'custom') { const customLabel = await promptInput(getLabel('issue.labelSelection.customPrompt', lang)); return customLabel?.split(',').map((l) => l.trim()).filter((l) => l.length > 0) ?? []; } return [selected]; } async function promptWorktreeSettings(): Promise { const customPath = await promptInput('Worktree path (Enter for auto)'); const worktree: boolean | string = customPath || true; const customBranch = await promptInput('Branch name (Enter for auto)'); const branch = customBranch || undefined; const autoPr = await confirm('Auto-create PR?', true); const draftPr = autoPr ? await confirm('Create as draft?', true) : false; return { worktree, branch, autoPr, draftPr }; } /** * Save a task from interactive mode result. * Prompts for worktree/branch/auto_pr settings before saving. */ export async function saveTaskFromInteractive( cwd: string, task: string, piece?: string, options?: { issue?: number; confirmAtEndMessage?: string }, ): Promise { if (options?.confirmAtEndMessage) { const approved = await confirm(options.confirmAtEndMessage, true); if (!approved) { return; } } const settings = await promptWorktreeSettings(); const created = await saveTaskFile(cwd, task, { piece, issue: options?.issue, ...settings }); displayTaskCreationResult(created, settings, piece); } /** * add command handler * * Flow: * A) 引数なし: Usage表示して終了 * B) Issue参照の場合: issue取得 → ピース選択 → ワークツリー設定 → YAML作成 * C) 通常入力: 引数をそのまま保存 */ export async function addTask(cwd: string, task?: string): Promise { const rawTask = task ?? ''; const trimmedTask = rawTask.trim(); if (!trimmedTask) { info('Usage: takt add '); return; } let taskContent: string; let issueNumber: number | undefined; if (isIssueReference(trimmedTask)) { // Issue reference: fetch issue and use directly as task content try { const numbers = parseIssueNumbers([trimmedTask]); const primaryIssueNumber = numbers[0]; taskContent = await withProgress( 'Fetching GitHub Issue...', primaryIssueNumber ? `GitHub Issue fetched: #${primaryIssueNumber}` : 'GitHub Issue fetched', async () => resolveIssueTask(trimmedTask), ); if (numbers.length > 0) { issueNumber = numbers[0]; } } catch (e) { const msg = getErrorMessage(e); log.error('Failed to fetch GitHub Issue', { task: trimmedTask, error: msg }); info(`Failed to fetch issue ${trimmedTask}: ${msg}`); return; } } else { taskContent = rawTask; } const piece = await determinePiece(cwd); if (piece === null) { info('Cancelled.'); return; } const settings = await promptWorktreeSettings(); // YAMLファイル作成 const created = await saveTaskFile(cwd, taskContent, { piece, issue: issueNumber, ...settings, }); displayTaskCreationResult(created, settings, piece); }