@plannotator/pi-extension 0.16.7 → 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/agent-jobs.ts +11 -0
- package/generated/claude-review.ts +345 -0
- package/generated/codex-review.ts +382 -0
- package/generated/external-annotation.ts +6 -0
- package/generated/path-utils.ts +19 -0
- package/generated/pr-github.ts +100 -2
- package/generated/pr-gitlab.ts +1 -0
- package/generated/pr-provider.ts +26 -0
- package/generated/repo.ts +13 -5
- package/generated/resolve-file.ts +56 -3
- package/generated/review-core.ts +32 -0
- package/generated/worktree.ts +120 -0
- 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 +78 -77
- package/review-editor.html +596 -273
- package/server/agent-jobs.ts +139 -12
- package/server/external-annotations.ts +8 -0
- package/server/network.test.ts +96 -0
- package/server/network.ts +21 -4
- package/server/serverReview.ts +178 -52
- package/server.ts +1 -0
|
@@ -79,6 +79,20 @@ function isSearchableMarkdownPath(input: string): boolean {
|
|
|
79
79
|
return MARKDOWN_PATH_REGEX.test(input.trim());
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
function stripWrappingQuotes(input: string): string {
|
|
83
|
+
if (input.length < 2) {
|
|
84
|
+
return input;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const first = input[0];
|
|
88
|
+
const last = input[input.length - 1];
|
|
89
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
|
90
|
+
return input.slice(1, -1);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return input;
|
|
94
|
+
}
|
|
95
|
+
|
|
82
96
|
/** Check if a path looks like a Windows absolute path (e.g. C:\ or C:/) */
|
|
83
97
|
function hasWindowsDriveLetter(input: string): boolean {
|
|
84
98
|
return /^[a-zA-Z]:[/\\]/.test(input);
|
|
@@ -156,12 +170,10 @@ export function isAbsoluteMarkdownPath(
|
|
|
156
170
|
* @param input - User-provided path (absolute, relative, or bare filename)
|
|
157
171
|
* @param projectRoot - Project root directory to search within
|
|
158
172
|
*/
|
|
159
|
-
|
|
173
|
+
function resolveMarkdownFileCore(
|
|
160
174
|
input: string,
|
|
161
175
|
projectRoot: string,
|
|
162
176
|
): ResolveResult {
|
|
163
|
-
// Trim whitespace/CR that may leak from Windows shell pipelines
|
|
164
|
-
input = input.trim();
|
|
165
177
|
const normalizedInput = normalizeMarkdownPathInput(input);
|
|
166
178
|
const searchInput = normalizeSeparators(normalizedInput);
|
|
167
179
|
const isBareFilename = !searchInput.includes("/");
|
|
@@ -219,6 +231,47 @@ export function resolveMarkdownFile(
|
|
|
219
231
|
return { kind: "not_found", input };
|
|
220
232
|
}
|
|
221
233
|
|
|
234
|
+
/**
|
|
235
|
+
* Resolve a markdown file path within a project root.
|
|
236
|
+
*
|
|
237
|
+
* @param input - User-provided path (absolute, relative, or bare filename)
|
|
238
|
+
* @param projectRoot - Project root directory to search within
|
|
239
|
+
*/
|
|
240
|
+
export function resolveMarkdownFile(
|
|
241
|
+
input: string,
|
|
242
|
+
projectRoot: string,
|
|
243
|
+
): ResolveResult {
|
|
244
|
+
const originalInput = input.trim();
|
|
245
|
+
const unquotedInput = stripWrappingQuotes(originalInput);
|
|
246
|
+
|
|
247
|
+
const primary = resolveMarkdownFileCore(unquotedInput, projectRoot);
|
|
248
|
+
if (primary.kind === "found") {
|
|
249
|
+
return primary;
|
|
250
|
+
}
|
|
251
|
+
if (primary.kind === "ambiguous") {
|
|
252
|
+
return { ...primary, input: originalInput };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (!unquotedInput.startsWith("@")) {
|
|
256
|
+
return { kind: "not_found", input: originalInput };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const normalizedInput = unquotedInput.replace(/^@+/, "");
|
|
260
|
+
if (!normalizedInput) {
|
|
261
|
+
return { kind: "not_found", input: originalInput };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const fallback = resolveMarkdownFileCore(normalizedInput, projectRoot);
|
|
265
|
+
if (fallback.kind === "found") {
|
|
266
|
+
return fallback;
|
|
267
|
+
}
|
|
268
|
+
if (fallback.kind === "ambiguous") {
|
|
269
|
+
return { ...fallback, input: originalInput };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return { kind: "not_found", input: originalInput };
|
|
273
|
+
}
|
|
274
|
+
|
|
222
275
|
/**
|
|
223
276
|
* Check if a directory contains at least one markdown file.
|
|
224
277
|
* Used to validate folder annotation targets.
|
package/generated/review-core.ts
CHANGED
|
@@ -14,6 +14,7 @@ export type DiffType =
|
|
|
14
14
|
| "unstaged"
|
|
15
15
|
| "last-commit"
|
|
16
16
|
| "branch"
|
|
17
|
+
| "merge-base"
|
|
17
18
|
| `worktree:${string}`
|
|
18
19
|
| "p4-default"
|
|
19
20
|
| `p4-changelist:${string}`;
|
|
@@ -151,6 +152,7 @@ export async function getGitContext(
|
|
|
151
152
|
|
|
152
153
|
if (currentBranch !== defaultBranch) {
|
|
153
154
|
diffOptions.push({ id: "branch", label: `vs ${defaultBranch}` });
|
|
155
|
+
diffOptions.push({ id: "merge-base", label: `Current PR Diff` });
|
|
154
156
|
}
|
|
155
157
|
|
|
156
158
|
const [worktrees, currentTreePathResult] = await Promise.all([
|
|
@@ -386,6 +388,28 @@ export async function runGitDiff(
|
|
|
386
388
|
break;
|
|
387
389
|
}
|
|
388
390
|
|
|
391
|
+
case "merge-base": {
|
|
392
|
+
const mergeBaseResult = assertGitSuccess(
|
|
393
|
+
await runtime.runGit(["merge-base", defaultBranch, "HEAD"], { cwd }),
|
|
394
|
+
["merge-base", defaultBranch, "HEAD"],
|
|
395
|
+
);
|
|
396
|
+
const mergeBase = mergeBaseResult.stdout.trim();
|
|
397
|
+
const mergeBaseDiffArgs = [
|
|
398
|
+
"diff",
|
|
399
|
+
"--no-ext-diff",
|
|
400
|
+
`${mergeBase}..HEAD`,
|
|
401
|
+
"--src-prefix=a/",
|
|
402
|
+
"--dst-prefix=b/",
|
|
403
|
+
];
|
|
404
|
+
const mergeBaseDiff = assertGitSuccess(
|
|
405
|
+
await runtime.runGit(mergeBaseDiffArgs, { cwd }),
|
|
406
|
+
mergeBaseDiffArgs,
|
|
407
|
+
);
|
|
408
|
+
patch = mergeBaseDiff.stdout;
|
|
409
|
+
label = `PR diff vs ${defaultBranch}`;
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
|
|
389
413
|
default:
|
|
390
414
|
return { patch: "", label: "Unknown diff type" };
|
|
391
415
|
}
|
|
@@ -475,6 +499,14 @@ export async function getFileContentsForDiff(
|
|
|
475
499
|
oldContent: await gitShow(defaultBranch, oldFilePath),
|
|
476
500
|
newContent: await gitShow("HEAD", filePath),
|
|
477
501
|
};
|
|
502
|
+
case "merge-base": {
|
|
503
|
+
const mbResult = await runtime.runGit(["merge-base", defaultBranch, "HEAD"], { cwd });
|
|
504
|
+
const mb = mbResult.exitCode === 0 ? mbResult.stdout.trim() : defaultBranch;
|
|
505
|
+
return {
|
|
506
|
+
oldContent: await gitShow(mb, oldFilePath),
|
|
507
|
+
newContent: await gitShow("HEAD", filePath),
|
|
508
|
+
};
|
|
509
|
+
}
|
|
478
510
|
default:
|
|
479
511
|
return { oldContent: null, newContent: null };
|
|
480
512
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// @generated — DO NOT EDIT. Source: packages/shared/worktree.ts
|
|
2
|
+
/**
|
|
3
|
+
* Worktree — runtime-agnostic git worktree primitives.
|
|
4
|
+
*
|
|
5
|
+
* Uses ReviewGitRuntime so both Bun and Node runtimes can use the same logic.
|
|
6
|
+
* Lives in packages/shared/ and gets vendored to Pi via vendor.sh.
|
|
7
|
+
*
|
|
8
|
+
* Designed as composable primitives, not tied to any specific use case.
|
|
9
|
+
* PR local checkout, agent sandboxes, parallel sessions — all compose from these.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ReviewGitRuntime } from "./review-core";
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Types
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
export interface CreateWorktreeOptions {
|
|
19
|
+
/** Git ref to check out (branch name, SHA, FETCH_HEAD, etc.) */
|
|
20
|
+
ref: string;
|
|
21
|
+
/** Absolute path where the worktree will be created. */
|
|
22
|
+
path: string;
|
|
23
|
+
/** Create in detached HEAD mode (no branch). Default: false. */
|
|
24
|
+
detach?: boolean;
|
|
25
|
+
/** CWD of the source repository. Defaults to process.cwd(). */
|
|
26
|
+
cwd?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface RemoveWorktreeOptions {
|
|
30
|
+
/** Force removal even if the worktree has modifications. Default: false. */
|
|
31
|
+
force?: boolean;
|
|
32
|
+
/** CWD of the source repository. Required if the worktree was created from a different cwd. */
|
|
33
|
+
cwd?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Primitives
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Fetch a ref from origin.
|
|
42
|
+
* Runs: `git fetch origin <ref>`
|
|
43
|
+
* Throws on failure.
|
|
44
|
+
*/
|
|
45
|
+
export async function fetchRef(
|
|
46
|
+
runtime: ReviewGitRuntime,
|
|
47
|
+
ref: string,
|
|
48
|
+
options?: { cwd?: string },
|
|
49
|
+
): Promise<void> {
|
|
50
|
+
const result = await runtime.runGit(["fetch", "origin", "--", ref], { cwd: options?.cwd });
|
|
51
|
+
if (result.exitCode !== 0) {
|
|
52
|
+
throw new Error(`git fetch origin ${ref} failed: ${result.stderr.trim() || `exit code ${result.exitCode}`}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Ensure a git object (commit SHA) is available locally.
|
|
58
|
+
* Checks with `git cat-file -t`, fetches from origin if missing.
|
|
59
|
+
* Returns true if the object is available after the attempt.
|
|
60
|
+
*/
|
|
61
|
+
export async function ensureObjectAvailable(
|
|
62
|
+
runtime: ReviewGitRuntime,
|
|
63
|
+
sha: string,
|
|
64
|
+
options?: { cwd?: string },
|
|
65
|
+
): Promise<boolean> {
|
|
66
|
+
const check = await runtime.runGit(["cat-file", "-t", sha], { cwd: options?.cwd });
|
|
67
|
+
if (check.exitCode === 0) return true;
|
|
68
|
+
|
|
69
|
+
// Object missing locally — try fetching it
|
|
70
|
+
const fetch = await runtime.runGit(["fetch", "origin", "--", sha], { cwd: options?.cwd });
|
|
71
|
+
if (fetch.exitCode !== 0) return false;
|
|
72
|
+
|
|
73
|
+
// Verify it's now available
|
|
74
|
+
const recheck = await runtime.runGit(["cat-file", "-t", sha], { cwd: options?.cwd });
|
|
75
|
+
return recheck.exitCode === 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Create a git worktree.
|
|
80
|
+
* Runs: `git worktree add [--detach] <path> <ref>`
|
|
81
|
+
* Throws on failure with a descriptive error.
|
|
82
|
+
*/
|
|
83
|
+
export async function createWorktree(
|
|
84
|
+
runtime: ReviewGitRuntime,
|
|
85
|
+
options: CreateWorktreeOptions,
|
|
86
|
+
): Promise<{ worktreePath: string }> {
|
|
87
|
+
const args = ["worktree", "add"];
|
|
88
|
+
if (options.detach) args.push("--detach");
|
|
89
|
+
args.push(options.path, options.ref);
|
|
90
|
+
|
|
91
|
+
const result = await runtime.runGit(args, { cwd: options.cwd });
|
|
92
|
+
if (result.exitCode !== 0) {
|
|
93
|
+
throw new Error(`git worktree add failed: ${result.stderr.trim() || `exit code ${result.exitCode}`}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { worktreePath: options.path };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Remove a git worktree. Best-effort — logs errors but does not throw.
|
|
101
|
+
* Runs: `git worktree remove [--force] <path>`
|
|
102
|
+
*/
|
|
103
|
+
export async function removeWorktree(
|
|
104
|
+
runtime: ReviewGitRuntime,
|
|
105
|
+
worktreePath: string,
|
|
106
|
+
options?: RemoveWorktreeOptions,
|
|
107
|
+
): Promise<void> {
|
|
108
|
+
const args = ["worktree", "remove"];
|
|
109
|
+
if (options?.force) args.push("--force");
|
|
110
|
+
args.push(worktreePath);
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const result = await runtime.runGit(args, { cwd: options?.cwd });
|
|
114
|
+
if (result.exitCode !== 0) {
|
|
115
|
+
console.error(`Warning: git worktree remove failed for ${worktreePath}: ${result.stderr.trim()}`);
|
|
116
|
+
}
|
|
117
|
+
} catch (err) {
|
|
118
|
+
console.error(`Warning: worktree cleanup error: ${err instanceof Error ? err.message : String(err)}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
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;
|