@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/server.ts
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { stat } from "node:fs/promises";
|
|
4
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
5
|
+
import { extname, resolve, sep } from "node:path";
|
|
6
|
+
import { ANCHOR_CONTEXT_LINES, HEARTBEAT_CHECK_INTERVAL_MS, HEARTBEAT_LOST_MS, REQUEST_BODY_MAX_BYTES, REVIEW_PORT, TOKEN_COOKIE } from "./constants.js";
|
|
7
|
+
import { staticDir } from "./bundle.js";
|
|
8
|
+
import type { BrowserResult, CollectedDiff, Finding, FindingDraft, ReviewCategory, ReviewPayload, ReviewSeverity, ReviewSide, ReviewState } from "./types.js";
|
|
9
|
+
|
|
10
|
+
export type ReviewServer = {
|
|
11
|
+
id: string;
|
|
12
|
+
url: string;
|
|
13
|
+
result: Promise<Extract<BrowserResult, { type: "cancel" }>>;
|
|
14
|
+
close(reason: CancelReason): void;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type CancelReason = Extract<BrowserResult, { type: "cancel" }>["reason"];
|
|
18
|
+
type StartServerOptions = {
|
|
19
|
+
diff: CollectedDiff;
|
|
20
|
+
state: ReviewState;
|
|
21
|
+
round: number;
|
|
22
|
+
token?: string;
|
|
23
|
+
timeoutMs?: number;
|
|
24
|
+
refreshDiff?: () => Promise<CollectedDiff>;
|
|
25
|
+
onSubmit?: (result: Extract<BrowserResult, { type: "submit" }>, diff: CollectedDiff) => Promise<void> | void;
|
|
26
|
+
onHeartbeatLost?: () => void;
|
|
27
|
+
};
|
|
28
|
+
type RouteContext = StartServerOptions & { token: string; diff: CollectedDiff; finish(event: ServerEvent): void };
|
|
29
|
+
type ServerEvent = Extract<BrowserResult, { type: "cancel" }> | "heartbeat";
|
|
30
|
+
|
|
31
|
+
type Route = {
|
|
32
|
+
method: string;
|
|
33
|
+
path: string;
|
|
34
|
+
handle(req: IncomingMessage, res: ServerResponse, ctx: RouteContext): Promise<void> | void;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const REVIEW_CATEGORIES = new Set<ReviewCategory>(["bug", "style", "perf", "question"]);
|
|
38
|
+
const REVIEW_SEVERITIES = new Set<ReviewSeverity>(["high", "medium", "low"]);
|
|
39
|
+
const REVIEW_SIDES = new Set<ReviewSide>(["additions", "deletions"]);
|
|
40
|
+
const ROUTES: readonly Route[] = [
|
|
41
|
+
{ method: "GET", path: "/api/review", handle: getReview },
|
|
42
|
+
{ method: "POST", path: "/api/heartbeat", handle: heartbeat },
|
|
43
|
+
{ method: "POST", path: "/api/cancel", handle: cancel },
|
|
44
|
+
{ method: "POST", path: "/api/finding/validate", handle: validateFinding },
|
|
45
|
+
{ method: "POST", path: "/api/submit", handle: submitReview },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
export async function startReviewServer(options: StartServerOptions): Promise<ReviewServer> {
|
|
49
|
+
const token = options.token ?? randomBytes(32).toString("base64url");
|
|
50
|
+
const id = randomUUID();
|
|
51
|
+
let server: Server;
|
|
52
|
+
let finished = false;
|
|
53
|
+
let lastHeartbeatAt = Date.now();
|
|
54
|
+
let heartbeatWarned = false;
|
|
55
|
+
let resolveResult!: (result: Extract<BrowserResult, { type: "cancel" }>) => void;
|
|
56
|
+
const result = new Promise<Extract<BrowserResult, { type: "cancel" }>>((resolve) => { resolveResult = resolve; });
|
|
57
|
+
|
|
58
|
+
const finish = (event: ServerEvent) => {
|
|
59
|
+
if (event === "heartbeat") {
|
|
60
|
+
lastHeartbeatAt = Date.now();
|
|
61
|
+
heartbeatWarned = false;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (finished) return;
|
|
65
|
+
finished = true;
|
|
66
|
+
clearInterval(heartbeatTimer);
|
|
67
|
+
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
68
|
+
server.close();
|
|
69
|
+
resolveResult(event);
|
|
70
|
+
};
|
|
71
|
+
const ctx: RouteContext = { ...options, token, finish };
|
|
72
|
+
|
|
73
|
+
const heartbeatTimer = setInterval(() => {
|
|
74
|
+
if (heartbeatWarned || Date.now() - lastHeartbeatAt <= HEARTBEAT_LOST_MS) return;
|
|
75
|
+
heartbeatWarned = true;
|
|
76
|
+
options.onHeartbeatLost?.();
|
|
77
|
+
finish({ type: "cancel", reason: "browser" });
|
|
78
|
+
}, HEARTBEAT_CHECK_INTERVAL_MS);
|
|
79
|
+
heartbeatTimer.unref();
|
|
80
|
+
|
|
81
|
+
const timeoutTimer = options.timeoutMs === undefined ? undefined : setTimeout(() => finish({ type: "cancel", reason: "timeout" }), options.timeoutMs);
|
|
82
|
+
timeoutTimer?.unref();
|
|
83
|
+
|
|
84
|
+
server = createServer((req, res) => void dispatch(req, res, ctx));
|
|
85
|
+
try {
|
|
86
|
+
await listen(server, REVIEW_PORT);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
clearInterval(heartbeatTimer);
|
|
89
|
+
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
90
|
+
server.close();
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const address = server.address();
|
|
95
|
+
if (!address || typeof address === "string") throw new Error("Failed to bind review server");
|
|
96
|
+
return { id, url: `http://127.0.0.1:${address.port}/?token=${encodeURIComponent(token)}`, result, close: (reason) => finish({ type: "cancel", reason }) };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function dispatch(req: IncomingMessage, res: ServerResponse, ctx: RouteContext) {
|
|
100
|
+
try {
|
|
101
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
102
|
+
if (req.method === "GET" && url.pathname === "/") return serveIndex(res, ctx.token, url);
|
|
103
|
+
if (req.method === "GET" && url.pathname.startsWith("/static/")) return serveStatic(req, res, ctx.token, url.pathname.slice("/static/".length));
|
|
104
|
+
if (!authorized(req, ctx.token)) return json(res, 401, { error: "unauthorized" });
|
|
105
|
+
|
|
106
|
+
const route = ROUTES.find((candidate) => candidate.method === req.method && candidate.path === url.pathname);
|
|
107
|
+
if (!route) return json(res, 404, { error: "not found" });
|
|
108
|
+
await route.handle(req, res, ctx);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
const status = err instanceof HttpError ? err.status : 500;
|
|
111
|
+
json(res, status, { error: errorMessage(err) });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function getReview(_req: IncomingMessage, res: ServerResponse, ctx: RouteContext) {
|
|
116
|
+
if (ctx.refreshDiff) ctx.diff = await ctx.refreshDiff();
|
|
117
|
+
json(res, 200, reviewPayload(ctx));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function reviewPayload(ctx: RouteContext): ReviewPayload {
|
|
121
|
+
return {
|
|
122
|
+
round: ctx.round,
|
|
123
|
+
source: ctx.diff.source,
|
|
124
|
+
diffText: ctx.diff.diffText,
|
|
125
|
+
files: ctx.diff.files,
|
|
126
|
+
skippedFiles: ctx.diff.skippedFiles,
|
|
127
|
+
carriedFindings: ctx.state.findings.filter((finding) => finding.status === "open"),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function heartbeat(_req: IncomingMessage, res: ServerResponse, ctx: RouteContext) {
|
|
132
|
+
ctx.finish("heartbeat");
|
|
133
|
+
json(res, 200, { ok: true });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function cancel(_req: IncomingMessage, res: ServerResponse, ctx: RouteContext) {
|
|
137
|
+
json(res, 200, { ok: true });
|
|
138
|
+
ctx.finish({ type: "cancel", reason: "browser" });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function validateFinding(req: IncomingMessage, res: ServerResponse, ctx: RouteContext) {
|
|
142
|
+
const draft = await readJson<FindingDraft>(req, parseFindingDraft);
|
|
143
|
+
json(res, 200, { ok: true, anchor: anchorForDraft(ctx.diff, draft) });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function submitReview(req: IncomingMessage, res: ServerResponse, ctx: RouteContext) {
|
|
147
|
+
if (ctx.refreshDiff) ctx.diff = await ctx.refreshDiff();
|
|
148
|
+
const body = await readJson<{ notes?: string; findings: FindingDraft[] }>(req, parseSubmitBody);
|
|
149
|
+
const result: Extract<BrowserResult, { type: "submit" }> = { type: "submit", notes: body.notes, findings: body.findings.map((draft) => findingFromDraft(ctx.round, ctx.diff, draft)) };
|
|
150
|
+
await ctx.onSubmit?.(result, ctx.diff);
|
|
151
|
+
json(res, 200, { ok: true });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function serveIndex(res: ServerResponse, token: string, url: URL) {
|
|
155
|
+
if (url.searchParams.get("token") !== token) return json(res, 401, { error: "unauthorized" });
|
|
156
|
+
res.setHeader("Set-Cookie", `${TOKEN_COOKIE}=${token}; SameSite=Strict; Path=/`);
|
|
157
|
+
serveFile(res, resolve(staticDir(), "index.html"));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function serveStatic(req: IncomingMessage, res: ServerResponse, token: string, path: string) {
|
|
161
|
+
if (!cookieAuthorized(req, token)) return json(res, 401, { error: "unauthorized" });
|
|
162
|
+
const file = staticFilePath(path);
|
|
163
|
+
if (!file) return json(res, 404, { error: "not found" });
|
|
164
|
+
serveFile(res, file);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function staticFilePath(rawPath: string) {
|
|
168
|
+
try {
|
|
169
|
+
const root = resolve(staticDir());
|
|
170
|
+
const file = resolve(root, decodeURIComponent(rawPath));
|
|
171
|
+
return file === root || file.startsWith(root + sep) ? file : undefined;
|
|
172
|
+
} catch {
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function serveFile(res: ServerResponse, path: string) {
|
|
178
|
+
try {
|
|
179
|
+
const info = await stat(path);
|
|
180
|
+
if (!info.isFile()) return json(res, 404, { error: "not found" });
|
|
181
|
+
res.statusCode = 200;
|
|
182
|
+
res.setHeader("Content-Type", contentType(path));
|
|
183
|
+
res.setHeader("Cache-Control", "no-store");
|
|
184
|
+
createReadStream(path).pipe(res);
|
|
185
|
+
} catch {
|
|
186
|
+
json(res, 404, { error: "not found" });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function authorized(req: IncomingMessage, token: string) {
|
|
191
|
+
return req.headers.authorization === `Bearer ${token}` || cookieAuthorized(req, token);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function cookieAuthorized(req: IncomingMessage, token: string) {
|
|
195
|
+
return (req.headers.cookie ?? "").split(/;\s*/).some((cookie) => cookie === `${TOKEN_COOKIE}=${token}`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function json(res: ServerResponse, status: number, data: unknown) {
|
|
199
|
+
if (res.writableEnded) return;
|
|
200
|
+
res.statusCode = status;
|
|
201
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
202
|
+
res.end(JSON.stringify(data));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function contentType(path: string) {
|
|
206
|
+
switch (extname(path)) {
|
|
207
|
+
case ".html": return "text/html; charset=utf-8";
|
|
208
|
+
case ".js": return "text/javascript; charset=utf-8";
|
|
209
|
+
case ".css": return "text/css; charset=utf-8";
|
|
210
|
+
case ".json": return "application/json; charset=utf-8";
|
|
211
|
+
case ".svg": return "image/svg+xml";
|
|
212
|
+
case ".woff2": return "font/woff2";
|
|
213
|
+
default: return "application/octet-stream";
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function readJson<T>(req: IncomingMessage, parse: (value: unknown) => T): Promise<T> {
|
|
218
|
+
const body = await readBody(req);
|
|
219
|
+
try {
|
|
220
|
+
return parse(body ? JSON.parse(body) : {});
|
|
221
|
+
} catch (err) {
|
|
222
|
+
if (err instanceof HttpError) throw err;
|
|
223
|
+
throw new HttpError(400, err instanceof SyntaxError ? "invalid JSON" : errorMessage(err));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function readBody(req: IncomingMessage) {
|
|
228
|
+
let body = "";
|
|
229
|
+
for await (const chunk of req) {
|
|
230
|
+
body += String(chunk);
|
|
231
|
+
if (Buffer.byteLength(body, "utf8") > REQUEST_BODY_MAX_BYTES) throw new HttpError(413, "request body too large");
|
|
232
|
+
}
|
|
233
|
+
return body;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function parseSubmitBody(value: unknown): { notes?: string; findings: FindingDraft[] } {
|
|
237
|
+
if (!isRecord(value)) throw new Error("request body must be an object");
|
|
238
|
+
const notes = optionalString(value.notes, "notes")?.trim();
|
|
239
|
+
const rawFindings = value.findings ?? [];
|
|
240
|
+
if (!Array.isArray(rawFindings)) throw new Error("findings must be an array");
|
|
241
|
+
return { ...(notes && { notes }), findings: rawFindings.map(parseFindingDraft) };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function parseFindingDraft(value: unknown): FindingDraft {
|
|
245
|
+
if (!isRecord(value)) throw new Error("finding must be an object");
|
|
246
|
+
const startLine = positiveInteger(value.startLine, "startLine");
|
|
247
|
+
const endLine = positiveInteger(value.endLine, "endLine");
|
|
248
|
+
if (endLine < startLine) throw new Error("endLine must be >= startLine");
|
|
249
|
+
const comment = requiredString(value.comment, "comment").trim();
|
|
250
|
+
if (!comment) throw new Error("comment required");
|
|
251
|
+
const notes = optionalString(value.notes, "notes")?.trim();
|
|
252
|
+
const prevFile = optionalString(value.prevFile, "prevFile");
|
|
253
|
+
return {
|
|
254
|
+
file: requiredString(value.file, "file"),
|
|
255
|
+
...(prevFile && { prevFile }),
|
|
256
|
+
side: enumValue(value.side, REVIEW_SIDES, "side"),
|
|
257
|
+
startLine,
|
|
258
|
+
endLine,
|
|
259
|
+
category: enumValue(value.category, REVIEW_CATEGORIES, "category"),
|
|
260
|
+
severity: enumValue(value.severity, REVIEW_SEVERITIES, "severity"),
|
|
261
|
+
comment,
|
|
262
|
+
...(notes && { notes }),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function findingFromDraft(round: number, diff: CollectedDiff, draft: FindingDraft): Finding {
|
|
267
|
+
return {
|
|
268
|
+
id: randomUUID(),
|
|
269
|
+
round,
|
|
270
|
+
file: draft.file,
|
|
271
|
+
...(draft.prevFile && { prevFile: draft.prevFile }),
|
|
272
|
+
side: draft.side,
|
|
273
|
+
startLine: draft.startLine,
|
|
274
|
+
endLine: draft.endLine,
|
|
275
|
+
category: draft.category,
|
|
276
|
+
severity: draft.severity,
|
|
277
|
+
comment: draft.comment,
|
|
278
|
+
...(draft.notes && { notes: draft.notes }),
|
|
279
|
+
status: "open",
|
|
280
|
+
anchor: anchorForDraft(diff, draft),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function anchorForDraft(diff: CollectedDiff, draft: FindingDraft) {
|
|
285
|
+
const file = diff.diffIndex.get(draft.file);
|
|
286
|
+
if (!file) throw new HttpError(400, `Unknown file: ${draft.file}`);
|
|
287
|
+
const lines = draft.side === "additions" ? file.newLines : file.oldLines;
|
|
288
|
+
return {
|
|
289
|
+
before: nearby(lines, draft.startLine - ANCHOR_CONTEXT_LINES, draft.startLine - 1),
|
|
290
|
+
selected: selectedLines(lines, draft),
|
|
291
|
+
after: nearby(lines, draft.endLine + 1, draft.endLine + ANCHOR_CONTEXT_LINES),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function selectedLines(lines: Map<number, string>, draft: FindingDraft) {
|
|
296
|
+
const selected: string[] = [];
|
|
297
|
+
for (let line = draft.startLine; line <= draft.endLine; line++) {
|
|
298
|
+
const text = lines.get(line);
|
|
299
|
+
if (text === undefined) throw new HttpError(400, `Selected line is not in rendered diff context: ${draft.file}:${line}`);
|
|
300
|
+
selected.push(text);
|
|
301
|
+
}
|
|
302
|
+
return selected.join("\n");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function nearby(lines: Map<number, string>, start: number, end: number) {
|
|
306
|
+
const out: string[] = [];
|
|
307
|
+
for (let line = start; line <= end; line++) {
|
|
308
|
+
const text = lines.get(line);
|
|
309
|
+
if (text !== undefined) out.push(text);
|
|
310
|
+
}
|
|
311
|
+
return out.join("\n");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function requiredString(value: unknown, name: string) {
|
|
315
|
+
if (typeof value !== "string") throw new Error(`${name} must be a string`);
|
|
316
|
+
return value;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function optionalString(value: unknown, name: string) {
|
|
320
|
+
if (value === undefined) return undefined;
|
|
321
|
+
return requiredString(value, name);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function positiveInteger(value: unknown, name: string) {
|
|
325
|
+
if (!Number.isInteger(value) || (value as number) < 1) throw new Error(`${name} must be a positive integer`);
|
|
326
|
+
return value as number;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function enumValue<T extends string>(value: unknown, choices: ReadonlySet<T>, name: string): T {
|
|
330
|
+
if (typeof value === "string" && choices.has(value as T)) return value as T;
|
|
331
|
+
throw new Error(`invalid ${name}`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
335
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function listen(server: Server, preferredPort: number) {
|
|
339
|
+
return new Promise<void>((resolveListen, reject) => {
|
|
340
|
+
const onError = (err: NodeJS.ErrnoException) => {
|
|
341
|
+
server.off("error", onError);
|
|
342
|
+
if (err.code !== "EADDRINUSE") return reject(err);
|
|
343
|
+
server.listen(0, "127.0.0.1", () => resolveListen());
|
|
344
|
+
};
|
|
345
|
+
server.once("error", onError);
|
|
346
|
+
server.listen(preferredPort, "127.0.0.1", () => {
|
|
347
|
+
server.off("error", onError);
|
|
348
|
+
resolveListen();
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function errorMessage(err: unknown) {
|
|
354
|
+
return err instanceof Error ? err.message : String(err);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
class HttpError extends Error {
|
|
358
|
+
constructor(readonly status: number, message: string) {
|
|
359
|
+
super(message);
|
|
360
|
+
}
|
|
361
|
+
}
|
package/session-state.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { EXTENSION_ID } from "./constants.js";
|
|
3
|
+
import type { ReviewLiveEntry, ReviewRoundEntry, ReviewState } from "./types.js";
|
|
4
|
+
|
|
5
|
+
type WritableSessionManager = ExtensionContext["sessionManager"] & {
|
|
6
|
+
appendCustomEntry(customType: string, data?: unknown): string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function appendReviewRound(ctx: ExtensionContext, data: ReviewRoundEntry): string {
|
|
10
|
+
return (ctx.sessionManager as WritableSessionManager).appendCustomEntry(EXTENSION_ID, data);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function appendLiveReview(ctx: ExtensionContext, data: ReviewLiveEntry): string {
|
|
14
|
+
return (ctx.sessionManager as WritableSessionManager).appendCustomEntry(EXTENSION_ID, data);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function restoreReviewState(ctx: ExtensionContext): ReviewState {
|
|
18
|
+
const rounds: ReviewRoundEntry[] = [];
|
|
19
|
+
let live: ReviewLiveEntry | undefined;
|
|
20
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
21
|
+
if (entry.type !== "custom" || entry.customType !== EXTENSION_ID) continue;
|
|
22
|
+
const data = entry.data as ReviewRoundEntry | ReviewLiveEntry | undefined;
|
|
23
|
+
if (data?.version !== 1) continue;
|
|
24
|
+
if (data.kind === "round") rounds.push(data);
|
|
25
|
+
if (data.kind === "live") live = data.status === "open" ? data : undefined;
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
round: Math.max(0, ...rounds.map((round) => round.round)),
|
|
29
|
+
findings: rounds.flatMap((round) => round.findings),
|
|
30
|
+
rounds,
|
|
31
|
+
...(live && { live }),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function openFindings(state: ReviewState) {
|
|
36
|
+
return state.findings.filter((finding) => finding.status === "open");
|
|
37
|
+
}
|
package/types.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
export type ReviewCategory = "bug" | "style" | "perf" | "question";
|
|
2
|
+
export type ReviewSeverity = "high" | "medium" | "low";
|
|
3
|
+
export type ReviewSide = "additions" | "deletions";
|
|
4
|
+
export type FindingStatus = "open" | "resolved" | "closed_auto";
|
|
5
|
+
|
|
6
|
+
export type Finding = {
|
|
7
|
+
id: string;
|
|
8
|
+
round: number;
|
|
9
|
+
file: string;
|
|
10
|
+
prevFile?: string;
|
|
11
|
+
side: ReviewSide;
|
|
12
|
+
startLine: number;
|
|
13
|
+
endLine: number;
|
|
14
|
+
category: ReviewCategory;
|
|
15
|
+
severity: ReviewSeverity;
|
|
16
|
+
comment: string;
|
|
17
|
+
notes?: string;
|
|
18
|
+
status: FindingStatus;
|
|
19
|
+
statusReason?: "file_removed" | "anchor_missing" | "manual";
|
|
20
|
+
anchor: {
|
|
21
|
+
before: string;
|
|
22
|
+
selected: string;
|
|
23
|
+
after: string;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type ReviewSource = {
|
|
28
|
+
kind: "working-tree" | "staged" | "base";
|
|
29
|
+
base?: string;
|
|
30
|
+
files?: string[];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type ReviewRoundEntry = {
|
|
34
|
+
version: 1;
|
|
35
|
+
kind: "round";
|
|
36
|
+
round: number;
|
|
37
|
+
source: ReviewSource;
|
|
38
|
+
findings: Finding[];
|
|
39
|
+
notes?: string;
|
|
40
|
+
diffSummary: {
|
|
41
|
+
filesChanged: number;
|
|
42
|
+
additions: number;
|
|
43
|
+
deletions: number;
|
|
44
|
+
skippedFiles: string[];
|
|
45
|
+
};
|
|
46
|
+
createdAt: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type ReviewLiveEntry = {
|
|
50
|
+
version: 1;
|
|
51
|
+
kind: "live";
|
|
52
|
+
id: string;
|
|
53
|
+
token: string;
|
|
54
|
+
round: number;
|
|
55
|
+
args: {
|
|
56
|
+
staged: boolean;
|
|
57
|
+
base?: string;
|
|
58
|
+
files?: string[];
|
|
59
|
+
};
|
|
60
|
+
status: "open" | "closed";
|
|
61
|
+
reason?: "browser" | "command" | "shutdown" | "timeout";
|
|
62
|
+
createdAt: string;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export type ReviewState = {
|
|
66
|
+
round: number;
|
|
67
|
+
findings: Finding[];
|
|
68
|
+
rounds: ReviewRoundEntry[];
|
|
69
|
+
live?: ReviewLiveEntry;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export type DiffFileStatus = "added" | "modified" | "deleted" | "renamed" | "binary";
|
|
73
|
+
|
|
74
|
+
export type DiffFileSummary = {
|
|
75
|
+
path: string;
|
|
76
|
+
prevPath?: string;
|
|
77
|
+
status: DiffFileStatus;
|
|
78
|
+
additions: number;
|
|
79
|
+
deletions: number;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export type ReviewPayload = {
|
|
83
|
+
round: number;
|
|
84
|
+
source: ReviewSource;
|
|
85
|
+
diffText: string;
|
|
86
|
+
files: DiffFileSummary[];
|
|
87
|
+
skippedFiles: string[];
|
|
88
|
+
carriedFindings: Finding[];
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export type FindingDraft = {
|
|
92
|
+
file: string;
|
|
93
|
+
prevFile?: string;
|
|
94
|
+
side: ReviewSide;
|
|
95
|
+
startLine: number;
|
|
96
|
+
endLine: number;
|
|
97
|
+
category: ReviewCategory;
|
|
98
|
+
severity: ReviewSeverity;
|
|
99
|
+
comment: string;
|
|
100
|
+
notes?: string;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export type BrowserResult =
|
|
104
|
+
| { type: "submit"; notes?: string; findings: Finding[] }
|
|
105
|
+
| { type: "cancel"; reason: "browser" | "command" | "shutdown" | "timeout" };
|
|
106
|
+
|
|
107
|
+
export type DiffLineMap = Map<number, string>;
|
|
108
|
+
|
|
109
|
+
export type IndexedFileDiff = {
|
|
110
|
+
file: string;
|
|
111
|
+
prevFile?: string;
|
|
112
|
+
status: DiffFileStatus;
|
|
113
|
+
additions: DiffLineMap;
|
|
114
|
+
deletions: DiffLineMap;
|
|
115
|
+
oldLines: DiffLineMap;
|
|
116
|
+
newLines: DiffLineMap;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export type DiffIndex = Map<string, IndexedFileDiff>;
|
|
120
|
+
|
|
121
|
+
export type CollectedDiff = {
|
|
122
|
+
repoRoot: string;
|
|
123
|
+
source: ReviewSource;
|
|
124
|
+
diffText: string;
|
|
125
|
+
files: DiffFileSummary[];
|
|
126
|
+
diffIndex: DiffIndex;
|
|
127
|
+
skippedFiles: string[];
|
|
128
|
+
additions: number;
|
|
129
|
+
deletions: number;
|
|
130
|
+
};
|