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:
github-actions[bot] 2026-02-02 00:05:57 +09:00 committed by GitHub
parent f3f50f4676
commit b648a8ea6b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 210 additions and 3 deletions

View File

@ -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', () => {

View File

@ -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}$/);
});
});

View File

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

View File

@ -234,6 +234,7 @@ export async function resolveTaskExecution(
worktree: data.worktree,
branch: data.branch,
taskSlug,
issueNumber: data.issue,
});
execCwd = result.path;
branch = result.branch;

View File

@ -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}`;
}

View File

@ -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>;