295 lines
9.8 KiB
TypeScript
295 lines
9.8 KiB
TypeScript
/**
|
|
* 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<TaskFileData, 'task'> = {
|
|
...(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<void> {
|
|
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<string[]> {
|
|
const selected = await selectOption<string>(
|
|
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<WorktreeSettings> {
|
|
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<void> {
|
|
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<void> {
|
|
const rawTask = task ?? '';
|
|
const trimmedTask = rawTask.trim();
|
|
if (!trimmedTask) {
|
|
info('Usage: takt add <task>');
|
|
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);
|
|
}
|