Skip to content

Commit bb404f8

Browse files
authored
feat: stacked PR review — PR switching, scope toggling, multi-PR posting (#620)
* feat(shared): add isSameProject, PR stack types, and PR list provider Extends PRRef/PRMetadata with defaultBranch, PRStackInfo, PRStackTree, PRStackNode, PRDiffScope, and PRListItem types. Adds isSameProject() for owner/repo validation on PR switching. Adds fetchPRStack() and fetchPRList() dispatch functions (GitHub-only for now, GitLab stubs). Includes 9 new tests for isSameProject covering GitHub, GitLab, and cross-platform scenarios. For provenance purposes, this commit was AI assisted. * feat(shared): add GitHub PR stack tree walking and PR list fetching Implements fetchGhPRStack() which walks up/down the PR stack via GraphQL, resolving numbers and titles for each node in the chain. Collapses queryPRsByHead/queryPRsByBase into a single queryPRsByRef helper. Adds fetchGhPRList() using gh pr list. Fixes GHE support by removing hostnameArgs from fetchGhPRList (--repo already handles GHE). Filters jq "null" string from defaultBranch detection. For provenance purposes, this commit was AI assisted. * feat(shared): fetch defaultBranch for GitLab MRs Queries the project's default_branch via glab API so getPRStackInfo can detect stacked MRs on GitLab. Best-effort — caught errors fall back to undefined. For provenance purposes, this commit was AI assisted. * feat(shared): add PR stack detection and full-stack diff module New pr-stack module with: - getPRStackInfo(): detects stacked PRs from baseBranch vs defaultBranch - getPRDiffScopeOptions(): generates layer/full-stack scope options - runPRFullStackDiff(): computes diff from default branch to HEAD - resolvePRFullStackBaseRef(): resolves origin/main or local main - checkoutPRHead(): fetches and checks out a PR head in a worktree - buildMinimalStackTree(): builds UI tree from stack info Includes 13 tests covering ref resolution, branch fallbacks, and GitLab ref formats. For provenance purposes, this commit was AI assisted. * feat(shared): add worktree pool for per-PR agent isolation Creates a session-scoped pool of git worktrees — each PR visited during a stacked review gets its own isolated checkout. Agents run in their PR's worktree undisturbed by PR switches. Handles deduplication of concurrent ensure() calls for the same PR. Includes 11 tests covering caching, cross-repo restrictions, GitLab ref formats, and cleanup. For provenance purposes, this commit was AI assisted. * feat(shared): add diffScope/prUrl to agent jobs, branch diff type Adds prUrl and diffScope optional fields to AgentJobInfo so agent findings carry the PR and scope context they were launched under. Exports new pr-stack and worktree-pool modules from package.json. Adds 'branch' to DefaultDiffType union for branch diff as default. For provenance purposes, this commit was AI assisted. * feat(ui): add PR annotation fields, Popover, and SearchableSelect Extends CodeAnnotation with prUrl, prNumber, prTitle, prRepo, and diffScope fields for stacked PR attribution. Adds shared Popover wrapper around radix-ui. Adds SearchableSelect for filterable dropdown lists (used by PR selector). For provenance purposes, this commit was AI assisted. * feat(ui): add branch diff as default option, new Git settings tab Adds 'Branch' as a fourth default diff type option in both the first-run dialog and settings panel. Moves the default diff type setting from the Display tab to a new Git tab in review mode. Updates config store validators to accept 'branch'. For provenance purposes, this commit was AI assisted. * feat(server): add prUrl/diffScope plumbing to agent jobs and prompts Threads prUrl and diffScope through the agent job lifecycle so findings carry the PR and scope they were generated under. Adds full-stack prompt branch to codex-review and tour-review — when in full-stack mode, the diff is inlined in the prompt instead of telling the agent to run git diff. Re-exports isSameProject and new PR provider functions from server/pr.ts. For provenance purposes, this commit was AI assisted. * feat(server): add stacked PR support to Bun review server Adds PR switching, layer/full-stack scope toggling, PR list caching, worktree pool integration, and multi-PR platform posting to the Bun review server. Key additions: - /api/pr-diff-scope: switch between layer and full-stack diffs - /api/pr-list: cached PR list for the current repo - /api/pr-switch: in-place navigation between PRs in a stack - /api/pr-action: targetPrUrl support for multi-PR posting - /api/file-content: full-stack branch for hunk expansion - prSwitchCache/prStackTreeCache for session-scoped caching - diffScope tagging on agent job completion - Scope guard: returns 400 on full-stack diff failure instead of overwriting the working diff with empty content For provenance purposes, this commit was AI assisted. * feat(ai): pass cwd to Claude agent SDK for worktree support Forwards the working directory to the Claude agent provider so agents run in the correct worktree when reviewing stacked PRs. For provenance purposes, this commit was AI assisted. * feat(pi): add stacked PR support to Pi server (Bun parity) Mirrors all stacked PR features from the Bun server: - PR switching, scope toggling, PR list, multi-PR posting - prSwitchCache/prStackTreeCache with initial PR seeding - diffScope/prUrl plumbing in agent jobs - Worktree pool creation and lifecycle - Full-stack file-content resolution matching Bun's guard structure - targetPrUrl support on /api/pr-action Hoists worktreePool declaration to outer scope in plannotator-browser to fix TS18004 scoping error. Updates vendor.sh for new shared modules. For provenance purposes, this commit was AI assisted. * feat(hook): create worktree pool for PR review sessions Creates a worktree pool when opening a PR review with --local, seeding it with the initial PR's checkout. Integrates pool cleanup into server shutdown. Passes the pool to startReviewServer for agent isolation during PR switching. For provenance purposes, this commit was AI assisted. * feat(review-editor): add hooks for PR stack, context, and annotations - useAnnotationFactory: stamps prUrl/prNumber/prTitle/prRepo/diffScope onto annotations, only when viewing a stacked PR - usePRStack: handles scope selection and PR switching with loading state - usePRContext: adds URL-change detection to prevent stale-fetch race when switching PRs (discards in-flight responses for previous PR) For provenance purposes, this commit was AI assisted. * feat(review-editor): add stacked PR UI components - PRSelector: searchable dropdown for switching between PRs in a repo - PRSwitchOverlay: loading animation during PR switch - StackedPRLabel: stack tree popover with scope selector and PR navigation - ReviewSubmissionDialog: multi-PR submission dialog with per-target status, orphaned findings section with copy-as-markdown, and partial failure retry For provenance purposes, this commit was AI assisted. * feat(review-editor): multi-PR export with heading hierarchy Updates exportReviewFeedback for multi-PR sessions: - Groups annotations by prUrl, then by file within each PR - Uses proper heading hierarchy (## for files, ### for annotations in multi-PR mode) - Detects single-PR mismatch (annotations from a different PR than the current view) and uses annotation-level PR context - Adds diffScope labels per PR group when present Includes 5 new tests: multi-PR headings, single-PR mismatch, diffScope labels, and non-stacked annotation handling. For provenance purposes, this commit was AI assisted. * feat(review-editor): integrate stacked PR into sidebar, diff panel, and agents - ReviewSidebar: groups annotations by PR in multi-PR sessions, shows PR headers with annotation counts - ReviewDiffPanel: filters annotations by prUrl and diffScope so only matching annotations appear in the diff gutter - ReviewStateContext: adds prDiffScope to shared review state - ReviewAgentJobDetailPanel: shows diffScope in job detail - PRSummaryTab: shows stack info in PR summary - index.css: PR switch shimmer and overlay animations For provenance purposes, this commit was AI assisted. * feat(review-editor): wire stacked PR into main review app Integrates all stacked PR features into the review editor: - PR stack state management (prStackInfo, prStackTree, prDiffScope) - applyPRResponse: shared handler for PR switch and scope toggle, preserves active file index on scope changes - Multi-PR platform posting via Promise.allSettled with parallel requests, partial failure retry, and per-target status tracking - ReviewSubmissionDialog replaces inline dialog JSX - useAnnotationFactory stamps PR context onto annotations - keepalive on /api/feedback to survive tab closure - Proper try/catch/finally on handlePlatformAction For provenance purposes, this commit was AI assisted. * docs: add stacked PR review documentation Updates AGENTS.md, code-review command docs, and AI code review guide with stacked PR review capabilities. For provenance purposes, this commit was AI assisted. * fix(server): stamp prNumber/prTitle/prRepo on agent findings Agent annotations only had prUrl and diffScope, missing prNumber, prTitle, and prRepo. When agent findings were the only annotations for a PR target in the submission dialog, the target rendered as #0 with no title. Now resolves full PR context from prSwitchCache at job completion and stamps all five fields. Both Bun and Pi. For provenance purposes, this commit was AI assisted. * feat(ui): rename diff options — "Committed" replaces "Branch" / "Current PR Diff" Consolidates two confusing committed-diff options into one: - Settings/first-run: "Committed" — "Everything you've committed on this branch" - Mid-session switcher: "Committed changes" (replaces both "vs main" and "Current PR Diff") Uses merge-base under the hood (matches GitHub PR behavior). Removes the two-dot branch diff from the UI — it stays in the runtime DiffType union for backwards compat. Old "branch" values in config/cookies are silently upgraded to merge-base. Git settings tab now uses radio cards with descriptions instead of a cramped segmented control. First-run dialog descriptions rewritten in plain language — no git commands. For provenance purposes, this commit was AI assisted. * fix(review-editor): rename client-side "PR Diff" labels to "Committed changes" DiffTypePicker.tsx had a hardcoded "PR Diff" override for merge-base when the base picker is present. exportFeedback.ts also used "PR Diff" in export labels. Both now say "Committed changes" to match the server-side label and settings UI. For provenance purposes, this commit was AI assisted. * fix(server): discover stack UI for root PRs targeting the default branch Root PRs (baseBranch === defaultBranch) were excluded from stack detection because getPRStackInfo returned null. Now the server always fetches the stack tree in PR mode. If the tree reveals descendant PRs, prStackInfo is retroactively set with source "tree-discovered", enabling the stack UI, scope selector, and PR navigation from the root of a stack. Both Bun and Pi servers updated. Adds "tree-discovered" to the PRStackInfo source union. For provenance purposes, this commit was AI assisted. * fix(review-editor): derive diff scope from annotations, not UI state The export function now reads diffScope from annotations instead of the prReviewScope parameter. Fixes two issues: 1. Agent job "Copy All" showed the wrong scope when the user switched between layer/full-stack after launching the agent 2. Mixed-scope annotations produced a confusing "layer, full-stack" comma-joined label instead of grouping by scope Extracts renderScopedGroups helper for scope-aware grouping — used by both single-PR and multi-PR export paths. When annotations share one scope, it appears in the header. When mixed, annotations are grouped under ## Layer / ## Full-stack headings. Includes 4 new tests: uniform scope derivation, mixed scope grouping, single scope header, and prReviewScope override prevention. For provenance purposes, this commit was AI assisted. * fix(server): add tree-discovered stack fallback to pr-switch handler The initial-load path upgrades prStackInfo for root PRs when the stack tree reveals descendants, but the pr-switch handler was missing this logic. The server now sends correct prStackInfo after switching to a root-of-stack PR. Both Bun and Pi. Also removes stale prReviewScope dependency from agent job panel's copyAllText useMemo. For provenance purposes, this commit was AI assisted. * fix: extract resolveStackInfo helper, fix stack UI on non-stacked PRs Extracts the tree-discovered stack fallback into resolveStackInfo() in pr-stack.ts — eliminates 4 copies of the same logic across Bun startup, Bun pr-switch, Pi startup, and Pi pr-switch. Fixes StackedPRLabel showing on every PR: the check now counts non-default-branch nodes (> 1) instead of all nodes (> 1). Without this, every PR showed a "Stack (1 PR)" popover because the tree always has at least [defaultBranch, currentPR]. For provenance purposes, this commit was AI assisted. * fix(review-editor): don't re-open already-succeeded PR tabs on retry On partial failure retry, openUrls was pre-seeded with URLs from previously succeeded targets, causing those PR pages to re-open in the browser alongside newly succeeded ones. Now starts empty — only URLs from the current attempt are opened. For provenance purposes, this commit was AI assisted. * refactor(review-editor): extract PR session state into usePRSession hook Consolidates 5 independent useState calls (prMetadata, prStackInfo, prStackTree, prDiffScope, prDiffScopeOptions) into a single usePRSession hook with atomic updatePRSession callback. Replaces two identical 5-line setter blocks (initial load and applyPRResponse) with single updatePRSession calls. All ~60 consumer sites unchanged — same variable names via destructuring. For provenance purposes, this commit was AI assisted.
1 parent 9a32022 commit bb404f8

52 files changed

Lines changed: 3641 additions & 449 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,9 @@ During normal plan review, an Archive sidebar tab provides the same browsing via
250250
| `/api/agents/jobs` | POST | Launch an agent job (body: `{ provider, command, label }`) |
251251
| `/api/agents/jobs` | DELETE | Kill all running agent jobs |
252252
| `/api/agents/jobs/:id` | DELETE | Kill a specific agent job |
253+
| `/api/pr-diff-scope` | POST | Switch between layer and full-stack diff scope |
254+
| `/api/pr-list` | GET | List PRs for the current repo (cached 30s) |
255+
| `/api/pr-switch` | POST | Switch to a different PR in-place (body: `{ url }`) |
253256
| `/api/tour/:jobId` | GET | Fetch Code Tour result (greeting, stops, checklist) for a completed tour job |
254257
| `/api/tour/:jobId/checklist` | PUT | Persist checklist item state for a Code Tour |
255258

apps/hook/server/index.ts

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ import { stripAtPrefix, resolveAtReference } from "@plannotator/shared/at-refere
6969
import { htmlToMarkdown } from "@plannotator/shared/html-to-markdown";
7070
import { urlToMarkdown } from "@plannotator/shared/url-to-markdown";
7171
import { fetchRef, createWorktree, removeWorktree, ensureObjectAvailable } from "@plannotator/shared/worktree";
72+
import { createWorktreePool, type WorktreePool } from "@plannotator/shared/worktree-pool";
7273
import { parsePRUrl, checkPRAuth, fetchPR, getCliName, getCliInstallUrl, getMRLabel, getMRNumberLabel, getDisplayRepo } from "@plannotator/server/pr";
7374
import { writeRemoteShareLink } from "@plannotator/server/share-url";
7475
import { resolveMarkdownFile, resolveUserPath, hasMarkdownFiles } from "@plannotator/shared/resolve-file";
@@ -293,6 +294,7 @@ if (args[0] === "sessions") {
293294
let prMetadata: Awaited<ReturnType<typeof fetchPR>>["metadata"] | undefined;
294295
let initialDiffType: DiffType | undefined;
295296
let agentCwd: string | undefined;
297+
let worktreePool: WorktreePool | undefined;
296298
let worktreeCleanup: (() => void | Promise<void>) | undefined;
297299

298300
if (isPRMode) {
@@ -337,6 +339,7 @@ if (args[0] === "sessions") {
337339
if (useLocal && prMetadata) {
338340
// Hoisted so catch block can clean up partially-created directories
339341
let localPath: string | undefined;
342+
let sessionDir: string | undefined;
340343
try {
341344
const repoDir = process.cwd();
342345
const identifier = prMetadata.platform === "github"
@@ -345,7 +348,9 @@ if (args[0] === "sessions") {
345348
const suffix = Math.random().toString(36).slice(2, 8);
346349
// Resolve tmpdir to its real path — on macOS, tmpdir() returns /var/folders/...
347350
// but processes report /private/var/folders/... which breaks path stripping.
348-
localPath = path.join(realpathSync(tmpdir()), `plannotator-pr-${identifier}-${suffix}`);
351+
sessionDir = path.join(realpathSync(tmpdir()), `plannotator-pr-${identifier}-${suffix}`);
352+
const prNumber = prMetadata.platform === "github" ? prMetadata.number : prMetadata.iid;
353+
localPath = path.join(sessionDir, "pool", `pr-${prNumber}`);
349354
const fetchRefStr = prMetadata.platform === "github"
350355
? `refs/pull/${prMetadata.number}/head`
351356
: `refs/merge-requests/${prMetadata.iid}/head`;
@@ -393,9 +398,18 @@ if (args[0] === "sessions") {
393398
cwd: repoDir,
394399
});
395400

396-
worktreeCleanup = () => removeWorktree(gitRuntime, localPath, { force: true, cwd: repoDir });
401+
worktreeCleanup = async () => {
402+
if (worktreePool) await worktreePool.cleanup(gitRuntime);
403+
try { rmSync(sessionDir, { recursive: true, force: true }); } catch {}
404+
};
397405
process.once("exit", () => {
398-
try { Bun.spawnSync(["git", "worktree", "remove", "--force", localPath]); } catch {}
406+
// Best-effort sync cleanup: remove each pool worktree from git, then rm session dir
407+
try {
408+
for (const entry of worktreePool?.entries() ?? []) {
409+
Bun.spawnSync(["git", "worktree", "remove", "--force", entry.path], { cwd: repoDir });
410+
}
411+
} catch {}
412+
try { Bun.spawnSync(["rm", "-rf", sessionDir]); } catch {}
399413
});
400414
} else {
401415
// ── Cross-repo: shallow clone + fetch PR head ──
@@ -443,23 +457,29 @@ if (args[0] === "sessions") {
443457
Bun.spawnSync(["git", "branch", "--", prMetadata.baseBranch, prMetadata.baseSha], { cwd: localPath, stderr: "pipe" });
444458
Bun.spawnSync(["git", "update-ref", `refs/remotes/origin/${prMetadata.baseBranch}`, prMetadata.baseSha], { cwd: localPath, stderr: "pipe" });
445459

446-
worktreeCleanup = () => { try { rmSync(localPath, { recursive: true, force: true }); } catch {} };
460+
worktreeCleanup = () => { try { rmSync(sessionDir, { recursive: true, force: true }); } catch {} };
447461
process.once("exit", () => {
448-
try { Bun.spawnSync(["rm", "-rf", localPath]); } catch {}
462+
try { Bun.spawnSync(["rm", "-rf", sessionDir]); } catch {}
449463
});
450464
}
451465

452466
// --local only provides a sandbox path for agent processes.
453467
// Do NOT set gitContext — that would contaminate the diff pipeline.
454468
agentCwd = localPath;
455469

470+
// Create worktree pool with the initial PR as the first entry
471+
worktreePool = createWorktreePool(
472+
{ sessionDir, repoDir, isSameRepo },
473+
{ path: localPath, prUrl: prMetadata.url, number: prNumber, ready: true },
474+
);
475+
456476
console.error(`Local checkout ready at ${localPath}`);
457477
} catch (err) {
458478
console.error(`Warning: --local failed, falling back to remote diff`);
459479
console.error(err instanceof Error ? err.message : String(err));
460-
// Clean up partially-created directory (clone may have succeeded before fetch/checkout failed)
461-
if (localPath) try { rmSync(localPath, { recursive: true, force: true }); } catch {}
480+
if (sessionDir) try { rmSync(sessionDir, { recursive: true, force: true }); } catch {}
462481
agentCwd = undefined;
482+
worktreePool = undefined;
463483
worktreeCleanup = undefined;
464484
}
465485
}
@@ -485,6 +505,7 @@ if (args[0] === "sessions") {
485505
gitContext,
486506
prMetadata,
487507
agentCwd,
508+
worktreePool,
488509
sharingEnabled,
489510
shareBaseUrl,
490511
htmlContent: reviewHtmlContent,

apps/marketing/src/content/docs/commands/code-review.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ The `/plannotator-review` command opens an interactive code review UI for your l
2424

2525
PR review uses the `gh` CLI for authentication, so private repos work automatically if you're authenticated with `gh auth login`.
2626

27+
GitLab merge request URLs are also supported when the `glab` CLI is installed and authenticated.
28+
2729
## How it works
2830

2931
**Local review:**
@@ -60,6 +62,14 @@ Send Feedback → PR context included in feedback
6062
Approve → "LGTM" sent to agent
6163
```
6264

65+
## Stacked PRs and MRs
66+
67+
When a PR or MR targets a non-default branch, Plannotator marks it as stacked in the review header. The default view remains **Layer**, which matches the platform diff and is the safe mode for posting inline review comments.
68+
69+
If Plannotator has a local checkout for the PR or MR, the header also offers **Full stack**. Full stack shows everything from the repository default branch through the current checked-out head, which helps you understand the whole chain before reviewing the current layer.
70+
71+
Platform posting is intentionally limited to **Layer** because GitHub and GitLab inline comments are anchored to the PR or MR's own diff. Use **Full stack** for comprehension and agent review, then switch back to **Layer** before posting to the platform.
72+
6373
## Switching diff types
6474

6575
By default the review opens showing uncommitted changes, but you can switch what you're comparing using the diff type dropdown in the toolbar. The available options are:

apps/marketing/src/content/docs/guides/ai-code-review.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ Both integrations are derived from official tooling. Claude's review model is ba
2424

2525
For PR reviews, a temporary local checkout is created by default so the agent has file access beyond the diff. Pass `--no-local` to skip this.
2626

27+
For stacked PRs and MRs, the review header lets you choose what the agent sees:
28+
29+
- **Layer** reviews only the current PR or MR relative to its parent branch.
30+
- **Full stack** reviews the cumulative diff from the repository default branch through the current head.
31+
32+
Layer review is best for avoiding duplicate feedback on parent PRs. Full stack review is useful for integration issues that only appear when the whole chain is considered together. Posting inline comments back to GitHub or GitLab stays limited to Layer because platform comments must anchor to the platform diff.
33+
2734
## Findings
2835

2936
Each finding includes a file path, line range, description, and severity or priority. Claude findings also include a reasoning trace that explains how the issue was verified.

apps/pi-extension/plannotator-browser.ts

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, realpathSync, rmSync, statSync } from "node:f
22
import { dirname, join, resolve } from "node:path";
33
import { tmpdir } from "node:os";
44
import { spawnSync } from "node:child_process";
5+
import { createWorktreePool, type WorktreePool } from "./generated/worktree-pool.js";
56
import { fileURLToPath } from "node:url";
67
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
78
import {
@@ -183,6 +184,7 @@ export async function openCodeReview(
183184
let agentCwd: string | undefined;
184185
let initialBase: string | undefined;
185186
let worktreeCleanup: (() => void | Promise<void>) | undefined;
187+
let worktreePool: WorktreePool | undefined;
186188
let exitHandler: (() => void) | undefined;
187189

188190
if (isPRMode && urlArg) {
@@ -218,13 +220,16 @@ export async function openCodeReview(
218220

219221
// Create local worktree for agent file access (--local is the default for PR reviews)
220222
let localPath: string | undefined;
223+
let sessionDir: string | undefined;
221224
try {
222225
const repoDir = options.cwd ?? ctx.cwd;
223226
const identifier = prMetadata.platform === "github"
224227
? `${prMetadata.owner}-${prMetadata.repo}-${prMetadata.number}`
225228
: `${prMetadata.projectPath.replace(/\//g, "-")}-${prMetadata.iid}`;
226229
const suffix = Math.random().toString(36).slice(2, 8);
227-
localPath = join(realpathSync(tmpdir()), `plannotator-pr-${identifier}-${suffix}`);
230+
const prNumber = prMetadata.platform === "github" ? prMetadata.number : prMetadata.iid;
231+
sessionDir = join(realpathSync(tmpdir()), `plannotator-pr-${identifier}-${suffix}`);
232+
localPath = join(sessionDir, "pool", `pr-${prNumber}`);
228233
const fetchRefStr = prMetadata.platform === "github"
229234
? `refs/pull/${prMetadata.number}/head`
230235
: `refs/merge-requests/${prMetadata.iid}/head`;
@@ -266,14 +271,19 @@ export async function openCodeReview(
266271
cwd: repoDir,
267272
});
268273

269-
const worktreePath = localPath;
270274
const wtRepoDir = repoDir;
271275
exitHandler = () => {
272-
try { spawnSync("git", ["worktree", "remove", "--force", worktreePath], { cwd: wtRepoDir }); } catch {}
276+
try {
277+
for (const entry of worktreePool?.entries() ?? []) {
278+
spawnSync("git", ["worktree", "remove", "--force", entry.path], { cwd: wtRepoDir });
279+
}
280+
} catch {}
281+
if (sessionDir) try { rmSync(sessionDir, { recursive: true, force: true }); } catch {}
273282
};
274-
worktreeCleanup = () => {
283+
worktreeCleanup = async () => {
275284
if (exitHandler) { process.removeListener("exit", exitHandler); exitHandler = undefined; }
276-
return removeWorktree(reviewRuntime, worktreePath, { force: true, cwd: wtRepoDir });
285+
if (worktreePool) await worktreePool.cleanup(reviewRuntime);
286+
if (sessionDir) try { rmSync(sessionDir, { recursive: true, force: true }); } catch {}
277287
};
278288
process.once("exit", exitHandler);
279289
} else {
@@ -312,25 +322,29 @@ export async function openCodeReview(
312322
await reviewRuntime.runGit(["branch", "--", prMetadata.baseBranch, prMetadata.baseSha], { cwd: localPath });
313323
await reviewRuntime.runGit(["update-ref", `refs/remotes/origin/${prMetadata.baseBranch}`, prMetadata.baseSha], { cwd: localPath });
314324

315-
const clonePath = localPath;
316325
exitHandler = () => {
317-
try { rmSync(clonePath, { recursive: true, force: true }); } catch {}
326+
if (sessionDir) try { rmSync(sessionDir, { recursive: true, force: true }); } catch {}
318327
};
319328
worktreeCleanup = () => {
320329
if (exitHandler) { process.removeListener("exit", exitHandler); exitHandler = undefined; }
321-
try { rmSync(clonePath, { recursive: true, force: true }); } catch {}
330+
if (sessionDir) try { rmSync(sessionDir, { recursive: true, force: true }); } catch {}
322331
};
323332
process.once("exit", exitHandler);
324333
}
325334

326335
agentCwd = localPath;
336+
worktreePool = createWorktreePool(
337+
{ sessionDir: sessionDir!, repoDir, isSameRepo },
338+
{ path: localPath, prUrl: prMetadata.url, number: prNumber, ready: true },
339+
);
327340
console.error(`Local checkout ready at ${localPath}`);
328341
} catch (err) {
329342
console.error("Warning: local worktree creation failed, falling back to remote diff");
330343
console.error(err instanceof Error ? err.message : String(err));
331344
if (exitHandler) { process.removeListener("exit", exitHandler); exitHandler = undefined; }
332-
if (localPath) try { rmSync(localPath, { recursive: true, force: true }); } catch {}
345+
if (sessionDir) try { rmSync(sessionDir, { recursive: true, force: true }); } catch {}
333346
agentCwd = undefined;
347+
worktreePool = undefined;
334348
worktreeCleanup = undefined;
335349
}
336350
} else {
@@ -359,6 +373,7 @@ export async function openCodeReview(
359373
initialBase,
360374
prMetadata,
361375
agentCwd,
376+
worktreePool,
362377
htmlContent: reviewHtmlContent,
363378
sharingEnabled: process.env.PLANNOTATOR_SHARE !== "disabled",
364379
shareBaseUrl: process.env.PLANNOTATOR_SHARE_URL || undefined,

apps/pi-extension/server/agent-jobs.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ export interface AgentJobHandlerOptions {
7373
reasoningEffort?: string;
7474
/** Whether Codex fast mode was enabled. */
7575
fastMode?: boolean;
76+
/** PR URL at launch time. */
77+
prUrl?: string;
78+
/** PR diff scope at launch time. */
79+
diffScope?: string;
7680
/** Diff context snapshot at launch (stored on AgentJobInfo for per-job "Copy All"). */
7781
diffContext?: AgentJobInfo["diffContext"];
7882
} | null>;
@@ -120,7 +124,7 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
120124
command: string[],
121125
label: string,
122126
outputPath?: string,
123-
spawnOptions?: { captureStdout?: boolean; stdinPrompt?: string; cwd?: string; prompt?: string; engine?: string; model?: string; effort?: string; reasoningEffort?: string; fastMode?: boolean; diffContext?: AgentJobInfo["diffContext"] },
127+
spawnOptions?: { captureStdout?: boolean; stdinPrompt?: string; cwd?: string; prompt?: string; engine?: string; model?: string; effort?: string; reasoningEffort?: string; fastMode?: boolean; prUrl?: string; diffScope?: string; diffContext?: AgentJobInfo["diffContext"] },
124128
): AgentJobInfo {
125129
const id = crypto.randomUUID();
126130
const source = jobSource(id);
@@ -139,6 +143,8 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
139143
...(spawnOptions?.effort && { effort: spawnOptions.effort }),
140144
...(spawnOptions?.reasoningEffort && { reasoningEffort: spawnOptions.reasoningEffort }),
141145
...(spawnOptions?.fastMode && { fastMode: spawnOptions.fastMode }),
146+
...(spawnOptions?.prUrl && { prUrl: spawnOptions.prUrl }),
147+
...(spawnOptions?.diffScope && { diffScope: spawnOptions.diffScope }),
142148
...(spawnOptions?.diffContext && { diffContext: spawnOptions.diffContext }),
143149
};
144150

@@ -422,6 +428,8 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
422428
let jobEffort: string | undefined;
423429
let jobReasoningEffort: string | undefined;
424430
let jobFastMode: boolean | undefined;
431+
let jobPrUrl: string | undefined;
432+
let jobDiffScope: string | undefined;
425433
let jobDiffContext: AgentJobInfo["diffContext"] | undefined;
426434
if (options.buildCommand) {
427435
// Thread config from POST body to buildCommand
@@ -445,6 +453,8 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
445453
jobEffort = built.effort;
446454
jobReasoningEffort = built.reasoningEffort;
447455
jobFastMode = built.fastMode;
456+
jobPrUrl = built.prUrl;
457+
jobDiffScope = built.diffScope;
448458
jobDiffContext = built.diffContext;
449459
}
450460
}
@@ -464,6 +474,8 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
464474
effort: jobEffort,
465475
reasoningEffort: jobReasoningEffort,
466476
fastMode: jobFastMode,
477+
prUrl: jobPrUrl,
478+
diffScope: jobDiffScope,
467479
diffContext: jobDiffContext,
468480
});
469481
json(res, { job }, 201);

apps/pi-extension/server/pr.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,16 @@ import {
1111
fetchPR as fetchPRCore,
1212
fetchPRFileContent as fetchPRFileContentCore,
1313
fetchPRViewedFiles as fetchPRViewedFilesCore,
14+
fetchPRStack as fetchPRStackCore,
15+
fetchPRList as fetchPRListCore,
1416
getUser as getUserCore,
1517
markPRFilesViewed as markPRFilesViewedCore,
18+
type PRMetadata,
1619
type PRRef,
1720
type PRReviewFileComment,
1821
type PRRuntime,
22+
type PRStackTree,
23+
type PRListItem,
1924
parsePRUrl as parsePRUrlCore,
2025
submitPRReview as submitPRReviewCore,
2126
} from "../generated/pr-provider.js";
@@ -104,3 +109,16 @@ export function markPRFilesViewed(
104109
): Promise<void> {
105110
return markPRFilesViewedCore(prRuntime, ref, prNodeId, filePaths, viewed);
106111
}
112+
113+
export function fetchPRStack(
114+
ref: PRRef,
115+
metadata: PRMetadata,
116+
): Promise<PRStackTree | null> {
117+
return fetchPRStackCore(prRuntime, ref, metadata);
118+
}
119+
120+
export function fetchPRList(
121+
ref: PRRef,
122+
): Promise<PRListItem[]> {
123+
return fetchPRListCore(prRuntime, ref);
124+
}

0 commit comments

Comments
 (0)