takt: takt-list (#310)
This commit is contained in:
parent
80a79683ac
commit
43f6fa6ade
@ -1,11 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Unit tests for task naming utilities
|
* Unit tests for task naming utilities
|
||||||
*
|
*
|
||||||
* Tests nowIso, firstLine, and sanitizeTaskName functions.
|
* Tests nowIso and firstLine functions.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
import { nowIso, firstLine, sanitizeTaskName } from '../infra/task/naming.js';
|
import { nowIso, firstLine } from '../infra/task/naming.js';
|
||||||
|
|
||||||
describe('nowIso', () => {
|
describe('nowIso', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@ -54,34 +54,3 @@ describe('firstLine', () => {
|
|||||||
expect(firstLine(' \n ')).toBe('');
|
expect(firstLine(' \n ')).toBe('');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('sanitizeTaskName', () => {
|
|
||||||
it('should lowercase the input', () => {
|
|
||||||
expect(sanitizeTaskName('Hello World')).toBe('hello-world');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should replace special characters with spaces then hyphens', () => {
|
|
||||||
expect(sanitizeTaskName('task@name#123')).toBe('task-name-123');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should collapse multiple hyphens', () => {
|
|
||||||
expect(sanitizeTaskName('a---b')).toBe('a-b');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should trim leading/trailing whitespace', () => {
|
|
||||||
expect(sanitizeTaskName(' hello ')).toBe('hello');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle typical task names', () => {
|
|
||||||
expect(sanitizeTaskName('Fix: login bug (#42)')).toBe('fix-login-bug-42');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should generate fallback name for empty result', () => {
|
|
||||||
const result = sanitizeTaskName('!@#$%');
|
|
||||||
expect(result).toMatch(/^task-\d+$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should preserve numbers and lowercase letters', () => {
|
|
||||||
expect(sanitizeTaskName('abc123def')).toBe('abc123def');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@ -37,12 +37,13 @@ describe('generateReportDir', () => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should preserve Japanese characters in summary', () => {
|
it('should strip CJK characters from summary', () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.setSystemTime(new Date('2025-06-01T12:00:00.000Z'));
|
vi.setSystemTime(new Date('2025-06-01T12:00:00.000Z'));
|
||||||
|
|
||||||
const result = generateReportDir('タスク指示書の実装');
|
const result = generateReportDir('タスク指示書の実装');
|
||||||
expect(result).toContain('タスク指示書の実装');
|
// CJK characters are removed by slugify, leaving empty → falls back to 'task'
|
||||||
|
expect(result).toBe('20250601-120000-task');
|
||||||
|
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
@ -53,7 +54,7 @@ describe('generateReportDir', () => {
|
|||||||
|
|
||||||
const result = generateReportDir('Fix: bug (#42)');
|
const result = generateReportDir('Fix: bug (#42)');
|
||||||
const slug = result.replace(/^20250101-000000-/, '');
|
const slug = result.replace(/^20250101-000000-/, '');
|
||||||
expect(slug).not.toMatch(/[^a-z0-9\u3040-\u309f\u30a0-\u30ff\u4e00-\u9faf-]/);
|
expect(slug).not.toMatch(/[^a-z0-9-]/);
|
||||||
|
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -66,6 +66,8 @@ describe('saveTaskFile', () => {
|
|||||||
expect(tasks).toHaveLength(1);
|
expect(tasks).toHaveLength(1);
|
||||||
expect(tasks[0]?.content).toBeUndefined();
|
expect(tasks[0]?.content).toBeUndefined();
|
||||||
expect(tasks[0]?.task_dir).toBeTypeOf('string');
|
expect(tasks[0]?.task_dir).toBeTypeOf('string');
|
||||||
|
expect(tasks[0]?.slug).toBeTypeOf('string');
|
||||||
|
expect(tasks[0]?.summary).toBe('Implement feature X');
|
||||||
const taskDir = path.join(testDir, String(tasks[0]?.task_dir));
|
const taskDir = path.join(testDir, String(tasks[0]?.task_dir));
|
||||||
expect(fs.existsSync(path.join(taskDir, 'order.md'))).toBe(true);
|
expect(fs.existsSync(path.join(taskDir, 'order.md'))).toBe(true);
|
||||||
expect(fs.readFileSync(path.join(taskDir, 'order.md'), 'utf-8')).toContain('Implement feature X');
|
expect(fs.readFileSync(path.join(taskDir, 'order.md'), 'utf-8')).toContain('Implement feature X');
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Unit tests for slugify utility
|
* Unit tests for slugify utility
|
||||||
*
|
*
|
||||||
* Tests URL/filename-safe slug generation with CJK support.
|
* Tests URL/filename-safe slug generation (a-z 0-9 hyphen, max 30 chars).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
@ -25,17 +25,17 @@ describe('slugify', () => {
|
|||||||
expect(slugify(' hello ')).toBe('hello');
|
expect(slugify(' hello ')).toBe('hello');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should truncate to 50 characters', () => {
|
it('should truncate to 30 characters', () => {
|
||||||
const long = 'a'.repeat(100);
|
const long = 'a'.repeat(100);
|
||||||
expect(slugify(long).length).toBeLessThanOrEqual(50);
|
expect(slugify(long).length).toBeLessThanOrEqual(30);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should preserve CJK characters', () => {
|
it('should strip CJK characters', () => {
|
||||||
expect(slugify('タスク指示書')).toBe('タスク指示書');
|
expect(slugify('タスク指示書')).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle mixed ASCII and CJK', () => {
|
it('should handle mixed ASCII and CJK', () => {
|
||||||
expect(slugify('Add タスク Feature')).toBe('add-タスク-feature');
|
expect(slugify('Add タスク Feature')).toBe('add-feature');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle numbers', () => {
|
it('should handle numbers', () => {
|
||||||
@ -43,11 +43,18 @@ describe('slugify', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should handle empty result after stripping', () => {
|
it('should handle empty result after stripping', () => {
|
||||||
// All special characters → becomes empty string
|
|
||||||
expect(slugify('!@#$%')).toBe('');
|
expect(slugify('!@#$%')).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle typical GitHub issue titles', () => {
|
it('should handle typical GitHub issue titles', () => {
|
||||||
expect(slugify('Fix: login not working (#42)')).toBe('fix-login-not-working-42');
|
expect(slugify('Fix: login not working (#42)')).toBe('fix-login-not-working-42');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should strip trailing hyphen after truncation', () => {
|
||||||
|
// 30 chars of slug that ends with a hyphen after slice
|
||||||
|
const input = 'abcdefghijklmnopqrstuvwxyz-abc-xyz';
|
||||||
|
const result = slugify(input);
|
||||||
|
expect(result.length).toBeLessThanOrEqual(30);
|
||||||
|
expect(result).not.toMatch(/-$/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,39 +1,50 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { formatTaskStatusLabel } from '../features/tasks/list/taskStatusLabel.js';
|
import { formatTaskStatusLabel, formatShortDate } from '../features/tasks/list/taskStatusLabel.js';
|
||||||
import type { TaskListItem } from '../infra/task/types.js';
|
import type { TaskListItem } from '../infra/task/types.js';
|
||||||
|
|
||||||
|
function makeTask(overrides: Partial<TaskListItem>): TaskListItem {
|
||||||
|
return {
|
||||||
|
kind: 'pending',
|
||||||
|
name: 'test-task',
|
||||||
|
createdAt: '2026-02-11T00:00:00.000Z',
|
||||||
|
filePath: '/tmp/task.md',
|
||||||
|
content: 'content',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('formatTaskStatusLabel', () => {
|
describe('formatTaskStatusLabel', () => {
|
||||||
it("should format pending task as '[pending] name'", () => {
|
it("should format pending task as '[pending] name'", () => {
|
||||||
// Given: pending タスク
|
const task = makeTask({ kind: 'pending', name: 'implement-test' });
|
||||||
const task: TaskListItem = {
|
expect(formatTaskStatusLabel(task)).toBe('[pending] implement-test');
|
||||||
kind: 'pending',
|
|
||||||
name: 'implement test',
|
|
||||||
createdAt: '2026-02-11T00:00:00.000Z',
|
|
||||||
filePath: '/tmp/task.md',
|
|
||||||
content: 'content',
|
|
||||||
};
|
|
||||||
|
|
||||||
// When: ステータスラベルを生成する
|
|
||||||
const result = formatTaskStatusLabel(task);
|
|
||||||
|
|
||||||
// Then: pending は pending 表示になる
|
|
||||||
expect(result).toBe('[pending] implement test');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should format failed task as '[failed] name'", () => {
|
it("should format failed task as '[failed] name'", () => {
|
||||||
// Given: failed タスク
|
const task = makeTask({ kind: 'failed', name: 'retry-payment' });
|
||||||
const task: TaskListItem = {
|
expect(formatTaskStatusLabel(task)).toBe('[failed] retry-payment');
|
||||||
kind: 'failed',
|
});
|
||||||
name: 'retry payment',
|
|
||||||
createdAt: '2026-02-11T00:00:00.000Z',
|
|
||||||
filePath: '/tmp/task.md',
|
|
||||||
content: 'content',
|
|
||||||
};
|
|
||||||
|
|
||||||
// When: ステータスラベルを生成する
|
it('should include branch when present', () => {
|
||||||
const result = formatTaskStatusLabel(task);
|
const task = makeTask({
|
||||||
|
kind: 'completed',
|
||||||
|
name: 'fix-login-bug',
|
||||||
|
branch: 'takt/284/fix-login-bug',
|
||||||
|
});
|
||||||
|
expect(formatTaskStatusLabel(task)).toBe('[completed] fix-login-bug (takt/284/fix-login-bug)');
|
||||||
|
});
|
||||||
|
|
||||||
// Then: failed は failed 表示になる
|
it('should not include branch when absent', () => {
|
||||||
expect(result).toBe('[failed] retry payment');
|
const task = makeTask({ kind: 'running', name: 'my-task' });
|
||||||
|
expect(formatTaskStatusLabel(task)).toBe('[running] my-task');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatShortDate', () => {
|
||||||
|
it('should format ISO string to MM/DD HH:mm', () => {
|
||||||
|
expect(formatShortDate('2025-02-18T14:30:00.000Z')).toBe('02/18 14:30');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should zero-pad single digit values', () => {
|
||||||
|
expect(formatShortDate('2025-01-05T03:07:00.000Z')).toBe('01/05 03:07');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -8,10 +8,11 @@ import * as path from 'node:path';
|
|||||||
import * as fs from 'node:fs';
|
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, withProgress } from '../../../shared/ui/index.js';
|
import { success, info, error, withProgress } from '../../../shared/ui/index.js';
|
||||||
import { TaskRunner, type TaskFileData } from '../../../infra/task/index.js';
|
import { TaskRunner, type TaskFileData, summarizeTaskName } from '../../../infra/task/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 { firstLine } from '../../../infra/task/naming.js';
|
||||||
|
|
||||||
const log = createLogger('add-task');
|
const log = createLogger('add-task');
|
||||||
|
|
||||||
@ -39,9 +40,11 @@ export async function saveTaskFile(
|
|||||||
options?: { piece?: string; issue?: number; worktree?: boolean | string; branch?: string; autoPr?: boolean },
|
options?: { piece?: string; issue?: number; worktree?: boolean | string; branch?: string; autoPr?: boolean },
|
||||||
): Promise<{ taskName: string; tasksFile: string }> {
|
): Promise<{ taskName: string; tasksFile: string }> {
|
||||||
const runner = new TaskRunner(cwd);
|
const runner = new TaskRunner(cwd);
|
||||||
const taskSlug = resolveUniqueTaskSlug(cwd, generateReportDir(taskContent));
|
const slug = await summarizeTaskName(taskContent, { cwd });
|
||||||
const taskDir = path.join(cwd, '.takt', 'tasks', taskSlug);
|
const summary = firstLine(taskContent);
|
||||||
const taskDirRelative = `.takt/tasks/${taskSlug}`;
|
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');
|
const orderPath = path.join(taskDir, 'order.md');
|
||||||
fs.mkdirSync(taskDir, { recursive: true });
|
fs.mkdirSync(taskDir, { recursive: true });
|
||||||
fs.writeFileSync(orderPath, taskContent, 'utf-8');
|
fs.writeFileSync(orderPath, taskContent, 'utf-8');
|
||||||
@ -55,6 +58,8 @@ export async function saveTaskFile(
|
|||||||
const created = runner.addTask(taskContent, {
|
const created = runner.addTask(taskContent, {
|
||||||
...config,
|
...config,
|
||||||
task_dir: taskDirRelative,
|
task_dir: taskDirRelative,
|
||||||
|
slug,
|
||||||
|
summary,
|
||||||
});
|
});
|
||||||
const tasksFile = path.join(cwd, '.takt', 'tasks.yaml');
|
const tasksFile = path.join(cwd, '.takt', 'tasks.yaml');
|
||||||
log.info('Task created', { taskName: created.name, tasksFile, config });
|
log.info('Task created', { taskName: created.name, tasksFile, config });
|
||||||
@ -69,8 +74,8 @@ export async function saveTaskFile(
|
|||||||
*/
|
*/
|
||||||
export function createIssueFromTask(task: string): number | undefined {
|
export function createIssueFromTask(task: string): number | undefined {
|
||||||
info('Creating GitHub Issue...');
|
info('Creating GitHub Issue...');
|
||||||
const firstLine = task.split('\n')[0] || task;
|
const titleLine = task.split('\n')[0] || task;
|
||||||
const title = firstLine.length > 100 ? `${firstLine.slice(0, 97)}...` : firstLine;
|
const title = titleLine.length > 100 ? `${titleLine.slice(0, 97)}...` : titleLine;
|
||||||
const issueResult = createIssue({ title, body: task });
|
const issueResult = createIssue({ title, body: task });
|
||||||
if (issueResult.success) {
|
if (issueResult.success) {
|
||||||
success(`Issue created: ${issueResult.url}`);
|
success(`Issue created: ${issueResult.url}`);
|
||||||
|
|||||||
@ -104,7 +104,7 @@ export async function resolveTaskExecution(
|
|||||||
worktreePath = task.worktreePath;
|
worktreePath = task.worktreePath;
|
||||||
isWorktree = true;
|
isWorktree = true;
|
||||||
} else {
|
} else {
|
||||||
const taskSlug = await withProgress(
|
const taskSlug = task.slug ?? await withProgress(
|
||||||
'Generating branch name...',
|
'Generating branch name...',
|
||||||
(slug) => `Branch name generated: ${slug}`,
|
(slug) => `Branch name generated: ${slug}`,
|
||||||
() => summarizeTaskName(task.content, { cwd: defaultCwd }),
|
() => summarizeTaskName(task.content, { cwd: defaultCwd }),
|
||||||
|
|||||||
@ -72,7 +72,7 @@ export async function confirmAndCreateWorktree(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return { execCwd: result.path, isWorktree: true, branch: result.branch, baseBranch };
|
return { execCwd: result.path, isWorktree: true, branch: result.branch, baseBranch, taskSlug };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -92,7 +92,7 @@ export async function selectAndExecuteTask(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { execCwd, isWorktree, branch, baseBranch } = await confirmAndCreateWorktree(
|
const { execCwd, isWorktree, branch, baseBranch, taskSlug } = await confirmAndCreateWorktree(
|
||||||
cwd,
|
cwd,
|
||||||
task,
|
task,
|
||||||
options?.createWorktree,
|
options?.createWorktree,
|
||||||
@ -112,6 +112,7 @@ export async function selectAndExecuteTask(
|
|||||||
...(branch ? { branch } : {}),
|
...(branch ? { branch } : {}),
|
||||||
...(isWorktree ? { worktree_path: execCwd } : {}),
|
...(isWorktree ? { worktree_path: execCwd } : {}),
|
||||||
auto_pr: shouldCreatePr,
|
auto_pr: shouldCreatePr,
|
||||||
|
...(taskSlug ? { slug: taskSlug } : {}),
|
||||||
});
|
});
|
||||||
const startedAt = new Date().toISOString();
|
const startedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
|||||||
@ -122,6 +122,7 @@ export interface WorktreeConfirmationResult {
|
|||||||
isWorktree: boolean;
|
isWorktree: boolean;
|
||||||
branch?: string;
|
branch?: string;
|
||||||
baseBranch?: string;
|
baseBranch?: string;
|
||||||
|
taskSlug?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SelectAndExecuteOptions {
|
export interface SelectAndExecuteOptions {
|
||||||
|
|||||||
@ -26,7 +26,7 @@ import {
|
|||||||
import { deletePendingTask, deleteFailedTask, deleteCompletedTask } from './taskDeleteActions.js';
|
import { deletePendingTask, deleteFailedTask, deleteCompletedTask } from './taskDeleteActions.js';
|
||||||
import { retryFailedTask } from './taskRetryActions.js';
|
import { retryFailedTask } from './taskRetryActions.js';
|
||||||
import { listTasksNonInteractive, type ListNonInteractiveOptions } from './listNonInteractive.js';
|
import { listTasksNonInteractive, type ListNonInteractiveOptions } from './listNonInteractive.js';
|
||||||
import { formatTaskStatusLabel } from './taskStatusLabel.js';
|
import { formatTaskStatusLabel, formatShortDate } from './taskStatusLabel.js';
|
||||||
|
|
||||||
export type { ListNonInteractiveOptions } from './listNonInteractive.js';
|
export type { ListNonInteractiveOptions } from './listNonInteractive.js';
|
||||||
|
|
||||||
@ -130,7 +130,7 @@ export async function listTasks(
|
|||||||
const menuOptions = tasks.map((task, idx) => ({
|
const menuOptions = tasks.map((task, idx) => ({
|
||||||
label: formatTaskStatusLabel(task),
|
label: formatTaskStatusLabel(task),
|
||||||
value: `${task.kind}:${idx}`,
|
value: `${task.kind}:${idx}`,
|
||||||
description: `${task.content} | ${task.createdAt}`,
|
description: `${task.summary ?? task.content} | ${formatShortDate(task.createdAt)}`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const selected = await selectOption<string>(
|
const selected = await selectOption<string>(
|
||||||
|
|||||||
@ -18,7 +18,7 @@ import {
|
|||||||
mergeBranch,
|
mergeBranch,
|
||||||
deleteBranch,
|
deleteBranch,
|
||||||
} from './taskActions.js';
|
} from './taskActions.js';
|
||||||
import { formatTaskStatusLabel } from './taskStatusLabel.js';
|
import { formatTaskStatusLabel, formatShortDate } from './taskStatusLabel.js';
|
||||||
|
|
||||||
export interface ListNonInteractiveOptions {
|
export interface ListNonInteractiveOptions {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@ -43,7 +43,7 @@ function printNonInteractiveList(tasks: TaskListItem[], format?: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
info(`${formatTaskStatusLabel(task)} - ${task.content} (${task.createdAt})`);
|
info(`${formatTaskStatusLabel(task)} - ${task.summary ?? task.content} (${formatShortDate(task.createdAt)})`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,5 +8,18 @@ const TASK_STATUS_BY_KIND: Record<TaskListItem['kind'], string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function formatTaskStatusLabel(task: TaskListItem): string {
|
export function formatTaskStatusLabel(task: TaskListItem): string {
|
||||||
return `[${TASK_STATUS_BY_KIND[task.kind]}] ${task.name}`;
|
const status = `[${TASK_STATUS_BY_KIND[task.kind]}] ${task.name}`;
|
||||||
|
if (task.branch) {
|
||||||
|
return `${status} (${task.branch})`;
|
||||||
|
}
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatShortDate(isoString: string): string {
|
||||||
|
const date = new Date(isoString);
|
||||||
|
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getUTCDate()).padStart(2, '0');
|
||||||
|
const hours = String(date.getUTCHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
|
||||||
|
return `${month}/${day} ${hours}:${minutes}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { createLogger, slugify } from '../../shared/utils/index.js';
|
import { createLogger } from '../../shared/utils/index.js';
|
||||||
import { resolveConfigValue } from '../config/index.js';
|
import { resolveConfigValue } from '../config/index.js';
|
||||||
import type { WorktreeOptions, WorktreeResult } from './types.js';
|
import type { WorktreeOptions, WorktreeResult } from './types.js';
|
||||||
|
|
||||||
@ -48,7 +48,7 @@ export class CloneManager {
|
|||||||
/** Resolve the clone path based on options and global config */
|
/** Resolve the clone path based on options and global config */
|
||||||
private static resolveClonePath(projectDir: string, options: WorktreeOptions): string {
|
private static resolveClonePath(projectDir: string, options: WorktreeOptions): string {
|
||||||
const timestamp = CloneManager.generateTimestamp();
|
const timestamp = CloneManager.generateTimestamp();
|
||||||
const slug = slugify(options.taskSlug);
|
const slug = options.taskSlug;
|
||||||
|
|
||||||
let dirName: string;
|
let dirName: string;
|
||||||
if (options.issueNumber !== undefined && slug) {
|
if (options.issueNumber !== undefined && slug) {
|
||||||
@ -74,7 +74,7 @@ export class CloneManager {
|
|||||||
return options.branch;
|
return options.branch;
|
||||||
}
|
}
|
||||||
|
|
||||||
const slug = slugify(options.taskSlug);
|
const slug = options.taskSlug;
|
||||||
|
|
||||||
if (options.issueNumber !== undefined && slug) {
|
if (options.issueNumber !== undefined && slug) {
|
||||||
return `takt/${options.issueNumber}/${slug}`;
|
return `takt/${options.issueNumber}/${slug}`;
|
||||||
|
|||||||
@ -1,12 +1,9 @@
|
|||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { TaskFileSchema, type TaskFileData, type TaskRecord } from './schema.js';
|
import { TaskFileSchema, type TaskFileData, type TaskRecord } from './schema.js';
|
||||||
|
import { firstLine } from './naming.js';
|
||||||
import type { TaskInfo, TaskListItem } from './types.js';
|
import type { TaskInfo, TaskListItem } from './types.js';
|
||||||
|
|
||||||
function firstLine(content: string): string {
|
|
||||||
return content.trim().split('\n')[0]?.slice(0, 80) ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function toDisplayPath(projectDir: string, targetPath: string): string {
|
function toDisplayPath(projectDir: string, targetPath: string): string {
|
||||||
const relativePath = path.relative(projectDir, targetPath);
|
const relativePath = path.relative(projectDir, targetPath);
|
||||||
if (!relativePath || relativePath.startsWith('..')) {
|
if (!relativePath || relativePath.startsWith('..')) {
|
||||||
@ -66,6 +63,7 @@ export function toTaskInfo(projectDir: string, tasksFile: string, task: TaskReco
|
|||||||
return {
|
return {
|
||||||
filePath: tasksFile,
|
filePath: tasksFile,
|
||||||
name: task.name,
|
name: task.name,
|
||||||
|
slug: task.slug,
|
||||||
content,
|
content,
|
||||||
taskDir: task.task_dir,
|
taskDir: task.task_dir,
|
||||||
createdAt: task.created_at,
|
createdAt: task.created_at,
|
||||||
@ -119,6 +117,7 @@ function toBaseTaskListItem(projectDir: string, tasksFile: string, task: TaskRec
|
|||||||
createdAt: task.created_at,
|
createdAt: task.created_at,
|
||||||
filePath: tasksFile,
|
filePath: tasksFile,
|
||||||
content: firstLine(resolveTaskContent(projectDir, task)),
|
content: firstLine(resolveTaskContent(projectDir, task)),
|
||||||
|
summary: task.summary,
|
||||||
branch: task.branch,
|
branch: task.branch,
|
||||||
worktreePath: task.worktree_path,
|
worktreePath: task.worktree_path,
|
||||||
startedAt: task.started_at ?? undefined,
|
startedAt: task.started_at ?? undefined,
|
||||||
|
|||||||
@ -5,18 +5,3 @@ export function nowIso(): string {
|
|||||||
export function firstLine(content: string): string {
|
export function firstLine(content: string): string {
|
||||||
return content.trim().split('\n')[0]?.slice(0, 80) ?? '';
|
return content.trim().split('\n')[0]?.slice(0, 80) ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeTaskName(base: string): string {
|
|
||||||
const normalized = base
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9\s-]/g, ' ')
|
|
||||||
.trim()
|
|
||||||
.replace(/\s+/g, '-')
|
|
||||||
.replace(/-+/g, '-');
|
|
||||||
|
|
||||||
if (!normalized) {
|
|
||||||
return `task-${Date.now()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -33,7 +33,13 @@ export class TaskRunner {
|
|||||||
|
|
||||||
addTask(
|
addTask(
|
||||||
content: string,
|
content: string,
|
||||||
options?: Omit<TaskFileData, 'task'> & { content_file?: string; task_dir?: string; worktree_path?: string },
|
options?: Omit<TaskFileData, 'task'> & {
|
||||||
|
content_file?: string;
|
||||||
|
task_dir?: string;
|
||||||
|
worktree_path?: string;
|
||||||
|
slug?: string;
|
||||||
|
summary?: string;
|
||||||
|
},
|
||||||
): TaskInfo {
|
): TaskInfo {
|
||||||
return this.lifecycle.addTask(content, options);
|
return this.lifecycle.addTask(content, options);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,6 +41,8 @@ export type TaskFailure = z.infer<typeof TaskFailureSchema>;
|
|||||||
export const TaskRecordSchema = TaskExecutionConfigSchema.extend({
|
export const TaskRecordSchema = TaskExecutionConfigSchema.extend({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
status: TaskStatusSchema,
|
status: TaskStatusSchema,
|
||||||
|
slug: z.string().optional(),
|
||||||
|
summary: z.string().optional(),
|
||||||
worktree_path: z.string().optional(),
|
worktree_path: z.string().optional(),
|
||||||
content: z.string().min(1).optional(),
|
content: z.string().min(1).optional(),
|
||||||
content_file: z.string().min(1).optional(),
|
content_file: z.string().min(1).optional(),
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
import * as wanakana from 'wanakana';
|
import * as wanakana from 'wanakana';
|
||||||
import { resolveConfigValues } from '../config/index.js';
|
import { resolveConfigValues } from '../config/index.js';
|
||||||
import { getProvider, type ProviderType } from '../providers/index.js';
|
import { getProvider, type ProviderType } from '../providers/index.js';
|
||||||
import { createLogger } from '../../shared/utils/index.js';
|
import { createLogger, slugify } from '../../shared/utils/index.js';
|
||||||
import { loadTemplate } from '../../shared/prompts/index.js';
|
import { loadTemplate } from '../../shared/prompts/index.js';
|
||||||
import type { SummarizeOptions } from './types.js';
|
import type { SummarizeOptions } from './types.js';
|
||||||
|
|
||||||
@ -15,27 +15,12 @@ export type { SummarizeOptions };
|
|||||||
|
|
||||||
const log = createLogger('summarize');
|
const log = createLogger('summarize');
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitize a string for use as git branch name and directory name.
|
|
||||||
* Allows only: a-z, 0-9, hyphen.
|
|
||||||
*/
|
|
||||||
function sanitizeSlug(input: string, maxLength = 30): string {
|
|
||||||
return input
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9-]/g, '-')
|
|
||||||
.replace(/-+/g, '-')
|
|
||||||
.replace(/^-+/, '')
|
|
||||||
.slice(0, maxLength)
|
|
||||||
.replace(/-+$/, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert Japanese text to romaji slug.
|
* Convert Japanese text to romaji slug.
|
||||||
*/
|
*/
|
||||||
function toRomajiSlug(text: string): string {
|
function toRomajiSlug(text: string): string {
|
||||||
const romaji = wanakana.toRomaji(text, { customRomajiMapping: {} });
|
const romaji = wanakana.toRomaji(text, { customRomajiMapping: {} });
|
||||||
return sanitizeSlug(romaji);
|
return slugify(romaji);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -77,7 +62,7 @@ export class TaskSummarizer {
|
|||||||
permissionMode: 'readonly',
|
permissionMode: 'readonly',
|
||||||
});
|
});
|
||||||
|
|
||||||
const slug = sanitizeSlug(response.content);
|
const slug = slugify(response.content);
|
||||||
log.info('Task name summarized', { original: taskName, slug });
|
log.info('Task name summarized', { original: taskName, slug });
|
||||||
|
|
||||||
return slug || 'task';
|
return slug || 'task';
|
||||||
|
|||||||
@ -3,7 +3,8 @@ import { TaskRecordSchema, type TaskFileData, type TaskRecord, type TaskFailure
|
|||||||
import type { TaskInfo, TaskResult } from './types.js';
|
import type { TaskInfo, TaskResult } from './types.js';
|
||||||
import { toTaskInfo } from './mapper.js';
|
import { toTaskInfo } from './mapper.js';
|
||||||
import { TaskStore } from './store.js';
|
import { TaskStore } from './store.js';
|
||||||
import { firstLine, nowIso, sanitizeTaskName } from './naming.js';
|
import { firstLine, nowIso } from './naming.js';
|
||||||
|
import { slugify } from '../../shared/utils/slug.js';
|
||||||
import { isStaleRunningTask } from './process.js';
|
import { isStaleRunningTask } from './process.js';
|
||||||
import type { TaskStatus } from './schema.js';
|
import type { TaskStatus } from './schema.js';
|
||||||
|
|
||||||
@ -16,13 +17,22 @@ export class TaskLifecycleService {
|
|||||||
|
|
||||||
addTask(
|
addTask(
|
||||||
content: string,
|
content: string,
|
||||||
options?: Omit<TaskFileData, 'task'> & { content_file?: string; task_dir?: string; worktree_path?: string },
|
options?: Omit<TaskFileData, 'task'> & {
|
||||||
|
content_file?: string;
|
||||||
|
task_dir?: string;
|
||||||
|
worktree_path?: string;
|
||||||
|
slug?: string;
|
||||||
|
summary?: string;
|
||||||
|
},
|
||||||
): TaskInfo {
|
): TaskInfo {
|
||||||
const state = this.store.update((current) => {
|
const state = this.store.update((current) => {
|
||||||
const name = this.generateTaskName(content, current.tasks.map((task) => task.name));
|
const slug = options?.slug ?? slugify(firstLine(content));
|
||||||
|
const name = this.generateTaskName(slug, current.tasks.map((task) => task.name));
|
||||||
const contentValue = options?.task_dir ? undefined : content;
|
const contentValue = options?.task_dir ? undefined : content;
|
||||||
const record: TaskRecord = TaskRecordSchema.parse({
|
const record: TaskRecord = TaskRecordSchema.parse({
|
||||||
name,
|
name,
|
||||||
|
slug,
|
||||||
|
summary: options?.summary,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
content: contentValue,
|
content: contentValue,
|
||||||
created_at: nowIso(),
|
created_at: nowIso(),
|
||||||
@ -258,8 +268,8 @@ export class TaskLifecycleService {
|
|||||||
return isStaleRunningTask(task.owner_pid ?? undefined);
|
return isStaleRunningTask(task.owner_pid ?? undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
private generateTaskName(content: string, existingNames: string[]): string {
|
private generateTaskName(slug: string, existingNames: string[]): string {
|
||||||
const base = sanitizeTaskName(firstLine(content));
|
const base = slug || `task-${Date.now()}`;
|
||||||
let candidate = base;
|
let candidate = base;
|
||||||
let counter = 1;
|
let counter = 1;
|
||||||
while (existingNames.includes(candidate)) {
|
while (existingNames.includes(candidate)) {
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import type { TaskFailure, TaskStatus } from './schema.js';
|
|||||||
export interface TaskInfo {
|
export interface TaskInfo {
|
||||||
filePath: string;
|
filePath: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
slug?: string;
|
||||||
content: string;
|
content: string;
|
||||||
taskDir?: string;
|
taskDir?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@ -81,6 +82,7 @@ export interface TaskListItem {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
filePath: string;
|
filePath: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
summary?: string;
|
||||||
branch?: string;
|
branch?: string;
|
||||||
worktreePath?: string;
|
worktreePath?: string;
|
||||||
data?: TaskFileData;
|
data?: TaskFileData;
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
* Report directory name generation.
|
* Report directory name generation.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { slugify } from './slug.js';
|
||||||
|
|
||||||
export function generateReportDir(task: string): string {
|
export function generateReportDir(task: string): string {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const timestamp = now.toISOString()
|
const timestamp = now.toISOString()
|
||||||
@ -9,12 +11,7 @@ export function generateReportDir(task: string): string {
|
|||||||
.slice(0, 14)
|
.slice(0, 14)
|
||||||
.replace(/(\d{8})(\d{6})/, '$1-$2');
|
.replace(/(\d{8})(\d{6})/, '$1-$2');
|
||||||
|
|
||||||
const summary = task
|
const summary = slugify(task.slice(0, 80)) || 'task';
|
||||||
.slice(0, 30)
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9\u3040-\u309f\u30a0-\u30ff\u4e00-\u9faf]+/g, '-')
|
|
||||||
.replace(/^-+|-+$/g, '')
|
|
||||||
|| 'task';
|
|
||||||
|
|
||||||
return `${timestamp}-${summary}`;
|
return `${timestamp}-${summary}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,17 +2,18 @@
|
|||||||
* Text slugification utility
|
* Text slugification utility
|
||||||
*
|
*
|
||||||
* Converts text into URL/filename-safe slugs.
|
* Converts text into URL/filename-safe slugs.
|
||||||
* Supports ASCII alphanumerics and CJK characters.
|
* Allowed characters: a-z, 0-9, hyphen. Max 30 characters.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert text into a slug for use in filenames, paths, and branch names.
|
* Convert text into a slug for use in filenames, paths, and branch names.
|
||||||
* Preserves CJK characters (U+3000-9FFF, FF00-FFEF).
|
* Allowed: a-z 0-9 hyphen. Max 30 characters.
|
||||||
*/
|
*/
|
||||||
export function slugify(text: string): string {
|
export function slugify(text: string): string {
|
||||||
return text
|
return text
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[^a-z0-9\u3000-\u9fff\uff00-\uffef]+/g, '-')
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
.replace(/^-+|-+$/g, '')
|
.replace(/^-+|-+$/g, '')
|
||||||
.slice(0, 50);
|
.slice(0, 30)
|
||||||
|
.replace(/-+$/, '');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
const TASK_SLUG_PATTERN =
|
const TASK_SLUG_PATTERN =
|
||||||
'[a-z0-9\\u3040-\\u309f\\u30a0-\\u30ff\\u4e00-\\u9faf](?:[a-z0-9\\u3040-\\u309f\\u30a0-\\u30ff\\u4e00-\\u9faf-]*[a-z0-9\\u3040-\\u309f\\u30a0-\\u30ff\\u4e00-\\u9faf])?';
|
'[a-z0-9](?:[a-z0-9-]*[a-z0-9])?';
|
||||||
const TASK_DIR_PREFIX = '.takt/tasks/';
|
const TASK_DIR_PREFIX = '.takt/tasks/';
|
||||||
const TASK_DIR_PATTERN = new RegExp(`^\\.takt/tasks/${TASK_SLUG_PATTERN}$`);
|
const TASK_DIR_PATTERN = new RegExp(`^\\.takt/tasks/${TASK_SLUG_PATTERN}$`);
|
||||||
const REPORT_DIR_NAME_PATTERN = new RegExp(`^${TASK_SLUG_PATTERN}$`);
|
const REPORT_DIR_NAME_PATTERN = new RegExp(`^${TASK_SLUG_PATTERN}$`);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user