feat: --pr インタラクティブモードで create_issue 除外・save_task 時の PR ブランチ自動設定

- --pr 指定時のインタラクティブモードで create_issue を選択肢から除外
- execute アクション時に PR ブランチを fetch + checkout してから実行
- save_task アクション時に worktree/branch/autoPr を自動設定しプロンプトをスキップ
- saveTaskFromInteractive に presetSettings オプションを追加
- interactiveMode に InteractiveModeOptions(excludeActions)を追加
- checkoutBranch() を git.ts に追加し steps.ts の重複コードを DRY 化
This commit is contained in:
nrslib 2026-03-02 23:01:24 +09:00
parent e5f296a3e0
commit c843858f2e
9 changed files with 110 additions and 12 deletions

View File

@ -231,6 +231,8 @@ describe('Issue resolution in routing', () => {
'## GitHub Issue #131: Issue #131', '## GitHub Issue #131: Issue #131',
expect.anything(), expect.anything(),
undefined, undefined,
undefined,
undefined,
); );
// Then: selectAndExecuteTask should be called (issues are used only for initialInput, not selectOptions) // Then: selectAndExecuteTask should be called (issues are used only for initialInput, not selectOptions)
@ -282,6 +284,8 @@ describe('Issue resolution in routing', () => {
'## GitHub Issue #131: Issue #131', '## GitHub Issue #131: Issue #131',
expect.anything(), expect.anything(),
undefined, undefined,
undefined,
undefined,
); );
// Then: selectAndExecuteTask should be called // Then: selectAndExecuteTask should be called
@ -305,6 +309,8 @@ describe('Issue resolution in routing', () => {
'refactor the code', 'refactor the code',
expect.anything(), expect.anything(),
undefined, undefined,
undefined,
undefined,
); );
// Then: no issue fetching should occur // Then: no issue fetching should occur
@ -324,6 +330,8 @@ describe('Issue resolution in routing', () => {
undefined, undefined,
expect.anything(), expect.anything(),
undefined, undefined,
undefined,
undefined,
); );
// Then: no issue fetching should occur // Then: no issue fetching should occur
@ -399,6 +407,8 @@ describe('Issue resolution in routing', () => {
]), ]),
}), }),
undefined, undefined,
undefined,
undefined,
); );
}); });
@ -433,6 +443,8 @@ describe('Issue resolution in routing', () => {
]), ]),
}), }),
undefined, undefined,
undefined,
undefined,
); );
}); });
@ -450,6 +462,8 @@ describe('Issue resolution in routing', () => {
'fix issue', 'fix issue',
expect.objectContaining({ taskHistory: [] }), expect.objectContaining({ taskHistory: [] }),
undefined, undefined,
undefined,
undefined,
); );
}); });
@ -463,6 +477,8 @@ describe('Issue resolution in routing', () => {
'verify history', 'verify history',
expect.objectContaining({ taskHistory: [] }), expect.objectContaining({ taskHistory: [] }),
undefined, undefined,
undefined,
undefined,
); );
}); });
}); });
@ -533,6 +549,8 @@ describe('Issue resolution in routing', () => {
undefined, undefined,
expect.anything(), expect.anything(),
'saved-session-123', 'saved-session-123',
undefined,
undefined,
); );
}); });
@ -556,6 +574,8 @@ describe('Issue resolution in routing', () => {
undefined, undefined,
expect.anything(), expect.anything(),
undefined, undefined,
undefined,
undefined,
); );
}); });
@ -572,6 +592,8 @@ describe('Issue resolution in routing', () => {
undefined, undefined,
expect.anything(), expect.anything(),
undefined, undefined,
undefined,
undefined,
); );
}); });
}); });
@ -586,6 +608,8 @@ describe('Issue resolution in routing', () => {
undefined, undefined,
expect.anything(), expect.anything(),
undefined, undefined,
undefined,
undefined,
); );
}); });
}); });

View File

@ -9,6 +9,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../shared/ui/index.js', () => ({ vi.mock('../shared/ui/index.js', () => ({
info: vi.fn(), info: vi.fn(),
success: vi.fn(),
error: vi.fn(), error: vi.fn(),
withProgress: vi.fn(async (_start, _done, operation) => operation()), withProgress: vi.fn(async (_start, _done, operation) => operation()),
})); }));
@ -76,11 +77,13 @@ vi.mock('../features/interactive/index.js', () => ({
const mockListAllTaskItems = vi.fn(); const mockListAllTaskItems = vi.fn();
const mockIsStaleRunningTask = vi.fn(); const mockIsStaleRunningTask = vi.fn();
const mockCheckoutBranch = vi.fn();
vi.mock('../infra/task/index.js', () => ({ vi.mock('../infra/task/index.js', () => ({
TaskRunner: vi.fn(() => ({ TaskRunner: vi.fn(() => ({
listAllTaskItems: mockListAllTaskItems, listAllTaskItems: mockListAllTaskItems,
})), })),
isStaleRunningTask: (...args: unknown[]) => mockIsStaleRunningTask(...args), isStaleRunningTask: (...args: unknown[]) => mockIsStaleRunningTask(...args),
checkoutBranch: (...args: unknown[]) => mockCheckoutBranch(...args),
})); }));
vi.mock('../infra/config/index.js', () => ({ vi.mock('../infra/config/index.js', () => ({
@ -171,6 +174,8 @@ describe('PR resolution in routing', () => {
expect.stringContaining('## PR #456 Review Comments:'), expect.stringContaining('## PR #456 Review Comments:'),
expect.anything(), expect.anything(),
undefined, undefined,
undefined,
{ excludeActions: ['create_issue'] },
); );
}); });
@ -184,7 +189,7 @@ describe('PR resolution in routing', () => {
// When // When
await executeDefaultAction(); await executeDefaultAction();
// Then: selectAndExecuteTask is called (branch is no longer passed via selectOptions) // Then: selectAndExecuteTask is called
expect(mockSelectAndExecuteTask).toHaveBeenCalledWith( expect(mockSelectAndExecuteTask).toHaveBeenCalledWith(
'/test/cwd', '/test/cwd',
'summarized task', 'summarized task',
@ -193,6 +198,20 @@ describe('PR resolution in routing', () => {
); );
}); });
it('should checkout PR branch before executing task', async () => {
// Given
mockOpts.pr = 456;
const prReview = createMockPrReview({ headRefName: 'feat/my-pr-branch' });
mockCheckCliStatus.mockReturnValue({ available: true });
mockFetchPrReviewComments.mockReturnValue(prReview);
// When
await executeDefaultAction();
// Then: checkoutBranch is called with the PR's head branch
expect(mockCheckoutBranch).toHaveBeenCalledWith('/test/cwd', 'feat/my-pr-branch');
});
it('should exit with error when gh CLI is unavailable', async () => { it('should exit with error when gh CLI is unavailable', async () => {
// Given // Given
mockOpts.pr = 456; mockOpts.pr = 456;
@ -229,6 +248,8 @@ describe('PR resolution in routing', () => {
expect.stringContaining('## PR #456 Review Comments:'), expect.stringContaining('## PR #456 Review Comments:'),
expect.anything(), expect.anything(),
undefined, undefined,
undefined,
{ excludeActions: ['create_issue'] },
); );
}); });

View File

@ -5,12 +5,13 @@
* pipeline mode, or interactive mode. * pipeline mode, or interactive mode.
*/ */
import { info, error as logError, withProgress } from '../../shared/ui/index.js'; import { info, success, error as logError, withProgress } from '../../shared/ui/index.js';
import { getErrorMessage } from '../../shared/utils/index.js'; import { getErrorMessage } from '../../shared/utils/index.js';
import { getLabel } from '../../shared/i18n/index.js'; import { getLabel } from '../../shared/i18n/index.js';
import { formatIssueAsTask, parseIssueNumbers, formatPrReviewAsTask } from '../../infra/github/index.js'; import { formatIssueAsTask, parseIssueNumbers, formatPrReviewAsTask } from '../../infra/github/index.js';
import { getGitProvider } from '../../infra/git/index.js'; import { getGitProvider } from '../../infra/git/index.js';
import type { PrReviewData } from '../../infra/git/index.js'; import type { PrReviewData } from '../../infra/git/index.js';
import { checkoutBranch } from '../../infra/task/index.js';
import { selectAndExecuteTask, determinePiece, saveTaskFromInteractive, createIssueAndSaveTask, promptLabelSelection, type SelectAndExecuteOptions } from '../../features/tasks/index.js'; import { selectAndExecuteTask, determinePiece, saveTaskFromInteractive, createIssueAndSaveTask, promptLabelSelection, type SelectAndExecuteOptions } from '../../features/tasks/index.js';
import { executePipeline } from '../../features/pipeline/index.js'; import { executePipeline } from '../../features/pipeline/index.js';
import { import {
@ -85,7 +86,7 @@ async function resolveIssueInput(
*/ */
async function resolvePrInput( async function resolvePrInput(
prNumber: number, prNumber: number,
): Promise<{ initialInput: string }> { ): Promise<{ initialInput: string; prBranch: string }> {
const ghStatus = getGitProvider().checkCliStatus(); const ghStatus = getGitProvider().checkCliStatus();
if (!ghStatus.available) { if (!ghStatus.available) {
throw new Error(ghStatus.error); throw new Error(ghStatus.error);
@ -97,7 +98,7 @@ async function resolvePrInput(
async () => getGitProvider().fetchPrReviewComments(prNumber), async () => getGitProvider().fetchPrReviewComments(prNumber),
); );
return { initialInput: formatPrReviewAsTask(prReview) }; return { initialInput: formatPrReviewAsTask(prReview), prBranch: prReview.headRefName };
} }
/** /**
@ -169,11 +170,13 @@ export async function executeDefaultAction(task?: string): Promise<void> {
// Resolve PR review comments (--pr N) before interactive mode // Resolve PR review comments (--pr N) before interactive mode
let initialInput: string | undefined = task; let initialInput: string | undefined = task;
let prBranch: string | undefined;
if (prNumber) { if (prNumber) {
try { try {
const prResult = await resolvePrInput(prNumber); const prResult = await resolvePrInput(prNumber);
initialInput = prResult.initialInput; initialInput = prResult.initialInput;
prBranch = prResult.prBranch;
} catch (e) { } catch (e) {
logError(getErrorMessage(e)); logError(getErrorMessage(e));
process.exit(1); process.exit(1);
@ -234,7 +237,8 @@ export async function executeDefaultAction(task?: string): Promise<void> {
info(getLabel('interactive.continueNoSession', lang)); info(getLabel('interactive.continueNoSession', lang));
} }
} }
result = await interactiveMode(resolvedCwd, initialInput, pieceContext, selectedSessionId); const interactiveOpts = prBranch ? { excludeActions: ['create_issue'] as const } : undefined;
result = await interactiveMode(resolvedCwd, initialInput, pieceContext, selectedSessionId, undefined, interactiveOpts);
break; break;
} }
@ -259,6 +263,11 @@ export async function executeDefaultAction(task?: string): Promise<void> {
await dispatchConversationAction(result, { await dispatchConversationAction(result, {
execute: async ({ task: confirmedTask }) => { execute: async ({ task: confirmedTask }) => {
if (prBranch) {
info(`Fetching and checking out PR branch: ${prBranch}`);
checkoutBranch(resolvedCwd, prBranch);
success(`Checked out PR branch: ${prBranch}`);
}
selectOptions.interactiveUserInput = true; selectOptions.interactiveUserInput = true;
selectOptions.piece = pieceId; selectOptions.piece = pieceId;
selectOptions.interactiveMetadata = { confirmed: true, task: confirmedTask }; selectOptions.interactiveMetadata = { confirmed: true, task: confirmedTask };
@ -273,7 +282,10 @@ export async function executeDefaultAction(task?: string): Promise<void> {
}); });
}, },
save_task: async ({ task: confirmedTask }) => { save_task: async ({ task: confirmedTask }) => {
await saveTaskFromInteractive(resolvedCwd, confirmedTask, pieceId); const presetSettings = prBranch
? { worktree: true as const, branch: prBranch, autoPr: false }
: undefined;
await saveTaskFromInteractive(resolvedCwd, confirmedTask, pieceId, { presetSettings });
}, },
cancel: () => undefined, cancel: () => undefined,
}); });

View File

@ -14,6 +14,7 @@ export {
type TaskHistorySummaryItem, type TaskHistorySummaryItem,
type InteractiveModeResult, type InteractiveModeResult,
type InteractiveModeAction, type InteractiveModeAction,
type InteractiveModeOptions,
} from './interactive.js'; } from './interactive.js';
export { selectInteractiveMode } from './modeSelection.js'; export { selectInteractiveMode } from './modeSelection.js';

View File

@ -25,6 +25,10 @@ import {
type PieceContext, type PieceContext,
formatMovementPreviews, formatMovementPreviews,
type InteractiveModeAction, type InteractiveModeAction,
type SummaryActionValue,
type PostSummaryAction,
buildSummaryActionOptions,
selectSummaryAction,
} from './interactive-summary.js'; } from './interactive-summary.js';
import { type RunSessionContext, formatRunSessionForPrompt } from './runSessionReader.js'; import { type RunSessionContext, formatRunSessionForPrompt } from './runSessionReader.js';
@ -113,12 +117,18 @@ export {
* /cancel exits without executing * /cancel exits without executing
* Ctrl+D exits without executing * Ctrl+D exits without executing
*/ */
export interface InteractiveModeOptions {
/** Actions to exclude from the post-summary action selector. */
excludeActions?: readonly SummaryActionValue[];
}
export async function interactiveMode( export async function interactiveMode(
cwd: string, cwd: string,
initialInput?: string, initialInput?: string,
pieceContext?: PieceContext, pieceContext?: PieceContext,
sessionId?: string, sessionId?: string,
runSessionContext?: RunSessionContext, runSessionContext?: RunSessionContext,
options?: InteractiveModeOptions,
): Promise<InteractiveModeResult> { ): Promise<InteractiveModeResult> {
const baseCtx = initializeSession(cwd, 'interactive'); const baseCtx = initializeSession(cwd, 'interactive');
const ctx = sessionId ? { ...baseCtx, sessionId } : baseCtx; const ctx = sessionId ? { ...baseCtx, sessionId } : baseCtx;
@ -155,11 +165,32 @@ export async function interactiveMode(
return `## Policy\n${policyIntro}\n\n${policyContent}\n\n---\n\n${userMessage}\n\n---\n**Policy Reminder:** ${reminderLabel}`; return `## Policy\n${policyIntro}\n\n${policyContent}\n\n---\n\n${userMessage}\n\n---\n**Policy Reminder:** ${reminderLabel}`;
} }
const excludeActions = options?.excludeActions;
const selectAction = excludeActions?.length
? (task: string): Promise<PostSummaryAction | null> =>
selectSummaryAction(
task,
ui.proposed,
ui.actionPrompt,
buildSummaryActionOptions(
{
execute: ui.actions.execute,
createIssue: ui.actions.createIssue,
saveTask: ui.actions.saveTask,
continue: ui.actions.continue,
},
['create_issue'],
excludeActions,
),
)
: undefined;
return runConversationLoop(cwd, ctx, { return runConversationLoop(cwd, ctx, {
systemPrompt, systemPrompt,
allowedTools: DEFAULT_INTERACTIVE_TOOLS, allowedTools: DEFAULT_INTERACTIVE_TOOLS,
transformPrompt: injectPolicy, transformPrompt: injectPolicy,
introMessage: ui.intro, introMessage: ui.intro,
selectAction,
}, pieceContext, initialInput); }, pieceContext, initialInput);
} }

View File

@ -8,7 +8,7 @@
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import { formatIssueAsTask, buildPrBody, formatPrReviewAsTask } from '../../infra/github/index.js'; import { formatIssueAsTask, buildPrBody, formatPrReviewAsTask } from '../../infra/github/index.js';
import { getGitProvider, type Issue } from '../../infra/git/index.js'; import { getGitProvider, type Issue } from '../../infra/git/index.js';
import { stageAndCommit, resolveBaseBranch, pushBranch } from '../../infra/task/index.js'; import { stageAndCommit, resolveBaseBranch, pushBranch, checkoutBranch } from '../../infra/task/index.js';
import { executeTask, confirmAndCreateWorktree, type TaskExecutionOptions, type PipelineExecutionOptions } from '../tasks/index.js'; import { executeTask, confirmAndCreateWorktree, type TaskExecutionOptions, type PipelineExecutionOptions } from '../tasks/index.js';
import { info, error, success } from '../../shared/ui/index.js'; import { info, error, success } from '../../shared/ui/index.js';
import { getErrorMessage } from '../../shared/utils/index.js'; import { getErrorMessage } from '../../shared/utils/index.js';
@ -150,8 +150,7 @@ export async function resolveExecutionContext(
} }
if (prBranch) { if (prBranch) {
info(`Fetching and checking out PR branch: ${prBranch}`); info(`Fetching and checking out PR branch: ${prBranch}`);
execFileSync('git', ['fetch', 'origin', prBranch], { cwd, stdio: 'pipe' }); checkoutBranch(cwd, prBranch);
execFileSync('git', ['checkout', prBranch], { cwd, stdio: 'pipe' });
success(`Checked out PR branch: ${prBranch}`); success(`Checked out PR branch: ${prBranch}`);
return { execCwd: cwd, branch: prBranch, baseBranch: resolveBaseBranch(cwd).branch, isWorktree: false }; return { execCwd: cwd, branch: prBranch, baseBranch: resolveBaseBranch(cwd).branch, isWorktree: false };
} }

View File

@ -142,12 +142,13 @@ async function promptWorktreeSettings(): Promise<WorktreeSettings> {
/** /**
* Save a task from interactive mode result. * Save a task from interactive mode result.
* Prompts for worktree/branch/auto_pr settings before saving. * Prompts for worktree/branch/auto_pr settings before saving.
* If presetSettings is provided, skips the prompt and uses those settings directly.
*/ */
export async function saveTaskFromInteractive( export async function saveTaskFromInteractive(
cwd: string, cwd: string,
task: string, task: string,
piece?: string, piece?: string,
options?: { issue?: number; confirmAtEndMessage?: string }, options?: { issue?: number; confirmAtEndMessage?: string; presetSettings?: WorktreeSettings },
): Promise<void> { ): Promise<void> {
if (options?.confirmAtEndMessage) { if (options?.confirmAtEndMessage) {
const approved = await confirm(options.confirmAtEndMessage, true); const approved = await confirm(options.confirmAtEndMessage, true);
@ -155,7 +156,7 @@ export async function saveTaskFromInteractive(
return; return;
} }
} }
const settings = await promptWorktreeSettings(); const settings = options?.presetSettings ?? await promptWorktreeSettings();
const created = await saveTaskFile(cwd, task, { piece, issue: options?.issue, ...settings }); const created = await saveTaskFile(cwd, task, { piece, issue: options?.issue, ...settings });
displayTaskCreationResult(created, settings, piece); displayTaskCreationResult(created, settings, piece);
} }

View File

@ -40,6 +40,15 @@ export function stageAndCommit(cwd: string, message: string): string | undefined
}).trim(); }).trim();
} }
/**
* Fetches and checks out a branch from origin. Throws on failure.
*/
export function checkoutBranch(cwd: string, branch: string): void {
log.info('Checking out branch from origin', { branch });
execFileSync('git', ['fetch', 'origin', branch], { cwd, stdio: 'pipe' });
execFileSync('git', ['checkout', branch], { cwd, stdio: 'pipe' });
}
/** /**
* Throws on failure. * Throws on failure.
*/ */

View File

@ -55,7 +55,7 @@ export {
getOriginalInstruction, getOriginalInstruction,
buildListItems, buildListItems,
} from './branchList.js'; } from './branchList.js';
export { stageAndCommit, getCurrentBranch, pushBranch } from './git.js'; export { stageAndCommit, getCurrentBranch, pushBranch, checkoutBranch } from './git.js';
export { autoCommitAndPush, type AutoCommitResult } from './autoCommit.js'; export { autoCommitAndPush, type AutoCommitResult } from './autoCommit.js';
export { summarizeTaskName } from './summarize.js'; export { summarizeTaskName } from './summarize.js';
export { TaskWatcher, type TaskWatcherOptions } from './watcher.js'; export { TaskWatcher, type TaskWatcherOptions } from './watcher.js';