auto-PR 機能の追加とPR作成ロジックの共通化 #98

This commit is contained in:
nrslib 2026-02-06 19:07:18 +09:00
parent 973c7df85d
commit 24361b34e3
10 changed files with 284 additions and 25 deletions

View File

@ -121,6 +121,49 @@ describe('loadGlobalConfig', () => {
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', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });

View File

@ -121,6 +121,25 @@ describe('saveTaskFile', () => {
expect(content).not.toContain('issue:');
expect(content).not.toContain('worktree:');
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 () => {

View File

@ -11,6 +11,9 @@ vi.mock('../infra/config/index.js', () => ({
loadGlobalConfig: vi.fn(() => ({})),
}));
import { loadGlobalConfig } from '../infra/config/index.js';
const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig);
vi.mock('../infra/task/index.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
TaskRunner: vi.fn(),
@ -280,4 +283,117 @@ describe('resolveTaskExecution', () => {
'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;
/** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */
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 */
disabledBuiltins?: string[];
/** Enable builtin pieces from resources/global/{lang}/pieces */

View File

@ -252,6 +252,8 @@ export const GlobalConfigSchema = z.object({
debug: DebugConfigSchema.optional(),
/** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */
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 */
disabled_builtins: z.array(z.string()).optional().default([]),
/** 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(
cwd: 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> {
const tasksDir = path.join(cwd, '.takt', 'tasks');
fs.mkdirSync(tasksDir, { recursive: true });
@ -57,6 +57,7 @@ export async function saveTaskFile(
...(options?.branch && { branch: options.branch }),
...(options?.piece && { piece: options.piece }),
...(options?.issue !== undefined && { issue: options.issue }),
...(options?.autoPr !== undefined && { auto_pr: options.autoPr }),
};
const filePath = path.join(tasksDir, filename);
@ -169,9 +170,10 @@ export async function addTask(cwd: string, task?: string): Promise<void> {
taskContent = result.task;
}
// ワークツリー/ブランチ設定
// ワークツリー/ブランチ/PR設定
let worktree: boolean | string | undefined;
let branch: string | undefined;
let autoPr: boolean | undefined;
const useWorktree = await confirm('Create worktree?', true);
if (useWorktree) {
@ -182,6 +184,8 @@ export async function addTask(cwd: string, task?: string): Promise<void> {
if (customBranch) {
branch = customBranch;
}
autoPr = await confirm('Auto-create PR?', false);
}
// YAMLファイル作成
@ -190,6 +194,7 @@ export async function addTask(cwd: string, task?: string): Promise<void> {
issue: issueNumber,
worktree,
branch,
autoPr,
});
const filename = path.basename(filePath);
@ -201,6 +206,9 @@ export async function addTask(cwd: string, task?: string): Promise<void> {
if (branch) {
info(` Branch: ${branch}`);
}
if (autoPr) {
info(` Auto-PR: yes`);
}
if (piece) {
info(` Piece: ${piece}`);
}

View File

@ -14,14 +14,14 @@ import {
loadAllPiecesWithSources,
getPieceCategories,
buildCategorizedPieces,
loadGlobalConfig,
} from '../../../infra/config/index.js';
import { confirm } from '../../../shared/prompt/index.js';
import { createSharedClone, autoCommitAndPush, summarizeTaskName } from '../../../infra/task/index.js';
import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js';
import { info, error, success } from '../../../shared/ui/index.js';
import { createLogger } from '../../../shared/utils/index.js';
import { createPullRequest, buildPrBody } from '../../../infra/github/index.js';
import { executeTask } from './taskExecution.js';
import { executeTask, pushAndCreatePr } from './taskExecution.js';
import type { TaskExecutionOptions, WorktreeConfirmationResult, SelectAndExecuteOptions } from './types.js';
import {
warnMissingPieces,
@ -122,6 +122,26 @@ export async function confirmAndCreateWorktree(
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.
* Shared by direct task execution and interactive mode.
@ -145,7 +165,12 @@ export async function selectAndExecuteTask(
options?.createWorktree,
);
log.info('Starting task execution', { piece: pieceIdentifier, worktree: isWorktree });
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({
task,
cwd: execCwd,
@ -164,23 +189,14 @@ export async function selectAndExecuteTask(
error(`Auto-commit failed: ${commitResult.message}`);
}
if (commitResult.success && commitResult.commitHash && branch) {
const shouldCreatePr = options?.autoPr === true || await confirm('Create pull request?', false);
if (shouldCreatePr) {
info('Creating pull request...');
const prBody = buildPrBody(options?.issues, `Piece \`${pieceIdentifier}\` completed successfully.`);
const prResult = createPullRequest(execCwd, {
branch,
title: task.length > 100 ? `${task.slice(0, 97)}...` : task,
body: prBody,
repo: options?.repo,
});
if (prResult.success) {
success(`PR created: ${prResult.url}`);
} else {
error(`PR creation failed: ${prResult.error}`);
}
}
if (commitResult.success && commitResult.commitHash && branch && shouldCreatePr) {
pushAndCreatePr(
cwd,
branch,
task,
`Piece \`${pieceIdentifier}\` completed successfully.`,
{ issues: options?.issues, repo: options?.repo },
);
}
}

View File

@ -16,6 +16,7 @@ import { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
import { executePiece } from './pieceExecution.js';
import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js';
import type { TaskExecutionOptions, ExecuteTaskOptions } from './types.js';
import { createPullRequest, buildPrBody, pushBranch, type GitHubIssue } from '../../../infra/github/index.js';
export type { TaskExecutionOptions, ExecuteTaskOptions };
@ -58,6 +59,39 @@ export async function executeTask(options: ExecuteTaskOptions): Promise<boolean>
return result.success;
}
/**
* Push branch to origin and create a PR.
* clone origin shared clone project cwd push
*/
export function pushAndCreatePr(
cwd: string,
branch: string,
title: string,
description: string,
options?: { issues?: GitHubIssue[]; repo?: string },
): void {
info('Creating pull request...');
try {
pushBranch(cwd, branch);
} catch (e) {
error(`Failed to push branch to origin: ${getErrorMessage(e)}`);
return;
}
const prBody = buildPrBody(options?.issues, description);
const truncatedTitle = title.length > 100 ? `${title.slice(0, 97)}...` : title;
const prResult = createPullRequest(cwd, {
branch,
title: truncatedTitle,
body: prBody,
repo: options?.repo,
});
if (prResult.success) {
success(`PR created: ${prResult.url}`);
} else {
error(`PR creation failed: ${prResult.error}`);
}
}
/**
* Execute a task: resolve clone run piece auto-commit+push remove clone record completion.
*
@ -77,7 +111,7 @@ export async function executeAndCompleteTask(
const executionLog: string[] = [];
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
const taskSuccess = await executeTask({
@ -98,6 +132,10 @@ export async function executeAndCompleteTask(
} else if (!commitResult.success) {
error(`Auto-commit failed: ${commitResult.message}`);
}
if (commitResult.success && commitResult.commitHash && branch && autoPr) {
pushAndCreatePr(cwd, branch, task.name, `Task "${task.name}" completed successfully.`);
}
}
const taskResult = {
@ -198,7 +236,7 @@ export async function resolveTaskExecution(
task: TaskInfo,
defaultCwd: 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;
// No structured data: use defaults
@ -237,5 +275,14 @@ export async function resolveTaskExecution(
// Handle 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,
} : undefined,
worktreeDir: parsed.worktree_dir,
autoPr: parsed.auto_pr,
disabledBuiltins: parsed.disabled_builtins,
enableBuiltinPieces: parsed.enable_builtin_pieces,
anthropicApiKey: parsed.anthropic_api_key,
@ -114,6 +115,9 @@ export class GlobalConfigManager {
if (config.worktreeDir) {
raw.worktree_dir = config.worktreeDir;
}
if (config.autoPr !== undefined) {
raw.auto_pr = config.autoPr;
}
if (config.disabledBuiltins && config.disabledBuiltins.length > 0) {
raw.disabled_builtins = config.disabledBuiltins;
}

View File

@ -32,6 +32,8 @@ export const TaskFileSchema = z.object({
issue: z.number().int().positive().optional(),
start_movement: 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>;