takt: add-draft-pr-option (#323)

This commit is contained in:
nrs 2026-02-20 00:35:41 +09:00 committed by GitHub
parent 5960a0d212
commit 4f8255d509
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 323 additions and 25 deletions

View File

@ -42,6 +42,15 @@ describe('config env overrides', () => {
}); });
}); });
it('TAKT_DRAFT_PR が draft_pr に反映される', () => {
process.env.TAKT_DRAFT_PR = 'true';
const raw: Record<string, unknown> = {};
applyGlobalConfigEnvOverrides(raw);
expect(raw.draft_pr).toBe(true);
});
it('should apply project env overrides from generated env names', () => { it('should apply project env overrides from generated env names', () => {
process.env.TAKT_VERBOSE = 'true'; process.env.TAKT_VERBOSE = 'true';

View File

@ -26,7 +26,7 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
getErrorMessage: (e: unknown) => String(e), getErrorMessage: (e: unknown) => String(e),
})); }));
import { buildPrBody, findExistingPr } from '../infra/github/pr.js'; import { buildPrBody, findExistingPr, createPullRequest } from '../infra/github/pr.js';
import type { GitHubIssue } from '../infra/github/types.js'; import type { GitHubIssue } from '../infra/github/types.js';
describe('findExistingPr', () => { describe('findExistingPr', () => {
@ -59,6 +59,53 @@ describe('findExistingPr', () => {
}); });
}); });
describe('createPullRequest', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('draft: true の場合、args に --draft が含まれる', () => {
mockExecFileSync.mockReturnValue('https://github.com/org/repo/pull/1\n');
createPullRequest('/project', {
branch: 'feat/my-branch',
title: 'My PR',
body: 'PR body',
draft: true,
});
const call = mockExecFileSync.mock.calls[0];
expect(call[1]).toContain('--draft');
});
it('draft: false の場合、args に --draft が含まれない', () => {
mockExecFileSync.mockReturnValue('https://github.com/org/repo/pull/2\n');
createPullRequest('/project', {
branch: 'feat/my-branch',
title: 'My PR',
body: 'PR body',
draft: false,
});
const call = mockExecFileSync.mock.calls[0];
expect(call[1]).not.toContain('--draft');
});
it('draft が未指定の場合、args に --draft が含まれない', () => {
mockExecFileSync.mockReturnValue('https://github.com/org/repo/pull/3\n');
createPullRequest('/project', {
branch: 'feat/my-branch',
title: 'My PR',
body: 'PR body',
});
const call = mockExecFileSync.mock.calls[0];
expect(call[1]).not.toContain('--draft');
});
});
describe('buildPrBody', () => { describe('buildPrBody', () => {
it('should build body with single issue and report', () => { it('should build body with single issue and report', () => {
const issue: GitHubIssue = { const issue: GitHubIssue = {

View File

@ -218,6 +218,46 @@ describe('executePipeline', () => {
); );
}); });
it('draftPr: true の場合、createPullRequest に draft: true が渡される', async () => {
mockExecuteTask.mockResolvedValueOnce(true);
mockCreatePullRequest.mockReturnValueOnce({ success: true, url: 'https://github.com/test/pr/1' });
const exitCode = await executePipeline({
task: 'Fix the bug',
piece: 'default',
branch: 'fix/my-branch',
autoPr: true,
draftPr: true,
cwd: '/tmp/test',
});
expect(exitCode).toBe(0);
expect(mockCreatePullRequest).toHaveBeenCalledWith(
'/tmp/test',
expect.objectContaining({ draft: true }),
);
});
it('draftPr: false の場合、createPullRequest に draft: false が渡される', async () => {
mockExecuteTask.mockResolvedValueOnce(true);
mockCreatePullRequest.mockReturnValueOnce({ success: true, url: 'https://github.com/test/pr/1' });
const exitCode = await executePipeline({
task: 'Fix the bug',
piece: 'default',
branch: 'fix/my-branch',
autoPr: true,
draftPr: false,
cwd: '/tmp/test',
});
expect(exitCode).toBe(0);
expect(mockCreatePullRequest).toHaveBeenCalledWith(
'/tmp/test',
expect.objectContaining({ draft: false }),
);
});
it('should pass baseBranch as base to createPullRequest', async () => { it('should pass baseBranch as base to createPullRequest', async () => {
// Given: getCurrentBranch returns 'develop' before branch creation // Given: getCurrentBranch returns 'develop' before branch creation
mockExecFileSync.mockImplementation((_cmd: string, args: string[]) => { mockExecFileSync.mockImplementation((_cmd: string, args: string[]) => {

View File

@ -51,7 +51,12 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
}), }),
})); }));
import { postExecutionFlow } from '../features/tasks/execute/postExecution.js'; import { postExecutionFlow, resolveDraftPr } from '../features/tasks/execute/postExecution.js';
import { resolvePieceConfigValue } from '../infra/config/index.js';
import { confirm } from '../shared/prompt/index.js';
const mockResolvePieceConfigValue = vi.mocked(resolvePieceConfigValue);
const mockConfirm = vi.mocked(confirm);
const baseOptions = { const baseOptions = {
execCwd: '/clone', execCwd: '/clone',
@ -60,6 +65,7 @@ const baseOptions = {
branch: 'task/fix-the-bug', branch: 'task/fix-the-bug',
baseBranch: 'main', baseBranch: 'main',
shouldCreatePr: true, shouldCreatePr: true,
draftPr: false,
pieceIdentifier: 'default', pieceIdentifier: 'default',
}; };
@ -113,4 +119,60 @@ describe('postExecutionFlow', () => {
expect(mockFindExistingPr).not.toHaveBeenCalled(); expect(mockFindExistingPr).not.toHaveBeenCalled();
expect(mockCreatePullRequest).not.toHaveBeenCalled(); expect(mockCreatePullRequest).not.toHaveBeenCalled();
}); });
it('draftPr: true の場合、createPullRequest に draft: true が渡される', async () => {
mockFindExistingPr.mockReturnValue(undefined);
await postExecutionFlow({ ...baseOptions, draftPr: true });
expect(mockCreatePullRequest).toHaveBeenCalledWith(
'/project',
expect.objectContaining({ draft: true }),
);
});
it('draftPr: false の場合、createPullRequest に draft: false が渡される', async () => {
mockFindExistingPr.mockReturnValue(undefined);
await postExecutionFlow({ ...baseOptions, draftPr: false });
expect(mockCreatePullRequest).toHaveBeenCalledWith(
'/project',
expect.objectContaining({ draft: false }),
);
});
});
describe('resolveDraftPr', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('CLI オプション true が渡された場合は true を返す', async () => {
const result = await resolveDraftPr(true, '/project');
expect(result).toBe(true);
});
it('CLI オプション false が渡された場合は false を返す', async () => {
const result = await resolveDraftPr(false, '/project');
expect(result).toBe(false);
});
it('CLI オプションが未指定で config が true の場合は true を返す', async () => {
mockResolvePieceConfigValue.mockReturnValue(true);
const result = await resolveDraftPr(undefined, '/project');
expect(result).toBe(true);
});
it('CLI オプション・config ともに未指定の場合はプロンプトを表示する', async () => {
mockResolvePieceConfigValue.mockReturnValue(undefined);
mockConfirm.mockResolvedValue(false);
const result = await resolveDraftPr(undefined, '/project');
expect(mockConfirm).toHaveBeenCalledWith('Create as draft?', true);
expect(result).toBe(false);
});
}); });

View File

@ -48,6 +48,7 @@ describe('resolveTaskExecution', () => {
execPiece: 'default', execPiece: 'default',
isWorktree: false, isWorktree: false,
autoPr: false, autoPr: false,
draftPr: false,
}); });
}); });
@ -76,6 +77,7 @@ describe('resolveTaskExecution', () => {
execPiece: 'default', execPiece: 'default',
isWorktree: false, isWorktree: false,
autoPr: true, autoPr: true,
draftPr: false,
reportDirName: 'issue-task-123', reportDirName: 'issue-task-123',
issueNumber: 12345, issueNumber: 12345,
taskPrompt: expect.stringContaining('Primary spec: `.takt/runs/issue-task-123/context/task/order.md`'), taskPrompt: expect.stringContaining('Primary spec: `.takt/runs/issue-task-123/context/task/order.md`'),
@ -83,4 +85,20 @@ describe('resolveTaskExecution', () => {
expect(fs.existsSync(expectedReportOrderPath)).toBe(true); expect(fs.existsSync(expectedReportOrderPath)).toBe(true);
expect(fs.readFileSync(expectedReportOrderPath, 'utf-8')).toBe('# task instruction'); expect(fs.readFileSync(expectedReportOrderPath, 'utf-8')).toBe('# task instruction');
}); });
it('draft_pr: true が draftPr: true として解決される', async () => {
const root = createTempProjectDir();
const task = createTask({
data: {
task: 'Run draft task',
auto_pr: true,
draft_pr: true,
},
});
const result = await resolveTaskExecution(task, root, 'default');
expect(result.draftPr).toBe(true);
expect(result.autoPr).toBe(true);
});
}); });

View File

@ -103,6 +103,17 @@ describe('saveTaskFile', () => {
expect(task.task_dir).toBeTypeOf('string'); expect(task.task_dir).toBeTypeOf('string');
}); });
it('draftPr: true が draft_pr: true として保存される', async () => {
await saveTaskFile(testDir, 'Draft task', {
autoPr: true,
draftPr: true,
});
const task = loadTasks(testDir).tasks[0]!;
expect(task.auto_pr).toBe(true);
expect(task.draft_pr).toBe(true);
});
it('should generate unique names on duplicates', async () => { it('should generate unique names on duplicates', async () => {
const first = await saveTaskFile(testDir, 'Same title'); const first = await saveTaskFile(testDir, 'Same title');
const second = await saveTaskFile(testDir, 'Same title'); const second = await saveTaskFile(testDir, 'Same title');
@ -122,7 +133,8 @@ describe('saveTaskFromInteractive', () => {
it('should always save task with worktree settings', async () => { it('should always save task with worktree settings', async () => {
mockPromptInput.mockResolvedValueOnce(''); mockPromptInput.mockResolvedValueOnce('');
mockPromptInput.mockResolvedValueOnce(''); mockPromptInput.mockResolvedValueOnce('');
mockConfirm.mockResolvedValueOnce(true); mockConfirm.mockResolvedValueOnce(true); // auto-create PR?
mockConfirm.mockResolvedValueOnce(true); // create as draft?
await saveTaskFromInteractive(testDir, 'Task content'); await saveTaskFromInteractive(testDir, 'Task content');
@ -130,6 +142,7 @@ describe('saveTaskFromInteractive', () => {
const task = loadTasks(testDir).tasks[0]!; const task = loadTasks(testDir).tasks[0]!;
expect(task.worktree).toBe(true); expect(task.worktree).toBe(true);
expect(task.auto_pr).toBe(true); expect(task.auto_pr).toBe(true);
expect(task.draft_pr).toBe(true);
}); });
it('should keep worktree enabled even when auto-pr is declined', async () => { it('should keep worktree enabled even when auto-pr is declined', async () => {

View File

@ -127,6 +127,50 @@ describe('resolveAutoPr default in selectAndExecuteTask', () => {
expect(autoPrCall![1]).toBe(true); expect(autoPrCall![1]).toBe(true);
}); });
it('shouldCreatePr=true の場合、"Create as draft?" プロンプトが表示される', async () => {
// confirm はすべての呼び出しに対して true を返すautoPr=true → draftPr prompt
mockConfirm.mockResolvedValue(true);
mockSummarizeTaskName.mockResolvedValue('test-task');
mockCreateSharedClone.mockReturnValue({
path: '/project/../clone',
branch: 'takt/test-task',
});
mockAutoCommitAndPush.mockReturnValue({
success: false,
message: 'no changes',
});
await selectAndExecuteTask('/project', 'test task', {
piece: 'default',
createWorktree: true,
});
const draftPrCall = mockConfirm.mock.calls.find((call) => call[0] === 'Create as draft?');
expect(draftPrCall).toBeDefined();
expect(draftPrCall![1]).toBe(true);
});
it('shouldCreatePr=false の場合、"Create as draft?" プロンプトは表示されない', async () => {
mockConfirm.mockResolvedValue(false); // autoPr=false → draft prompt skipped
mockSummarizeTaskName.mockResolvedValue('test-task');
mockCreateSharedClone.mockReturnValue({
path: '/project/../clone',
branch: 'takt/test-task',
});
mockAutoCommitAndPush.mockReturnValue({
success: false,
message: 'no changes',
});
await selectAndExecuteTask('/project', 'test task', {
piece: 'default',
createWorktree: true,
});
const draftPrCall = mockConfirm.mock.calls.find((call) => call[0] === 'Create as draft?');
expect(draftPrCall).toBeUndefined();
});
it('should call selectPiece when no override is provided', async () => { it('should call selectPiece when no override is provided', async () => {
mockSelectPiece.mockResolvedValue('selected-piece'); mockSelectPiece.mockResolvedValue('selected-piece');
@ -175,6 +219,7 @@ describe('resolveAutoPr default in selectAndExecuteTask', () => {
branch: 'takt/test-task', branch: 'takt/test-task',
worktree_path: '/project/../clone', worktree_path: '/project/../clone',
auto_pr: true, auto_pr: true,
draft_pr: true,
})); }));
expect(mockCompleteTask).toHaveBeenCalledTimes(1); expect(mockCompleteTask).toHaveBeenCalledTimes(1);
expect(mockFailTask).not.toHaveBeenCalled(); expect(mockFailTask).not.toHaveBeenCalled();

View File

@ -44,6 +44,7 @@ program
.option('-w, --piece <name>', 'Piece name or path to piece file') .option('-w, --piece <name>', 'Piece name or path to piece file')
.option('-b, --branch <name>', 'Branch name (auto-generated if omitted)') .option('-b, --branch <name>', 'Branch name (auto-generated if omitted)')
.option('--auto-pr', 'Create PR after successful execution') .option('--auto-pr', 'Create PR after successful execution')
.option('--draft', 'Create PR as draft (requires --auto-pr or auto_pr config)')
.option('--repo <owner/repo>', 'Repository (defaults to current)') .option('--repo <owner/repo>', 'Repository (defaults to current)')
.option('--provider <name>', 'Override agent provider (claude|codex|opencode|mock)') .option('--provider <name>', 'Override agent provider (claude|codex|opencode|mock)')
.option('--model <name>', 'Override agent model') .option('--model <name>', 'Override agent model')

View File

@ -86,8 +86,12 @@ export async function executeDefaultAction(task?: string): Promise<void> {
const resolvedPipelineAutoPr = opts.autoPr === true const resolvedPipelineAutoPr = opts.autoPr === true
? true ? true
: (resolveConfigValue(resolvedCwd, 'autoPr') ?? false); : (resolveConfigValue(resolvedCwd, 'autoPr') ?? false);
const resolvedPipelineDraftPr = opts.draft === true
? true
: (resolveConfigValue(resolvedCwd, 'draftPr') ?? false);
const selectOptions: SelectAndExecuteOptions = { const selectOptions: SelectAndExecuteOptions = {
autoPr: opts.autoPr === true ? true : undefined, autoPr: opts.autoPr === true ? true : undefined,
draftPr: opts.draft === true ? true : undefined,
repo: opts.repo as string | undefined, repo: opts.repo as string | undefined,
piece: opts.piece as string | undefined, piece: opts.piece as string | undefined,
createWorktree: createWorktreeOverride, createWorktree: createWorktreeOverride,
@ -101,6 +105,7 @@ export async function executeDefaultAction(task?: string): Promise<void> {
piece: resolvedPipelinePiece, piece: resolvedPipelinePiece,
branch: opts.branch as string | undefined, branch: opts.branch as string | undefined,
autoPr: resolvedPipelineAutoPr, autoPr: resolvedPipelineAutoPr,
draftPr: resolvedPipelineDraftPr,
repo: opts.repo as string | undefined, repo: opts.repo as string | undefined,
skipGit: opts.skipGit === true, skipGit: opts.skipGit === true,
cwd: resolvedCwd, cwd: resolvedCwd,

View File

@ -72,6 +72,8 @@ export interface GlobalConfig {
worktreeDir?: string; worktreeDir?: string;
/** Auto-create PR after worktree execution (default: prompt in interactive mode) */ /** Auto-create PR after worktree execution (default: prompt in interactive mode) */
autoPr?: boolean; autoPr?: boolean;
/** Create PR as draft (default: prompt in interactive mode when autoPr is true) */
draftPr?: 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 builtins/{lang}/pieces */ /** Enable builtin pieces from builtins/{lang}/pieces */

View File

@ -421,6 +421,8 @@ export const GlobalConfigSchema = z.object({
worktree_dir: z.string().optional(), worktree_dir: z.string().optional(),
/** Auto-create PR after worktree execution (default: prompt in interactive mode) */ /** Auto-create PR after worktree execution (default: prompt in interactive mode) */
auto_pr: z.boolean().optional(), auto_pr: z.boolean().optional(),
/** Create PR as draft (default: prompt in interactive mode when auto_pr is true) */
draft_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 builtins/{lang}/pieces */ /** Enable builtin pieces from builtins/{lang}/pieces */

View File

@ -105,7 +105,7 @@ function buildPipelinePrBody(
* Returns a process exit code (0 on success, 2-5 on specific failures). * Returns a process exit code (0 on success, 2-5 on specific failures).
*/ */
export async function executePipeline(options: PipelineExecutionOptions): Promise<number> { export async function executePipeline(options: PipelineExecutionOptions): Promise<number> {
const { cwd, piece, autoPr, skipGit } = options; const { cwd, piece, autoPr, draftPr, skipGit } = options;
const globalConfig = resolveConfigValues(cwd, ['pipeline']); const globalConfig = resolveConfigValues(cwd, ['pipeline']);
const pipelineConfig = globalConfig.pipeline; const pipelineConfig = globalConfig.pipeline;
let issue: GitHubIssue | undefined; let issue: GitHubIssue | undefined;
@ -210,6 +210,7 @@ export async function executePipeline(options: PipelineExecutionOptions): Promis
body: prBody, body: prBody,
base: baseBranch, base: baseBranch,
repo: options.repo, repo: options.repo,
draft: draftPr,
}); });
if (prResult.success) { if (prResult.success) {

View File

@ -37,7 +37,7 @@ function resolveUniqueTaskSlug(cwd: string, baseSlug: string): string {
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; autoPr?: boolean }, options?: { piece?: string; issue?: number; worktree?: boolean | string; branch?: string; autoPr?: boolean; draftPr?: boolean },
): Promise<{ taskName: string; tasksFile: string }> { ): Promise<{ taskName: string; tasksFile: string }> {
const runner = new TaskRunner(cwd); const runner = new TaskRunner(cwd);
const slug = await summarizeTaskName(taskContent, { cwd }); const slug = await summarizeTaskName(taskContent, { cwd });
@ -54,6 +54,7 @@ export async function saveTaskFile(
...(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 }), ...(options?.autoPr !== undefined && { auto_pr: options.autoPr }),
...(options?.draftPr !== undefined && { draft_pr: options.draftPr }),
}; };
const created = runner.addTask(taskContent, { const created = runner.addTask(taskContent, {
...config, ...config,
@ -95,6 +96,7 @@ interface WorktreeSettings {
worktree?: boolean | string; worktree?: boolean | string;
branch?: string; branch?: string;
autoPr?: boolean; autoPr?: boolean;
draftPr?: boolean;
} }
function displayTaskCreationResult( function displayTaskCreationResult(
@ -113,6 +115,9 @@ function displayTaskCreationResult(
if (settings.autoPr) { if (settings.autoPr) {
info(` Auto-PR: yes`); info(` Auto-PR: yes`);
} }
if (settings.draftPr) {
info(` Draft PR: yes`);
}
if (piece) info(` Piece: ${piece}`); if (piece) info(` Piece: ${piece}`);
} }
@ -137,8 +142,9 @@ async function promptWorktreeSettings(): Promise<WorktreeSettings> {
const branch = customBranch || undefined; const branch = customBranch || undefined;
const autoPr = await confirm('Auto-create PR?', true); const autoPr = await confirm('Auto-create PR?', true);
const draftPr = autoPr ? await confirm('Create as draft?', true) : false;
return { worktree, branch, autoPr }; return { worktree, branch, autoPr, draftPr };
} }
/** /**

View File

@ -15,19 +15,38 @@ import type { GitHubIssue } from '../../../infra/github/index.js';
const log = createLogger('postExecution'); const log = createLogger('postExecution');
/**
* Resolve a boolean PR option with priority: CLI option > config > prompt.
*/
async function resolvePrBooleanOption(
option: boolean | undefined,
cwd: string,
configKey: 'autoPr' | 'draftPr',
promptMessage: string,
): Promise<boolean> {
if (typeof option === 'boolean') {
return option;
}
const configValue = resolvePieceConfigValue(cwd, configKey);
if (typeof configValue === 'boolean') {
return configValue;
}
return confirm(promptMessage, true);
}
/** /**
* Resolve auto-PR setting with priority: CLI option > config > prompt. * Resolve auto-PR setting with priority: CLI option > config > prompt.
*/ */
export async function resolveAutoPr(optionAutoPr: boolean | undefined, cwd: string): Promise<boolean> { export async function resolveAutoPr(optionAutoPr: boolean | undefined, cwd: string): Promise<boolean> {
if (typeof optionAutoPr === 'boolean') { return resolvePrBooleanOption(optionAutoPr, cwd, 'autoPr', 'Create pull request?');
return optionAutoPr; }
}
const autoPr = resolvePieceConfigValue(cwd, 'autoPr'); /**
if (typeof autoPr === 'boolean') { * Resolve draft-PR setting with priority: CLI option > config > prompt.
return autoPr; * Only called when shouldCreatePr is true.
} */
return confirm('Create pull request?', true); export async function resolveDraftPr(optionDraftPr: boolean | undefined, cwd: string): Promise<boolean> {
return resolvePrBooleanOption(optionDraftPr, cwd, 'draftPr', 'Create as draft?');
} }
export interface PostExecutionOptions { export interface PostExecutionOptions {
@ -37,6 +56,7 @@ export interface PostExecutionOptions {
branch?: string; branch?: string;
baseBranch?: string; baseBranch?: string;
shouldCreatePr: boolean; shouldCreatePr: boolean;
draftPr: boolean;
pieceIdentifier?: string; pieceIdentifier?: string;
issues?: GitHubIssue[]; issues?: GitHubIssue[];
repo?: string; repo?: string;
@ -50,7 +70,7 @@ export interface PostExecutionResult {
* Auto-commit, push, and optionally create a PR after successful task execution. * Auto-commit, push, and optionally create a PR after successful task execution.
*/ */
export async function postExecutionFlow(options: PostExecutionOptions): Promise<PostExecutionResult> { export async function postExecutionFlow(options: PostExecutionOptions): Promise<PostExecutionResult> {
const { execCwd, projectCwd, task, branch, baseBranch, shouldCreatePr, pieceIdentifier, issues, repo } = options; const { execCwd, projectCwd, task, branch, baseBranch, shouldCreatePr, draftPr, pieceIdentifier, issues, repo } = options;
const commitResult = autoCommitAndPush(execCwd, task, projectCwd); const commitResult = autoCommitAndPush(execCwd, task, projectCwd);
if (commitResult.success && commitResult.commitHash) { if (commitResult.success && commitResult.commitHash) {
@ -86,6 +106,7 @@ export async function postExecutionFlow(options: PostExecutionOptions): Promise<
body: prBody, body: prBody,
base: baseBranch, base: baseBranch,
repo, repo,
draft: draftPr,
}); });
if (prResult.success) { if (prResult.success) {
success(`PR created: ${prResult.url}`); success(`PR created: ${prResult.url}`);

View File

@ -25,6 +25,7 @@ export interface ResolvedTaskExecution {
startMovement?: string; startMovement?: string;
retryNote?: string; retryNote?: string;
autoPr: boolean; autoPr: boolean;
draftPr: boolean;
issueNumber?: number; issueNumber?: number;
} }
@ -103,7 +104,7 @@ export async function resolveTaskExecution(
const data = task.data; const data = task.data;
if (!data) { if (!data) {
return { execCwd: defaultCwd, execPiece: defaultPiece, isWorktree: false, autoPr: false }; return { execCwd: defaultCwd, execPiece: defaultPiece, isWorktree: false, autoPr: false, draftPr: false };
} }
let execCwd = defaultCwd; let execCwd = defaultCwd;
@ -165,18 +166,15 @@ export async function resolveTaskExecution(
const startMovement = data.start_movement; const startMovement = data.start_movement;
const retryNote = data.retry_note; const retryNote = data.retry_note;
let autoPr: boolean; const autoPr = data.auto_pr ?? resolvePieceConfigValue(defaultCwd, 'autoPr') ?? false;
if (data.auto_pr !== undefined) { const draftPr = data.draft_pr ?? resolvePieceConfigValue(defaultCwd, 'draftPr') ?? false;
autoPr = data.auto_pr;
} else {
autoPr = resolvePieceConfigValue(defaultCwd, 'autoPr') ?? false;
}
return { return {
execCwd, execCwd,
execPiece, execPiece,
isWorktree, isWorktree,
autoPr, autoPr,
draftPr,
...(taskPrompt ? { taskPrompt } : {}), ...(taskPrompt ? { taskPrompt } : {}),
...(reportDirName ? { reportDirName } : {}), ...(reportDirName ? { reportDirName } : {}),
...(branch ? { branch } : {}), ...(branch ? { branch } : {}),

View File

@ -16,7 +16,7 @@ import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js';
import { info, error, withProgress } from '../../../shared/ui/index.js'; import { info, error, withProgress } from '../../../shared/ui/index.js';
import { createLogger } from '../../../shared/utils/index.js'; import { createLogger } from '../../../shared/utils/index.js';
import { executeTask } from './taskExecution.js'; import { executeTask } from './taskExecution.js';
import { resolveAutoPr, postExecutionFlow } from './postExecution.js'; import { resolveAutoPr, resolveDraftPr, postExecutionFlow } from './postExecution.js';
import type { TaskExecutionOptions, WorktreeConfirmationResult, SelectAndExecuteOptions } from './types.js'; import type { TaskExecutionOptions, WorktreeConfirmationResult, SelectAndExecuteOptions } from './types.js';
import { selectPiece } from '../../pieceSelection/index.js'; import { selectPiece } from '../../pieceSelection/index.js';
import { buildBooleanTaskResult, persistTaskError, persistTaskResult } from './taskResultHandler.js'; import { buildBooleanTaskResult, persistTaskError, persistTaskResult } from './taskResultHandler.js';
@ -100,11 +100,15 @@ export async function selectAndExecuteTask(
// Ask for PR creation BEFORE execution (only if worktree is enabled) // Ask for PR creation BEFORE execution (only if worktree is enabled)
let shouldCreatePr = false; let shouldCreatePr = false;
let shouldDraftPr = false;
if (isWorktree) { if (isWorktree) {
shouldCreatePr = await resolveAutoPr(options?.autoPr, cwd); shouldCreatePr = await resolveAutoPr(options?.autoPr, cwd);
if (shouldCreatePr) {
shouldDraftPr = await resolveDraftPr(options?.draftPr, cwd);
}
} }
log.info('Starting task execution', { piece: pieceIdentifier, worktree: isWorktree, autoPr: shouldCreatePr }); log.info('Starting task execution', { piece: pieceIdentifier, worktree: isWorktree, autoPr: shouldCreatePr, draftPr: shouldDraftPr });
const taskRunner = new TaskRunner(cwd); const taskRunner = new TaskRunner(cwd);
const taskRecord = taskRunner.addTask(task, { const taskRecord = taskRunner.addTask(task, {
piece: pieceIdentifier, piece: pieceIdentifier,
@ -112,6 +116,7 @@ export async function selectAndExecuteTask(
...(branch ? { branch } : {}), ...(branch ? { branch } : {}),
...(isWorktree ? { worktree_path: execCwd } : {}), ...(isWorktree ? { worktree_path: execCwd } : {}),
auto_pr: shouldCreatePr, auto_pr: shouldCreatePr,
draft_pr: shouldDraftPr,
...(taskSlug ? { slug: taskSlug } : {}), ...(taskSlug ? { slug: taskSlug } : {}),
}); });
const startedAt = new Date().toISOString(); const startedAt = new Date().toISOString();
@ -157,6 +162,7 @@ export async function selectAndExecuteTask(
branch, branch,
baseBranch, baseBranch,
shouldCreatePr, shouldCreatePr,
draftPr: shouldDraftPr,
pieceIdentifier, pieceIdentifier,
issues: options?.issues, issues: options?.issues,
repo: options?.repo, repo: options?.repo,

View File

@ -144,6 +144,7 @@ export async function executeAndCompleteTask(
startMovement, startMovement,
retryNote, retryNote,
autoPr, autoPr,
draftPr,
issueNumber, issueNumber,
} = await resolveTaskExecution(task, cwd, pieceName, taskAbortSignal); } = await resolveTaskExecution(task, cwd, pieceName, taskAbortSignal);
@ -176,6 +177,7 @@ export async function executeAndCompleteTask(
branch, branch,
baseBranch, baseBranch,
shouldCreatePr: autoPr, shouldCreatePr: autoPr,
draftPr,
pieceIdentifier: execPiece, pieceIdentifier: execPiece,
issues, issues,
}); });

View File

@ -107,6 +107,8 @@ export interface PipelineExecutionOptions {
branch?: string; branch?: string;
/** Whether to create a PR after successful execution */ /** Whether to create a PR after successful execution */
autoPr: boolean; autoPr: boolean;
/** Whether to create PR as draft */
draftPr?: boolean;
/** Repository in owner/repo format */ /** Repository in owner/repo format */
repo?: string; repo?: string;
/** Skip branch creation, commit, and push (piece-only execution) */ /** Skip branch creation, commit, and push (piece-only execution) */
@ -127,6 +129,7 @@ export interface WorktreeConfirmationResult {
export interface SelectAndExecuteOptions { export interface SelectAndExecuteOptions {
autoPr?: boolean; autoPr?: boolean;
draftPr?: boolean;
repo?: string; repo?: string;
piece?: string; piece?: string;
createWorktree?: boolean | undefined; createWorktree?: boolean | undefined;

View File

@ -84,6 +84,7 @@ const GLOBAL_ENV_SPECS: readonly EnvSpec[] = [
{ path: 'observability.provider_events', type: 'boolean' }, { path: 'observability.provider_events', type: 'boolean' },
{ path: 'worktree_dir', type: 'string' }, { path: 'worktree_dir', type: 'string' },
{ path: 'auto_pr', type: 'boolean' }, { path: 'auto_pr', type: 'boolean' },
{ path: 'draft_pr', type: 'boolean' },
{ path: 'disabled_builtins', type: 'json' }, { path: 'disabled_builtins', type: 'json' },
{ path: 'enable_builtin_pieces', type: 'boolean' }, { path: 'enable_builtin_pieces', type: 'boolean' },
{ path: 'anthropic_api_key', type: 'string' }, { path: 'anthropic_api_key', type: 'string' },

View File

@ -171,6 +171,7 @@ export class GlobalConfigManager {
} : undefined, } : undefined,
worktreeDir: parsed.worktree_dir, worktreeDir: parsed.worktree_dir,
autoPr: parsed.auto_pr, autoPr: parsed.auto_pr,
draftPr: parsed.draft_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,
@ -242,6 +243,9 @@ export class GlobalConfigManager {
if (config.autoPr !== undefined) { if (config.autoPr !== undefined) {
raw.auto_pr = config.autoPr; raw.auto_pr = config.autoPr;
} }
if (config.draftPr !== undefined) {
raw.draft_pr = config.draftPr;
}
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

@ -23,6 +23,7 @@ export function loadConfig(projectDir: string): LoadedConfig {
piece: project.piece ?? 'default', piece: project.piece ?? 'default',
provider, provider,
autoPr: project.auto_pr ?? global.autoPr, autoPr: project.auto_pr ?? global.autoPr,
draftPr: project.draft_pr ?? global.draftPr,
model: resolveModel(global, provider), model: resolveModel(global, provider),
verbose: resolveVerbose(project.verbose, global.verbose), verbose: resolveVerbose(project.verbose, global.verbose),
providerOptions: mergeProviderOptions(global.providerOptions, project.providerOptions), providerOptions: mergeProviderOptions(global.providerOptions, project.providerOptions),

View File

@ -13,6 +13,8 @@ export interface ProjectLocalConfig {
provider?: 'claude' | 'codex' | 'opencode' | 'mock'; provider?: 'claude' | 'codex' | 'opencode' | 'mock';
/** Auto-create PR after worktree execution */ /** Auto-create PR after worktree execution */
auto_pr?: boolean; auto_pr?: boolean;
/** Create PR as draft */
draft_pr?: boolean;
/** Verbose output mode */ /** Verbose output mode */
verbose?: boolean; verbose?: boolean;
/** Provider-specific options (overrides global, overridden by piece/movement) */ /** Provider-specific options (overrides global, overridden by piece/movement) */

View File

@ -97,7 +97,11 @@ export function createPullRequest(cwd: string, options: CreatePrOptions): Create
args.push('--repo', options.repo); args.push('--repo', options.repo);
} }
log.info('Creating PR', { branch: options.branch, title: options.title }); if (options.draft) {
args.push('--draft');
}
log.info('Creating PR', { branch: options.branch, title: options.title, draft: options.draft });
try { try {
const output = execFileSync('gh', args, { const output = execFileSync('gh', args, {

View File

@ -26,6 +26,8 @@ export interface CreatePrOptions {
base?: string; base?: string;
/** Repository in owner/repo format (optional, uses current repo if omitted) */ /** Repository in owner/repo format (optional, uses current repo if omitted) */
repo?: string; repo?: string;
/** Create PR as draft */
draft?: boolean;
} }
export interface CreatePrResult { export interface CreatePrResult {

View File

@ -55,6 +55,7 @@ export function toTaskData(projectDir: string, task: TaskRecord): TaskFileData {
start_movement: task.start_movement, start_movement: task.start_movement,
retry_note: task.retry_note, retry_note: task.retry_note,
auto_pr: task.auto_pr, auto_pr: task.auto_pr,
draft_pr: task.draft_pr,
}); });
} }
@ -78,6 +79,7 @@ export function toTaskInfo(projectDir: string, tasksFile: string, task: TaskReco
start_movement: task.start_movement, start_movement: task.start_movement,
retry_note: task.retry_note, retry_note: task.retry_note,
auto_pr: task.auto_pr, auto_pr: task.auto_pr,
draft_pr: task.draft_pr,
}), }),
}; };
} }

View File

@ -17,6 +17,7 @@ export const TaskExecutionConfigSchema = z.object({
start_movement: z.string().optional(), start_movement: z.string().optional(),
retry_note: z.string().optional(), retry_note: z.string().optional(),
auto_pr: z.boolean().optional(), auto_pr: z.boolean().optional(),
draft_pr: z.boolean().optional(),
}); });
/** /**