@plannotator/pi-extension 0.19.10 → 0.19.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,13 +6,13 @@ import { createWorktreePool, type WorktreePool } from "./generated/worktree-pool
6
6
  import { fileURLToPath } from "node:url";
7
7
  import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
8
8
  import {
9
- getGitContext,
9
+ prepareLocalReviewDiff,
10
10
  reviewRuntime,
11
- runGitDiff,
12
11
  startAnnotateServer,
13
12
  startPlanReviewServer,
14
13
  startReviewServer,
15
14
  type DiffType,
15
+ type VcsSelection,
16
16
  } from "./server.js";
17
17
  import { openBrowser, isRemoteSession } from "./server/network.js";
18
18
  import { parsePRUrl, checkPRAuth, fetchPR } from "./server/pr.js";
@@ -190,9 +190,13 @@ export async function openPlanReviewBrowser(
190
190
  return session.waitForDecision();
191
191
  }
192
192
 
193
+ export function shouldUseLocalPrCheckout(options: { useLocal?: boolean }): boolean {
194
+ return options.useLocal !== false;
195
+ }
196
+
193
197
  export async function openCodeReview(
194
198
  ctx: ExtensionContext,
195
- options: { cwd?: string; defaultBranch?: string; diffType?: DiffType; prUrl?: string } = {},
199
+ options: { cwd?: string; defaultBranch?: string; diffType?: DiffType; prUrl?: string; vcsType?: VcsSelection; useLocal?: boolean } = {},
196
200
  ): Promise<{ approved: boolean; feedback?: string; annotations?: unknown[]; agentSwitch?: string; exit?: boolean }> {
197
201
  const session = await startCodeReviewBrowserSession(ctx, options);
198
202
  return session.waitForDecision();
@@ -200,7 +204,7 @@ export async function openCodeReview(
200
204
 
201
205
  export async function startCodeReviewBrowserSession(
202
206
  ctx: ExtensionContext,
203
- options: { cwd?: string; defaultBranch?: string; diffType?: DiffType; prUrl?: string } = {},
207
+ options: { cwd?: string; defaultBranch?: string; diffType?: DiffType; prUrl?: string; vcsType?: VcsSelection; useLocal?: boolean } = {},
204
208
  ): Promise<
205
209
  BrowserDecisionSession<{
206
210
  approved: boolean;
@@ -220,7 +224,7 @@ export async function startCodeReviewBrowserSession(
220
224
  let rawPatch: string;
221
225
  let gitRef: string;
222
226
  let diffError: string | undefined;
223
- let gitCtx: Awaited<ReturnType<typeof getGitContext>> | undefined;
227
+ let gitCtx: Awaited<ReturnType<typeof prepareLocalReviewDiff>>["gitContext"] | undefined;
224
228
  let prMetadata: Awaited<ReturnType<typeof fetchPR>>["metadata"] | undefined;
225
229
  let diffType: DiffType | undefined;
226
230
  let agentCwd: string | undefined;
@@ -260,152 +264,158 @@ export async function startCodeReviewBrowserSession(
260
264
  gitRef = `${getMRLabel(prRef)} ${getMRNumberLabel(prRef)}`;
261
265
  prMetadata = pr.metadata;
262
266
 
263
- // Create local worktree for agent file access (--local is the default for PR reviews)
264
- let localPath: string | undefined;
265
- let sessionDir: string | undefined;
266
- try {
267
- const repoDir = options.cwd ?? ctx.cwd;
268
- const identifier = prMetadata.platform === "github"
269
- ? `${prMetadata.owner}-${prMetadata.repo}-${prMetadata.number}`
270
- : `${prMetadata.projectPath.replace(/\//g, "-")}-${prMetadata.iid}`;
271
- const suffix = Math.random().toString(36).slice(2, 8);
272
- const prNumber = prMetadata.platform === "github" ? prMetadata.number : prMetadata.iid;
273
- sessionDir = join(realpathSync(tmpdir()), `plannotator-pr-${identifier}-${suffix}`);
274
- localPath = join(sessionDir, "pool", `pr-${prNumber}`);
275
- const fetchRefStr = prMetadata.platform === "github"
276
- ? `refs/pull/${prMetadata.number}/head`
277
- : `refs/merge-requests/${prMetadata.iid}/head`;
278
-
279
- // Validate inputs from platform API to prevent git flag/path injection
280
- if (prMetadata.baseBranch.includes('..') || prMetadata.baseBranch.startsWith('-')) throw new Error(`Invalid base branch: ${prMetadata.baseBranch}`);
281
- if (!/^[0-9a-f]{40,64}$/i.test(prMetadata.baseSha)) throw new Error(`Invalid base SHA: ${prMetadata.baseSha}`);
282
-
283
- // Detect same-repo vs cross-repo (must match both owner/repo AND host)
284
- let isSameRepo = false;
267
+ if (shouldUseLocalPrCheckout(options)) {
268
+ // Create local worktree for agent file access (--local is the default for PR reviews)
269
+ let localPath: string | undefined;
270
+ let sessionDir: string | undefined;
285
271
  try {
286
- const remoteResult = await reviewRuntime.runGit(["remote", "get-url", "origin"], { cwd: repoDir });
287
- if (remoteResult.exitCode === 0) {
288
- const remoteUrl = remoteResult.stdout.trim();
289
- const currentRepo = parseRemoteUrl(remoteUrl);
272
+ const repoDir = options.cwd ?? ctx.cwd;
273
+ const identifier = prMetadata.platform === "github"
274
+ ? `${prMetadata.owner}-${prMetadata.repo}-${prMetadata.number}`
275
+ : `${prMetadata.projectPath.replace(/\//g, "-")}-${prMetadata.iid}`;
276
+ const suffix = Math.random().toString(36).slice(2, 8);
277
+ const prNumber = prMetadata.platform === "github" ? prMetadata.number : prMetadata.iid;
278
+ sessionDir = join(realpathSync(tmpdir()), `plannotator-pr-${identifier}-${suffix}`);
279
+ localPath = join(sessionDir, "pool", `pr-${prNumber}`);
280
+ const fetchRefStr = prMetadata.platform === "github"
281
+ ? `refs/pull/${prMetadata.number}/head`
282
+ : `refs/merge-requests/${prMetadata.iid}/head`;
283
+
284
+ // Validate inputs from platform API to prevent git flag/path injection
285
+ if (prMetadata.baseBranch.includes('..') || prMetadata.baseBranch.startsWith('-')) throw new Error(`Invalid base branch: ${prMetadata.baseBranch}`);
286
+ if (!/^[0-9a-f]{40,64}$/i.test(prMetadata.baseSha)) throw new Error(`Invalid base SHA: ${prMetadata.baseSha}`);
287
+
288
+ // Detect same-repo vs cross-repo (must match both owner/repo AND host)
289
+ let isSameRepo = false;
290
+ try {
291
+ const remoteResult = await reviewRuntime.runGit(["remote", "get-url", "origin"], { cwd: repoDir });
292
+ if (remoteResult.exitCode === 0) {
293
+ const remoteUrl = remoteResult.stdout.trim();
294
+ const currentRepo = parseRemoteUrl(remoteUrl);
295
+ const prRepo = prMetadata.platform === "github"
296
+ ? `${prMetadata.owner}/${prMetadata.repo}`
297
+ : prMetadata.projectPath;
298
+ const repoMatches = !!currentRepo && currentRepo.toLowerCase() === prRepo.toLowerCase();
299
+ const sshHost = remoteUrl.match(/^[^@]+@([^:]+):/)?.[1];
300
+ const httpsHost = (() => { try { return new URL(remoteUrl).hostname; } catch { return null; } })();
301
+ const remoteHost = (sshHost || httpsHost || "").toLowerCase();
302
+ const prHost = prMetadata.host.toLowerCase();
303
+ isSameRepo = repoMatches && remoteHost === prHost;
304
+ }
305
+ } catch { /* not in a git repo — cross-repo path */ }
306
+
307
+ if (isSameRepo) {
308
+ // ── Same-repo: fast worktree path ──
309
+ console.error("Fetching PR branch and creating local worktree...");
310
+ await fetchRef(reviewRuntime, prMetadata.baseBranch, { cwd: repoDir });
311
+ await ensureObjectAvailable(reviewRuntime, prMetadata.baseSha, { cwd: repoDir });
312
+ await fetchRef(reviewRuntime, fetchRefStr, { cwd: repoDir });
313
+
314
+ await createWorktree(reviewRuntime, {
315
+ ref: "FETCH_HEAD",
316
+ path: localPath,
317
+ detach: true,
318
+ cwd: repoDir,
319
+ });
320
+
321
+ const wtRepoDir = repoDir;
322
+ exitHandler = () => {
323
+ try {
324
+ for (const entry of worktreePool?.entries() ?? []) {
325
+ spawnSync("git", ["worktree", "remove", "--force", entry.path], { cwd: wtRepoDir });
326
+ }
327
+ } catch {}
328
+ if (sessionDir) try { rmSync(sessionDir, { recursive: true, force: true }); } catch {}
329
+ };
330
+ worktreeCleanup = async () => {
331
+ if (exitHandler) { process.removeListener("exit", exitHandler); exitHandler = undefined; }
332
+ if (worktreePool) await worktreePool.cleanup(reviewRuntime);
333
+ if (sessionDir) try { rmSync(sessionDir, { recursive: true, force: true }); } catch {}
334
+ };
335
+ process.once("exit", exitHandler);
336
+ } else {
337
+ // ── Cross-repo: shallow clone + fetch PR head ──
290
338
  const prRepo = prMetadata.platform === "github"
291
339
  ? `${prMetadata.owner}/${prMetadata.repo}`
292
340
  : prMetadata.projectPath;
293
- const repoMatches = !!currentRepo && currentRepo.toLowerCase() === prRepo.toLowerCase();
294
- const sshHost = remoteUrl.match(/^[^@]+@([^:]+):/)?.[1];
295
- const httpsHost = (() => { try { return new URL(remoteUrl).hostname; } catch { return null; } })();
296
- const remoteHost = (sshHost || httpsHost || "").toLowerCase();
297
- const prHost = prMetadata.host.toLowerCase();
298
- isSameRepo = repoMatches && remoteHost === prHost;
299
- }
300
- } catch { /* not in a git repo — cross-repo path */ }
301
-
302
- if (isSameRepo) {
303
- // ── Same-repo: fast worktree path ──
304
- console.error("Fetching PR branch and creating local worktree...");
305
- await fetchRef(reviewRuntime, prMetadata.baseBranch, { cwd: repoDir });
306
- await ensureObjectAvailable(reviewRuntime, prMetadata.baseSha, { cwd: repoDir });
307
- await fetchRef(reviewRuntime, fetchRefStr, { cwd: repoDir });
308
-
309
- await createWorktree(reviewRuntime, {
310
- ref: "FETCH_HEAD",
311
- path: localPath,
312
- detach: true,
313
- cwd: repoDir,
314
- });
315
-
316
- const wtRepoDir = repoDir;
317
- exitHandler = () => {
318
- try {
319
- for (const entry of worktreePool?.entries() ?? []) {
320
- spawnSync("git", ["worktree", "remove", "--force", entry.path], { cwd: wtRepoDir });
321
- }
322
- } catch {}
323
- if (sessionDir) try { rmSync(sessionDir, { recursive: true, force: true }); } catch {}
324
- };
325
- worktreeCleanup = async () => {
326
- if (exitHandler) { process.removeListener("exit", exitHandler); exitHandler = undefined; }
327
- if (worktreePool) await worktreePool.cleanup(reviewRuntime);
328
- if (sessionDir) try { rmSync(sessionDir, { recursive: true, force: true }); } catch {}
329
- };
330
- process.once("exit", exitHandler);
331
- } else {
332
- // ── Cross-repo: shallow clone + fetch PR head ──
333
- const prRepo = prMetadata.platform === "github"
334
- ? `${prMetadata.owner}/${prMetadata.repo}`
335
- : prMetadata.projectPath;
336
- if (/^-/.test(prRepo)) throw new Error(`Invalid repository identifier: ${prRepo}`);
337
- const cli = prMetadata.platform === "github" ? "gh" : "glab";
338
- const host = prMetadata.host;
339
- // gh/glab repo clone doesn't accept --hostname; set GH_HOST/GITLAB_HOST env instead
340
- const isDefaultHost = host === "github.com" || host === "gitlab.com";
341
- const cloneEnv = isDefaultHost ? undefined : {
342
- ...process.env,
343
- ...(prMetadata.platform === "github" ? { GH_HOST: host } : { GITLAB_HOST: host }),
344
- };
345
-
346
- console.error(`Cloning ${prRepo} (shallow)...`);
347
- const cloneResult = spawnSync(cli, ["repo", "clone", prRepo, localPath, "--", "--depth=1", "--no-checkout"], { encoding: "utf-8", env: cloneEnv });
348
- if ((cloneResult.status ?? 1) !== 0) {
349
- throw new Error(`${cli} repo clone failed: ${(cloneResult.stderr ?? "").trim()}`);
341
+ if (/^-/.test(prRepo)) throw new Error(`Invalid repository identifier: ${prRepo}`);
342
+ const cli = prMetadata.platform === "github" ? "gh" : "glab";
343
+ const host = prMetadata.host;
344
+ // gh/glab repo clone doesn't accept --hostname; set GH_HOST/GITLAB_HOST env instead
345
+ const isDefaultHost = host === "github.com" || host === "gitlab.com";
346
+ const cloneEnv = isDefaultHost ? undefined : {
347
+ ...process.env,
348
+ ...(prMetadata.platform === "github" ? { GH_HOST: host } : { GITLAB_HOST: host }),
349
+ };
350
+
351
+ console.error(`Cloning ${prRepo} (shallow)...`);
352
+ const cloneResult = spawnSync(cli, ["repo", "clone", prRepo, localPath, "--", "--depth=1", "--no-checkout"], { encoding: "utf-8", env: cloneEnv });
353
+ if ((cloneResult.status ?? 1) !== 0) {
354
+ throw new Error(`${cli} repo clone failed: ${(cloneResult.stderr ?? "").trim()}`);
355
+ }
356
+
357
+ console.error("Fetching PR branch...");
358
+ const fetchResult = await reviewRuntime.runGit(["fetch", "--depth=200", "origin", fetchRefStr], { cwd: localPath });
359
+ if (fetchResult.exitCode !== 0) throw new Error(`Failed to fetch PR head ref: ${fetchResult.stderr.trim()}`);
360
+
361
+ const checkoutResult = await reviewRuntime.runGit(["checkout", "FETCH_HEAD"], { cwd: localPath });
362
+ if (checkoutResult.exitCode !== 0) {
363
+ throw new Error(`git checkout FETCH_HEAD failed: ${checkoutResult.stderr.trim()}`);
364
+ }
365
+
366
+ // Best-effort: create base refs so agent diffs work
367
+ const baseFetch = await reviewRuntime.runGit(["fetch", "--depth=200", "origin", prMetadata.baseSha], { cwd: localPath });
368
+ if (baseFetch.exitCode !== 0) console.error("Warning: failed to fetch baseSha, agent diffs may be inaccurate");
369
+ await reviewRuntime.runGit(["branch", "--", prMetadata.baseBranch, prMetadata.baseSha], { cwd: localPath });
370
+ await reviewRuntime.runGit(["update-ref", `refs/remotes/origin/${prMetadata.baseBranch}`, prMetadata.baseSha], { cwd: localPath });
371
+
372
+ exitHandler = () => {
373
+ if (sessionDir) try { rmSync(sessionDir, { recursive: true, force: true }); } catch {}
374
+ };
375
+ worktreeCleanup = () => {
376
+ if (exitHandler) { process.removeListener("exit", exitHandler); exitHandler = undefined; }
377
+ if (sessionDir) try { rmSync(sessionDir, { recursive: true, force: true }); } catch {}
378
+ };
379
+ process.once("exit", exitHandler);
350
380
  }
351
381
 
352
- console.error("Fetching PR branch...");
353
- const fetchResult = await reviewRuntime.runGit(["fetch", "--depth=200", "origin", fetchRefStr], { cwd: localPath });
354
- if (fetchResult.exitCode !== 0) throw new Error(`Failed to fetch PR head ref: ${fetchResult.stderr.trim()}`);
355
-
356
- const checkoutResult = await reviewRuntime.runGit(["checkout", "FETCH_HEAD"], { cwd: localPath });
357
- if (checkoutResult.exitCode !== 0) {
358
- throw new Error(`git checkout FETCH_HEAD failed: ${checkoutResult.stderr.trim()}`);
359
- }
360
-
361
- // Best-effort: create base refs so agent diffs work
362
- const baseFetch = await reviewRuntime.runGit(["fetch", "--depth=200", "origin", prMetadata.baseSha], { cwd: localPath });
363
- if (baseFetch.exitCode !== 0) console.error("Warning: failed to fetch baseSha, agent diffs may be inaccurate");
364
- await reviewRuntime.runGit(["branch", "--", prMetadata.baseBranch, prMetadata.baseSha], { cwd: localPath });
365
- await reviewRuntime.runGit(["update-ref", `refs/remotes/origin/${prMetadata.baseBranch}`, prMetadata.baseSha], { cwd: localPath });
366
-
367
- exitHandler = () => {
368
- if (sessionDir) try { rmSync(sessionDir, { recursive: true, force: true }); } catch {}
369
- };
370
- worktreeCleanup = () => {
371
- if (exitHandler) { process.removeListener("exit", exitHandler); exitHandler = undefined; }
372
- if (sessionDir) try { rmSync(sessionDir, { recursive: true, force: true }); } catch {}
373
- };
374
- process.once("exit", exitHandler);
382
+ agentCwd = localPath;
383
+ worktreePool = createWorktreePool(
384
+ { sessionDir: sessionDir!, repoDir, isSameRepo },
385
+ { path: localPath, prUrl: prMetadata.url, number: prNumber, ready: true },
386
+ );
387
+ console.error(`Local checkout ready at ${localPath}`);
388
+ } catch (err) {
389
+ console.error("Warning: local worktree creation failed, falling back to remote diff");
390
+ console.error(err instanceof Error ? err.message : String(err));
391
+ if (exitHandler) { process.removeListener("exit", exitHandler); exitHandler = undefined; }
392
+ if (sessionDir) try { rmSync(sessionDir, { recursive: true, force: true }); } catch {}
393
+ agentCwd = undefined;
394
+ worktreePool = undefined;
395
+ worktreeCleanup = undefined;
375
396
  }
376
-
377
- agentCwd = localPath;
378
- worktreePool = createWorktreePool(
379
- { sessionDir: sessionDir!, repoDir, isSameRepo },
380
- { path: localPath, prUrl: prMetadata.url, number: prNumber, ready: true },
381
- );
382
- console.error(`Local checkout ready at ${localPath}`);
383
- } catch (err) {
384
- console.error("Warning: local worktree creation failed, falling back to remote diff");
385
- console.error(err instanceof Error ? err.message : String(err));
386
- if (exitHandler) { process.removeListener("exit", exitHandler); exitHandler = undefined; }
387
- if (sessionDir) try { rmSync(sessionDir, { recursive: true, force: true }); } catch {}
388
- agentCwd = undefined;
389
- worktreePool = undefined;
390
- worktreeCleanup = undefined;
391
397
  }
392
398
  } else {
393
399
  // --- Local Review Mode ---
394
400
  const cwd = options.cwd ?? ctx.cwd;
395
- gitCtx = await getGitContext(cwd);
396
- const defaultBranch = options.defaultBranch ?? gitCtx.defaultBranch;
397
401
  const config = loadConfig();
398
- diffType = options.diffType ?? resolveDefaultDiffType(config);
399
- const result = await runGitDiff(diffType, defaultBranch, cwd, {
402
+ const result = await prepareLocalReviewDiff({
403
+ cwd,
404
+ vcsType: options.vcsType,
405
+ requestedDiffType: options.diffType,
406
+ requestedBase: options.defaultBranch,
407
+ configuredDiffType: resolveDefaultDiffType(config),
400
408
  hideWhitespace: config.diffOptions?.hideWhitespace ?? false,
401
409
  });
402
- rawPatch = result.patch;
403
- gitRef = result.label;
410
+ gitCtx = result.gitContext;
411
+ diffType = result.diffType;
412
+ rawPatch = result.rawPatch;
413
+ gitRef = result.gitRef;
404
414
  diffError = result.error;
405
415
  // Remember which base the initial diff was computed against so it can
406
416
  // be forwarded to the server below. Only matters when the caller
407
417
  // overrode the detected default; otherwise it matches gitCtx already.
408
- initialBase = defaultBranch;
418
+ initialBase = result.base;
409
419
  }
410
420
 
411
421
  const server = await startReviewServer({
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
5
- import type { DiffType } from "./server.js";
5
+ import type { DiffType, VcsSelection } from "./server.js";
6
6
  import {
7
7
  getLastAssistantMessageText,
8
8
  getStartupErrorMessage,
@@ -87,6 +87,8 @@ export type PlannotatorReviewStatusResult =
87
87
  export interface PlannotatorCodeReviewPayload {
88
88
  diffType?: DiffType;
89
89
  defaultBranch?: string;
90
+ vcsType?: VcsSelection;
91
+ useLocal?: boolean;
90
92
  cwd?: string;
91
93
  prUrl?: string;
92
94
  }
@@ -264,6 +266,8 @@ export function registerPlannotatorEventListeners(pi: ExtensionAPI): void {
264
266
  cwd: request.payload?.cwd,
265
267
  defaultBranch: request.payload?.defaultBranch,
266
268
  diffType: request.payload?.diffType,
269
+ vcsType: request.payload?.vcsType,
270
+ useLocal: request.payload?.useLocal,
267
271
  prUrl: request.payload?.prUrl,
268
272
  });
269
273
  request.respond({ status: "handled", result });