nrs deca6a2f3d
[#368] fix-broken-issue-title-session (#371)
* takt: fix-broken-issue-title-session

* takt: fix-broken-issue-title-session
2026-02-26 13:33:02 +09:00

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);
}