レビュー画面に元のタスク指示を表示 & タイムスタンプ短縮
- getOriginalInstruction: ブランチ最初のコミットから元の指示を抽出 - /review-tasks の選択肢と詳細表示に元の指示を追加 - タイムスタンプのハイフンを除去 (2026-01-29T0225 → 20260129T0225)
This commit is contained in:
parent
63d6932c01
commit
ea0d04c4fe
97
src/__tests__/getOriginalInstruction.test.ts
Normal file
97
src/__tests__/getOriginalInstruction.test.ts
Normal 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' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -93,7 +93,7 @@ describe('extractTaskSlug', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('buildReviewItems', () => {
|
describe('buildReviewItems', () => {
|
||||||
it('should build items with correct task slug', () => {
|
it('should build items with correct task slug and originalInstruction', () => {
|
||||||
const branches: BranchInfo[] = [
|
const branches: BranchInfo[] = [
|
||||||
{
|
{
|
||||||
branch: 'takt/20260128-fix-auth',
|
branch: 'takt/20260128-fix-auth',
|
||||||
@ -107,6 +107,8 @@ describe('buildReviewItems', () => {
|
|||||||
expect(items[0]!.info).toBe(branches[0]);
|
expect(items[0]!.info).toBe(branches[0]);
|
||||||
// filesChanged will be 0 since we don't have a real git repo
|
// filesChanged will be 0 since we don't have a real git repo
|
||||||
expect(items[0]!.filesChanged).toBe(0);
|
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', () => {
|
it('should handle multiple branches', () => {
|
||||||
|
|||||||
@ -78,6 +78,9 @@ async function showDiffAndPromptAction(
|
|||||||
): Promise<ReviewAction | null> {
|
): Promise<ReviewAction | null> {
|
||||||
console.log();
|
console.log();
|
||||||
console.log(chalk.bold.cyan(`=== ${item.info.branch} ===`));
|
console.log(chalk.bold.cyan(`=== ${item.info.branch} ===`));
|
||||||
|
if (item.originalInstruction) {
|
||||||
|
console.log(chalk.dim(` ${item.originalInstruction}`));
|
||||||
|
}
|
||||||
console.log();
|
console.log();
|
||||||
|
|
||||||
// Show diff stat
|
// Show diff stat
|
||||||
@ -353,11 +356,17 @@ export async function reviewTasks(cwd: string): Promise<void> {
|
|||||||
const items = buildReviewItems(cwd, branches, defaultBranch);
|
const items = buildReviewItems(cwd, branches, defaultBranch);
|
||||||
|
|
||||||
// Build selection options
|
// Build selection options
|
||||||
const options = items.map((item, idx) => ({
|
const options = items.map((item, idx) => {
|
||||||
label: item.info.branch,
|
const filesSummary = `${item.filesChanged} file${item.filesChanged !== 1 ? 's' : ''} changed`;
|
||||||
value: String(idx),
|
const description = item.originalInstruction
|
||||||
description: `${item.filesChanged} file${item.filesChanged !== 1 ? 's' : ''} changed`,
|
? `${filesSummary} | ${item.originalInstruction}`
|
||||||
}));
|
: filesSummary;
|
||||||
|
return {
|
||||||
|
label: item.info.branch,
|
||||||
|
value: String(idx),
|
||||||
|
description,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const selected = await selectOption<string>(
|
const selected = await selectOption<string>(
|
||||||
'Review Tasks (Branches)',
|
'Review Tasks (Branches)',
|
||||||
|
|||||||
@ -43,13 +43,15 @@ export interface BranchReviewItem {
|
|||||||
info: BranchInfo;
|
info: BranchInfo;
|
||||||
filesChanged: number;
|
filesChanged: number;
|
||||||
taskSlug: string;
|
taskSlug: string;
|
||||||
|
/** Original task instruction extracted from first commit message */
|
||||||
|
originalInstruction: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a timestamp string for paths/branches
|
* Generate a timestamp string for paths/branches
|
||||||
*/
|
*/
|
||||||
function generateTimestamp(): string {
|
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;
|
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.
|
* Build review items from branch list, enriching with diff stats.
|
||||||
*/
|
*/
|
||||||
@ -325,5 +362,6 @@ export function buildReviewItems(
|
|||||||
info: br,
|
info: br,
|
||||||
filesChanged: getFilesChanged(projectDir, defaultBranch, br.branch),
|
filesChanged: getFilesChanged(projectDir, defaultBranch, br.branch),
|
||||||
taskSlug: extractTaskSlug(br.branch),
|
taskSlug: extractTaskSlug(br.branch),
|
||||||
|
originalInstruction: getOriginalInstruction(projectDir, defaultBranch, br.branch),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user