@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 +13 -5
- package/index.ts +9 -3
- package/package.json +1 -1
- package/plannotator-browser.ts +193 -9
- package/plannotator-events.ts +2 -0
- package/plannotator.html +2 -2
- package/review-editor.html +122 -116
- package/server/serverReview.ts +91 -49
- package/server.ts +1 -0
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
|
|
21
|
-
const
|
|
22
|
-
if (
|
|
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
|
-
//
|
|
25
|
-
|
|
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 (
|
|
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
|
|
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
package/plannotator-browser.ts
CHANGED
|
@@ -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
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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);
|
package/plannotator-events.ts
CHANGED
|
@@ -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;
|