@plannotator/pi-extension 0.17.0 → 0.17.1

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.
package/generated/repo.ts CHANGED
@@ -13,16 +13,24 @@ export interface RepoInfo {
13
13
  * - SSH: git@github.com:org/repo.git
14
14
  * - HTTPS: https://github.com/org/repo.git
15
15
  * - SSH with port: ssh://git@github.com:22/org/repo.git
16
+ * - GitLab subgroups: git@gitlab.com:group/subgroup/project.git
16
17
  */
17
18
  export function parseRemoteUrl(url: string): string | null {
18
19
  if (!url) return null;
19
20
 
20
- // SSH format: git@github.com:org/repo.git
21
- const sshMatch = url.match(/:([^/]+\/[^/]+?)(?:\.git)?$/);
22
- if (sshMatch) return sshMatch[1];
21
+ // SSH with port: ssh://git@host:22/path.git — strip scheme+host+port
22
+ const sshPortMatch = url.match(/^ssh:\/\/[^/]+(?::\d+)?\/(.+?)(?:\.git)?$/);
23
+ if (sshPortMatch) return sshPortMatch[1];
23
24
 
24
- // HTTPS format: https://github.com/org/repo.git
25
- const httpsMatch = url.match(/\/([^/]+\/[^/]+?)(?:\.git)?$/);
25
+ // SSH format: git@host:path.git — capture full path after ':'
26
+ // Reject URLs with :// scheme (HTTPS with non-standard ports like :8443)
27
+ if (!url.includes("://")) {
28
+ const sshMatch = url.match(/:([^/][^:]*?)(?:\.git)?$/);
29
+ if (sshMatch) return sshMatch[1];
30
+ }
31
+
32
+ // HTTPS format: https://host/path.git — capture full path after host
33
+ const httpsMatch = url.match(/^https?:\/\/[^/]+\/(.+?)(?:\.git)?$/);
26
34
  if (httpsMatch) return httpsMatch[1];
27
35
 
28
36
  return null;
package/index.ts CHANGED
@@ -333,8 +333,8 @@ export default function plannotator(pi: ExtensionAPI): void {
333
333
  });
334
334
 
335
335
  pi.registerCommand("plannotator-review", {
336
- description: "Open interactive code review for current changes",
337
- handler: async (_args, ctx) => {
336
+ description: "Open interactive code review for current changes or a PR URL",
337
+ handler: async (args, ctx) => {
338
338
  if (!hasReviewBrowserHtml()) {
339
339
  ctx.ui.notify(
340
340
  "Code review UI not available. Run 'bun run build' in the pi-extension directory.",
@@ -344,12 +344,18 @@ export default function plannotator(pi: ExtensionAPI): void {
344
344
  }
345
345
 
346
346
  try {
347
- const result = await openCodeReview(ctx);
347
+ const prUrl = args?.trim() || undefined;
348
+ const isPRReview = prUrl?.startsWith("http://") || prUrl?.startsWith("https://");
349
+ const result = await openCodeReview(ctx, { prUrl });
348
350
  if (result.feedback) {
349
351
  if (result.approved) {
350
352
  pi.sendUserMessage(
351
353
  `# Code Review\n\nCode review completed — no changes requested.`,
352
354
  );
355
+ } else if (isPRReview) {
356
+ // Platform PR actions (approve/comment) return approved:false with a
357
+ // status message — don't tell the agent to "address" a platform action.
358
+ pi.sendUserMessage(result.feedback);
353
359
  } else {
354
360
  pi.sendUserMessage(
355
361
  `${result.feedback}\n\nPlease address this feedback.`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plannotator/pi-extension",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "type": "module",
5
5
  "description": "Plannotator extension for Pi coding agent - interactive plan review with visual annotation",
6
6
  "author": "backnotprop",
@@ -1,9 +1,12 @@
1
- import { existsSync, readFileSync, statSync } from "node:fs";
2
- import { dirname, resolve } from "node:path";
1
+ import { existsSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { spawnSync } from "node:child_process";
3
5
  import { fileURLToPath } from "node:url";
4
6
  import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
5
7
  import {
6
8
  getGitContext,
9
+ reviewRuntime,
7
10
  runGitDiff,
8
11
  startAnnotateServer,
9
12
  startPlanReviewServer,
@@ -11,6 +14,16 @@ import {
11
14
  type DiffType,
12
15
  } from "./server.js";
13
16
  import { openBrowser } from "./server/network.js";
17
+ import { parsePRUrl, checkPRAuth, fetchPR } from "./server/pr.js";
18
+ import {
19
+ getMRLabel,
20
+ getMRNumberLabel,
21
+ getDisplayRepo,
22
+ getCliName,
23
+ getCliInstallUrl,
24
+ } from "./generated/pr-provider.js";
25
+ import { parseRemoteUrl } from "./generated/repo.js";
26
+ import { fetchRef, createWorktree, removeWorktree, ensureObjectAvailable } from "./generated/worktree.js";
14
27
 
15
28
  export type AnnotateMode = "annotate" | "annotate-folder" | "annotate-last";
16
29
  export interface PlanReviewDecision {
@@ -151,27 +164,198 @@ export async function openPlanReviewBrowser(
151
164
 
152
165
  export async function openCodeReview(
153
166
  ctx: ExtensionContext,
154
- options: { cwd?: string; defaultBranch?: string; diffType?: DiffType } = {},
167
+ options: { cwd?: string; defaultBranch?: string; diffType?: DiffType; prUrl?: string } = {},
155
168
  ): Promise<{ approved: boolean; feedback?: string; annotations?: unknown[]; agentSwitch?: string }> {
156
169
  if (!ctx.hasUI || !reviewHtmlContent) {
157
170
  throw new Error("Plannotator code review browser is unavailable in this session.");
158
171
  }
159
172
 
160
- const cwd = options.cwd ?? ctx.cwd;
161
- const gitCtx = await getGitContext(cwd);
162
- const defaultBranch = options.defaultBranch ?? gitCtx.defaultBranch;
163
- const diffType: DiffType = options.diffType ?? "uncommitted";
164
- const { patch: rawPatch, label: gitRef, error } = await runGitDiff(diffType, defaultBranch, cwd);
173
+ const urlArg = options.prUrl;
174
+ const isPRMode = urlArg?.startsWith("http://") || urlArg?.startsWith("https://");
175
+
176
+ let rawPatch: string;
177
+ let gitRef: string;
178
+ let diffError: string | undefined;
179
+ let gitCtx: Awaited<ReturnType<typeof getGitContext>> | undefined;
180
+ let prMetadata: Awaited<ReturnType<typeof fetchPR>>["metadata"] | undefined;
181
+ let diffType: DiffType | undefined;
182
+ let agentCwd: string | undefined;
183
+ let worktreeCleanup: (() => void | Promise<void>) | undefined;
184
+ let exitHandler: (() => void) | undefined;
185
+
186
+ if (isPRMode && urlArg) {
187
+ // --- PR Review Mode ---
188
+ const prRef = parsePRUrl(urlArg);
189
+ if (!prRef) {
190
+ throw new Error(
191
+ `Invalid PR/MR URL: ${urlArg}\n` +
192
+ "Supported formats:\n" +
193
+ " GitHub: https://github.com/owner/repo/pull/123\n" +
194
+ " GitLab: https://gitlab.com/group/project/-/merge_requests/42",
195
+ );
196
+ }
197
+
198
+ const cliName = getCliName(prRef);
199
+ const cliUrl = getCliInstallUrl(prRef);
200
+
201
+ try {
202
+ await checkPRAuth(prRef);
203
+ } catch (err) {
204
+ const msg = err instanceof Error ? err.message : String(err);
205
+ if (msg.includes("not found") || msg.includes("ENOENT")) {
206
+ throw new Error(`${cliName === "gh" ? "GitHub" : "GitLab"} CLI (${cliName}) is not installed. Install it from ${cliUrl}`);
207
+ }
208
+ throw err;
209
+ }
210
+
211
+ console.error(`Fetching ${getMRLabel(prRef)} ${getMRNumberLabel(prRef)} from ${getDisplayRepo(prRef)}...`);
212
+ const pr = await fetchPR(prRef);
213
+ rawPatch = pr.rawPatch;
214
+ gitRef = `${getMRLabel(prRef)} ${getMRNumberLabel(prRef)}`;
215
+ prMetadata = pr.metadata;
216
+
217
+ // Create local worktree for agent file access (--local is the default for PR reviews)
218
+ let localPath: string | undefined;
219
+ try {
220
+ const repoDir = options.cwd ?? ctx.cwd;
221
+ const identifier = prMetadata.platform === "github"
222
+ ? `${prMetadata.owner}-${prMetadata.repo}-${prMetadata.number}`
223
+ : `${prMetadata.projectPath.replace(/\//g, "-")}-${prMetadata.iid}`;
224
+ const suffix = Math.random().toString(36).slice(2, 8);
225
+ localPath = join(realpathSync(tmpdir()), `plannotator-pr-${identifier}-${suffix}`);
226
+ const fetchRefStr = prMetadata.platform === "github"
227
+ ? `refs/pull/${prMetadata.number}/head`
228
+ : `refs/merge-requests/${prMetadata.iid}/head`;
229
+
230
+ // Validate inputs from platform API to prevent git flag/path injection
231
+ if (prMetadata.baseBranch.includes('..') || prMetadata.baseBranch.startsWith('-')) throw new Error(`Invalid base branch: ${prMetadata.baseBranch}`);
232
+ if (!/^[0-9a-f]{40,64}$/i.test(prMetadata.baseSha)) throw new Error(`Invalid base SHA: ${prMetadata.baseSha}`);
233
+
234
+ // Detect same-repo vs cross-repo (must match both owner/repo AND host)
235
+ let isSameRepo = false;
236
+ try {
237
+ const remoteResult = await reviewRuntime.runGit(["remote", "get-url", "origin"], { cwd: repoDir });
238
+ if (remoteResult.exitCode === 0) {
239
+ const remoteUrl = remoteResult.stdout.trim();
240
+ const currentRepo = parseRemoteUrl(remoteUrl);
241
+ const prRepo = prMetadata.platform === "github"
242
+ ? `${prMetadata.owner}/${prMetadata.repo}`
243
+ : prMetadata.projectPath;
244
+ const repoMatches = !!currentRepo && currentRepo.toLowerCase() === prRepo.toLowerCase();
245
+ const sshHost = remoteUrl.match(/^[^@]+@([^:]+):/)?.[1];
246
+ const httpsHost = (() => { try { return new URL(remoteUrl).hostname; } catch { return null; } })();
247
+ const remoteHost = (sshHost || httpsHost || "").toLowerCase();
248
+ const prHost = prMetadata.host.toLowerCase();
249
+ isSameRepo = repoMatches && remoteHost === prHost;
250
+ }
251
+ } catch { /* not in a git repo — cross-repo path */ }
252
+
253
+ if (isSameRepo) {
254
+ // ── Same-repo: fast worktree path ──
255
+ console.error("Fetching PR branch and creating local worktree...");
256
+ await fetchRef(reviewRuntime, prMetadata.baseBranch, { cwd: repoDir });
257
+ await ensureObjectAvailable(reviewRuntime, prMetadata.baseSha, { cwd: repoDir });
258
+ await fetchRef(reviewRuntime, fetchRefStr, { cwd: repoDir });
259
+
260
+ await createWorktree(reviewRuntime, {
261
+ ref: "FETCH_HEAD",
262
+ path: localPath,
263
+ detach: true,
264
+ cwd: repoDir,
265
+ });
266
+
267
+ const worktreePath = localPath;
268
+ const wtRepoDir = repoDir;
269
+ exitHandler = () => {
270
+ try { spawnSync("git", ["worktree", "remove", "--force", worktreePath], { cwd: wtRepoDir }); } catch {}
271
+ };
272
+ worktreeCleanup = () => {
273
+ if (exitHandler) { process.removeListener("exit", exitHandler); exitHandler = undefined; }
274
+ return removeWorktree(reviewRuntime, worktreePath, { force: true, cwd: wtRepoDir });
275
+ };
276
+ process.once("exit", exitHandler);
277
+ } else {
278
+ // ── Cross-repo: shallow clone + fetch PR head ──
279
+ const prRepo = prMetadata.platform === "github"
280
+ ? `${prMetadata.owner}/${prMetadata.repo}`
281
+ : prMetadata.projectPath;
282
+ if (/^-/.test(prRepo)) throw new Error(`Invalid repository identifier: ${prRepo}`);
283
+ const cli = prMetadata.platform === "github" ? "gh" : "glab";
284
+ const host = prMetadata.host;
285
+ // gh/glab repo clone doesn't accept --hostname; set GH_HOST/GITLAB_HOST env instead
286
+ const isDefaultHost = host === "github.com" || host === "gitlab.com";
287
+ const cloneEnv = isDefaultHost ? undefined : {
288
+ ...process.env,
289
+ ...(prMetadata.platform === "github" ? { GH_HOST: host } : { GITLAB_HOST: host }),
290
+ };
291
+
292
+ console.error(`Cloning ${prRepo} (shallow)...`);
293
+ const cloneResult = spawnSync(cli, ["repo", "clone", prRepo, localPath, "--", "--depth=1", "--no-checkout"], { encoding: "utf-8", env: cloneEnv });
294
+ if ((cloneResult.status ?? 1) !== 0) {
295
+ throw new Error(`${cli} repo clone failed: ${(cloneResult.stderr ?? "").trim()}`);
296
+ }
297
+
298
+ console.error("Fetching PR branch...");
299
+ const fetchResult = await reviewRuntime.runGit(["fetch", "--depth=200", "origin", fetchRefStr], { cwd: localPath });
300
+ if (fetchResult.exitCode !== 0) throw new Error(`Failed to fetch PR head ref: ${fetchResult.stderr.trim()}`);
301
+
302
+ const checkoutResult = await reviewRuntime.runGit(["checkout", "FETCH_HEAD"], { cwd: localPath });
303
+ if (checkoutResult.exitCode !== 0) {
304
+ throw new Error(`git checkout FETCH_HEAD failed: ${checkoutResult.stderr.trim()}`);
305
+ }
306
+
307
+ // Best-effort: create base refs so agent diffs work
308
+ const baseFetch = await reviewRuntime.runGit(["fetch", "--depth=200", "origin", prMetadata.baseSha], { cwd: localPath });
309
+ if (baseFetch.exitCode !== 0) console.error("Warning: failed to fetch baseSha, agent diffs may be inaccurate");
310
+ await reviewRuntime.runGit(["branch", "--", prMetadata.baseBranch, prMetadata.baseSha], { cwd: localPath });
311
+ await reviewRuntime.runGit(["update-ref", `refs/remotes/origin/${prMetadata.baseBranch}`, prMetadata.baseSha], { cwd: localPath });
312
+
313
+ const clonePath = localPath;
314
+ exitHandler = () => {
315
+ try { rmSync(clonePath, { recursive: true, force: true }); } catch {}
316
+ };
317
+ worktreeCleanup = () => {
318
+ if (exitHandler) { process.removeListener("exit", exitHandler); exitHandler = undefined; }
319
+ try { rmSync(clonePath, { recursive: true, force: true }); } catch {}
320
+ };
321
+ process.once("exit", exitHandler);
322
+ }
323
+
324
+ agentCwd = localPath;
325
+ console.error(`Local checkout ready at ${localPath}`);
326
+ } catch (err) {
327
+ console.error("Warning: local worktree creation failed, falling back to remote diff");
328
+ console.error(err instanceof Error ? err.message : String(err));
329
+ if (exitHandler) { process.removeListener("exit", exitHandler); exitHandler = undefined; }
330
+ if (localPath) try { rmSync(localPath, { recursive: true, force: true }); } catch {}
331
+ agentCwd = undefined;
332
+ worktreeCleanup = undefined;
333
+ }
334
+ } else {
335
+ // --- Local Review Mode ---
336
+ const cwd = options.cwd ?? ctx.cwd;
337
+ gitCtx = await getGitContext(cwd);
338
+ const defaultBranch = options.defaultBranch ?? gitCtx.defaultBranch;
339
+ diffType = options.diffType ?? "uncommitted";
340
+ const result = await runGitDiff(diffType, defaultBranch, cwd);
341
+ rawPatch = result.patch;
342
+ gitRef = result.label;
343
+ diffError = result.error;
344
+ }
345
+
165
346
  const server = await startReviewServer({
166
347
  rawPatch,
167
348
  gitRef,
168
- error,
349
+ error: diffError,
169
350
  origin: "pi",
170
351
  diffType,
171
352
  gitContext: gitCtx,
353
+ prMetadata,
354
+ agentCwd,
172
355
  htmlContent: reviewHtmlContent,
173
356
  sharingEnabled: process.env.PLANNOTATOR_SHARE !== "disabled",
174
357
  shareBaseUrl: process.env.PLANNOTATOR_SHARE_URL || undefined,
358
+ onCleanup: worktreeCleanup,
175
359
  });
176
360
 
177
361
  return openBrowserAndWait(server, ctx, server.waitForDecision);
@@ -85,6 +85,7 @@ export interface PlannotatorCodeReviewPayload {
85
85
  diffType?: DiffType;
86
86
  defaultBranch?: string;
87
87
  cwd?: string;
88
+ prUrl?: string;
88
89
  }
89
90
 
90
91
  export interface PlannotatorCodeReviewResult {
@@ -254,6 +255,7 @@ export function registerPlannotatorEventListeners(pi: ExtensionAPI): void {
254
255
  cwd: request.payload?.cwd,
255
256
  defaultBranch: request.payload?.defaultBranch,
256
257
  diffType: request.payload?.diffType,
258
+ prUrl: request.payload?.prUrl,
257
259
  });
258
260
  request.respond({ status: "handled", result });
259
261
  return;