takt add #N でIssue番号をブランチ名・ワークツリーパスに反映する (#78)
* feat: takt add #N でIssue番号をブランチ名・ワークツリーパスに反映する (#48) * remove md --------- Co-authored-by: nrslib <nrslib@users.noreply.github.com> Co-authored-by: nrslib <38722970+nrslib@users.noreply.github.com>
This commit is contained in:
parent
f3f50f4676
commit
b648a8ea6b
@ -54,6 +54,16 @@ vi.mock('../config/paths.js', () => ({
|
||||
vi.mock('../github/issue.js', () => ({
|
||||
isIssueReference: vi.fn((s: string) => /^#\d+$/.test(s)),
|
||||
resolveIssueTask: vi.fn(),
|
||||
parseIssueNumbers: vi.fn((args: string[]) => {
|
||||
const numbers: number[] = [];
|
||||
for (const arg of args) {
|
||||
const match = arg.match(/^#(\d+)$/);
|
||||
if (match?.[1]) {
|
||||
numbers.push(Number.parseInt(match[1], 10));
|
||||
}
|
||||
}
|
||||
return numbers;
|
||||
}),
|
||||
}));
|
||||
|
||||
import { interactiveMode } from '../commands/interactive.js';
|
||||
@ -348,6 +358,24 @@ describe('addTask', () => {
|
||||
expect(files.length).toBe(0);
|
||||
expect(mockGetProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should include issue number in task file when issue reference is used', async () => {
|
||||
// Given: issue reference "#99"
|
||||
const issueText = 'Issue #99: Fix login timeout';
|
||||
mockResolveIssueTask.mockReturnValue(issueText);
|
||||
mockSummarizeTaskName.mockResolvedValue('fix-login-timeout');
|
||||
mockConfirm.mockResolvedValue(false);
|
||||
mockListWorkflows.mockReturnValue([]);
|
||||
|
||||
// When
|
||||
await addTask(testDir, '#99');
|
||||
|
||||
// Then: task file contains issue field
|
||||
const taskFile = path.join(testDir, '.takt', 'tasks', 'fix-login-timeout.yaml');
|
||||
expect(fs.existsSync(taskFile)).toBe(true);
|
||||
const content = fs.readFileSync(taskFile, 'utf-8');
|
||||
expect(content).toContain('issue: 99');
|
||||
});
|
||||
});
|
||||
|
||||
describe('summarizeConversation', () => {
|
||||
|
||||
@ -168,3 +168,146 @@ describe('cloneAndIsolate git config propagation', () => {
|
||||
expect(configSetCalls).toContainEqual({ key: 'user.email', value: 'temp@example.com' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('branch and worktree path formatting with issue numbers', () => {
|
||||
function setupMockForPathTest() {
|
||||
mockExecFileSync.mockImplementation((cmd, args) => {
|
||||
const argsArr = args as string[];
|
||||
|
||||
// git clone
|
||||
if (argsArr[0] === 'clone') {
|
||||
const clonePath = argsArr[argsArr.length - 1];
|
||||
return Buffer.from(`Cloning into '${clonePath}'...`);
|
||||
}
|
||||
|
||||
// git remote remove origin
|
||||
if (argsArr[0] === 'remote' && argsArr[1] === 'remove') {
|
||||
return Buffer.from('');
|
||||
}
|
||||
|
||||
// git config
|
||||
if (argsArr[0] === 'config') {
|
||||
return Buffer.from('');
|
||||
}
|
||||
|
||||
// git rev-parse --verify (branchExists check)
|
||||
if (argsArr[0] === 'rev-parse') {
|
||||
throw new Error('branch not found');
|
||||
}
|
||||
|
||||
// git checkout -b (new branch)
|
||||
if (argsArr[0] === 'checkout' && argsArr[1] === '-b') {
|
||||
const branchName = argsArr[2];
|
||||
return Buffer.from(`Switched to a new branch '${branchName}'`);
|
||||
}
|
||||
|
||||
return Buffer.from('');
|
||||
});
|
||||
}
|
||||
|
||||
it('should format branch as takt/#{issue}/{slug} when issue number is provided', () => {
|
||||
// Given: issue number 99 with slug
|
||||
setupMockForPathTest();
|
||||
|
||||
// When
|
||||
const result = createSharedClone('/project', {
|
||||
worktree: true,
|
||||
taskSlug: 'fix-login-timeout',
|
||||
issueNumber: 99,
|
||||
});
|
||||
|
||||
// Then: branch should use issue format
|
||||
expect(result.branch).toBe('takt/#99/fix-login-timeout');
|
||||
});
|
||||
|
||||
it('should format branch as takt/{timestamp}-{slug} when no issue number', () => {
|
||||
// Given: no issue number
|
||||
setupMockForPathTest();
|
||||
|
||||
// When
|
||||
const result = createSharedClone('/project', {
|
||||
worktree: true,
|
||||
taskSlug: 'regular-task',
|
||||
});
|
||||
|
||||
// Then: branch should use timestamp format (13 chars: 8 digits + T + 4 digits)
|
||||
expect(result.branch).toMatch(/^takt\/\d{8}T\d{4}-regular-task$/);
|
||||
});
|
||||
|
||||
it('should format worktree path as {timestamp}-{issue}-{slug} when issue number is provided', () => {
|
||||
// Given: issue number 99 with slug
|
||||
setupMockForPathTest();
|
||||
|
||||
// When
|
||||
const result = createSharedClone('/project', {
|
||||
worktree: true,
|
||||
taskSlug: 'fix-bug',
|
||||
issueNumber: 99,
|
||||
});
|
||||
|
||||
// Then: path should include issue number (timestamp: 8 digits + T + 4 digits)
|
||||
expect(result.path).toMatch(/\/\d{8}T\d{4}-99-fix-bug$/);
|
||||
});
|
||||
|
||||
it('should format worktree path as {timestamp}-{slug} when no issue number', () => {
|
||||
// Given: no issue number
|
||||
setupMockForPathTest();
|
||||
|
||||
// When
|
||||
const result = createSharedClone('/project', {
|
||||
worktree: true,
|
||||
taskSlug: 'regular-task',
|
||||
});
|
||||
|
||||
// Then: path should NOT include issue number (timestamp: 8 digits + T + 4 digits)
|
||||
expect(result.path).toMatch(/\/\d{8}T\d{4}-regular-task$/);
|
||||
expect(result.path).not.toMatch(/-\d+-/);
|
||||
});
|
||||
|
||||
it('should use custom branch when provided, ignoring issue number', () => {
|
||||
// Given: custom branch with issue number
|
||||
setupMockForPathTest();
|
||||
|
||||
// When
|
||||
const result = createSharedClone('/project', {
|
||||
worktree: true,
|
||||
taskSlug: 'task',
|
||||
issueNumber: 99,
|
||||
branch: 'custom-branch-name',
|
||||
});
|
||||
|
||||
// Then: custom branch takes precedence
|
||||
expect(result.branch).toBe('custom-branch-name');
|
||||
});
|
||||
|
||||
it('should use custom worktree path when provided, ignoring issue formatting', () => {
|
||||
// Given: custom path with issue number
|
||||
setupMockForPathTest();
|
||||
|
||||
// When
|
||||
const result = createSharedClone('/project', {
|
||||
worktree: '/custom/path/to/worktree',
|
||||
taskSlug: 'task',
|
||||
issueNumber: 99,
|
||||
});
|
||||
|
||||
// Then: custom path takes precedence
|
||||
expect(result.path).toBe('/custom/path/to/worktree');
|
||||
});
|
||||
|
||||
it('should fall back to timestamp-only format when issue number provided but slug is empty', () => {
|
||||
// Given: issue number but taskSlug produces empty string after slugify
|
||||
setupMockForPathTest();
|
||||
|
||||
// When
|
||||
const result = createSharedClone('/project', {
|
||||
worktree: true,
|
||||
taskSlug: '', // empty slug
|
||||
issueNumber: 99,
|
||||
});
|
||||
|
||||
// Then: falls back to timestamp format (issue number not included due to empty slug)
|
||||
expect(result.branch).toMatch(/^takt\/\d{8}T\d{4}$/);
|
||||
expect(result.path).toMatch(/\/\d{8}T\d{4}$/);
|
||||
});
|
||||
});
|
||||
|
||||
@ -18,7 +18,7 @@ import { getErrorMessage } from '../utils/error.js';
|
||||
import { listWorkflows } from '../config/workflowLoader.js';
|
||||
import { getCurrentWorkflow } from '../config/paths.js';
|
||||
import { interactiveMode } from './interactive.js';
|
||||
import { isIssueReference, resolveIssueTask } from '../github/issue.js';
|
||||
import { isIssueReference, resolveIssueTask, parseIssueNumbers } from '../github/issue.js';
|
||||
import type { TaskFileData } from '../task/schema.js';
|
||||
|
||||
const log = createLogger('add-task');
|
||||
@ -81,12 +81,17 @@ export async function addTask(cwd: string, task?: string): Promise<void> {
|
||||
fs.mkdirSync(tasksDir, { recursive: true });
|
||||
|
||||
let taskContent: string;
|
||||
let issueNumber: number | undefined;
|
||||
|
||||
if (task && isIssueReference(task)) {
|
||||
// Issue reference: fetch issue and use directly as task content
|
||||
info('Fetching GitHub Issue...');
|
||||
try {
|
||||
taskContent = resolveIssueTask(task);
|
||||
const numbers = parseIssueNumbers([task]);
|
||||
if (numbers.length > 0) {
|
||||
issueNumber = numbers[0];
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = getErrorMessage(e);
|
||||
log.error('Failed to fetch GitHub Issue', { task, error: msg });
|
||||
@ -156,6 +161,9 @@ export async function addTask(cwd: string, task?: string): Promise<void> {
|
||||
if (workflow) {
|
||||
taskData.workflow = workflow;
|
||||
}
|
||||
if (issueNumber !== undefined) {
|
||||
taskData.issue = issueNumber;
|
||||
}
|
||||
|
||||
const filePath = path.join(tasksDir, filename);
|
||||
const yamlContent = stringifyYaml(taskData);
|
||||
|
||||
@ -234,6 +234,7 @@ export async function resolveTaskExecution(
|
||||
worktree: data.worktree,
|
||||
branch: data.branch,
|
||||
taskSlug,
|
||||
issueNumber: data.issue,
|
||||
});
|
||||
execCwd = result.path;
|
||||
branch = result.branch;
|
||||
|
||||
@ -23,6 +23,8 @@ export interface WorktreeOptions {
|
||||
branch?: string;
|
||||
/** Task slug for auto-generated paths/branches */
|
||||
taskSlug: string;
|
||||
/** GitHub Issue number (optional, for formatting branch/path) */
|
||||
issueNumber?: number;
|
||||
}
|
||||
|
||||
export interface WorktreeResult {
|
||||
@ -57,11 +59,22 @@ function resolveCloneBaseDir(projectDir: string): string {
|
||||
* 1. Custom path in options.worktree (string)
|
||||
* 2. worktree_dir from config.yaml (if set)
|
||||
* 3. Default: ../{dir-name}
|
||||
*
|
||||
* Format with issue: {timestamp}-{issue}-{slug}
|
||||
* Format without issue: {timestamp}-{slug}
|
||||
*/
|
||||
function resolveClonePath(projectDir: string, options: WorktreeOptions): string {
|
||||
const timestamp = generateTimestamp();
|
||||
const slug = slugify(options.taskSlug);
|
||||
const dirName = slug ? `${timestamp}-${slug}` : timestamp;
|
||||
|
||||
let dirName: string;
|
||||
if (options.issueNumber !== undefined && slug) {
|
||||
dirName = `${timestamp}-${options.issueNumber}-${slug}`;
|
||||
} else if (slug) {
|
||||
dirName = `${timestamp}-${slug}`;
|
||||
} else {
|
||||
dirName = timestamp;
|
||||
}
|
||||
|
||||
if (typeof options.worktree === 'string') {
|
||||
return path.isAbsolute(options.worktree)
|
||||
@ -72,12 +85,25 @@ function resolveClonePath(projectDir: string, options: WorktreeOptions): string
|
||||
return path.join(resolveCloneBaseDir(projectDir), dirName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve branch name from options.
|
||||
*
|
||||
* Format with issue: takt/#{issue}/{slug}
|
||||
* Format without issue: takt/{timestamp}-{slug}
|
||||
* Custom branch: use as-is
|
||||
*/
|
||||
function resolveBranchName(options: WorktreeOptions): string {
|
||||
if (options.branch) {
|
||||
return options.branch;
|
||||
}
|
||||
const timestamp = generateTimestamp();
|
||||
|
||||
const slug = slugify(options.taskSlug);
|
||||
|
||||
if (options.issueNumber !== undefined && slug) {
|
||||
return `takt/#${options.issueNumber}/${slug}`;
|
||||
}
|
||||
|
||||
const timestamp = generateTimestamp();
|
||||
return slug ? `takt/${timestamp}-${slug}` : `takt/${timestamp}`;
|
||||
}
|
||||
|
||||
|
||||
@ -29,6 +29,7 @@ export const TaskFileSchema = z.object({
|
||||
worktree: z.union([z.boolean(), z.string()]).optional(),
|
||||
branch: z.string().optional(),
|
||||
workflow: z.string().optional(),
|
||||
issue: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
export type TaskFileData = z.infer<typeof TaskFileSchema>;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user