diff --git a/src/__tests__/config-env-overrides.test.ts b/src/__tests__/config-env-overrides.test.ts index 3a2ce1a..144031f 100644 --- a/src/__tests__/config-env-overrides.test.ts +++ b/src/__tests__/config-env-overrides.test.ts @@ -42,6 +42,15 @@ describe('config env overrides', () => { }); }); + it('TAKT_DRAFT_PR が draft_pr に反映される', () => { + process.env.TAKT_DRAFT_PR = 'true'; + + const raw: Record = {}; + applyGlobalConfigEnvOverrides(raw); + + expect(raw.draft_pr).toBe(true); + }); + it('should apply project env overrides from generated env names', () => { process.env.TAKT_VERBOSE = 'true'; diff --git a/src/__tests__/github-pr.test.ts b/src/__tests__/github-pr.test.ts index 895dd76..1be0e18 100644 --- a/src/__tests__/github-pr.test.ts +++ b/src/__tests__/github-pr.test.ts @@ -26,7 +26,7 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({ 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'; 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', () => { it('should build body with single issue and report', () => { const issue: GitHubIssue = { diff --git a/src/__tests__/pipelineExecution.test.ts b/src/__tests__/pipelineExecution.test.ts index eeb9a8e..c44937a 100644 --- a/src/__tests__/pipelineExecution.test.ts +++ b/src/__tests__/pipelineExecution.test.ts @@ -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 () => { // Given: getCurrentBranch returns 'develop' before branch creation mockExecFileSync.mockImplementation((_cmd: string, args: string[]) => { diff --git a/src/__tests__/postExecution.test.ts b/src/__tests__/postExecution.test.ts index d1d6adf..eb6ac52 100644 --- a/src/__tests__/postExecution.test.ts +++ b/src/__tests__/postExecution.test.ts @@ -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 = { execCwd: '/clone', @@ -60,6 +65,7 @@ const baseOptions = { branch: 'task/fix-the-bug', baseBranch: 'main', shouldCreatePr: true, + draftPr: false, pieceIdentifier: 'default', }; @@ -113,4 +119,60 @@ describe('postExecutionFlow', () => { expect(mockFindExistingPr).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); + }); }); diff --git a/src/__tests__/resolveTask.test.ts b/src/__tests__/resolveTask.test.ts index ac9c736..3f075e4 100644 --- a/src/__tests__/resolveTask.test.ts +++ b/src/__tests__/resolveTask.test.ts @@ -48,6 +48,7 @@ describe('resolveTaskExecution', () => { execPiece: 'default', isWorktree: false, autoPr: false, + draftPr: false, }); }); @@ -76,6 +77,7 @@ describe('resolveTaskExecution', () => { execPiece: 'default', isWorktree: false, autoPr: true, + draftPr: false, reportDirName: 'issue-task-123', issueNumber: 12345, 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.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); + }); }); diff --git a/src/__tests__/saveTaskFile.test.ts b/src/__tests__/saveTaskFile.test.ts index bcb33a5..1b2b3a9 100644 --- a/src/__tests__/saveTaskFile.test.ts +++ b/src/__tests__/saveTaskFile.test.ts @@ -103,6 +103,17 @@ describe('saveTaskFile', () => { 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 () => { const first = 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 () => { mockPromptInput.mockResolvedValueOnce(''); mockPromptInput.mockResolvedValueOnce(''); - mockConfirm.mockResolvedValueOnce(true); + mockConfirm.mockResolvedValueOnce(true); // auto-create PR? + mockConfirm.mockResolvedValueOnce(true); // create as draft? await saveTaskFromInteractive(testDir, 'Task content'); @@ -130,6 +142,7 @@ describe('saveTaskFromInteractive', () => { const task = loadTasks(testDir).tasks[0]!; expect(task.worktree).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 () => { diff --git a/src/__tests__/selectAndExecute-autoPr.test.ts b/src/__tests__/selectAndExecute-autoPr.test.ts index 3c9157d..4dd60a7 100644 --- a/src/__tests__/selectAndExecute-autoPr.test.ts +++ b/src/__tests__/selectAndExecute-autoPr.test.ts @@ -127,6 +127,50 @@ describe('resolveAutoPr default in selectAndExecuteTask', () => { 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 () => { mockSelectPiece.mockResolvedValue('selected-piece'); @@ -175,6 +219,7 @@ describe('resolveAutoPr default in selectAndExecuteTask', () => { branch: 'takt/test-task', worktree_path: '/project/../clone', auto_pr: true, + draft_pr: true, })); expect(mockCompleteTask).toHaveBeenCalledTimes(1); expect(mockFailTask).not.toHaveBeenCalled(); diff --git a/src/app/cli/program.ts b/src/app/cli/program.ts index 47b9614..142ba02 100644 --- a/src/app/cli/program.ts +++ b/src/app/cli/program.ts @@ -44,6 +44,7 @@ program .option('-w, --piece ', 'Piece name or path to piece file') .option('-b, --branch ', 'Branch name (auto-generated if omitted)') .option('--auto-pr', 'Create PR after successful execution') + .option('--draft', 'Create PR as draft (requires --auto-pr or auto_pr config)') .option('--repo ', 'Repository (defaults to current)') .option('--provider ', 'Override agent provider (claude|codex|opencode|mock)') .option('--model ', 'Override agent model') diff --git a/src/app/cli/routing.ts b/src/app/cli/routing.ts index f97b023..b9b0836 100644 --- a/src/app/cli/routing.ts +++ b/src/app/cli/routing.ts @@ -86,8 +86,12 @@ export async function executeDefaultAction(task?: string): Promise { const resolvedPipelineAutoPr = opts.autoPr === true ? true : (resolveConfigValue(resolvedCwd, 'autoPr') ?? false); + const resolvedPipelineDraftPr = opts.draft === true + ? true + : (resolveConfigValue(resolvedCwd, 'draftPr') ?? false); const selectOptions: SelectAndExecuteOptions = { autoPr: opts.autoPr === true ? true : undefined, + draftPr: opts.draft === true ? true : undefined, repo: opts.repo as string | undefined, piece: opts.piece as string | undefined, createWorktree: createWorktreeOverride, @@ -101,6 +105,7 @@ export async function executeDefaultAction(task?: string): Promise { piece: resolvedPipelinePiece, branch: opts.branch as string | undefined, autoPr: resolvedPipelineAutoPr, + draftPr: resolvedPipelineDraftPr, repo: opts.repo as string | undefined, skipGit: opts.skipGit === true, cwd: resolvedCwd, diff --git a/src/core/models/global-config.ts b/src/core/models/global-config.ts index 802bdd9..fb774aa 100644 --- a/src/core/models/global-config.ts +++ b/src/core/models/global-config.ts @@ -72,6 +72,8 @@ export interface GlobalConfig { worktreeDir?: string; /** Auto-create PR after worktree execution (default: prompt in interactive mode) */ 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 */ disabledBuiltins?: string[]; /** Enable builtin pieces from builtins/{lang}/pieces */ diff --git a/src/core/models/schemas.ts b/src/core/models/schemas.ts index 0076130..f01b982 100644 --- a/src/core/models/schemas.ts +++ b/src/core/models/schemas.ts @@ -421,6 +421,8 @@ export const GlobalConfigSchema = z.object({ worktree_dir: z.string().optional(), /** Auto-create PR after worktree execution (default: prompt in interactive mode) */ 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 */ disabled_builtins: z.array(z.string()).optional().default([]), /** Enable builtin pieces from builtins/{lang}/pieces */ diff --git a/src/features/pipeline/execute.ts b/src/features/pipeline/execute.ts index 385e0f3..ca70ff2 100644 --- a/src/features/pipeline/execute.ts +++ b/src/features/pipeline/execute.ts @@ -105,7 +105,7 @@ function buildPipelinePrBody( * Returns a process exit code (0 on success, 2-5 on specific failures). */ export async function executePipeline(options: PipelineExecutionOptions): Promise { - const { cwd, piece, autoPr, skipGit } = options; + const { cwd, piece, autoPr, draftPr, skipGit } = options; const globalConfig = resolveConfigValues(cwd, ['pipeline']); const pipelineConfig = globalConfig.pipeline; let issue: GitHubIssue | undefined; @@ -210,6 +210,7 @@ export async function executePipeline(options: PipelineExecutionOptions): Promis body: prBody, base: baseBranch, repo: options.repo, + draft: draftPr, }); if (prResult.success) { diff --git a/src/features/tasks/add/index.ts b/src/features/tasks/add/index.ts index 107e2de..e21bbe9 100644 --- a/src/features/tasks/add/index.ts +++ b/src/features/tasks/add/index.ts @@ -37,7 +37,7 @@ function resolveUniqueTaskSlug(cwd: string, baseSlug: string): string { export async function saveTaskFile( cwd: 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 }> { const runner = new TaskRunner(cwd); const slug = await summarizeTaskName(taskContent, { cwd }); @@ -54,6 +54,7 @@ export async function saveTaskFile( ...(options?.piece && { piece: options.piece }), ...(options?.issue !== undefined && { issue: options.issue }), ...(options?.autoPr !== undefined && { auto_pr: options.autoPr }), + ...(options?.draftPr !== undefined && { draft_pr: options.draftPr }), }; const created = runner.addTask(taskContent, { ...config, @@ -95,6 +96,7 @@ interface WorktreeSettings { worktree?: boolean | string; branch?: string; autoPr?: boolean; + draftPr?: boolean; } function displayTaskCreationResult( @@ -113,6 +115,9 @@ function displayTaskCreationResult( if (settings.autoPr) { info(` Auto-PR: yes`); } + if (settings.draftPr) { + info(` Draft PR: yes`); + } if (piece) info(` Piece: ${piece}`); } @@ -137,8 +142,9 @@ async function promptWorktreeSettings(): Promise { const branch = customBranch || undefined; 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 }; } /** diff --git a/src/features/tasks/execute/postExecution.ts b/src/features/tasks/execute/postExecution.ts index ce0d72d..cffb6c6 100644 --- a/src/features/tasks/execute/postExecution.ts +++ b/src/features/tasks/execute/postExecution.ts @@ -15,19 +15,38 @@ import type { GitHubIssue } from '../../../infra/github/index.js'; 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 { + 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. */ export async function resolveAutoPr(optionAutoPr: boolean | undefined, cwd: string): Promise { - if (typeof optionAutoPr === 'boolean') { - return optionAutoPr; - } + return resolvePrBooleanOption(optionAutoPr, cwd, 'autoPr', 'Create pull request?'); +} - const autoPr = resolvePieceConfigValue(cwd, 'autoPr'); - if (typeof autoPr === 'boolean') { - return autoPr; - } - return confirm('Create pull request?', true); +/** + * Resolve draft-PR setting with priority: CLI option > config > prompt. + * Only called when shouldCreatePr is true. + */ +export async function resolveDraftPr(optionDraftPr: boolean | undefined, cwd: string): Promise { + return resolvePrBooleanOption(optionDraftPr, cwd, 'draftPr', 'Create as draft?'); } export interface PostExecutionOptions { @@ -37,6 +56,7 @@ export interface PostExecutionOptions { branch?: string; baseBranch?: string; shouldCreatePr: boolean; + draftPr: boolean; pieceIdentifier?: string; issues?: GitHubIssue[]; repo?: string; @@ -50,7 +70,7 @@ export interface PostExecutionResult { * Auto-commit, push, and optionally create a PR after successful task execution. */ export async function postExecutionFlow(options: PostExecutionOptions): Promise { - 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); if (commitResult.success && commitResult.commitHash) { @@ -86,6 +106,7 @@ export async function postExecutionFlow(options: PostExecutionOptions): Promise< body: prBody, base: baseBranch, repo, + draft: draftPr, }); if (prResult.success) { success(`PR created: ${prResult.url}`); diff --git a/src/features/tasks/execute/resolveTask.ts b/src/features/tasks/execute/resolveTask.ts index 7b475a3..e94f432 100644 --- a/src/features/tasks/execute/resolveTask.ts +++ b/src/features/tasks/execute/resolveTask.ts @@ -25,6 +25,7 @@ export interface ResolvedTaskExecution { startMovement?: string; retryNote?: string; autoPr: boolean; + draftPr: boolean; issueNumber?: number; } @@ -103,7 +104,7 @@ export async function resolveTaskExecution( const data = task.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; @@ -165,18 +166,15 @@ export async function resolveTaskExecution( const startMovement = data.start_movement; const retryNote = data.retry_note; - let autoPr: boolean; - if (data.auto_pr !== undefined) { - autoPr = data.auto_pr; - } else { - autoPr = resolvePieceConfigValue(defaultCwd, 'autoPr') ?? false; - } + const autoPr = data.auto_pr ?? resolvePieceConfigValue(defaultCwd, 'autoPr') ?? false; + const draftPr = data.draft_pr ?? resolvePieceConfigValue(defaultCwd, 'draftPr') ?? false; return { execCwd, execPiece, isWorktree, autoPr, + draftPr, ...(taskPrompt ? { taskPrompt } : {}), ...(reportDirName ? { reportDirName } : {}), ...(branch ? { branch } : {}), diff --git a/src/features/tasks/execute/selectAndExecute.ts b/src/features/tasks/execute/selectAndExecute.ts index dccfb31..ab22c09 100644 --- a/src/features/tasks/execute/selectAndExecute.ts +++ b/src/features/tasks/execute/selectAndExecute.ts @@ -16,7 +16,7 @@ import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js'; import { info, error, withProgress } from '../../../shared/ui/index.js'; import { createLogger } from '../../../shared/utils/index.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 { selectPiece } from '../../pieceSelection/index.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) let shouldCreatePr = false; + let shouldDraftPr = false; if (isWorktree) { 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 taskRecord = taskRunner.addTask(task, { piece: pieceIdentifier, @@ -112,6 +116,7 @@ export async function selectAndExecuteTask( ...(branch ? { branch } : {}), ...(isWorktree ? { worktree_path: execCwd } : {}), auto_pr: shouldCreatePr, + draft_pr: shouldDraftPr, ...(taskSlug ? { slug: taskSlug } : {}), }); const startedAt = new Date().toISOString(); @@ -157,6 +162,7 @@ export async function selectAndExecuteTask( branch, baseBranch, shouldCreatePr, + draftPr: shouldDraftPr, pieceIdentifier, issues: options?.issues, repo: options?.repo, diff --git a/src/features/tasks/execute/taskExecution.ts b/src/features/tasks/execute/taskExecution.ts index 1339394..6ebdf4f 100644 --- a/src/features/tasks/execute/taskExecution.ts +++ b/src/features/tasks/execute/taskExecution.ts @@ -144,6 +144,7 @@ export async function executeAndCompleteTask( startMovement, retryNote, autoPr, + draftPr, issueNumber, } = await resolveTaskExecution(task, cwd, pieceName, taskAbortSignal); @@ -176,6 +177,7 @@ export async function executeAndCompleteTask( branch, baseBranch, shouldCreatePr: autoPr, + draftPr, pieceIdentifier: execPiece, issues, }); diff --git a/src/features/tasks/execute/types.ts b/src/features/tasks/execute/types.ts index 2636115..a89f027 100644 --- a/src/features/tasks/execute/types.ts +++ b/src/features/tasks/execute/types.ts @@ -107,6 +107,8 @@ export interface PipelineExecutionOptions { branch?: string; /** Whether to create a PR after successful execution */ autoPr: boolean; + /** Whether to create PR as draft */ + draftPr?: boolean; /** Repository in owner/repo format */ repo?: string; /** Skip branch creation, commit, and push (piece-only execution) */ @@ -127,6 +129,7 @@ export interface WorktreeConfirmationResult { export interface SelectAndExecuteOptions { autoPr?: boolean; + draftPr?: boolean; repo?: string; piece?: string; createWorktree?: boolean | undefined; diff --git a/src/infra/config/env/config-env-overrides.ts b/src/infra/config/env/config-env-overrides.ts index db9df70..528e94f 100644 --- a/src/infra/config/env/config-env-overrides.ts +++ b/src/infra/config/env/config-env-overrides.ts @@ -84,6 +84,7 @@ const GLOBAL_ENV_SPECS: readonly EnvSpec[] = [ { path: 'observability.provider_events', type: 'boolean' }, { path: 'worktree_dir', type: 'string' }, { path: 'auto_pr', type: 'boolean' }, + { path: 'draft_pr', type: 'boolean' }, { path: 'disabled_builtins', type: 'json' }, { path: 'enable_builtin_pieces', type: 'boolean' }, { path: 'anthropic_api_key', type: 'string' }, diff --git a/src/infra/config/global/globalConfig.ts b/src/infra/config/global/globalConfig.ts index b8d98fc..1d17dc8 100644 --- a/src/infra/config/global/globalConfig.ts +++ b/src/infra/config/global/globalConfig.ts @@ -171,6 +171,7 @@ export class GlobalConfigManager { } : undefined, worktreeDir: parsed.worktree_dir, autoPr: parsed.auto_pr, + draftPr: parsed.draft_pr, disabledBuiltins: parsed.disabled_builtins, enableBuiltinPieces: parsed.enable_builtin_pieces, anthropicApiKey: parsed.anthropic_api_key, @@ -242,6 +243,9 @@ export class GlobalConfigManager { if (config.autoPr !== undefined) { raw.auto_pr = config.autoPr; } + if (config.draftPr !== undefined) { + raw.draft_pr = config.draftPr; + } if (config.disabledBuiltins && config.disabledBuiltins.length > 0) { raw.disabled_builtins = config.disabledBuiltins; } diff --git a/src/infra/config/loadConfig.ts b/src/infra/config/loadConfig.ts index c907a6f..b46e4a3 100644 --- a/src/infra/config/loadConfig.ts +++ b/src/infra/config/loadConfig.ts @@ -23,6 +23,7 @@ export function loadConfig(projectDir: string): LoadedConfig { piece: project.piece ?? 'default', provider, autoPr: project.auto_pr ?? global.autoPr, + draftPr: project.draft_pr ?? global.draftPr, model: resolveModel(global, provider), verbose: resolveVerbose(project.verbose, global.verbose), providerOptions: mergeProviderOptions(global.providerOptions, project.providerOptions), diff --git a/src/infra/config/types.ts b/src/infra/config/types.ts index e5d659a..d45d7ad 100644 --- a/src/infra/config/types.ts +++ b/src/infra/config/types.ts @@ -13,6 +13,8 @@ export interface ProjectLocalConfig { provider?: 'claude' | 'codex' | 'opencode' | 'mock'; /** Auto-create PR after worktree execution */ auto_pr?: boolean; + /** Create PR as draft */ + draft_pr?: boolean; /** Verbose output mode */ verbose?: boolean; /** Provider-specific options (overrides global, overridden by piece/movement) */ diff --git a/src/infra/github/pr.ts b/src/infra/github/pr.ts index 08b1668..33e52ff 100644 --- a/src/infra/github/pr.ts +++ b/src/infra/github/pr.ts @@ -97,7 +97,11 @@ export function createPullRequest(cwd: string, options: CreatePrOptions): Create 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 { const output = execFileSync('gh', args, { diff --git a/src/infra/github/types.ts b/src/infra/github/types.ts index 07e0376..9c5da39 100644 --- a/src/infra/github/types.ts +++ b/src/infra/github/types.ts @@ -26,6 +26,8 @@ export interface CreatePrOptions { base?: string; /** Repository in owner/repo format (optional, uses current repo if omitted) */ repo?: string; + /** Create PR as draft */ + draft?: boolean; } export interface CreatePrResult { diff --git a/src/infra/task/mapper.ts b/src/infra/task/mapper.ts index 58e7d45..a781921 100644 --- a/src/infra/task/mapper.ts +++ b/src/infra/task/mapper.ts @@ -55,6 +55,7 @@ export function toTaskData(projectDir: string, task: TaskRecord): TaskFileData { start_movement: task.start_movement, retry_note: task.retry_note, 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, retry_note: task.retry_note, auto_pr: task.auto_pr, + draft_pr: task.draft_pr, }), }; } diff --git a/src/infra/task/schema.ts b/src/infra/task/schema.ts index 3f9b9d9..9ccfdb5 100644 --- a/src/infra/task/schema.ts +++ b/src/infra/task/schema.ts @@ -17,6 +17,7 @@ export const TaskExecutionConfigSchema = z.object({ start_movement: z.string().optional(), retry_note: z.string().optional(), auto_pr: z.boolean().optional(), + draft_pr: z.boolean().optional(), }); /**