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',
expect.anything(),
undefined,
undefined,
undefined,
);
// 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',
expect.anything(),
undefined,
undefined,
undefined,
);
// Then: selectAndExecuteTask should be called
@ -305,6 +309,8 @@ describe('Issue resolution in routing', () => {
'refactor the code',
expect.anything(),
undefined,
undefined,
undefined,
);
// Then: no issue fetching should occur
@ -324,6 +330,8 @@ describe('Issue resolution in routing', () => {
undefined,
expect.anything(),
undefined,
undefined,
undefined,
);
// Then: no issue fetching should occur
@ -399,6 +407,8 @@ describe('Issue resolution in routing', () => {
]),
}),
undefined,
undefined,
undefined,
);
});
@ -433,6 +443,8 @@ describe('Issue resolution in routing', () => {
]),
}),
undefined,
undefined,
undefined,
);
});
@ -450,6 +462,8 @@ describe('Issue resolution in routing', () => {
'fix issue',
expect.objectContaining({ taskHistory: [] }),
undefined,
undefined,
undefined,
);
});
@ -463,6 +477,8 @@ describe('Issue resolution in routing', () => {
'verify history',
expect.objectContaining({ taskHistory: [] }),
undefined,
undefined,
undefined,
);
});
});
@ -533,6 +549,8 @@ describe('Issue resolution in routing', () => {
undefined,
expect.anything(),
'saved-session-123',
undefined,
undefined,
);
});
@ -556,6 +574,8 @@ describe('Issue resolution in routing', () => {
undefined,
expect.anything(),
undefined,
undefined,
undefined,
);
});
@ -572,6 +592,8 @@ describe('Issue resolution in routing', () => {
undefined,
expect.anything(),
undefined,
undefined,
undefined,
);
});
});
@ -586,6 +608,8 @@ describe('Issue resolution in routing', () => {
undefined,
expect.anything(),
undefined,
undefined,
undefined,
);
});
});

View File

@ -9,6 +9,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../shared/ui/index.js', () => ({
info: vi.fn(),
success: vi.fn(),
error: vi.fn(),
withProgress: vi.fn(async (_start, _done, operation) => operation()),
}));
@ -76,11 +77,13 @@ vi.mock('../features/interactive/index.js', () => ({
const mockListAllTaskItems = vi.fn();
const mockIsStaleRunningTask = vi.fn();
const mockCheckoutBranch = vi.fn();
vi.mock('../infra/task/index.js', () => ({
TaskRunner: vi.fn(() => ({
listAllTaskItems: mockListAllTaskItems,
})),
isStaleRunningTask: (...args: unknown[]) => mockIsStaleRunningTask(...args),
checkoutBranch: (...args: unknown[]) => mockCheckoutBranch(...args),
}));
vi.mock('../infra/config/index.js', () => ({
@ -171,6 +174,8 @@ describe('PR resolution in routing', () => {
expect.stringContaining('## PR #456 Review Comments:'),
expect.anything(),
undefined,
undefined,
{ excludeActions: ['create_issue'] },
);
});
@ -184,7 +189,7 @@ describe('PR resolution in routing', () => {
// When
await executeDefaultAction();
// Then: selectAndExecuteTask is called (branch is no longer passed via selectOptions)
// Then: selectAndExecuteTask is called
expect(mockSelectAndExecuteTask).toHaveBeenCalledWith(
'/test/cwd',
'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 () => {
// Given
mockOpts.pr = 456;
@ -229,6 +248,8 @@ describe('PR resolution in routing', () => {
expect.stringContaining('## PR #456 Review Comments:'),
expect.anything(),
undefined,
undefined,
{ excludeActions: ['create_issue'] },
);
});

View File

@ -5,12 +5,13 @@
* 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 { getLabel } from '../../shared/i18n/index.js';
import { formatIssueAsTask, parseIssueNumbers, formatPrReviewAsTask } from '../../infra/github/index.js';
import { getGitProvider } 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 { executePipeline } from '../../features/pipeline/index.js';
import {
@ -85,7 +86,7 @@ async function resolveIssueInput(
*/
async function resolvePrInput(
prNumber: number,
): Promise<{ initialInput: string }> {
): Promise<{ initialInput: string; prBranch: string }> {
const ghStatus = getGitProvider().checkCliStatus();
if (!ghStatus.available) {
throw new Error(ghStatus.error);
@ -97,7 +98,7 @@ async function resolvePrInput(
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
let initialInput: string | undefined = task;
let prBranch: string | undefined;
if (prNumber) {
try {
const prResult = await resolvePrInput(prNumber);
initialInput = prResult.initialInput;
prBranch = prResult.prBranch;
} catch (e) {
logError(getErrorMessage(e));
process.exit(1);
@ -234,7 +237,8 @@ export async function executeDefaultAction(task?: string): Promise<void> {
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;
}
@ -259,6 +263,11 @@ export async function executeDefaultAction(task?: string): Promise<void> {
await dispatchConversationAction(result, {
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.piece = pieceId;
selectOptions.interactiveMetadata = { confirmed: true, task: confirmedTask };
@ -273,7 +282,10 @@ export async function executeDefaultAction(task?: string): Promise<void> {
});
},
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,
});

View File

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

View File

@ -25,6 +25,10 @@ import {
type PieceContext,
formatMovementPreviews,
type InteractiveModeAction,
type SummaryActionValue,
type PostSummaryAction,
buildSummaryActionOptions,
selectSummaryAction,
} from './interactive-summary.js';
import { type RunSessionContext, formatRunSessionForPrompt } from './runSessionReader.js';
@ -113,12 +117,18 @@ export {
* /cancel 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(
cwd: string,
initialInput?: string,
pieceContext?: PieceContext,
sessionId?: string,
runSessionContext?: RunSessionContext,
options?: InteractiveModeOptions,
): Promise<InteractiveModeResult> {
const baseCtx = initializeSession(cwd, 'interactive');
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}`;
}
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, {
systemPrompt,
allowedTools: DEFAULT_INTERACTIVE_TOOLS,
transformPrompt: injectPolicy,
introMessage: ui.intro,
selectAction,
}, pieceContext, initialInput);
}

View File

@ -8,7 +8,7 @@
import { execFileSync } from 'node:child_process';
import { formatIssueAsTask, buildPrBody, formatPrReviewAsTask } from '../../infra/github/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 { info, error, success } from '../../shared/ui/index.js';
import { getErrorMessage } from '../../shared/utils/index.js';
@ -150,8 +150,7 @@ export async function resolveExecutionContext(
}
if (prBranch) {
info(`Fetching and checking out PR branch: ${prBranch}`);
execFileSync('git', ['fetch', 'origin', prBranch], { cwd, stdio: 'pipe' });
execFileSync('git', ['checkout', prBranch], { cwd, stdio: 'pipe' });
checkoutBranch(cwd, prBranch);
success(`Checked out PR branch: ${prBranch}`);
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.
* 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(
cwd: string,
task: string,
piece?: string,
options?: { issue?: number; confirmAtEndMessage?: string },
options?: { issue?: number; confirmAtEndMessage?: string; presetSettings?: WorktreeSettings },
): Promise<void> {
if (options?.confirmAtEndMessage) {
const approved = await confirm(options.confirmAtEndMessage, true);
@ -155,7 +156,7 @@ export async function saveTaskFromInteractive(
return;
}
}
const settings = await promptWorktreeSettings();
const settings = options?.presetSettings ?? await promptWorktreeSettings();
const created = await saveTaskFile(cwd, task, { piece, issue: options?.issue, ...settings });
displayTaskCreationResult(created, settings, piece);
}

View File

@ -40,6 +40,15 @@ export function stageAndCommit(cwd: string, message: string): string | undefined
}).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.
*/

View File

@ -55,7 +55,7 @@ export {
getOriginalInstruction,
buildListItems,
} 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 { summarizeTaskName } from './summarize.js';
export { TaskWatcher, type TaskWatcherOptions } from './watcher.js';