レビュー画面に元のタスク指示を表示 & タイムスタンプ短縮

- getOriginalInstruction: ブランチ最初のコミットから元の指示を抽出
- /review-tasks の選択肢と詳細表示に元の指示を追加
- タイムスタンプのハイフンを除去 (2026-01-29T0225 → 20260129T0225)
This commit is contained in:
nrslib 2026-01-29 11:34:12 +09:00
parent 63d6932c01
commit ea0d04c4fe
4 changed files with 153 additions and 7 deletions

View File

@ -0,0 +1,97 @@
/**
* Tests for getOriginalInstruction
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock child_process.execFileSync
vi.mock('node:child_process', () => ({
execFileSync: vi.fn(),
}));
import { execFileSync } from 'node:child_process';
const mockExecFileSync = vi.mocked(execFileSync);
import { getOriginalInstruction } from '../task/worktree.js';
beforeEach(() => {
vi.clearAllMocks();
});
describe('getOriginalInstruction', () => {
it('should extract instruction from takt-prefixed commit message', () => {
mockExecFileSync.mockReturnValue('takt: 認証機能を追加する\ntakt: fix-auth\n');
const result = getOriginalInstruction('/project', 'main', 'takt/20260128-fix-auth');
expect(result).toBe('認証機能を追加する');
expect(mockExecFileSync).toHaveBeenCalledWith(
'git',
['log', '--format=%s', '--reverse', 'main..takt/20260128-fix-auth'],
expect.objectContaining({ cwd: '/project', encoding: 'utf-8' }),
);
});
it('should return first commit message without takt prefix if not present', () => {
mockExecFileSync.mockReturnValue('Initial implementation\n');
const result = getOriginalInstruction('/project', 'main', 'takt/20260128-fix-auth');
expect(result).toBe('Initial implementation');
});
it('should return empty string when no commits on branch', () => {
mockExecFileSync.mockReturnValue('');
const result = getOriginalInstruction('/project', 'main', 'takt/20260128-fix-auth');
expect(result).toBe('');
});
it('should return empty string when git command fails', () => {
mockExecFileSync.mockImplementation(() => {
throw new Error('not a git repository');
});
const result = getOriginalInstruction('/non-existent', 'main', 'takt/20260128-fix-auth');
expect(result).toBe('');
});
it('should handle multi-line commit messages (use only first line)', () => {
mockExecFileSync.mockReturnValue('takt: Fix the login bug\ntakt: follow-up fix\n');
const result = getOriginalInstruction('/project', 'main', 'takt/20260128-fix-login');
expect(result).toBe('Fix the login bug');
});
it('should return empty string when takt prefix has no content', () => {
// "takt: \n" trimmed → "takt:", starts with "takt:" → slice + trim → ""
mockExecFileSync.mockReturnValue('takt: \n');
const result = getOriginalInstruction('/project', 'main', 'takt/20260128-task');
expect(result).toBe('');
});
it('should return instruction text when takt prefix has content', () => {
mockExecFileSync.mockReturnValue('takt: add search feature\n');
const result = getOriginalInstruction('/project', 'main', 'takt/20260128-task');
expect(result).toBe('add search feature');
});
it('should use correct git range with custom default branch', () => {
mockExecFileSync.mockReturnValue('takt: Add search feature\n');
getOriginalInstruction('/project', 'master', 'takt/20260128-add-search');
expect(mockExecFileSync).toHaveBeenCalledWith(
'git',
['log', '--format=%s', '--reverse', 'master..takt/20260128-add-search'],
expect.objectContaining({ cwd: '/project' }),
);
});
});

View File

@ -93,7 +93,7 @@ describe('extractTaskSlug', () => {
});
describe('buildReviewItems', () => {
it('should build items with correct task slug', () => {
it('should build items with correct task slug and originalInstruction', () => {
const branches: BranchInfo[] = [
{
branch: 'takt/20260128-fix-auth',
@ -107,6 +107,8 @@ describe('buildReviewItems', () => {
expect(items[0]!.info).toBe(branches[0]);
// filesChanged will be 0 since we don't have a real git repo
expect(items[0]!.filesChanged).toBe(0);
// originalInstruction will be empty since git command fails on non-existent repo
expect(items[0]!.originalInstruction).toBe('');
});
it('should handle multiple branches', () => {

View File

@ -78,6 +78,9 @@ async function showDiffAndPromptAction(
): Promise<ReviewAction | null> {
console.log();
console.log(chalk.bold.cyan(`=== ${item.info.branch} ===`));
if (item.originalInstruction) {
console.log(chalk.dim(` ${item.originalInstruction}`));
}
console.log();
// Show diff stat
@ -353,11 +356,17 @@ export async function reviewTasks(cwd: string): Promise<void> {
const items = buildReviewItems(cwd, branches, defaultBranch);
// Build selection options
const options = items.map((item, idx) => ({
const options = items.map((item, idx) => {
const filesSummary = `${item.filesChanged} file${item.filesChanged !== 1 ? 's' : ''} changed`;
const description = item.originalInstruction
? `${filesSummary} | ${item.originalInstruction}`
: filesSummary;
return {
label: item.info.branch,
value: String(idx),
description: `${item.filesChanged} file${item.filesChanged !== 1 ? 's' : ''} changed`,
}));
description,
};
});
const selected = await selectOption<string>(
'Review Tasks (Branches)',

View File

@ -43,13 +43,15 @@ export interface BranchReviewItem {
info: BranchInfo;
filesChanged: number;
taskSlug: string;
/** Original task instruction extracted from first commit message */
originalInstruction: string;
}
/**
* Generate a timestamp string for paths/branches
*/
function generateTimestamp(): string {
return new Date().toISOString().replace(/[:.]/g, '').slice(0, 15);
return new Date().toISOString().replace(/[-:.]/g, '').slice(0, 13);
}
/**
@ -313,6 +315,41 @@ export function extractTaskSlug(branch: string): string {
return withoutTimestamp || name;
}
/**
* Extract the original task instruction from the first commit message on a branch.
*
* The first commit on a takt branch has the format: "takt: {original instruction}".
* This function retrieves the first commit's message and strips the "takt: " prefix.
* Returns empty string if extraction fails.
*/
export function getOriginalInstruction(
cwd: string,
defaultBranch: string,
branch: string,
): string {
try {
// Get the first commit message on the branch (oldest first)
const output = execFileSync(
'git',
['log', '--format=%s', '--reverse', `${defaultBranch}..${branch}`],
{ cwd, encoding: 'utf-8', stdio: 'pipe' },
).trim();
if (!output) return '';
const firstLine = output.split('\n')[0] || '';
// Strip "takt: " prefix if present
const TAKT_COMMIT_PREFIX = 'takt:';
if (firstLine.startsWith(TAKT_COMMIT_PREFIX)) {
return firstLine.slice(TAKT_COMMIT_PREFIX.length).trim();
}
return firstLine;
} catch {
return '';
}
}
/**
* Build review items from branch list, enriching with diff stats.
*/
@ -325,5 +362,6 @@ export function buildReviewItems(
info: br,
filesChanged: getFilesChanged(projectDir, defaultBranch, br.branch),
taskSlug: extractTaskSlug(br.branch),
originalInstruction: getOriginalInstruction(projectDir, defaultBranch, br.branch),
}));
}