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', () => ({
|
vi.mock('../github/issue.js', () => ({
|
||||||
isIssueReference: vi.fn((s: string) => /^#\d+$/.test(s)),
|
isIssueReference: vi.fn((s: string) => /^#\d+$/.test(s)),
|
||||||
resolveIssueTask: vi.fn(),
|
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';
|
import { interactiveMode } from '../commands/interactive.js';
|
||||||
@ -348,6 +358,24 @@ describe('addTask', () => {
|
|||||||
expect(files.length).toBe(0);
|
expect(files.length).toBe(0);
|
||||||
expect(mockGetProvider).not.toHaveBeenCalled();
|
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', () => {
|
describe('summarizeConversation', () => {
|
||||||
|
|||||||
@ -168,3 +168,146 @@ describe('cloneAndIsolate git config propagation', () => {
|
|||||||
expect(configSetCalls).toContainEqual({ key: 'user.email', value: 'temp@example.com' });
|
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 { listWorkflows } from '../config/workflowLoader.js';
|
||||||
import { getCurrentWorkflow } from '../config/paths.js';
|
import { getCurrentWorkflow } from '../config/paths.js';
|
||||||
import { interactiveMode } from './interactive.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';
|
import type { TaskFileData } from '../task/schema.js';
|
||||||
|
|
||||||
const log = createLogger('add-task');
|
const log = createLogger('add-task');
|
||||||
@ -81,12 +81,17 @@ export async function addTask(cwd: string, task?: string): Promise<void> {
|
|||||||
fs.mkdirSync(tasksDir, { recursive: true });
|
fs.mkdirSync(tasksDir, { recursive: true });
|
||||||
|
|
||||||
let taskContent: string;
|
let taskContent: string;
|
||||||
|
let issueNumber: number | undefined;
|
||||||
|
|
||||||
if (task && isIssueReference(task)) {
|
if (task && isIssueReference(task)) {
|
||||||
// Issue reference: fetch issue and use directly as task content
|
// Issue reference: fetch issue and use directly as task content
|
||||||
info('Fetching GitHub Issue...');
|
info('Fetching GitHub Issue...');
|
||||||
try {
|
try {
|
||||||
taskContent = resolveIssueTask(task);
|
taskContent = resolveIssueTask(task);
|
||||||
|
const numbers = parseIssueNumbers([task]);
|
||||||
|
if (numbers.length > 0) {
|
||||||
|
issueNumber = numbers[0];
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = getErrorMessage(e);
|
const msg = getErrorMessage(e);
|
||||||
log.error('Failed to fetch GitHub Issue', { task, error: msg });
|
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) {
|
if (workflow) {
|
||||||
taskData.workflow = workflow;
|
taskData.workflow = workflow;
|
||||||
}
|
}
|
||||||
|
if (issueNumber !== undefined) {
|
||||||
|
taskData.issue = issueNumber;
|
||||||
|
}
|
||||||
|
|
||||||
const filePath = path.join(tasksDir, filename);
|
const filePath = path.join(tasksDir, filename);
|
||||||
const yamlContent = stringifyYaml(taskData);
|
const yamlContent = stringifyYaml(taskData);
|
||||||
|
|||||||
@ -234,6 +234,7 @@ export async function resolveTaskExecution(
|
|||||||
worktree: data.worktree,
|
worktree: data.worktree,
|
||||||
branch: data.branch,
|
branch: data.branch,
|
||||||
taskSlug,
|
taskSlug,
|
||||||
|
issueNumber: data.issue,
|
||||||
});
|
});
|
||||||
execCwd = result.path;
|
execCwd = result.path;
|
||||||
branch = result.branch;
|
branch = result.branch;
|
||||||
|
|||||||
@ -23,6 +23,8 @@ export interface WorktreeOptions {
|
|||||||
branch?: string;
|
branch?: string;
|
||||||
/** Task slug for auto-generated paths/branches */
|
/** Task slug for auto-generated paths/branches */
|
||||||
taskSlug: string;
|
taskSlug: string;
|
||||||
|
/** GitHub Issue number (optional, for formatting branch/path) */
|
||||||
|
issueNumber?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorktreeResult {
|
export interface WorktreeResult {
|
||||||
@ -57,11 +59,22 @@ function resolveCloneBaseDir(projectDir: string): string {
|
|||||||
* 1. Custom path in options.worktree (string)
|
* 1. Custom path in options.worktree (string)
|
||||||
* 2. worktree_dir from config.yaml (if set)
|
* 2. worktree_dir from config.yaml (if set)
|
||||||
* 3. Default: ../{dir-name}
|
* 3. Default: ../{dir-name}
|
||||||
|
*
|
||||||
|
* Format with issue: {timestamp}-{issue}-{slug}
|
||||||
|
* Format without issue: {timestamp}-{slug}
|
||||||
*/
|
*/
|
||||||
function resolveClonePath(projectDir: string, options: WorktreeOptions): string {
|
function resolveClonePath(projectDir: string, options: WorktreeOptions): string {
|
||||||
const timestamp = generateTimestamp();
|
const timestamp = generateTimestamp();
|
||||||
const slug = slugify(options.taskSlug);
|
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') {
|
if (typeof options.worktree === 'string') {
|
||||||
return path.isAbsolute(options.worktree)
|
return path.isAbsolute(options.worktree)
|
||||||
@ -72,12 +85,25 @@ function resolveClonePath(projectDir: string, options: WorktreeOptions): string
|
|||||||
return path.join(resolveCloneBaseDir(projectDir), dirName);
|
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 {
|
function resolveBranchName(options: WorktreeOptions): string {
|
||||||
if (options.branch) {
|
if (options.branch) {
|
||||||
return options.branch;
|
return options.branch;
|
||||||
}
|
}
|
||||||
const timestamp = generateTimestamp();
|
|
||||||
const slug = slugify(options.taskSlug);
|
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}`;
|
return slug ? `takt/${timestamp}-${slug}` : `takt/${timestamp}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -29,6 +29,7 @@ export const TaskFileSchema = z.object({
|
|||||||
worktree: z.union([z.boolean(), z.string()]).optional(),
|
worktree: z.union([z.boolean(), z.string()]).optional(),
|
||||||
branch: z.string().optional(),
|
branch: z.string().optional(),
|
||||||
workflow: z.string().optional(),
|
workflow: z.string().optional(),
|
||||||
|
issue: z.number().int().positive().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TaskFileData = z.infer<typeof TaskFileSchema>;
|
export type TaskFileData = z.infer<typeof TaskFileSchema>;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user