takt: github-issue-98-pr-no-wo-ni-sh

This commit is contained in:
nrslib 2026-02-06 18:05:19 +09:00
parent 919215fad3
commit 4c0b3c1593
10 changed files with 284 additions and 23 deletions

View File

@ -121,6 +121,49 @@ describe('loadGlobalConfig', () => {
expect(reloaded.pipeline!.commitMessageTemplate).toBe('feat: {title} (#{issue})'); expect(reloaded.pipeline!.commitMessageTemplate).toBe('feat: {title} (#{issue})');
}); });
it('should load auto_pr config from config.yaml', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(
getGlobalConfigPath(),
'language: en\nauto_pr: true\n',
'utf-8',
);
const config = loadGlobalConfig();
expect(config.autoPr).toBe(true);
});
it('should save and reload auto_pr config', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
// Create minimal config first
writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8');
const config = loadGlobalConfig();
config.autoPr = true;
saveGlobalConfig(config);
invalidateGlobalConfigCache();
const reloaded = loadGlobalConfig();
expect(reloaded.autoPr).toBe(true);
});
it('should save auto_pr: false when explicitly set', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8');
const config = loadGlobalConfig();
config.autoPr = false;
saveGlobalConfig(config);
invalidateGlobalConfigCache();
const reloaded = loadGlobalConfig();
expect(reloaded.autoPr).toBe(false);
});
it('should read from cache without hitting disk on second call', () => { it('should read from cache without hitting disk on second call', () => {
const taktDir = join(testHomeDir, '.takt'); const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true }); mkdirSync(taktDir, { recursive: true });

View File

@ -121,6 +121,25 @@ describe('saveTaskFile', () => {
expect(content).not.toContain('issue:'); expect(content).not.toContain('issue:');
expect(content).not.toContain('worktree:'); expect(content).not.toContain('worktree:');
expect(content).not.toContain('branch:'); expect(content).not.toContain('branch:');
expect(content).not.toContain('auto_pr:');
});
it('should include auto_pr in YAML when specified', async () => {
// When
const filePath = await saveTaskFile(testDir, 'Task', { autoPr: true });
// Then
const content = fs.readFileSync(filePath, 'utf-8');
expect(content).toContain('auto_pr: true');
});
it('should include auto_pr: false in YAML when specified as false', async () => {
// When
const filePath = await saveTaskFile(testDir, 'Task', { autoPr: false });
// Then
const content = fs.readFileSync(filePath, 'utf-8');
expect(content).toContain('auto_pr: false');
}); });
it('should use first line for filename generation', async () => { it('should use first line for filename generation', async () => {

View File

@ -11,6 +11,9 @@ vi.mock('../infra/config/index.js', () => ({
loadGlobalConfig: vi.fn(() => ({})), loadGlobalConfig: vi.fn(() => ({})),
})); }));
import { loadGlobalConfig } from '../infra/config/index.js';
const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig);
vi.mock('../infra/task/index.js', async (importOriginal) => ({ vi.mock('../infra/task/index.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()), ...(await importOriginal<Record<string, unknown>>()),
TaskRunner: vi.fn(), TaskRunner: vi.fn(),
@ -280,4 +283,117 @@ describe('resolveTaskExecution', () => {
'Clone created: /project/../20260128-info-task (branch: takt/20260128-info-task)' 'Clone created: /project/../20260128-info-task (branch: takt/20260128-info-task)'
); );
}); });
it('should return autoPr from task YAML when specified', async () => {
// Given: Task with auto_pr option
const task: TaskInfo = {
name: 'task-with-auto-pr',
content: 'Task content',
filePath: '/tasks/task.yaml',
data: {
task: 'Task content',
auto_pr: true,
},
};
// When
const result = await resolveTaskExecution(task, '/project', 'default');
// Then
expect(result.autoPr).toBe(true);
});
it('should return autoPr: false from task YAML when specified as false', async () => {
// Given: Task with auto_pr: false
const task: TaskInfo = {
name: 'task-no-auto-pr',
content: 'Task content',
filePath: '/tasks/task.yaml',
data: {
task: 'Task content',
auto_pr: false,
},
};
// When
const result = await resolveTaskExecution(task, '/project', 'default');
// Then
expect(result.autoPr).toBe(false);
});
it('should fall back to global config autoPr when task YAML does not specify', async () => {
// Given: Task without auto_pr, global config has autoPr
mockLoadGlobalConfig.mockReturnValue({
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
autoPr: true,
});
const task: TaskInfo = {
name: 'task-no-auto-pr-setting',
content: 'Task content',
filePath: '/tasks/task.yaml',
data: {
task: 'Task content',
},
};
// When
const result = await resolveTaskExecution(task, '/project', 'default');
// Then
expect(result.autoPr).toBe(true);
});
it('should return undefined autoPr when neither task nor config specifies', async () => {
// Given: Neither task nor config has autoPr
mockLoadGlobalConfig.mockReturnValue({
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
});
const task: TaskInfo = {
name: 'task-default',
content: 'Task content',
filePath: '/tasks/task.yaml',
data: {
task: 'Task content',
},
};
// When
const result = await resolveTaskExecution(task, '/project', 'default');
// Then
expect(result.autoPr).toBeUndefined();
});
it('should prioritize task YAML auto_pr over global config', async () => {
// Given: Task has auto_pr: false, global config has autoPr: true
mockLoadGlobalConfig.mockReturnValue({
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
autoPr: true,
});
const task: TaskInfo = {
name: 'task-override',
content: 'Task content',
filePath: '/tasks/task.yaml',
data: {
task: 'Task content',
auto_pr: false,
},
};
// When
const result = await resolveTaskExecution(task, '/project', 'default');
// Then
expect(result.autoPr).toBe(false);
});
}); });

View File

@ -43,6 +43,8 @@ export interface GlobalConfig {
debug?: DebugConfig; debug?: DebugConfig;
/** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */ /** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */
worktreeDir?: string; worktreeDir?: string;
/** Auto-create PR after worktree execution (default: prompt in interactive mode) */
autoPr?: boolean;
/** List of builtin piece/agent names to exclude from fallback loading */ /** List of builtin piece/agent names to exclude from fallback loading */
disabledBuiltins?: string[]; disabledBuiltins?: string[];
/** Enable builtin pieces from resources/global/{lang}/pieces */ /** Enable builtin pieces from resources/global/{lang}/pieces */

View File

@ -252,6 +252,8 @@ export const GlobalConfigSchema = z.object({
debug: DebugConfigSchema.optional(), debug: DebugConfigSchema.optional(),
/** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */ /** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */
worktree_dir: z.string().optional(), worktree_dir: z.string().optional(),
/** Auto-create PR after worktree execution (default: prompt in interactive mode) */
auto_pr: z.boolean().optional(),
/** List of builtin piece/agent names to exclude from fallback loading */ /** List of builtin piece/agent names to exclude from fallback loading */
disabled_builtins: z.array(z.string()).optional().default([]), disabled_builtins: z.array(z.string()).optional().default([]),
/** Enable builtin pieces from resources/global/{lang}/pieces */ /** Enable builtin pieces from resources/global/{lang}/pieces */

View File

@ -43,7 +43,7 @@ async function generateFilename(tasksDir: string, taskContent: string, cwd: stri
export async function saveTaskFile( export async function saveTaskFile(
cwd: string, cwd: string,
taskContent: string, taskContent: string,
options?: { piece?: string; issue?: number; worktree?: boolean | string; branch?: string }, options?: { piece?: string; issue?: number; worktree?: boolean | string; branch?: string; autoPr?: boolean },
): Promise<string> { ): Promise<string> {
const tasksDir = path.join(cwd, '.takt', 'tasks'); const tasksDir = path.join(cwd, '.takt', 'tasks');
fs.mkdirSync(tasksDir, { recursive: true }); fs.mkdirSync(tasksDir, { recursive: true });
@ -57,6 +57,7 @@ export async function saveTaskFile(
...(options?.branch && { branch: options.branch }), ...(options?.branch && { branch: options.branch }),
...(options?.piece && { piece: options.piece }), ...(options?.piece && { piece: options.piece }),
...(options?.issue !== undefined && { issue: options.issue }), ...(options?.issue !== undefined && { issue: options.issue }),
...(options?.autoPr !== undefined && { auto_pr: options.autoPr }),
}; };
const filePath = path.join(tasksDir, filename); const filePath = path.join(tasksDir, filename);
@ -165,9 +166,10 @@ export async function addTask(cwd: string, task?: string): Promise<void> {
taskContent = result.task; taskContent = result.task;
} }
// 3. ワークツリー/ブランチ設定 // 3. ワークツリー/ブランチ/PR設定
let worktree: boolean | string | undefined; let worktree: boolean | string | undefined;
let branch: string | undefined; let branch: string | undefined;
let autoPr: boolean | undefined;
const useWorktree = await confirm('Create worktree?', true); const useWorktree = await confirm('Create worktree?', true);
if (useWorktree) { if (useWorktree) {
@ -178,6 +180,9 @@ export async function addTask(cwd: string, task?: string): Promise<void> {
if (customBranch) { if (customBranch) {
branch = customBranch; branch = customBranch;
} }
// PR確認worktreeが有効な場合のみ
autoPr = await confirm('Auto-create PR?', false);
} }
// 4. YAMLファイル作成 // 4. YAMLファイル作成
@ -186,6 +191,7 @@ export async function addTask(cwd: string, task?: string): Promise<void> {
issue: issueNumber, issue: issueNumber,
worktree, worktree,
branch, branch,
autoPr,
}); });
const filename = path.basename(filePath); const filename = path.basename(filePath);
@ -197,6 +203,9 @@ export async function addTask(cwd: string, task?: string): Promise<void> {
if (branch) { if (branch) {
info(` Branch: ${branch}`); info(` Branch: ${branch}`);
} }
if (autoPr) {
info(` Auto-PR: yes`);
}
if (piece) { if (piece) {
info(` Piece: ${piece}`); info(` Piece: ${piece}`);
} }

View File

@ -14,13 +14,14 @@ import {
loadAllPiecesWithSources, loadAllPiecesWithSources,
getPieceCategories, getPieceCategories,
buildCategorizedPieces, buildCategorizedPieces,
loadGlobalConfig,
} from '../../../infra/config/index.js'; } from '../../../infra/config/index.js';
import { confirm } from '../../../shared/prompt/index.js'; import { confirm } from '../../../shared/prompt/index.js';
import { createSharedClone, autoCommitAndPush, summarizeTaskName } from '../../../infra/task/index.js'; import { createSharedClone, autoCommitAndPush, summarizeTaskName } from '../../../infra/task/index.js';
import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js'; import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js';
import { info, error, success } from '../../../shared/ui/index.js'; import { info, error, success } from '../../../shared/ui/index.js';
import { createLogger } from '../../../shared/utils/index.js'; import { createLogger } from '../../../shared/utils/index.js';
import { createPullRequest, buildPrBody } from '../../../infra/github/index.js'; import { createPullRequest, buildPrBody, pushBranch } from '../../../infra/github/index.js';
import { executeTask } from './taskExecution.js'; import { executeTask } from './taskExecution.js';
import type { TaskExecutionOptions, WorktreeConfirmationResult, SelectAndExecuteOptions } from './types.js'; import type { TaskExecutionOptions, WorktreeConfirmationResult, SelectAndExecuteOptions } from './types.js';
import { import {
@ -122,6 +123,26 @@ export async function confirmAndCreateWorktree(
return { execCwd: result.path, isWorktree: true, branch: result.branch }; return { execCwd: result.path, isWorktree: true, branch: result.branch };
} }
/**
* Resolve auto-PR setting with priority: CLI option > config > prompt.
* Only applicable when worktree is enabled.
*/
async function resolveAutoPr(optionAutoPr: boolean | undefined): Promise<boolean> {
// CLI option takes precedence
if (typeof optionAutoPr === 'boolean') {
return optionAutoPr;
}
// Check global config
const globalConfig = loadGlobalConfig();
if (typeof globalConfig.autoPr === 'boolean') {
return globalConfig.autoPr;
}
// Fall back to interactive prompt
return confirm('Create pull request?', false);
}
/** /**
* Execute a task with piece selection, optional worktree, and auto-commit. * Execute a task with piece selection, optional worktree, and auto-commit.
* Shared by direct task execution and interactive mode. * Shared by direct task execution and interactive mode.
@ -145,7 +166,13 @@ export async function selectAndExecuteTask(
options?.createWorktree, options?.createWorktree,
); );
log.info('Starting task execution', { piece: pieceIdentifier, worktree: isWorktree }); // Ask for PR creation BEFORE execution (only if worktree is enabled)
let shouldCreatePr = false;
if (isWorktree) {
shouldCreatePr = await resolveAutoPr(options?.autoPr);
}
log.info('Starting task execution', { piece: pieceIdentifier, worktree: isWorktree, autoPr: shouldCreatePr });
const taskSuccess = await executeTask({ const taskSuccess = await executeTask({
task, task,
cwd: execCwd, cwd: execCwd,
@ -164,22 +191,26 @@ export async function selectAndExecuteTask(
error(`Auto-commit failed: ${commitResult.message}`); error(`Auto-commit failed: ${commitResult.message}`);
} }
if (commitResult.success && commitResult.commitHash && branch) { if (commitResult.success && commitResult.commitHash && branch && shouldCreatePr) {
const shouldCreatePr = options?.autoPr === true || await confirm('Create pull request?', false); info('Creating pull request...');
if (shouldCreatePr) { // Push branch from project cwd to origin (clone's origin is removed after shared clone)
info('Creating pull request...'); try {
const prBody = buildPrBody(options?.issues, `Piece \`${pieceIdentifier}\` completed successfully.`); pushBranch(cwd, branch);
const prResult = createPullRequest(execCwd, { } catch (pushError) {
branch, // Branch may already be pushed by autoCommitAndPush, continue to PR creation
title: task.length > 100 ? `${task.slice(0, 97)}...` : task, log.info('Branch push from project cwd failed (may already exist)', { error: pushError });
body: prBody, }
repo: options?.repo, const prBody = buildPrBody(options?.issues, `Piece \`${pieceIdentifier}\` completed successfully.`);
}); const prResult = createPullRequest(cwd, {
if (prResult.success) { branch,
success(`PR created: ${prResult.url}`); title: task.length > 100 ? `${task.slice(0, 97)}...` : task,
} else { body: prBody,
error(`PR creation failed: ${prResult.error}`); repo: options?.repo,
} });
if (prResult.success) {
success(`PR created: ${prResult.url}`);
} else {
error(`PR creation failed: ${prResult.error}`);
} }
} }
} }

View File

@ -16,6 +16,7 @@ import { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
import { executePiece } from './pieceExecution.js'; import { executePiece } from './pieceExecution.js';
import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js'; import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js';
import type { TaskExecutionOptions, ExecuteTaskOptions } from './types.js'; import type { TaskExecutionOptions, ExecuteTaskOptions } from './types.js';
import { createPullRequest, buildPrBody, pushBranch } from '../../../infra/github/index.js';
export type { TaskExecutionOptions, ExecuteTaskOptions }; export type { TaskExecutionOptions, ExecuteTaskOptions };
@ -77,7 +78,7 @@ export async function executeAndCompleteTask(
const executionLog: string[] = []; const executionLog: string[] = [];
try { try {
const { execCwd, execPiece, isWorktree, startMovement, retryNote } = await resolveTaskExecution(task, cwd, pieceName); const { execCwd, execPiece, isWorktree, branch, startMovement, retryNote, autoPr } = await resolveTaskExecution(task, cwd, pieceName);
// cwd is always the project root; pass it as projectCwd so reports/sessions go there // cwd is always the project root; pass it as projectCwd so reports/sessions go there
const taskSuccess = await executeTask({ const taskSuccess = await executeTask({
@ -98,6 +99,29 @@ export async function executeAndCompleteTask(
} else if (!commitResult.success) { } else if (!commitResult.success) {
error(`Auto-commit failed: ${commitResult.message}`); error(`Auto-commit failed: ${commitResult.message}`);
} }
// Create PR if autoPr is enabled and commit succeeded
if (commitResult.success && commitResult.commitHash && branch && autoPr) {
info('Creating pull request...');
// Push branch from project cwd to origin
try {
pushBranch(cwd, branch);
} catch (pushError) {
// Branch may already be pushed, continue to PR creation
log.info('Branch push from project cwd failed (may already exist)', { error: pushError });
}
const prBody = buildPrBody(undefined, `Task "${task.name}" completed successfully.`);
const prResult = createPullRequest(cwd, {
branch,
title: task.name.length > 100 ? `${task.name.slice(0, 97)}...` : task.name,
body: prBody,
});
if (prResult.success) {
success(`PR created: ${prResult.url}`);
} else {
error(`PR creation failed: ${prResult.error}`);
}
}
} }
const taskResult = { const taskResult = {
@ -198,7 +222,7 @@ export async function resolveTaskExecution(
task: TaskInfo, task: TaskInfo,
defaultCwd: string, defaultCwd: string,
defaultPiece: string defaultPiece: string
): Promise<{ execCwd: string; execPiece: string; isWorktree: boolean; branch?: string; startMovement?: string; retryNote?: string }> { ): Promise<{ execCwd: string; execPiece: string; isWorktree: boolean; branch?: string; startMovement?: string; retryNote?: string; autoPr?: boolean }> {
const data = task.data; const data = task.data;
// No structured data: use defaults // No structured data: use defaults
@ -237,5 +261,14 @@ export async function resolveTaskExecution(
// Handle retry_note // Handle retry_note
const retryNote = data.retry_note; const retryNote = data.retry_note;
return { execCwd, execPiece, isWorktree, branch, startMovement, retryNote }; // Handle auto_pr (task YAML > global config)
let autoPr: boolean | undefined;
if (data.auto_pr !== undefined) {
autoPr = data.auto_pr;
} else {
const globalConfig = loadGlobalConfig();
autoPr = globalConfig.autoPr;
}
return { execCwd, execPiece, isWorktree, branch, startMovement, retryNote, autoPr };
} }

View File

@ -75,6 +75,7 @@ export class GlobalConfigManager {
logFile: parsed.debug.log_file, logFile: parsed.debug.log_file,
} : undefined, } : undefined,
worktreeDir: parsed.worktree_dir, worktreeDir: parsed.worktree_dir,
autoPr: parsed.auto_pr,
disabledBuiltins: parsed.disabled_builtins, disabledBuiltins: parsed.disabled_builtins,
enableBuiltinPieces: parsed.enable_builtin_pieces, enableBuiltinPieces: parsed.enable_builtin_pieces,
anthropicApiKey: parsed.anthropic_api_key, anthropicApiKey: parsed.anthropic_api_key,
@ -114,6 +115,9 @@ export class GlobalConfigManager {
if (config.worktreeDir) { if (config.worktreeDir) {
raw.worktree_dir = config.worktreeDir; raw.worktree_dir = config.worktreeDir;
} }
if (config.autoPr !== undefined) {
raw.auto_pr = config.autoPr;
}
if (config.disabledBuiltins && config.disabledBuiltins.length > 0) { if (config.disabledBuiltins && config.disabledBuiltins.length > 0) {
raw.disabled_builtins = config.disabledBuiltins; raw.disabled_builtins = config.disabledBuiltins;
} }

View File

@ -32,6 +32,8 @@ export const TaskFileSchema = z.object({
issue: z.number().int().positive().optional(), issue: z.number().int().positive().optional(),
start_movement: z.string().optional(), start_movement: z.string().optional(),
retry_note: z.string().optional(), retry_note: z.string().optional(),
/** Auto-create PR after worktree execution (default: prompt in interactive mode) */
auto_pr: z.boolean().optional(),
}); });
export type TaskFileData = z.infer<typeof TaskFileSchema>; export type TaskFileData = z.infer<typeof TaskFileSchema>;