@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
package/server/serverReview.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execSync, spawnSync } from "node:child_process";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
2
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
3
3
|
import { createServer } from "node:http";
|
|
4
|
+
import os from "node:os";
|
|
4
5
|
|
|
5
6
|
import { Readable } from "node:stream";
|
|
6
7
|
|
|
@@ -46,7 +47,7 @@ import {
|
|
|
46
47
|
} from "./handlers.js";
|
|
47
48
|
import { html, json, parseBody, requestUrl, toWebRequest } from "./helpers.js";
|
|
48
49
|
|
|
49
|
-
import { listenOnPort } from "./network.js";
|
|
50
|
+
import { isRemoteSession, listenOnPort } from "./network.js";
|
|
50
51
|
import {
|
|
51
52
|
fetchPRContext,
|
|
52
53
|
fetchPRFileContent,
|
|
@@ -56,11 +57,39 @@ import {
|
|
|
56
57
|
submitPRReview,
|
|
57
58
|
} from "./pr.js";
|
|
58
59
|
import { getRepoInfo } from "./project.js";
|
|
60
|
+
import {
|
|
61
|
+
CODEX_REVIEW_SYSTEM_PROMPT,
|
|
62
|
+
buildCodexReviewUserMessage,
|
|
63
|
+
buildCodexCommand,
|
|
64
|
+
generateOutputPath,
|
|
65
|
+
parseCodexOutput,
|
|
66
|
+
transformReviewFindings,
|
|
67
|
+
} from "../generated/codex-review.js";
|
|
68
|
+
import {
|
|
69
|
+
CLAUDE_REVIEW_PROMPT,
|
|
70
|
+
buildClaudeCommand,
|
|
71
|
+
parseClaudeStreamOutput,
|
|
72
|
+
transformClaudeFindings,
|
|
73
|
+
} from "../generated/claude-review.js";
|
|
74
|
+
|
|
75
|
+
/** Detect if running inside WSL (Windows Subsystem for Linux) */
|
|
76
|
+
function detectWSL(): boolean {
|
|
77
|
+
if (process.platform !== "linux") return false;
|
|
78
|
+
if (os.release().toLowerCase().includes("microsoft")) return true;
|
|
79
|
+
try {
|
|
80
|
+
if (existsSync("/proc/version")) {
|
|
81
|
+
const content = readFileSync("/proc/version", "utf-8").toLowerCase();
|
|
82
|
+
return content.includes("wsl") || content.includes("microsoft");
|
|
83
|
+
}
|
|
84
|
+
} catch { /* ignore */ }
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
59
87
|
|
|
60
88
|
export interface ReviewServerResult {
|
|
61
89
|
port: number;
|
|
62
90
|
portSource: "env" | "remote-default" | "random";
|
|
63
91
|
url: string;
|
|
92
|
+
isRemote: boolean;
|
|
64
93
|
waitForDecision: () => Promise<{
|
|
65
94
|
approved: boolean;
|
|
66
95
|
feedback: string;
|
|
@@ -70,7 +99,7 @@ export interface ReviewServerResult {
|
|
|
70
99
|
stop: () => void;
|
|
71
100
|
}
|
|
72
101
|
|
|
73
|
-
const reviewRuntime: ReviewGitRuntime = {
|
|
102
|
+
export const reviewRuntime: ReviewGitRuntime = {
|
|
74
103
|
async runGit(
|
|
75
104
|
args: string[],
|
|
76
105
|
options?: { cwd?: string },
|
|
@@ -118,11 +147,20 @@ export async function startReviewServer(options: {
|
|
|
118
147
|
sharingEnabled?: boolean;
|
|
119
148
|
shareBaseUrl?: string;
|
|
120
149
|
prMetadata?: PRMetadata;
|
|
150
|
+
/** Working directory for agent processes (e.g., --local worktree). Independent of diff pipeline. */
|
|
151
|
+
agentCwd?: string;
|
|
152
|
+
/** Cleanup callback invoked when server stops (e.g., remove temp worktree) */
|
|
153
|
+
onCleanup?: () => void | Promise<void>;
|
|
154
|
+
/** Called when server starts with the URL, remote status, and port */
|
|
155
|
+
onReady?: (url: string, isRemote: boolean, port: number) => void;
|
|
121
156
|
}): Promise<ReviewServerResult> {
|
|
122
157
|
const gitUser = detectGitUser();
|
|
123
158
|
const draftKey = contentHash(options.rawPatch);
|
|
124
159
|
const prMeta = options.prMetadata;
|
|
125
160
|
const isPRMode = !!prMeta;
|
|
161
|
+
const hasLocalAccess = !!options.gitContext;
|
|
162
|
+
const isRemote = isRemoteSession();
|
|
163
|
+
const wslFlag = detectWSL();
|
|
126
164
|
const prRef = prMeta ? prRefFromMetadata(prMeta) : null;
|
|
127
165
|
const platformUser = prRef ? await getPRUser(prRef) : null;
|
|
128
166
|
|
|
@@ -154,15 +192,88 @@ export async function startReviewServer(options: {
|
|
|
154
192
|
|
|
155
193
|
// Agent jobs — background process manager (late-binds serverUrl via getter)
|
|
156
194
|
let serverUrl = "";
|
|
195
|
+
// Worktree-aware cwd resolver — shared by getCwd, buildCommand, and onJobComplete
|
|
196
|
+
function resolveAgentCwd(): string {
|
|
197
|
+
if (options.agentCwd) return options.agentCwd;
|
|
198
|
+
if (currentDiffType.startsWith("worktree:")) {
|
|
199
|
+
const parsed = parseWorktreeDiffType(currentDiffType);
|
|
200
|
+
if (parsed) return parsed.path;
|
|
201
|
+
}
|
|
202
|
+
return options.gitContext?.cwd ?? process.cwd();
|
|
203
|
+
}
|
|
157
204
|
const agentJobs = createAgentJobHandler({
|
|
158
205
|
mode: "review",
|
|
159
206
|
getServerUrl: () => serverUrl,
|
|
160
|
-
getCwd:
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
207
|
+
getCwd: resolveAgentCwd,
|
|
208
|
+
|
|
209
|
+
async buildCommand(provider) {
|
|
210
|
+
const cwd = resolveAgentCwd();
|
|
211
|
+
const hasAgentLocalAccess = !!options.agentCwd || !!options.gitContext;
|
|
212
|
+
const userMessage = buildCodexReviewUserMessage(
|
|
213
|
+
currentPatch,
|
|
214
|
+
currentDiffType,
|
|
215
|
+
{ defaultBranch: options.gitContext?.defaultBranch, hasLocalAccess: hasAgentLocalAccess },
|
|
216
|
+
options.prMetadata,
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
if (provider === "codex") {
|
|
220
|
+
const outputPath = generateOutputPath();
|
|
221
|
+
const prompt = CODEX_REVIEW_SYSTEM_PROMPT + "\n\n---\n\n" + userMessage;
|
|
222
|
+
const command = await buildCodexCommand({ cwd, outputPath, prompt });
|
|
223
|
+
return { command, outputPath, prompt, label: "Codex Review" };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (provider === "claude") {
|
|
227
|
+
const prompt = CLAUDE_REVIEW_PROMPT + "\n\n---\n\n" + userMessage;
|
|
228
|
+
const { command, stdinPrompt } = buildClaudeCommand(prompt);
|
|
229
|
+
return { command, stdinPrompt, prompt, cwd, label: "Claude Code Review", captureStdout: true };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return null;
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
async onJobComplete(job, meta) {
|
|
236
|
+
const cwd = resolveAgentCwd();
|
|
237
|
+
|
|
238
|
+
if (job.provider === "codex" && meta.outputPath) {
|
|
239
|
+
const output = await parseCodexOutput(meta.outputPath);
|
|
240
|
+
if (!output) return;
|
|
241
|
+
|
|
242
|
+
// Override verdict if there are blocking findings (P0/P1) — Codex's
|
|
243
|
+
// freeform correctness string can say "mostly correct" with real bugs.
|
|
244
|
+
const hasBlockingFindings = output.findings.some((f: any) => f.priority !== null && f.priority <= 1);
|
|
245
|
+
job.summary = {
|
|
246
|
+
correctness: hasBlockingFindings ? "Issues Found" : output.overall_correctness,
|
|
247
|
+
explanation: output.overall_explanation,
|
|
248
|
+
confidence: output.overall_confidence_score,
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
if (output.findings.length > 0) {
|
|
252
|
+
const annotations = transformReviewFindings(output.findings, job.source, cwd, "Codex");
|
|
253
|
+
const result = externalAnnotations.addAnnotations({ annotations });
|
|
254
|
+
if ("error" in result) console.error(`[codex-review] addAnnotations error:`, result.error);
|
|
255
|
+
}
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (job.provider === "claude" && meta.stdout) {
|
|
260
|
+
const output = parseClaudeStreamOutput(meta.stdout);
|
|
261
|
+
if (!output) return;
|
|
262
|
+
|
|
263
|
+
const total = output.summary.important + output.summary.nit + output.summary.pre_existing;
|
|
264
|
+
job.summary = {
|
|
265
|
+
correctness: output.summary.important === 0 ? "Correct" : "Issues Found",
|
|
266
|
+
explanation: `${output.summary.important} important, ${output.summary.nit} nit, ${output.summary.pre_existing} pre-existing`,
|
|
267
|
+
confidence: total === 0 ? 1.0 : Math.max(0, 1.0 - (output.summary.important * 0.2)),
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
if (output.findings.length > 0) {
|
|
271
|
+
const annotations = transformClaudeFindings(output.findings, job.source, cwd);
|
|
272
|
+
const result = externalAnnotations.addAnnotations({ annotations });
|
|
273
|
+
if ("error" in result) console.error(`[claude-review] addAnnotations error:`, result.error);
|
|
274
|
+
}
|
|
275
|
+
return;
|
|
164
276
|
}
|
|
165
|
-
return options.gitContext?.cwd ?? process.cwd();
|
|
166
277
|
},
|
|
167
278
|
});
|
|
168
279
|
const sharingEnabled =
|
|
@@ -286,13 +397,7 @@ export async function startReviewServer(options: {
|
|
|
286
397
|
aiEndpoints = ai.createAIEndpoints({
|
|
287
398
|
registry,
|
|
288
399
|
sessionManager,
|
|
289
|
-
getCwd:
|
|
290
|
-
if (currentDiffType.startsWith("worktree:")) {
|
|
291
|
-
const parsed = parseWorktreeDiffType(currentDiffType);
|
|
292
|
-
if (parsed) return parsed.path;
|
|
293
|
-
}
|
|
294
|
-
return options.gitContext?.cwd ?? process.cwd();
|
|
295
|
-
},
|
|
400
|
+
getCwd: resolveAgentCwd,
|
|
296
401
|
});
|
|
297
402
|
aiSessionManager = sessionManager;
|
|
298
403
|
aiRegistry = registry;
|
|
@@ -309,19 +414,21 @@ export async function startReviewServer(options: {
|
|
|
309
414
|
rawPatch: currentPatch,
|
|
310
415
|
gitRef: currentGitRef,
|
|
311
416
|
origin: options.origin ?? "pi",
|
|
312
|
-
diffType:
|
|
313
|
-
gitContext:
|
|
417
|
+
diffType: hasLocalAccess ? currentDiffType : undefined,
|
|
418
|
+
gitContext: hasLocalAccess ? options.gitContext : undefined,
|
|
314
419
|
sharingEnabled,
|
|
315
420
|
shareBaseUrl,
|
|
316
421
|
repoInfo,
|
|
422
|
+
isWSL: wslFlag,
|
|
423
|
+
...(options.agentCwd && { agentCwd: options.agentCwd }),
|
|
317
424
|
...(isPRMode && { prMetadata: prMeta, platformUser }),
|
|
318
425
|
...(isPRMode && initialViewedFiles.length > 0 && { viewedFiles: initialViewedFiles }),
|
|
319
|
-
...(currentError
|
|
426
|
+
...(currentError && { error: currentError }),
|
|
320
427
|
serverConfig: getServerConfig(gitUser),
|
|
321
428
|
});
|
|
322
429
|
} else if (url.pathname === "/api/diff/switch" && req.method === "POST") {
|
|
323
|
-
if (
|
|
324
|
-
json(res, { error: "Not available
|
|
430
|
+
if (!hasLocalAccess) {
|
|
431
|
+
json(res, { error: "Not available without local file access" }, 400);
|
|
325
432
|
return;
|
|
326
433
|
}
|
|
327
434
|
try {
|
|
@@ -373,23 +480,21 @@ export async function startReviewServer(options: {
|
|
|
373
480
|
}
|
|
374
481
|
try {
|
|
375
482
|
const body = await parseBody(req);
|
|
483
|
+
const fileComments = (body.fileComments as PRReviewFileComment[]) || [];
|
|
484
|
+
console.error(`[pr-action] ${body.action} with ${fileComments.length} file comment(s), headSha=${prMeta.headSha}`);
|
|
376
485
|
await submitPRReview(
|
|
377
486
|
prRef,
|
|
378
487
|
prMeta.headSha,
|
|
379
488
|
body.action as "approve" | "comment",
|
|
380
489
|
body.body as string,
|
|
381
|
-
|
|
490
|
+
fileComments,
|
|
382
491
|
);
|
|
492
|
+
console.error(`[pr-action] Success`);
|
|
383
493
|
json(res, { ok: true, prUrl: prMeta.url });
|
|
384
494
|
} catch (err) {
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
error:
|
|
389
|
-
err instanceof Error ? err.message : "Failed to submit PR review",
|
|
390
|
-
},
|
|
391
|
-
500,
|
|
392
|
-
);
|
|
495
|
+
const message = err instanceof Error ? err.message : "Failed to submit PR review";
|
|
496
|
+
console.error(`[pr-action] Failed: ${message}`);
|
|
497
|
+
json(res, { error: message }, 500);
|
|
393
498
|
}
|
|
394
499
|
} else if (url.pathname === "/api/pr-viewed" && req.method === "POST") {
|
|
395
500
|
if (!isPRMode || !prMeta || !prRef) {
|
|
@@ -415,14 +520,9 @@ export async function startReviewServer(options: {
|
|
|
415
520
|
);
|
|
416
521
|
json(res, { ok: true });
|
|
417
522
|
} catch (err) {
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
error:
|
|
422
|
-
err instanceof Error ? err.message : "Failed to update viewed state",
|
|
423
|
-
},
|
|
424
|
-
500,
|
|
425
|
-
);
|
|
523
|
+
const message = err instanceof Error ? err.message : "Failed to update viewed state";
|
|
524
|
+
console.error("[plannotator] /api/pr-viewed error:", message);
|
|
525
|
+
json(res, { error: message }, 500);
|
|
426
526
|
}
|
|
427
527
|
} else if (url.pathname === "/api/file-content" && req.method === "GET") {
|
|
428
528
|
const filePath = url.searchParams.get("path");
|
|
@@ -446,10 +546,28 @@ export async function startReviewServer(options: {
|
|
|
446
546
|
}
|
|
447
547
|
}
|
|
448
548
|
|
|
549
|
+
// Local mode first (matches Bun server priority)
|
|
550
|
+
if (hasLocalAccess && !isPRMode) {
|
|
551
|
+
const defaultBranch = options.gitContext?.defaultBranch || "main";
|
|
552
|
+
const defaultCwd = options.gitContext?.cwd;
|
|
553
|
+
const result = await getFileContentsForDiffCore(
|
|
554
|
+
reviewRuntime,
|
|
555
|
+
currentDiffType,
|
|
556
|
+
defaultBranch,
|
|
557
|
+
filePath,
|
|
558
|
+
oldPath,
|
|
559
|
+
defaultCwd,
|
|
560
|
+
);
|
|
561
|
+
json(res, result);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// PR mode: fetch from platform API using merge-base/head SHAs
|
|
449
566
|
if (isPRMode && prRef && prMeta) {
|
|
450
567
|
try {
|
|
568
|
+
const oldSha = prMeta.mergeBaseSha ?? prMeta.baseSha;
|
|
451
569
|
const [oldContent, newContent] = await Promise.all([
|
|
452
|
-
fetchPRFileContent(prRef,
|
|
570
|
+
fetchPRFileContent(prRef, oldSha, oldPath || filePath),
|
|
453
571
|
fetchPRFileContent(prRef, prMeta.headSha, filePath),
|
|
454
572
|
]);
|
|
455
573
|
json(res, { oldContent, newContent });
|
|
@@ -468,17 +586,7 @@ export async function startReviewServer(options: {
|
|
|
468
586
|
return;
|
|
469
587
|
}
|
|
470
588
|
|
|
471
|
-
|
|
472
|
-
const defaultCwd = options.gitContext?.cwd;
|
|
473
|
-
const result = await getFileContentsForDiffCore(
|
|
474
|
-
reviewRuntime,
|
|
475
|
-
currentDiffType,
|
|
476
|
-
defaultBranch,
|
|
477
|
-
filePath,
|
|
478
|
-
oldPath,
|
|
479
|
-
defaultCwd,
|
|
480
|
-
);
|
|
481
|
-
json(res, result);
|
|
589
|
+
json(res, { error: "No file access available" }, 400);
|
|
482
590
|
} else if (url.pathname === "/api/config" && req.method === "POST") {
|
|
483
591
|
try {
|
|
484
592
|
const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record<string, unknown> };
|
|
@@ -497,8 +605,14 @@ export async function startReviewServer(options: {
|
|
|
497
605
|
} else if (url.pathname === "/api/agents" && req.method === "GET") {
|
|
498
606
|
json(res, { agents: [] });
|
|
499
607
|
} else if (url.pathname === "/api/git-add" && req.method === "POST") {
|
|
500
|
-
|
|
501
|
-
|
|
608
|
+
// Staging only available for local diff types that support it (not PR mode, not branch diffs).
|
|
609
|
+
// Worktree diff types use composite format "worktree:/path:uncommitted" — extract the base type.
|
|
610
|
+
const baseDiffType = currentDiffType.startsWith("worktree:")
|
|
611
|
+
? (parseWorktreeDiffType(currentDiffType)?.subType ?? currentDiffType)
|
|
612
|
+
: currentDiffType;
|
|
613
|
+
const canStage = baseDiffType === "uncommitted" || baseDiffType === "unstaged";
|
|
614
|
+
if (isPRMode || !canStage) {
|
|
615
|
+
json(res, { error: "Staging not available" }, 400);
|
|
502
616
|
return;
|
|
503
617
|
}
|
|
504
618
|
try {
|
|
@@ -524,7 +638,7 @@ export async function startReviewServer(options: {
|
|
|
524
638
|
json(res, { ok: true });
|
|
525
639
|
} catch (err) {
|
|
526
640
|
const message =
|
|
527
|
-
err instanceof Error ? err.message : "Failed to
|
|
641
|
+
err instanceof Error ? err.message : "Failed to stage file";
|
|
528
642
|
json(res, { error: message }, 500);
|
|
529
643
|
}
|
|
530
644
|
} else if (url.pathname === "/api/draft") {
|
|
@@ -590,10 +704,15 @@ export async function startReviewServer(options: {
|
|
|
590
704
|
const exitHandler = () => agentJobs.killAll();
|
|
591
705
|
process.once("exit", exitHandler);
|
|
592
706
|
|
|
707
|
+
if (options.onReady) {
|
|
708
|
+
options.onReady(serverUrl, isRemote, port);
|
|
709
|
+
}
|
|
710
|
+
|
|
593
711
|
return {
|
|
594
712
|
port,
|
|
595
713
|
portSource,
|
|
596
714
|
url: serverUrl,
|
|
715
|
+
isRemote,
|
|
597
716
|
waitForDecision: () => decisionPromise,
|
|
598
717
|
stop: () => {
|
|
599
718
|
process.removeListener("exit", exitHandler);
|
|
@@ -601,6 +720,13 @@ export async function startReviewServer(options: {
|
|
|
601
720
|
aiSessionManager?.disposeAll();
|
|
602
721
|
aiRegistry?.disposeAll();
|
|
603
722
|
server.close();
|
|
723
|
+
// Invoke cleanup callback (e.g., remove temp worktree)
|
|
724
|
+
if (options.onCleanup) {
|
|
725
|
+
try {
|
|
726
|
+
const result = options.onCleanup();
|
|
727
|
+
if (result instanceof Promise) result.catch(() => {});
|
|
728
|
+
} catch { /* best effort */ }
|
|
729
|
+
}
|
|
604
730
|
},
|
|
605
731
|
};
|
|
606
732
|
}
|