@bugabinga/pi-ext-diff-review 0.1.0
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/CHANGELOG.md +14 -0
- package/PLAN.md +770 -0
- package/README.md +16 -0
- package/args.ts +101 -0
- package/assets/workflow_suite.gif +0 -0
- package/browser/assets/JetBrainsMonoNuguCode.LICENSE +96 -0
- package/browser/assets/JetBrainsMonoNuguCode.woff2 +0 -0
- package/browser/index.html +119 -0
- package/browser/index.ts +1184 -0
- package/browser/shadow-css.ts +179 -0
- package/browser/style.css +772 -0
- package/browser/theme.ts +49 -0
- package/bun.lock +407 -0
- package/bundle.ts +75 -0
- package/constants.ts +14 -0
- package/format.ts +74 -0
- package/git.ts +299 -0
- package/index.ts +157 -0
- package/open-browser.ts +39 -0
- package/package.json +24 -0
- package/scripts/browser-regression.ts +206 -0
- package/scripts/build-browser.ts +56 -0
- package/scripts/smoke-browser.ts +268 -0
- package/server.ts +361 -0
- package/session-state.ts +37 -0
- package/types.ts +130 -0
package/format.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { CollectedDiff, Finding, ReviewRoundEntry } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export function formatReviewMarkdown(round: ReviewRoundEntry): string {
|
|
4
|
+
const lines: string[] = [`# Diff Review Round ${round.round}`, ""];
|
|
5
|
+
if (round.diffSummary.skippedFiles.length > 0) lines.push("## Skipped Files", ...round.diffSummary.skippedFiles.map((file) => `- ${file}`), "");
|
|
6
|
+
if (round.findings.length === 0) {
|
|
7
|
+
lines.push("No findings recorded.", "", "Use this review result to decide whether any follow-up is needed. Do not edit files yet.");
|
|
8
|
+
return lines.join("\n");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
lines.push("## Findings");
|
|
12
|
+
for (const [index, finding] of round.findings.entries()) lines.push(formatFinding(finding, index + 1));
|
|
13
|
+
if (round.notes?.trim()) lines.push("", "## Notes", round.notes.trim());
|
|
14
|
+
lines.push("", "Use this review to propose and discuss a fix plan first. Do not edit files yet.");
|
|
15
|
+
return lines.join("\n");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function makeReviewRound(round: number, diff: CollectedDiff, findings: Finding[], notes?: string): ReviewRoundEntry {
|
|
19
|
+
return {
|
|
20
|
+
version: 1,
|
|
21
|
+
kind: "round",
|
|
22
|
+
round,
|
|
23
|
+
source: diff.source,
|
|
24
|
+
findings,
|
|
25
|
+
...(notes?.trim() && { notes: notes.trim() }),
|
|
26
|
+
diffSummary: {
|
|
27
|
+
filesChanged: diff.files.length,
|
|
28
|
+
additions: diff.additions,
|
|
29
|
+
deletions: diff.deletions,
|
|
30
|
+
skippedFiles: diff.skippedFiles,
|
|
31
|
+
},
|
|
32
|
+
createdAt: new Date().toISOString(),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function formatFinding(finding: Finding, index: number): string {
|
|
37
|
+
const range = finding.startLine === finding.endLine ? `${finding.startLine}` : `${finding.startLine}-${finding.endLine}`;
|
|
38
|
+
const location = `${finding.file}:${range}`;
|
|
39
|
+
const lineSemantics =
|
|
40
|
+
finding.side === "additions"
|
|
41
|
+
? "file line number on new/additions side (current file)"
|
|
42
|
+
: "file line number on old/deletions side (preimage; may not exist in working tree)";
|
|
43
|
+
const context = [finding.anchor.before, finding.anchor.selected, finding.anchor.after].filter(Boolean).join("\n");
|
|
44
|
+
const lines = [
|
|
45
|
+
`### Finding ${index}: [${finding.severity}][${finding.category}] ${location} ${finding.side}`,
|
|
46
|
+
"",
|
|
47
|
+
`- Location: \`${location}\``,
|
|
48
|
+
`- Side: \`${finding.side}\``,
|
|
49
|
+
`- Line semantics: ${lineSemantics}`,
|
|
50
|
+
`- Comment: ${finding.comment}`,
|
|
51
|
+
];
|
|
52
|
+
if (finding.notes?.trim()) lines.push(`- Notes: ${finding.notes.trim()}`);
|
|
53
|
+
lines.push("", "Selected code:", fencedText(finding.anchor.selected));
|
|
54
|
+
if (context.trim()) lines.push("", "Nearby context:", fencedText(context));
|
|
55
|
+
return lines.join("\n");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function fencedText(text: string): string {
|
|
59
|
+
const fence = longestBacktickRun(text) >= 3 ? "`".repeat(longestBacktickRun(text) + 1) : "```";
|
|
60
|
+
return `${fence}text\n${text}\n${fence}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function longestBacktickRun(text: string): number {
|
|
64
|
+
let longest = 0;
|
|
65
|
+
let current = 0;
|
|
66
|
+
for (const char of text) {
|
|
67
|
+
if (char === "`") current++;
|
|
68
|
+
else {
|
|
69
|
+
longest = Math.max(longest, current);
|
|
70
|
+
current = 0;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return Math.max(longest, current);
|
|
74
|
+
}
|
package/git.ts
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import { MAX_DIFF_BYTES, MAX_FILE_DIFF_BYTES } from "./constants.js";
|
|
5
|
+
import { sourceFromArgs, type DiffReviewArgs } from "./args.js";
|
|
6
|
+
import type { CollectedDiff, DiffFileSummary, DiffFileStatus, DiffIndex, IndexedFileDiff } from "./types.js";
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
const DEFAULT_GIT_BUFFER_BYTES = 10 * 1024 * 1024;
|
|
10
|
+
const STDERR_BUFFER_BYTES = 64 * 1024;
|
|
11
|
+
const HUNK_HEADER = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/;
|
|
12
|
+
const DIFF_GIT_HEADER = /^diff --git a\/(.*) b\/(.*)$/;
|
|
13
|
+
|
|
14
|
+
export async function collectDiff(cwd: string, args: DiffReviewArgs): Promise<CollectedDiff> {
|
|
15
|
+
const repoRoot = (await git(cwd, ["rev-parse", "--show-toplevel"])).trim();
|
|
16
|
+
const repoArgs = rebasePathArgs(args, repoRoot, cwd);
|
|
17
|
+
const source = sourceFromArgs(repoArgs);
|
|
18
|
+
const pieces = await collectRenderableDiff(repoRoot, repoArgs);
|
|
19
|
+
const parsed = parseUnifiedDiff(pieces.diffText);
|
|
20
|
+
const skippedFiles = [...parsed.skippedFiles, ...pieces.skippedFiles];
|
|
21
|
+
if (!pieces.diffText.trim() && skippedFiles.length > 0) throw new Error(`No renderable diff; skipped oversized/binary files: ${skippedFiles.join(", ")}`);
|
|
22
|
+
return { repoRoot, source, diffText: pieces.diffText, ...parsed, skippedFiles };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function rebasePathArgs(args: DiffReviewArgs, repoRoot: string, cwd: string): DiffReviewArgs {
|
|
26
|
+
if (!args.files) return args;
|
|
27
|
+
const prefix = relative(repoRoot, cwd).replace(/\\/g, "/");
|
|
28
|
+
return { ...args, files: args.files.map((file) => prefix ? join(prefix, file).replace(/\\/g, "/") : file) };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function collectRenderableDiff(cwd: string, args: DiffReviewArgs) {
|
|
32
|
+
const files = await changedFiles(cwd, args);
|
|
33
|
+
const chunks: string[] = [];
|
|
34
|
+
const skippedFiles: string[] = [];
|
|
35
|
+
let totalBytes = 0;
|
|
36
|
+
|
|
37
|
+
for (const file of files) {
|
|
38
|
+
try {
|
|
39
|
+
const chunk = await gitDiff(cwd, diffArgs(args, [file]), MAX_FILE_DIFF_BYTES);
|
|
40
|
+
const bytes = Buffer.byteLength(chunk, "utf8");
|
|
41
|
+
if (totalBytes + bytes > MAX_DIFF_BYTES) {
|
|
42
|
+
skippedFiles.push(`${file} (review diff limit)`);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
totalBytes += bytes;
|
|
46
|
+
chunks.push(chunk);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
if (err instanceof DiffTooLargeError) skippedFiles.push(`${file} (${Math.round(MAX_FILE_DIFF_BYTES / 1024 / 1024)} MiB per-file limit)`);
|
|
49
|
+
else throw err;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { diffText: chunks.join(""), skippedFiles };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function changedFiles(cwd: string, args: DiffReviewArgs) {
|
|
57
|
+
const output = await git(cwd, nameOnlyArgs(args), { maxBuffer: DEFAULT_GIT_BUFFER_BYTES });
|
|
58
|
+
return output.split("\0").filter(Boolean);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function diffArgs(args: DiffReviewArgs, files = args.files): string[] {
|
|
62
|
+
const out = baseDiffArgs(args);
|
|
63
|
+
out.push("--");
|
|
64
|
+
if (files) out.push(...files);
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function nameOnlyArgs(args: DiffReviewArgs): string[] {
|
|
69
|
+
const out = [...baseDiffArgs(args), "--name-only", "-z", "--"];
|
|
70
|
+
if (args.files) out.push(...args.files);
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function baseDiffArgs(args: DiffReviewArgs): string[] {
|
|
75
|
+
const out = ["diff", "--no-ext-diff", "--find-renames", "--src-prefix=a/", "--dst-prefix=b/", "--unified=3"];
|
|
76
|
+
if (args.staged) out.push("--cached");
|
|
77
|
+
if (args.base) out.push(`${args.base}...HEAD`);
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function git(cwd: string, args: string[], options: { maxBuffer?: number } = {}): Promise<string> {
|
|
82
|
+
try {
|
|
83
|
+
const { stdout } = await execFileAsync("git", args, { cwd, maxBuffer: options.maxBuffer ?? DEFAULT_GIT_BUFFER_BYTES });
|
|
84
|
+
return String(stdout);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
throw new Error(gitError(err));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function gitDiff(cwd: string, args: string[], maxBytes: number): Promise<string> {
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
93
|
+
const stdout: Buffer[] = [];
|
|
94
|
+
const stderr: Buffer[] = [];
|
|
95
|
+
let stdoutBytes = 0;
|
|
96
|
+
let stderrBytes = 0;
|
|
97
|
+
let settled = false;
|
|
98
|
+
|
|
99
|
+
const resolveOnce = (text: string) => {
|
|
100
|
+
if (settled) return;
|
|
101
|
+
settled = true;
|
|
102
|
+
resolve(text);
|
|
103
|
+
};
|
|
104
|
+
const rejectOnce = (error: Error) => {
|
|
105
|
+
if (settled) return;
|
|
106
|
+
settled = true;
|
|
107
|
+
reject(error);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
111
|
+
stdoutBytes += chunk.length;
|
|
112
|
+
if (stdoutBytes > maxBytes) {
|
|
113
|
+
child.kill("SIGTERM");
|
|
114
|
+
rejectOnce(new DiffTooLargeError(maxBytes));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
stdout.push(chunk);
|
|
118
|
+
});
|
|
119
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
120
|
+
if (stderrBytes >= STDERR_BUFFER_BYTES) return;
|
|
121
|
+
const remaining = STDERR_BUFFER_BYTES - stderrBytes;
|
|
122
|
+
stderr.push(chunk.subarray(0, remaining));
|
|
123
|
+
stderrBytes += Math.min(chunk.length, remaining);
|
|
124
|
+
});
|
|
125
|
+
child.once("error", rejectOnce);
|
|
126
|
+
child.once("close", (code) => {
|
|
127
|
+
if (settled) return;
|
|
128
|
+
if (code === 0) resolveOnce(Buffer.concat(stdout).toString("utf8"));
|
|
129
|
+
else rejectOnce(new Error(Buffer.concat(stderr).toString("utf8").trim() || `git diff exited with code ${code ?? "unknown"}`));
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function gitError(err: unknown) {
|
|
135
|
+
const e = err as { stderr?: string | Buffer; message?: string };
|
|
136
|
+
return String(e.stderr ?? "").trim() || e.message || "git failed";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function parseUnifiedDiff(diffText: string): Omit<CollectedDiff, "repoRoot" | "source" | "diffText"> {
|
|
140
|
+
const state: DiffParserState = {
|
|
141
|
+
diffIndex: new Map(),
|
|
142
|
+
files: [],
|
|
143
|
+
skippedFiles: [],
|
|
144
|
+
oldLine: 0,
|
|
145
|
+
newLine: 0,
|
|
146
|
+
inHunk: false,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
for (const line of diffText.split(/\r?\n/)) parseDiffLine(state, line);
|
|
150
|
+
finishFile(state);
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
files: state.files,
|
|
154
|
+
diffIndex: state.diffIndex,
|
|
155
|
+
skippedFiles: state.skippedFiles,
|
|
156
|
+
additions: sumChanges(state.files, "additions"),
|
|
157
|
+
deletions: sumChanges(state.files, "deletions"),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
type DiffParserState = {
|
|
162
|
+
current?: IndexedFileDiff;
|
|
163
|
+
diffIndex: DiffIndex;
|
|
164
|
+
files: DiffFileSummary[];
|
|
165
|
+
skippedFiles: string[];
|
|
166
|
+
oldLine: number;
|
|
167
|
+
newLine: number;
|
|
168
|
+
inHunk: boolean;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
function parseDiffLine(state: DiffParserState, line: string) {
|
|
172
|
+
if (line.startsWith("diff --git ")) return startFile(state, line);
|
|
173
|
+
if (!state.current) return;
|
|
174
|
+
if (updateFileMetadata(state.current, line)) return;
|
|
175
|
+
if (updateHeaderPath(state.current, line)) return;
|
|
176
|
+
if (startHunk(state, line)) return;
|
|
177
|
+
if (!state.inHunk) return;
|
|
178
|
+
applyHunkLine(state, line);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function startFile(state: DiffParserState, line: string) {
|
|
182
|
+
finishFile(state);
|
|
183
|
+
const { oldPath, newPath } = parseDiffGitLine(line);
|
|
184
|
+
state.current = { file: newPath, prevFile: oldPath, status: "modified", additions: new Map(), deletions: new Map(), oldLines: new Map(), newLines: new Map() };
|
|
185
|
+
state.oldLine = 0;
|
|
186
|
+
state.newLine = 0;
|
|
187
|
+
state.inHunk = false;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function finishFile(state: DiffParserState) {
|
|
191
|
+
const file = state.current;
|
|
192
|
+
if (!file) return;
|
|
193
|
+
if (file.status === "binary") state.skippedFiles.push(file.file);
|
|
194
|
+
state.diffIndex.set(file.file, file);
|
|
195
|
+
state.files.push(summary(file));
|
|
196
|
+
state.current = undefined;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function summary(file: IndexedFileDiff): DiffFileSummary {
|
|
200
|
+
return {
|
|
201
|
+
path: file.file,
|
|
202
|
+
...(file.prevFile && file.prevFile !== file.file && { prevPath: file.prevFile }),
|
|
203
|
+
status: file.status,
|
|
204
|
+
additions: file.additions.size,
|
|
205
|
+
deletions: file.deletions.size,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function updateFileMetadata(file: IndexedFileDiff, line: string) {
|
|
210
|
+
const status = statusFromMetadata(line, file.status);
|
|
211
|
+
if (!status) return false;
|
|
212
|
+
file.status = status;
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function statusFromMetadata(line: string, current: DiffFileStatus): DiffFileStatus | undefined {
|
|
217
|
+
if (line.startsWith("new file mode ")) return "added";
|
|
218
|
+
if (line.startsWith("deleted file mode ")) return "deleted";
|
|
219
|
+
if (line.startsWith("Binary files ")) return "binary";
|
|
220
|
+
if (line.startsWith("similarity index ") || line.startsWith("rename from ") || line.startsWith("rename to ")) return current === "added" || current === "deleted" ? current : "renamed";
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function updateHeaderPath(file: IndexedFileDiff, line: string) {
|
|
225
|
+
if (line.startsWith("--- ")) {
|
|
226
|
+
const path = parseHeaderPath(line.slice(4));
|
|
227
|
+
if (path && path !== "/dev/null") file.prevFile = path;
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
if (line.startsWith("+++ ")) {
|
|
231
|
+
const path = parseHeaderPath(line.slice(4));
|
|
232
|
+
if (path && path !== "/dev/null") file.file = path;
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function startHunk(state: DiffParserState, line: string) {
|
|
239
|
+
const match = HUNK_HEADER.exec(line);
|
|
240
|
+
if (!match) return false;
|
|
241
|
+
state.oldLine = Number(match[1]);
|
|
242
|
+
state.newLine = Number(match[2]);
|
|
243
|
+
state.inHunk = true;
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function applyHunkLine(state: DiffParserState, line: string) {
|
|
248
|
+
const file = state.current;
|
|
249
|
+
if (!file || line.startsWith("\\")) return;
|
|
250
|
+
if (line.startsWith("+")) return addLine(file, state, line);
|
|
251
|
+
if (line.startsWith("-")) return deleteLine(file, state, line);
|
|
252
|
+
if (line.startsWith(" ")) return contextLine(state, line);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function addLine(file: IndexedFileDiff, state: DiffParserState, line: string) {
|
|
256
|
+
const text = line.slice(1);
|
|
257
|
+
file.additions.set(state.newLine, text);
|
|
258
|
+
file.newLines.set(state.newLine, text);
|
|
259
|
+
state.newLine++;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function deleteLine(file: IndexedFileDiff, state: DiffParserState, line: string) {
|
|
263
|
+
const text = line.slice(1);
|
|
264
|
+
file.deletions.set(state.oldLine, text);
|
|
265
|
+
file.oldLines.set(state.oldLine, text);
|
|
266
|
+
state.oldLine++;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function contextLine(state: DiffParserState, line: string) {
|
|
270
|
+
if (state.current) {
|
|
271
|
+
const text = line.slice(1);
|
|
272
|
+
state.current.oldLines.set(state.oldLine, text);
|
|
273
|
+
state.current.newLines.set(state.newLine, text);
|
|
274
|
+
}
|
|
275
|
+
state.oldLine++;
|
|
276
|
+
state.newLine++;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function parseDiffGitLine(line: string) {
|
|
280
|
+
const match = DIFF_GIT_HEADER.exec(line);
|
|
281
|
+
return { oldPath: match?.[1] ?? "", newPath: match?.[2] ?? match?.[1] ?? "" };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function parseHeaderPath(header: string): string | undefined {
|
|
285
|
+
const trimmed = header.trim();
|
|
286
|
+
if (trimmed === "/dev/null") return trimmed;
|
|
287
|
+
if (trimmed.startsWith("a/") || trimmed.startsWith("b/")) return trimmed.slice(2);
|
|
288
|
+
return trimmed.split(/\s+/)[0];
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function sumChanges(files: DiffFileSummary[], key: "additions" | "deletions") {
|
|
292
|
+
return files.reduce((sum, file) => sum + file[key], 0);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
class DiffTooLargeError extends Error {
|
|
296
|
+
constructor(readonly maxBytes: number) {
|
|
297
|
+
super(`diff exceeds ${Math.round(maxBytes / 1024 / 1024)} MiB limit`);
|
|
298
|
+
}
|
|
299
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { CANCEL_COMMAND, REVIEW_COMMAND, STATUS_ID } from "./constants.js";
|
|
4
|
+
import { parseArgs, type DiffReviewArgs } from "./args.js";
|
|
5
|
+
import { ensureBrowserBundle } from "./bundle.js";
|
|
6
|
+
import { collectDiff } from "./git.js";
|
|
7
|
+
import { openBrowser } from "./open-browser.js";
|
|
8
|
+
import { formatReviewMarkdown, makeReviewRound } from "./format.js";
|
|
9
|
+
import { appendLiveReview, appendReviewRound, openFindings, restoreReviewState } from "./session-state.js";
|
|
10
|
+
import { startReviewServer, type ReviewServer } from "./server.js";
|
|
11
|
+
import type { ReviewLiveEntry, ReviewState } from "./types.js";
|
|
12
|
+
|
|
13
|
+
export default function diffReviewExtension(pi: ExtensionAPI) {
|
|
14
|
+
let state: ReviewState = { round: 0, findings: [], rounds: [] };
|
|
15
|
+
let active: ReviewServer | undefined;
|
|
16
|
+
let restoring: Promise<void> | undefined;
|
|
17
|
+
|
|
18
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
19
|
+
state = restoreReviewState(ctx);
|
|
20
|
+
const open = openFindings(state).length;
|
|
21
|
+
if (open > 0 && ctx.hasUI) ctx.ui.setStatus(STATUS_ID, `${open} open review finding${open === 1 ? "" : "s"}`);
|
|
22
|
+
if (state.live && ctx.hasUI) restoring = restoreLiveReview(ctx, state.live).finally(() => { restoring = undefined; });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
pi.on("session_shutdown", async () => {
|
|
26
|
+
active?.close("shutdown");
|
|
27
|
+
active = undefined;
|
|
28
|
+
restoring = undefined;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
pi.registerCommand(REVIEW_COMMAND, {
|
|
32
|
+
description: "Open browser UI to review current git diff and send findings back to Pi",
|
|
33
|
+
handler: async (rawArgs, ctx) => {
|
|
34
|
+
if (!ctx.hasUI) return ctx.ui.notify("/diff-review requires interactive mode", "error");
|
|
35
|
+
if (restoring) await restoring;
|
|
36
|
+
state = restoreReviewState(ctx);
|
|
37
|
+
if (active) {
|
|
38
|
+
ctx.ui.notify(`diff-review already active. Refresh existing browser tab. URL: ${active.url}`, "info");
|
|
39
|
+
ctx.ui.setStatus(STATUS_ID, `active ${active.url}`);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (state.live) {
|
|
43
|
+
restoring = restoreLiveReview(ctx, state.live).finally(() => { restoring = undefined; });
|
|
44
|
+
await restoring;
|
|
45
|
+
if (active) {
|
|
46
|
+
ctx.ui.notify(`diff-review restored. Refresh existing browser tab. URL: ${active.url}`, "info");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
await ctx.waitForIdle();
|
|
52
|
+
|
|
53
|
+
let args: DiffReviewArgs;
|
|
54
|
+
try {
|
|
55
|
+
args = parseArgs(rawArgs);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
ctx.ui.notify((err as Error).message, "error");
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
await ensureBrowserBundle((message) => ctx.ui.setStatus(STATUS_ID, message));
|
|
63
|
+
|
|
64
|
+
ctx.ui.setStatus(STATUS_ID, "collecting diff");
|
|
65
|
+
const diff = await collectDiff(ctx.cwd, args);
|
|
66
|
+
if (!diff.diffText.trim()) {
|
|
67
|
+
ctx.ui.setStatus(STATUS_ID, undefined);
|
|
68
|
+
ctx.ui.notify("No diff to review", "info");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const live = makeLiveEntry(state.round + 1, args);
|
|
73
|
+
appendLiveReview(ctx, live);
|
|
74
|
+
state = restoreReviewState(ctx);
|
|
75
|
+
const server = await launchReviewServer(pi, ctx, live, state, false);
|
|
76
|
+
active = server;
|
|
77
|
+
ctx.ui.setStatus(STATUS_ID, `live ${server.url}`);
|
|
78
|
+
ctx.ui.notify(`Diff review live: ${server.url}`, "info");
|
|
79
|
+
|
|
80
|
+
const opened = await openBrowser(server.url);
|
|
81
|
+
if (!opened.ok) ctx.ui.notify(`Could not open browser automatically. Open manually: ${server.url}`, "warning");
|
|
82
|
+
} catch (err) {
|
|
83
|
+
active?.close("command");
|
|
84
|
+
active = undefined;
|
|
85
|
+
ctx.ui.setStatus(STATUS_ID, undefined);
|
|
86
|
+
ctx.ui.notify(`diff-review failed: ${(err as Error).message}`, "error");
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
pi.registerCommand(CANCEL_COMMAND, {
|
|
92
|
+
description: "Cancel active /diff-review browser session",
|
|
93
|
+
handler: async (_args, ctx) => {
|
|
94
|
+
if (!active) return ctx.ui.notify("No active diff-review", "info");
|
|
95
|
+
active.close("command");
|
|
96
|
+
active = undefined;
|
|
97
|
+
if (state.live) appendLiveReview(ctx, { ...state.live, status: "closed", reason: "command" });
|
|
98
|
+
state = restoreReviewState(ctx);
|
|
99
|
+
ctx.ui.setStatus(STATUS_ID, undefined);
|
|
100
|
+
ctx.ui.notify("diff-review cancelled", "info");
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
async function restoreLiveReview(ctx: ExtensionContext, live: ReviewLiveEntry) {
|
|
105
|
+
if (active) return;
|
|
106
|
+
try {
|
|
107
|
+
ctx.ui.setStatus(STATUS_ID, "restoring browser review");
|
|
108
|
+
await ensureBrowserBundle((message) => ctx.ui.setStatus(STATUS_ID, message));
|
|
109
|
+
active = await launchReviewServer(pi, ctx, live, state, true);
|
|
110
|
+
ctx.ui.setStatus(STATUS_ID, `restored ${active.url}`);
|
|
111
|
+
ctx.ui.notify(`diff-review restored. Refresh existing tab or run /${REVIEW_COMMAND} to reopen.`, "info");
|
|
112
|
+
} catch (err) {
|
|
113
|
+
ctx.ui.notify(`diff-review reconnect failed: ${(err as Error).message}`, "warning");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function launchReviewServer(pi: ExtensionAPI, ctx: ExtensionContext, live: ReviewLiveEntry, currentState: ReviewState, restored: boolean) {
|
|
118
|
+
const args = live.args;
|
|
119
|
+
const diff = await collectDiff(ctx.cwd, args);
|
|
120
|
+
const server = await startReviewServer({
|
|
121
|
+
diff,
|
|
122
|
+
state: currentState,
|
|
123
|
+
round: live.round,
|
|
124
|
+
token: live.token,
|
|
125
|
+
refreshDiff: () => collectDiff(ctx.cwd, args),
|
|
126
|
+
onSubmit: (result, latestDiff) => {
|
|
127
|
+
const entry = makeReviewRound(live.round, latestDiff, result.findings, result.notes);
|
|
128
|
+
appendReviewRound(ctx, entry);
|
|
129
|
+
state = restoreReviewState(ctx);
|
|
130
|
+
const markdown = formatReviewMarkdown(entry);
|
|
131
|
+
ctx.ui.notify(`diff review sent to Pi: ${result.findings.length} finding${result.findings.length === 1 ? "" : "s"}`, "info");
|
|
132
|
+
pi.sendUserMessage([{ type: "text", text: markdown }]);
|
|
133
|
+
},
|
|
134
|
+
onHeartbeatLost: () => ctx.ui.notify(restored ? "diff-review browser still disconnected" : "diff-review browser closed", "info"),
|
|
135
|
+
});
|
|
136
|
+
server.result.then((result) => {
|
|
137
|
+
if (active?.id === server.id) active = undefined;
|
|
138
|
+
ctx.ui.setStatus(STATUS_ID, undefined);
|
|
139
|
+
if (result.reason !== "shutdown") appendLiveReview(ctx, { ...live, status: "closed", reason: result.reason });
|
|
140
|
+
ctx.ui.notify(`/diff-review ended (${result.reason})`, result.reason === "timeout" ? "warning" : "info");
|
|
141
|
+
});
|
|
142
|
+
return server;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function makeLiveEntry(round: number, args: DiffReviewArgs): ReviewLiveEntry {
|
|
147
|
+
return {
|
|
148
|
+
version: 1,
|
|
149
|
+
kind: "live",
|
|
150
|
+
id: randomUUID(),
|
|
151
|
+
token: randomBytes(32).toString("base64url"),
|
|
152
|
+
round,
|
|
153
|
+
args,
|
|
154
|
+
status: "open",
|
|
155
|
+
createdAt: new Date().toISOString(),
|
|
156
|
+
};
|
|
157
|
+
}
|
package/open-browser.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export async function openBrowser(url: string): Promise<{ ok: boolean; command?: string; error?: string }> {
|
|
4
|
+
const candidates = openerCandidates(url);
|
|
5
|
+
for (const candidate of candidates) {
|
|
6
|
+
const result = await trySpawn(candidate.command, candidate.args);
|
|
7
|
+
if (result.ok) return { ok: true, command: candidate.command };
|
|
8
|
+
}
|
|
9
|
+
return { ok: false, error: "No browser opener found" };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function openerCandidates(url: string): Array<{ command: string; args: string[] }> {
|
|
13
|
+
if (process.env.TERMUX_VERSION) return [{ command: "termux-open-url", args: [url] }];
|
|
14
|
+
if (process.platform === "darwin") return [{ command: "open", args: [url] }];
|
|
15
|
+
if (process.platform === "win32") return [{ command: "cmd.exe", args: ["/c", "start", "", url] }];
|
|
16
|
+
return [
|
|
17
|
+
{ command: "xdg-open", args: [url] },
|
|
18
|
+
{ command: "wslview", args: [url] },
|
|
19
|
+
{ command: "gio", args: ["open", url] },
|
|
20
|
+
];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function trySpawn(command: string, args: string[]): Promise<{ ok: boolean; error?: string }> {
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
26
|
+
let settled = false;
|
|
27
|
+
const done = (ok: boolean, error?: string) => {
|
|
28
|
+
if (settled) return;
|
|
29
|
+
settled = true;
|
|
30
|
+
resolve({ ok, error });
|
|
31
|
+
};
|
|
32
|
+
child.once("error", (err) => done(false, err.message));
|
|
33
|
+
child.once("spawn", () => {
|
|
34
|
+
child.unref();
|
|
35
|
+
done(true);
|
|
36
|
+
});
|
|
37
|
+
setTimeout(() => done(false, "timeout"), 1500).unref();
|
|
38
|
+
});
|
|
39
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bugabinga/pi-ext-diff-review",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "index.ts",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"build": "bun ./scripts/build-browser.ts",
|
|
8
|
+
"smoke:browser": "bun ./scripts/smoke-browser.ts",
|
|
9
|
+
"test:browser": "bun ./scripts/browser-regression.ts"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@pierre/diffs": "^1.1.22",
|
|
13
|
+
"@pierre/trees": "^1.0.0-beta.4"
|
|
14
|
+
},
|
|
15
|
+
"peerDependencies": {
|
|
16
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
17
|
+
},
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"description": "Browser-based git diff review bridge for Pi.",
|
|
20
|
+
"keywords": [
|
|
21
|
+
"pi",
|
|
22
|
+
"pi-extension"
|
|
23
|
+
]
|
|
24
|
+
}
|