prreviewbuddy 0.12.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/README.md +61 -0
- package/dist/log-DdPaO6Wo.js +6640 -0
- package/dist/main.js +873 -0
- package/dist/server.js +3849 -0
- package/package.json +27 -0
- package/static/comfortaa-700.woff2 +0 -0
- package/static/icon128.png +0 -0
- package/static/reviews.js +1 -0
- package/static/shell.css +2018 -0
- package/static/sidebar.css +4802 -0
- package/static/workspace.js +0 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,3849 @@
|
|
|
1
|
+
import { B as lineagePosition, C as clearClaim, D as removeWorktree, E as readMarker, F as loadJob, G as saveWorkspace, I as saveJob, J as processDiscussion, M as deleteJob, N as fail, O as relativeTime$1, P as isTerminal, R as STORE_ROOT, S as fillFileUrlTemplate, T as MANAGED_ROOT, U as recentReviewGroups, V as loadWorkspace, Y as describeAuthorship, _ as feedbackUrl, a as refreshPrContext, b as EXPLAIN_SIMPLY_PROMPT, c as agentEnvOf, et as agentFor, g as BUILD_VERSION, h as writeServerRecord, i as updateReview, j as allJobs, k as checkFreshness, n as record, nt as AgentUnavailableError, o as isUnchanged, q as touchWorkspace, r as RefreshUnavailableError, s as askCheckout, t as readEvents, u as clearServerRecord, v as runJob, w as isClaimed } from "./log-DdPaO6Wo.js";
|
|
2
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
3
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { createServer } from "node:http";
|
|
7
|
+
//#region ../../packages/review-harness/src/domain/ai_review/review_composition.ts
|
|
8
|
+
/**
|
|
9
|
+
* What a follow-up actually reviewed — the files it wrote about, not the files it was handed.
|
|
10
|
+
*
|
|
11
|
+
* This distinction is the whole of a bug found on a live run. Composing from `scope.paths` made
|
|
12
|
+
* the Overview claim "480 of 482 reviewed in depth" while the card beside it read `20 / 482`: the
|
|
13
|
+
* card counts files the model sectioned, and a follow-up given 467 files had sectioned seven of
|
|
14
|
+
* them. The runbook's phase 2 rule is explicit — the numerator is what the model sectioned, and
|
|
15
|
+
* crossing it with what we sent produces a confidently wrong number, which is worse than the
|
|
16
|
+
* missing one it replaced.
|
|
17
|
+
*
|
|
18
|
+
* Being handed a file is not evidence it was read. Only the model's own output is.
|
|
19
|
+
*/
|
|
20
|
+
function coveredByRun(run) {
|
|
21
|
+
return [...run.result.sections.flatMap((section) => section.files.map((file) => file.path)), ...run.result.fileNotes.map((note) => note.path)];
|
|
22
|
+
}
|
|
23
|
+
var RISK_RANK = {
|
|
24
|
+
low: 0,
|
|
25
|
+
medium: 1,
|
|
26
|
+
high: 2
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Risk after follow-ups, derived from the model's own severity rather than from coverage.
|
|
30
|
+
*
|
|
31
|
+
* Only ever escalates, and only on a `critical` finding. Two things this deliberately is not:
|
|
32
|
+
* it is not a fresh verdict from a second model (nobody asked for one), and it is not a coverage
|
|
33
|
+
* heuristic — "more files reviewed means lower risk" would fire on every large PR and is exactly
|
|
34
|
+
* the kind of threshold rule invariant 0 rejects. A follow-up that finds a critical problem has
|
|
35
|
+
* demonstrably raised the stakes; a follow-up that finds nothing has not lowered them, because
|
|
36
|
+
* absence of a finding in one area says nothing about the areas still unread.
|
|
37
|
+
*/
|
|
38
|
+
function deriveOverallRisk(primary, followUpIssues) {
|
|
39
|
+
return followUpIssues.some((issue) => issue.severity === "critical") && RISK_RANK[primary] < RISK_RANK.high ? "high" : primary;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Namespaces a follow-up's ids so they can never collide with the primary's — or each other's.
|
|
43
|
+
*
|
|
44
|
+
* Primary ids are never renumbered, in any circumstance, for any reason: that is the invariant
|
|
45
|
+
* `completedSectionIds` and `answeredQuestionIds` depend on. Models also reuse obvious ids like
|
|
46
|
+
* "s1" across runs, so without a per-run prefix a second follow-up would silently overwrite the
|
|
47
|
+
* first in every id-keyed lookup.
|
|
48
|
+
*/
|
|
49
|
+
function namespaced(runId, id) {
|
|
50
|
+
return `${runId}:${id}`;
|
|
51
|
+
}
|
|
52
|
+
function composeSections(primary, runs, notesBySection) {
|
|
53
|
+
const composed = primary.map((section) => {
|
|
54
|
+
const notes = notesBySection.get(section.id);
|
|
55
|
+
if (!notes) return { ...section };
|
|
56
|
+
const files = section.files.map((file) => {
|
|
57
|
+
const focus = notes.get(file.path);
|
|
58
|
+
return focus ? {
|
|
59
|
+
...file,
|
|
60
|
+
focus
|
|
61
|
+
} : file;
|
|
62
|
+
});
|
|
63
|
+
const reassessed = section.files.some((file) => notes.has(file.path));
|
|
64
|
+
return {
|
|
65
|
+
...section,
|
|
66
|
+
files,
|
|
67
|
+
reassessed
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
const primaryCount = composed.length;
|
|
71
|
+
for (const run of runs) for (const section of run.result.sections) composed.push({
|
|
72
|
+
...section,
|
|
73
|
+
id: namespaced(run.id, section.id),
|
|
74
|
+
fromFollowUpId: run.id,
|
|
75
|
+
recommendedStartingPoint: false
|
|
76
|
+
});
|
|
77
|
+
return [...composed.slice(0, primaryCount).sort((a, b) => a.order - b.order), ...composed.slice(primaryCount)].map((section, index) => ({
|
|
78
|
+
...section,
|
|
79
|
+
order: index + 1
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Suggested areas with anything a follow-up has since reviewed removed.
|
|
84
|
+
*
|
|
85
|
+
* Without this the card that just ran stays on screen offering to run again — and clicking it
|
|
86
|
+
* would re-review the same files and bill for it twice. An area is a claim about work still
|
|
87
|
+
* outstanding, so it has to be derived from current coverage rather than frozen at plan time.
|
|
88
|
+
*
|
|
89
|
+
* A partially covered area keeps its card with a reduced count, which is honest: the rest of it
|
|
90
|
+
* genuinely has not been read. An area with nothing left disappears entirely.
|
|
91
|
+
*/
|
|
92
|
+
function composeSuggestedAreas(session, reviewedPaths) {
|
|
93
|
+
return (session.metadata.suggestedAreas ?? []).map((area) => {
|
|
94
|
+
if (!area.paths?.length) return area;
|
|
95
|
+
const remaining = area.paths.filter((path) => !reviewedPaths.has(path));
|
|
96
|
+
return {
|
|
97
|
+
...area,
|
|
98
|
+
paths: remaining,
|
|
99
|
+
fileCount: remaining.length
|
|
100
|
+
};
|
|
101
|
+
}).filter((area) => area.paths === void 0 || area.paths.length > 0);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Coverage after follow-ups, so the Overview's count climbs as more of the PR gets read.
|
|
105
|
+
*
|
|
106
|
+
* Only recomputed when the primary recorded `reviewedPaths`. Without that list there is no way to
|
|
107
|
+
* union anything — adding the follow-up's file count to the stored total would double-count every
|
|
108
|
+
* file both runs saw, and a coverage number that overstates itself is the exact bug phase 2
|
|
109
|
+
* existed to kill.
|
|
110
|
+
*/
|
|
111
|
+
function composeCoverage(session, reviewedPaths, runs) {
|
|
112
|
+
const coverage = session.metadata.fileCoverage;
|
|
113
|
+
if (!coverage?.reviewedPaths) return coverage;
|
|
114
|
+
const partialFiles = composePartialFiles(coverage.partialFiles, runs);
|
|
115
|
+
return {
|
|
116
|
+
...coverage,
|
|
117
|
+
reviewedFiles: reviewedPaths.size,
|
|
118
|
+
reviewedPaths: [...reviewedPaths],
|
|
119
|
+
...partialFiles ? {
|
|
120
|
+
partialFiles,
|
|
121
|
+
partiallyReadFiles: partialFiles.length
|
|
122
|
+
} : {}
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* How far each file has been read, after replaying the follow-up runs over the primary's answer.
|
|
127
|
+
*
|
|
128
|
+
* The rule is "the last run to touch a file is the one that knows": a run is given the file's
|
|
129
|
+
* outstanding remainder, so whatever it reports back supersedes what it was told. A file the run
|
|
130
|
+
* covered and did *not* report as partial has been read to the end and leaves the list — that
|
|
131
|
+
* absence is the only signal that anything ever completes, which is why `FollowUpRun.partialFiles`
|
|
132
|
+
* being `[]` and being `undefined` have to stay distinguishable.
|
|
133
|
+
*
|
|
134
|
+
* Returns `undefined` when nothing can be said: a primary that predates the field, with no run
|
|
135
|
+
* carrying the information either. `[]` is a real answer and means everything has been read whole.
|
|
136
|
+
*/
|
|
137
|
+
function composePartialFiles(primary, runs) {
|
|
138
|
+
if (primary === void 0 && runs.every((r) => r.partialFiles === void 0)) return void 0;
|
|
139
|
+
const outstanding = new Map((primary ?? []).map((p) => [p.path, p]));
|
|
140
|
+
for (const run of runs) {
|
|
141
|
+
if (run.partialFiles === void 0) continue;
|
|
142
|
+
for (const path of run.scope.paths) outstanding.delete(path);
|
|
143
|
+
for (const partial of run.partialFiles) outstanding.set(partial.path, partial);
|
|
144
|
+
}
|
|
145
|
+
return [...outstanding.values()];
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Composes a session's primary result with its follow-up runs. Pure, deterministic, total.
|
|
149
|
+
*
|
|
150
|
+
* Returns `null` only when there is no primary result — a session that has not produced one has
|
|
151
|
+
* nothing for follow-ups to attach to.
|
|
152
|
+
*/
|
|
153
|
+
function composeReview(session) {
|
|
154
|
+
const primary = session.result;
|
|
155
|
+
if (!primary) return null;
|
|
156
|
+
const runs = [...session.followUps ?? []].sort((a, b) => a.generatedAt - b.generatedAt);
|
|
157
|
+
const notesBySection = /* @__PURE__ */ new Map();
|
|
158
|
+
for (const run of runs) for (const note of run.result.fileNotes) {
|
|
159
|
+
const forSection = notesBySection.get(note.sectionId) ?? /* @__PURE__ */ new Map();
|
|
160
|
+
forSection.set(note.path, note.focus);
|
|
161
|
+
notesBySection.set(note.sectionId, forSection);
|
|
162
|
+
}
|
|
163
|
+
const alreadyRead = new Set(session.metadata.fileCoverage?.reviewedPaths ?? []);
|
|
164
|
+
const issues = primary.issues.map((issue) => ({ ...issue }));
|
|
165
|
+
const questions = primary.questions.map((question) => ({ ...question }));
|
|
166
|
+
const followUpIssues = [];
|
|
167
|
+
for (const run of runs) {
|
|
168
|
+
for (const issue of run.result.issues) {
|
|
169
|
+
followUpIssues.push(issue);
|
|
170
|
+
issues.push({
|
|
171
|
+
...issue,
|
|
172
|
+
id: namespaced(run.id, issue.id),
|
|
173
|
+
sectionId: issue.sectionId ? resolveSectionId(run, issue.sectionId) : void 0,
|
|
174
|
+
fromFollowUpId: run.id,
|
|
175
|
+
reassessment: issue.file !== void 0 && alreadyRead.has(issue.file) ? true : void 0
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
for (const question of run.result.questions) questions.push({
|
|
179
|
+
...question,
|
|
180
|
+
id: namespaced(run.id, question.id),
|
|
181
|
+
sectionId: question.sectionId ? resolveSectionId(run, question.sectionId) : void 0
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
const overallRisk = deriveOverallRisk(primary.overallRisk, followUpIssues);
|
|
185
|
+
const reviewedPaths = /* @__PURE__ */ new Set([...session.metadata.fileCoverage?.reviewedPaths ?? [], ...runs.flatMap(coveredByRun)]);
|
|
186
|
+
return {
|
|
187
|
+
schemaVersion: 1,
|
|
188
|
+
summary: primary.summary,
|
|
189
|
+
recommendation: primary.recommendation,
|
|
190
|
+
nextSteps: primary.nextSteps,
|
|
191
|
+
overallRisk,
|
|
192
|
+
riskEscalated: overallRisk !== primary.overallRisk,
|
|
193
|
+
sections: composeSections(primary.sections, runs, notesBySection),
|
|
194
|
+
issues,
|
|
195
|
+
questions,
|
|
196
|
+
reviewedPaths: [...reviewedPaths],
|
|
197
|
+
suggestedAreas: composeSuggestedAreas(session, reviewedPaths),
|
|
198
|
+
fileCoverage: composeCoverage(session, reviewedPaths, runs),
|
|
199
|
+
runs
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* An item's sectionId may point at a section the same run created (namespaced along with it) or
|
|
204
|
+
* at an existing primary section (left alone). Getting this backwards would orphan every issue a
|
|
205
|
+
* follow-up raised against the guide it was reviewing.
|
|
206
|
+
*/
|
|
207
|
+
function resolveSectionId(run, sectionId) {
|
|
208
|
+
return run.result.sections.some((section) => section.id === sectionId) ? namespaced(run.id, sectionId) : sectionId;
|
|
209
|
+
}
|
|
210
|
+
//#endregion
|
|
211
|
+
//#region ../../packages/review-harness/src/domain/utils/html.ts
|
|
212
|
+
function escapeHtml(value) {
|
|
213
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
214
|
+
}
|
|
215
|
+
function renderInlineText(value) {
|
|
216
|
+
return escapeHtml(value).replace(/`([^`\n]+)`/g, "<code>$1</code>");
|
|
217
|
+
}
|
|
218
|
+
//#endregion
|
|
219
|
+
//#region ../../packages/review-harness/src/domain/utils/file_path.ts
|
|
220
|
+
/**
|
|
221
|
+
* Splits a repo-relative path for display, so the filename can be shown prominently and the
|
|
222
|
+
* directory demoted to context. Deep monorepo paths are mostly prefix — rendering them at equal
|
|
223
|
+
* weight buries the one part that identifies the file.
|
|
224
|
+
*/
|
|
225
|
+
function splitFilePath(path) {
|
|
226
|
+
const lastSlash = path.lastIndexOf("/");
|
|
227
|
+
if (lastSlash === -1) return {
|
|
228
|
+
dir: "",
|
|
229
|
+
name: path
|
|
230
|
+
};
|
|
231
|
+
return {
|
|
232
|
+
dir: path.slice(0, lastSlash),
|
|
233
|
+
name: path.slice(lastSlash + 1)
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
//#endregion
|
|
237
|
+
//#region ../../packages/review-harness/src/domain/ai_review/review_metrics.ts
|
|
238
|
+
/**
|
|
239
|
+
* Overview counts, derived entirely from the review content the user actually reads.
|
|
240
|
+
*
|
|
241
|
+
* These were previously read straight off the model's output — in particular `files` used
|
|
242
|
+
* `result.files.length`, a top-level per-file summary list that the model was free to omit and
|
|
243
|
+
* routinely did on larger PRs, so the Overview reported "0 Files" on a 29-file review. Nothing
|
|
244
|
+
* was broken; the count simply measured a field the model had declined to fill in. That field no
|
|
245
|
+
* longer exists (see types.ts) and sections are now the single source of truth for files.
|
|
246
|
+
*
|
|
247
|
+
* The rule this encodes: the AI generates content, the extension counts it. Any metric that can
|
|
248
|
+
* be computed from content the user can see should be, so a number in the UI can never disagree
|
|
249
|
+
* with the thing it claims to be counting.
|
|
250
|
+
*/
|
|
251
|
+
function deriveReviewMetrics(result) {
|
|
252
|
+
return {
|
|
253
|
+
files: new Set(result.sections.flatMap((s) => s.files.map((f) => f.path))).size,
|
|
254
|
+
sections: result.sections.length,
|
|
255
|
+
issues: result.issues.length,
|
|
256
|
+
questions: result.questions.length
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
//#endregion
|
|
260
|
+
//#region ../../packages/review-harness/src/domain/utils/relative_time.ts
|
|
261
|
+
function formatRelativeTime(ts, now = Date.now()) {
|
|
262
|
+
const diffMs = Math.max(0, now - ts);
|
|
263
|
+
const minute = 6e4;
|
|
264
|
+
const hour = 60 * minute;
|
|
265
|
+
const day = 24 * hour;
|
|
266
|
+
if (diffMs < minute) return "Just now";
|
|
267
|
+
if (diffMs < hour) {
|
|
268
|
+
const mins = Math.floor(diffMs / minute);
|
|
269
|
+
return `${mins} min${mins === 1 ? "" : "s"} ago`;
|
|
270
|
+
}
|
|
271
|
+
if (diffMs < day) {
|
|
272
|
+
const hours = Math.floor(diffMs / hour);
|
|
273
|
+
return `${hours} hour${hours === 1 ? "" : "s"} ago`;
|
|
274
|
+
}
|
|
275
|
+
const days = Math.floor(diffMs / day);
|
|
276
|
+
return `${days} day${days === 1 ? "" : "s"} ago`;
|
|
277
|
+
}
|
|
278
|
+
//#endregion
|
|
279
|
+
//#region ../../packages/review-harness/src/domain/utils/code_block.ts
|
|
280
|
+
var CODE_BLOCK_CLASS = "pr-helper-code-block";
|
|
281
|
+
var CODE_COPY_CLASS = "pr-helper-code-copy";
|
|
282
|
+
//#endregion
|
|
283
|
+
//#region ../../packages/review-harness/src/domain/utils/markdown.ts
|
|
284
|
+
var BLOCK_MARKER = "\0";
|
|
285
|
+
var INLINE_MARKER = "";
|
|
286
|
+
var BLOCK_TOKEN = /\u0000(\d+)\u0000/g;
|
|
287
|
+
var BLOCK_TOKEN_LINE = /^\u0000\d+\u0000$/;
|
|
288
|
+
var INLINE_TOKEN = /\u0001(\d+)\u0001/g;
|
|
289
|
+
var SAFE_HREF = /^(?:https?:\/\/|#)/i;
|
|
290
|
+
function renderMarkdown(value) {
|
|
291
|
+
const { text, blocks } = extractFencedBlocks(value.replace(/[\u0000\u0001]/g, ""));
|
|
292
|
+
return restoreFencedBlocks(renderBlocks(text), blocks);
|
|
293
|
+
}
|
|
294
|
+
function extractFencedBlocks(value) {
|
|
295
|
+
const blocks = [];
|
|
296
|
+
const lines = value.split("\n");
|
|
297
|
+
const out = [];
|
|
298
|
+
let i = 0;
|
|
299
|
+
while (i < lines.length) {
|
|
300
|
+
const opening = lines[i].match(/^\s*(`{3,}|~{3,})\s*[\w+#.-]*\s*$/);
|
|
301
|
+
if (!opening) {
|
|
302
|
+
out.push(lines[i]);
|
|
303
|
+
i += 1;
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
const closing = new RegExp(`^\\s*${opening[1][0]}{3,}\\s*$`);
|
|
307
|
+
const body = [];
|
|
308
|
+
i += 1;
|
|
309
|
+
while (i < lines.length && !closing.test(lines[i])) {
|
|
310
|
+
body.push(lines[i]);
|
|
311
|
+
i += 1;
|
|
312
|
+
}
|
|
313
|
+
i += 1;
|
|
314
|
+
out.push(`${BLOCK_MARKER}${blocks.length}${BLOCK_MARKER}`);
|
|
315
|
+
blocks.push(body.join("\n"));
|
|
316
|
+
}
|
|
317
|
+
return {
|
|
318
|
+
text: out.join("\n"),
|
|
319
|
+
blocks
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
function restoreFencedBlocks(html, blocks) {
|
|
323
|
+
return html.replace(BLOCK_TOKEN, (_match, index) => `<div class="${CODE_BLOCK_CLASS}"><pre><code>${escapeHtml(blocks[Number(index)])}</code></pre><button type="button" class="${CODE_COPY_CLASS}" aria-label="Copy code">Copy</button></div>`);
|
|
324
|
+
}
|
|
325
|
+
function renderBlocks(text) {
|
|
326
|
+
const out = [];
|
|
327
|
+
let paragraph = [];
|
|
328
|
+
let quote = [];
|
|
329
|
+
let list = null;
|
|
330
|
+
const flush = () => {
|
|
331
|
+
if (paragraph.length) out.push(`<p>${renderInline(paragraph.join(" "))}</p>`);
|
|
332
|
+
if (quote.length) out.push(`<blockquote>${renderInline(quote.join(" "))}</blockquote>`);
|
|
333
|
+
if (list) out.push(`<${list.tag}>${list.items.map((item) => `<li>${renderInline(item)}</li>`).join("")}</${list.tag}>`);
|
|
334
|
+
paragraph = [];
|
|
335
|
+
quote = [];
|
|
336
|
+
list = null;
|
|
337
|
+
};
|
|
338
|
+
for (const raw of text.split("\n")) {
|
|
339
|
+
const line = raw.trim();
|
|
340
|
+
if (!line) {
|
|
341
|
+
flush();
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
if (BLOCK_TOKEN_LINE.test(line)) {
|
|
345
|
+
flush();
|
|
346
|
+
out.push(line);
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
if (/^(?:-{3,}|\*{3,}|_{3,})$/.test(line)) {
|
|
350
|
+
flush();
|
|
351
|
+
out.push("<hr>");
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
const heading = line.match(/^(#{1,6})\s+(.+)$/);
|
|
355
|
+
if (heading) {
|
|
356
|
+
flush();
|
|
357
|
+
const level = Math.min(heading[1].length, 3) + 3;
|
|
358
|
+
out.push(`<h${level}>${renderInline(heading[2])}</h${level}>`);
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
const bullet = line.match(/^[-*+]\s+(.+)$/);
|
|
362
|
+
if (bullet) {
|
|
363
|
+
if (list?.tag !== "ul") {
|
|
364
|
+
flush();
|
|
365
|
+
list = {
|
|
366
|
+
tag: "ul",
|
|
367
|
+
items: []
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
list.items.push(bullet[1]);
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
const ordered = line.match(/^\d{1,9}[.)]\s+(.+)$/);
|
|
374
|
+
if (ordered) {
|
|
375
|
+
if (list?.tag !== "ol") {
|
|
376
|
+
flush();
|
|
377
|
+
list = {
|
|
378
|
+
tag: "ol",
|
|
379
|
+
items: []
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
list.items.push(ordered[1]);
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
const quoted = line.match(/^>\s?(.*)$/);
|
|
386
|
+
if (quoted) {
|
|
387
|
+
if (!quote.length) flush();
|
|
388
|
+
quote.push(quoted[1]);
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
if (list && list.items.length) {
|
|
392
|
+
list.items[list.items.length - 1] += ` ${line}`;
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
if (quote.length) {
|
|
396
|
+
quote[quote.length - 1] += ` ${line}`;
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
paragraph.push(line);
|
|
400
|
+
}
|
|
401
|
+
flush();
|
|
402
|
+
return out.join("");
|
|
403
|
+
}
|
|
404
|
+
function renderInline(value) {
|
|
405
|
+
const codeSpans = [];
|
|
406
|
+
return escapeHtml(value).replace(/`([^`\n]+)`/g, (_match, code) => {
|
|
407
|
+
codeSpans.push(code);
|
|
408
|
+
return `${INLINE_MARKER}${codeSpans.length - 1}${INLINE_MARKER}`;
|
|
409
|
+
}).replace(/(?<!!)\[([^\]\n]+)\]\(([^)\s]+)\)/g, (match, label, href) => SAFE_HREF.test(href) ? `<a href="${href}" target="_blank" rel="noopener noreferrer">${label}</a>` : match).replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>").replace(/__([^_\n]+)__/g, "<strong>$1</strong>").replace(/(^|[^*\w])\*([^*\n]+)\*/g, "$1<em>$2</em>").replace(/(^|[^_\w])_([^_\n]+)_(?![\w_])/g, "$1<em>$2</em>").replace(INLINE_TOKEN, (_match, index) => `<code>${codeSpans[Number(index)]}</code>`);
|
|
410
|
+
}
|
|
411
|
+
//#endregion
|
|
412
|
+
//#region ../../packages/review-harness/src/domain/utils/model_label.ts
|
|
413
|
+
var KNOWN_BRANDS = {
|
|
414
|
+
gpt: "GPT",
|
|
415
|
+
claude: "Claude"
|
|
416
|
+
};
|
|
417
|
+
var isNumericToken = (token) => /^\d+(\.\d+)?$/.test(token);
|
|
418
|
+
function titleCase$1(token) {
|
|
419
|
+
return token.charAt(0).toUpperCase() + token.slice(1);
|
|
420
|
+
}
|
|
421
|
+
function humanizeModelId(id) {
|
|
422
|
+
const tokens = id.split("-").filter(Boolean);
|
|
423
|
+
if (tokens.length === 0) return id;
|
|
424
|
+
const [brandToken, ...rest] = tokens;
|
|
425
|
+
let display = KNOWN_BRANDS[brandToken.toLowerCase()] ?? titleCase$1(brandToken);
|
|
426
|
+
let prevWasNumeric = false;
|
|
427
|
+
rest.forEach((token, index) => {
|
|
428
|
+
const numeric = isNumericToken(token);
|
|
429
|
+
if (numeric && prevWasNumeric) display += `.${token}`;
|
|
430
|
+
else if (numeric && index === 0) display += `-${token}`;
|
|
431
|
+
else display += ` ${numeric ? token : titleCase$1(token)}`;
|
|
432
|
+
prevWasNumeric = numeric;
|
|
433
|
+
});
|
|
434
|
+
return display;
|
|
435
|
+
}
|
|
436
|
+
//#endregion
|
|
437
|
+
//#region ../../packages/review-harness/src/domain/ui/components/pill_tooltips.ts
|
|
438
|
+
/**
|
|
439
|
+
* What every pill in the sidebar actually means, in one place.
|
|
440
|
+
*
|
|
441
|
+
* A pill is a single uppercase word standing in for a whole axis of judgement, and the axes are
|
|
442
|
+
* not self-evident: MAJOR and "medium confidence" sit side by side on the same card and answer
|
|
443
|
+
* completely different questions. Severity is how much the finding would matter *if it is real*;
|
|
444
|
+
* confidence is how sure the analysis is that it *is* real. A reviewer who has collapsed those
|
|
445
|
+
* into one mental "how bad is this" scale is reading the card wrong, and no amount of colour
|
|
446
|
+
* fixes that — only words do.
|
|
447
|
+
*
|
|
448
|
+
* The wording is derived from the field guidance the model is actually given (see
|
|
449
|
+
* STRUCTURED_OUTPUT_INSTRUCTIONS in ai_review/pipeline.ts). This file must not invent a richer
|
|
450
|
+
* taxonomy than the prompt asks for: if the prompt does not define what separates "major" from
|
|
451
|
+
* "minor", neither does the tooltip. It names the axis and where the value sits on it, and
|
|
452
|
+
* leaves the grading to the model that did the grading.
|
|
453
|
+
*
|
|
454
|
+
* House style, because a tooltip is read once at a glance and never studied:
|
|
455
|
+
*
|
|
456
|
+
* 1. Open with the concept, not a sentence the reader has to parse into one. "PR risk — the
|
|
457
|
+
* overall risk level assigned to this pull request" lands before the clause finishes;
|
|
458
|
+
* "Overall risk this analysis assigned the pull request" makes them unpick the grammar first.
|
|
459
|
+
* 2. Describe what the thing *is*, never how the extension builds it. A follow-up "builds on the
|
|
460
|
+
* original analysis" — that it is composed into the stored session is our problem, not theirs.
|
|
461
|
+
* 3. Plain reviewer's English over product vocabulary. "During your review", not "before you
|
|
462
|
+
* sign off"; "the section of the Guide", not the section's own title read back at them.
|
|
463
|
+
*/
|
|
464
|
+
/** Ordered worst-first, matching how the prompt lists the enum. */
|
|
465
|
+
var SEVERITY_SCALE = [
|
|
466
|
+
"critical",
|
|
467
|
+
"major",
|
|
468
|
+
"minor",
|
|
469
|
+
"nit"
|
|
470
|
+
];
|
|
471
|
+
var ATTENTION_SCALE = [
|
|
472
|
+
"high",
|
|
473
|
+
"medium",
|
|
474
|
+
"low"
|
|
475
|
+
];
|
|
476
|
+
/**
|
|
477
|
+
* "2 of 4: critical → major → minor → nit" — the value's rank and the whole scale it sits on.
|
|
478
|
+
*
|
|
479
|
+
* Both halves earn their place: the rank alone leaves the reader guessing what the other steps
|
|
480
|
+
* are, and the scale alone makes them count. An arrow rather than a bullet or comma, so the
|
|
481
|
+
* ordering is visible rather than something the reader has to already know.
|
|
482
|
+
*/
|
|
483
|
+
function position(value, scale) {
|
|
484
|
+
const index = scale.indexOf(value);
|
|
485
|
+
if (index === -1) return scale.join(" → ");
|
|
486
|
+
return `${index + 1} of ${scale.length}: ${scale.join(" → ")}`;
|
|
487
|
+
}
|
|
488
|
+
function severityTip(severity) {
|
|
489
|
+
return `Severity — how much this would matter if it turns out to be real. ${position(severity, SEVERITY_SCALE)}.`;
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Only medium and low ever reach the screen (renderIssueConfidence suppresses "high"), but all
|
|
493
|
+
* three are worded here so the copy does not depend on that rendering decision staying put.
|
|
494
|
+
*
|
|
495
|
+
* The second sentence is the one that matters. Confidence and severity sit side by side on the
|
|
496
|
+
* same card, and a reader who has not been told otherwise will assume they are the same scale
|
|
497
|
+
* read twice — so the tooltip says they are not, in as many words.
|
|
498
|
+
*/
|
|
499
|
+
function confidenceTip(confidence) {
|
|
500
|
+
return `Confidence — how sure the analysis is that this issue is real. This is separate from how serious it would be. ${{
|
|
501
|
+
high: "High means the analysis is confident it’s real.",
|
|
502
|
+
medium: "Medium means it’s worth confirming in the code before acting on it.",
|
|
503
|
+
low: "Low means it was likely reasoned from the diff alone, so check the surrounding code first."
|
|
504
|
+
}[confidence]}`;
|
|
505
|
+
}
|
|
506
|
+
function importanceTip(importance) {
|
|
507
|
+
return `Importance — how much attention this question deserves during your review. ${position(importance, ATTENTION_SCALE)}.`;
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* The four statuses an "Analyse new changes" pass assigns to a finding.
|
|
511
|
+
*
|
|
512
|
+
* Each says what the *product* concluded and, where it matters, how much to trust it. "Still open"
|
|
513
|
+
* carries the load: it is both a real judgement ("the changes do not address this") and the safe
|
|
514
|
+
* default applied when the analysis said nothing about a finding at all, and the reviewer cannot
|
|
515
|
+
* tell those apart from the badge — so the copy commits to the weaker of the two readings rather
|
|
516
|
+
* than claiming a judgement that may not have been made.
|
|
517
|
+
*/
|
|
518
|
+
function findingStatusTip(status) {
|
|
519
|
+
return {
|
|
520
|
+
resolved: "Resolved — the new commits appear to address this finding. Worth a glance to confirm before you close it out.",
|
|
521
|
+
"still-open": "Still open — the new commits do not address this finding. Findings the analysis did not comment on are shown this way too, so this never disappears on its own.",
|
|
522
|
+
affected: "Affected — the code this finding concerns has changed, but not in a way that settles it. Re-read this one.",
|
|
523
|
+
new: "New — introduced by the changes pushed since your previous analysis."
|
|
524
|
+
}[status];
|
|
525
|
+
}
|
|
526
|
+
/** The reviewer-facing label for a status. Kept beside the tooltips so the two cannot drift. */
|
|
527
|
+
function findingStatusLabel(status) {
|
|
528
|
+
return {
|
|
529
|
+
resolved: "Resolved",
|
|
530
|
+
"still-open": "Still open",
|
|
531
|
+
affected: "Affected",
|
|
532
|
+
new: "New"
|
|
533
|
+
}[status];
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* What the commits pushed since did to an open question.
|
|
537
|
+
*
|
|
538
|
+
* "Still open" carries the same double load it does on a finding: a real judgement, and the default
|
|
539
|
+
* for a question the reassessment never mentioned. The copy commits to the weaker reading, because
|
|
540
|
+
* the reviewer must not read silence as an answer.
|
|
541
|
+
*/
|
|
542
|
+
function questionStatusTip(status) {
|
|
543
|
+
return {
|
|
544
|
+
answered: "Answered — the new commits settle this, and the answer is below. Worth a glance to confirm before you drop it.",
|
|
545
|
+
"still-open": "Still open — the new commits do not settle this. Questions the reassessment did not comment on are shown this way too, so this never disappears on its own.",
|
|
546
|
+
affected: "Affected — the code this question is about has changed without answering it. Read the question again before you ask it."
|
|
547
|
+
}[status];
|
|
548
|
+
}
|
|
549
|
+
/** The reviewer-facing label for a question status. Kept beside the tooltip so the two cannot drift. */
|
|
550
|
+
function questionStatusLabel(status) {
|
|
551
|
+
return {
|
|
552
|
+
answered: "Answered",
|
|
553
|
+
"still-open": "Still open",
|
|
554
|
+
affected: "Affected"
|
|
555
|
+
}[status];
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* The four impacts a repository investigation assigns to a finding.
|
|
559
|
+
*
|
|
560
|
+
* Every one of these says the evidence came from *outside* the diff, because that is the whole
|
|
561
|
+
* reason to trust them more than the analysis they revise — and equally the reason to say where
|
|
562
|
+
* they came from. "A coding agent read the repository" is the qualification, and a reviewer who
|
|
563
|
+
* does not know that cannot weigh it.
|
|
564
|
+
*
|
|
565
|
+
* "Investigated" carries the same load "Still open" does on the other axis: it is both a real
|
|
566
|
+
* judgement ("this evidence does not bear on that finding") and the default applied when the
|
|
567
|
+
* reassessment said nothing about a finding at all. The copy commits to the weaker reading rather
|
|
568
|
+
* than claiming a judgement that may not have been made.
|
|
569
|
+
*/
|
|
570
|
+
function investigationImpactTip(impact) {
|
|
571
|
+
return {
|
|
572
|
+
confirmed: "Confirmed — a repository investigation found evidence supporting this finding. It no longer rests on the diff alone.",
|
|
573
|
+
refuted: "Refuted — a repository investigation found evidence that this concern does not hold. Kept on the list, with the reasoning, rather than removed.",
|
|
574
|
+
revised: "Revised — a repository investigation changed what this finding says. Where its severity or confidence moved, the previous value is shown alongside.",
|
|
575
|
+
unchanged: "Investigated — this finding was reassessed against a repository investigation and stands as it was. Findings the reassessment did not comment on are shown this way too."
|
|
576
|
+
}[impact];
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* The reviewer-facing label. Note `unchanged` → "Investigated": the reviewer's world contains a
|
|
580
|
+
* finding that was looked into and survived, which is information. "Unchanged" describes our
|
|
581
|
+
* bookkeeping — what the field did — and says nothing about the code.
|
|
582
|
+
*/
|
|
583
|
+
function investigationImpactLabel(impact) {
|
|
584
|
+
return {
|
|
585
|
+
confirmed: "Confirmed",
|
|
586
|
+
refuted: "Refuted",
|
|
587
|
+
revised: "Revised",
|
|
588
|
+
unchanged: "Investigated"
|
|
589
|
+
}[impact];
|
|
590
|
+
}
|
|
591
|
+
var SECTION_TAG_TIP = "Guide section — the section of the Guide this finding belongs to. Click to open it in the Guide tab.";
|
|
592
|
+
/**
|
|
593
|
+
* Builds a pill's `class` and `data-tip` attributes together.
|
|
594
|
+
*
|
|
595
|
+
* They are emitted as one string rather than left to the call site because they are useless
|
|
596
|
+
* apart: `data-tip` without `pr-helper-tip` never renders, and `pr-helper-tip` without a tip
|
|
597
|
+
* renders an empty black rectangle on hover. Taking the pill's own classes as an argument means
|
|
598
|
+
* there is no way to add the tooltip and clobber the styling, which is what a bare
|
|
599
|
+
* `class="pr-helper-tip"` helper would have invited.
|
|
600
|
+
*
|
|
601
|
+
* Emits a leading space so it drops straight in after a tag name.
|
|
602
|
+
*/
|
|
603
|
+
function pillAttrs(classNames, tip) {
|
|
604
|
+
return ` class="${escapeHtml(classNames)} pr-helper-tip" data-tip="${escapeHtml(tip)}"`;
|
|
605
|
+
}
|
|
606
|
+
//#endregion
|
|
607
|
+
//#region ../../packages/review-harness/src/domain/ai_review/investigation_record.ts
|
|
608
|
+
/**
|
|
609
|
+
* The records for one finding, oldest first. Records accumulate: every "Investigate Again" appends.
|
|
610
|
+
*/
|
|
611
|
+
function investigationsForFinding(records, findingKind, findingId) {
|
|
612
|
+
return (records ?? []).filter((r) => r.findingKind === findingKind && r.findingId === findingId);
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* The record that decides which buttons a finding shows — the newest, because a reviewer who has
|
|
616
|
+
* just asked again is mid-investigation regardless of what earlier rounds concluded.
|
|
617
|
+
*/
|
|
618
|
+
function newestInvestigationForFinding(records, findingKind, findingId) {
|
|
619
|
+
const matches = investigationsForFinding(records, findingKind, findingId);
|
|
620
|
+
return matches.length ? matches[matches.length - 1] : null;
|
|
621
|
+
}
|
|
622
|
+
//#endregion
|
|
623
|
+
//#region ../../packages/review-harness/src/domain/ui/components/review_tabs.ts
|
|
624
|
+
function emptyState(message) {
|
|
625
|
+
return `<p class="pr-helper-review-empty">${escapeHtml(message)}</p>`;
|
|
626
|
+
}
|
|
627
|
+
function renderNoReviewState(iconUrl) {
|
|
628
|
+
return `
|
|
629
|
+
<div class="pr-helper-review-intro">
|
|
630
|
+
${iconUrl ? `<img src="${escapeHtml(iconUrl)}" class="pr-helper-intro-mark" alt="" />` : ""}
|
|
631
|
+
<p class="pr-helper-intro-brand">PR Review Buddy</p>
|
|
632
|
+
<h3 class="pr-helper-intro-title">No analysis yet</h3>
|
|
633
|
+
<p class="pr-helper-intro-body">Analyse this pull request to unlock summaries, issues, questions and the Assistant.</p>
|
|
634
|
+
<button type="button" class="pr-helper-generate-review-button">Generate Analysis</button>
|
|
635
|
+
</div>
|
|
636
|
+
`;
|
|
637
|
+
}
|
|
638
|
+
function titleCase(value) {
|
|
639
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
640
|
+
}
|
|
641
|
+
function hasExplainedSimply(session) {
|
|
642
|
+
return session.chat.some((m) => m.kind === "explain-simply");
|
|
643
|
+
}
|
|
644
|
+
var PROVIDER_DISPLAY_NAME = {
|
|
645
|
+
anthropic: "Claude",
|
|
646
|
+
openai: "GPT",
|
|
647
|
+
gemini: "Gemini",
|
|
648
|
+
openai_compatible: "the model"
|
|
649
|
+
};
|
|
650
|
+
var THINKING_PHRASES = [
|
|
651
|
+
"Buddy is reading the changes…",
|
|
652
|
+
"Buddy is connecting the dots…",
|
|
653
|
+
"Buddy is gathering context…",
|
|
654
|
+
"Buddy is preparing your analysis…"
|
|
655
|
+
];
|
|
656
|
+
var THINKING_PHRASE_CYCLE_SECONDS = 15;
|
|
657
|
+
function renderThinkingPhrases() {
|
|
658
|
+
const phrases = [...THINKING_PHRASES];
|
|
659
|
+
for (let i = phrases.length - 1; i > 0; i--) {
|
|
660
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
661
|
+
[phrases[i], phrases[j]] = [phrases[j], phrases[i]];
|
|
662
|
+
}
|
|
663
|
+
const segment = THINKING_PHRASE_CYCLE_SECONDS / phrases.length;
|
|
664
|
+
return phrases.map((phrase, i) => `<p class="pr-helper-thinking-phrase" style="animation-delay:${(i * segment).toFixed(2)}s">${escapeHtml(phrase)}</p>`).join("");
|
|
665
|
+
}
|
|
666
|
+
var CANCEL_BUTTON = `<button type="button" class="pr-helper-cancel-button">Cancel</button>`;
|
|
667
|
+
function renderGeneratingOverview(session, iconUrl) {
|
|
668
|
+
const activeIndex = session.progress.findIndex((step) => !step.done);
|
|
669
|
+
const brandMark = iconUrl ? `<img src="${escapeHtml(iconUrl)}" class="pr-helper-brand-mark" alt="" />` : "";
|
|
670
|
+
const poweredByName = session.provider === "openai_compatible" ? humanizeModelId(session.model) : PROVIDER_DISPLAY_NAME[session.provider];
|
|
671
|
+
const isThinking = activeIndex === 2;
|
|
672
|
+
return `
|
|
673
|
+
<div class="pr-helper-generating pr-helper-generating-focused">
|
|
674
|
+
<div class="pr-helper-generating-body">
|
|
675
|
+
${brandMark}
|
|
676
|
+
${isThinking ? `<div class="pr-helper-thinking-phrases">${renderThinkingPhrases()}</div>
|
|
677
|
+
<p class="pr-helper-generating-title pr-helper-direct-headline" hidden>This is taking longer than usual.</p>` : `<p class="pr-helper-generating-title">${activeIndex >= 0 && activeIndex < 2 ? "Preparing analysis" : "Finalizing analysis"}</p>`}
|
|
678
|
+
<div class="pr-helper-thinking-dots"><span></span><span></span><span></span></div>
|
|
679
|
+
<div class="pr-helper-progress-bar pr-helper-progress-bar-shimmer"></div>
|
|
680
|
+
${isThinking ? `<p class="pr-helper-generating-elapsed"></p>
|
|
681
|
+
<p class="pr-helper-escalation-hint" hidden>AI providers occasionally experience delays.</p>` : ""}
|
|
682
|
+
</div>
|
|
683
|
+
<div class="pr-helper-generating-footer">
|
|
684
|
+
<p class="pr-helper-generating-powered-by">Powered by ${escapeHtml(poweredByName)}</p>
|
|
685
|
+
${CANCEL_BUTTON}
|
|
686
|
+
</div>
|
|
687
|
+
</div>
|
|
688
|
+
`;
|
|
689
|
+
}
|
|
690
|
+
function renderFailedOverview(session) {
|
|
691
|
+
return `
|
|
692
|
+
<div class="pr-helper-generating-error">
|
|
693
|
+
<p class="pr-helper-generating-error-text">${escapeHtml(session.error ?? "The AI analysis failed.")}</p>
|
|
694
|
+
<button type="button" class="pr-helper-retry-button">Retry</button>
|
|
695
|
+
</div>
|
|
696
|
+
`;
|
|
697
|
+
}
|
|
698
|
+
function renderOverviewStat(value, label, tab, options = {}) {
|
|
699
|
+
const { info, ariaLabel } = options;
|
|
700
|
+
const wide = typeof value === "string" ? " pr-helper-overview-stat-value-wide" : "";
|
|
701
|
+
const icon = info ? `<span class="pr-helper-overview-stat-info" data-overview-info data-tip="${escapeHtml(info)}" aria-hidden="true"><svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg></span>` : "";
|
|
702
|
+
return `<button type="button" class="pr-helper-overview-stat" data-overview-nav="${tab}"${ariaLabel ? ` aria-label="${escapeHtml(ariaLabel)}"` : ""}><span class="pr-helper-overview-stat-value${wide}">${escapeHtml(String(value))}</span><span class="pr-helper-overview-stat-label">${icon}${escapeHtml(label)}<span class="pr-helper-overview-stat-arrow" aria-hidden="true">→</span></span></button>`;
|
|
703
|
+
}
|
|
704
|
+
function renderCoverageStat(session, reviewedFiles) {
|
|
705
|
+
const coverage = composeReview(session)?.fileCoverage;
|
|
706
|
+
if (!coverage || coverage.totalChangedFiles <= reviewedFiles) return renderOverviewStat(reviewedFiles, "Files", "files");
|
|
707
|
+
if (coverage.repositoryAvailable) return renderOverviewStat(coverage.totalChangedFiles, "Files", "files");
|
|
708
|
+
return renderOverviewStat(`${reviewedFiles} / ${coverage.totalChangedFiles}`, "Files", "files", {
|
|
709
|
+
info: `All ${coverage.totalChangedFiles} changed files were triaged. ${coverage.unplanned ? "No analysis plan was produced, so the files analysed in depth were chosen by size of change." : "An analysis plan selected these for in-depth analysis; the rest are accounted for in the Guide and the Files tab."}`,
|
|
710
|
+
ariaLabel: `Analysis coverage: ${reviewedFiles} of ${coverage.totalChangedFiles} changed files analysed`
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Provenance — how the review was produced, as opposed to what it found.
|
|
715
|
+
*
|
|
716
|
+
* These facts used to sit as two paragraphs in the middle of the Overview, between the summary
|
|
717
|
+
* and the suggested path. That was the wrong place: the Overview answers what the risk is, what
|
|
718
|
+
* is changing, where to start and what to watch for, and none of this answers any of them. It is
|
|
719
|
+
* the reviewer's right to audit how the review was made, but not at the cost of the four
|
|
720
|
+
* questions they came for — so it collapses, and becomes the home for any future metadata.
|
|
721
|
+
*/
|
|
722
|
+
function coverageDetails(session) {
|
|
723
|
+
const coverage = composeReview(session)?.fileCoverage;
|
|
724
|
+
if (!coverage) return [];
|
|
725
|
+
const details = [];
|
|
726
|
+
const skipped = coverage.totalChangedFiles - coverage.reviewedFiles;
|
|
727
|
+
if (coverage.repositoryAvailable) {
|
|
728
|
+
const inline = coverage.reviewedPaths?.length;
|
|
729
|
+
details.push(inline === void 0 ? `All ${coverage.totalChangedFiles} changed files were named for the analysis, which had the repository open and could read any of them.` : `All ${coverage.totalChangedFiles} changed files were named for the analysis, which had the repository open: ${inline} arrived with ${inline === 1 ? "its diff" : "their diffs"} inline and it could read the rest directly.`);
|
|
730
|
+
return details;
|
|
731
|
+
}
|
|
732
|
+
details.push(skipped > 0 ? `All ${coverage.totalChangedFiles} changed files were triaged; ${coverage.reviewedFiles} ${coverage.reviewedFiles === 1 ? "was" : "were"} analysed in depth. Generated, vendored and lockfile changes were excluded, and the remaining files are available in the Files tab for your own review.` : `All ${coverage.totalChangedFiles} changed files were triaged and analysed in depth.`);
|
|
733
|
+
if (coverage.unplanned && skipped > 0) details.push("No analysis plan was produced for this PR, so the files analysed in depth were chosen by size of change.");
|
|
734
|
+
if (coverage.capBound) details.push(`The analysis plan identified more files than one pass can cover, so it was cut off at ${coverage.reviewedFiles}; the areas it ranked lowest were not reached.`);
|
|
735
|
+
const outstanding = coverage.partialFiles;
|
|
736
|
+
if (outstanding?.length) {
|
|
737
|
+
const n = outstanding.length;
|
|
738
|
+
const scale = n === 1 ? `${outstanding[0].path} (${outstanding[0].linesRead} of ${outstanding[0].linesTotal} diff lines read)` : `${n} files`;
|
|
739
|
+
details.push(`A further pass read on from where the first stopped, and ${n === 1 ? "one file is" : `${n} files are`} still larger than that could cover: ${scale}. The analysis was told exactly which lines it had seen, and asked to qualify anything it concluded from them.`);
|
|
740
|
+
} else if (outstanding === void 0 && coverage.partiallyReadFiles) {
|
|
741
|
+
const n = coverage.partiallyReadFiles;
|
|
742
|
+
details.push(`${n} ${n === 1 ? "file was" : "files were"} too large for the whole diff to fit in one pass, so ${n === 1 ? "it was" : "they were"} read in part. The analysis was told which, and asked to qualify anything it concluded from them.`);
|
|
743
|
+
}
|
|
744
|
+
return details;
|
|
745
|
+
}
|
|
746
|
+
function renderReviewDetails(session) {
|
|
747
|
+
const items = [...discussionDetails(session), ...coverageDetails(session)];
|
|
748
|
+
if (items.length === 0) return "";
|
|
749
|
+
return `
|
|
750
|
+
<details class="pr-helper-review-details">
|
|
751
|
+
<summary class="pr-helper-review-details-summary">Analysis details</summary>
|
|
752
|
+
<ul class="pr-helper-review-details-list">
|
|
753
|
+
${items.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}
|
|
754
|
+
</ul>
|
|
755
|
+
</details>
|
|
756
|
+
`;
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* The risk verdict, qualified when the review could not see enough to give one outright.
|
|
760
|
+
*
|
|
761
|
+
* The trap this avoids: "coverage below some percentage means provisional" fires on every large
|
|
762
|
+
* PR, which makes it noise, and it is a threshold rule of exactly the kind invariant 0 rejects.
|
|
763
|
+
* The gate is instead the plan's *own* stated priority — an area the model itself called high
|
|
764
|
+
* priority, which the review then did not cover. The model supplied the judgement; the extension
|
|
765
|
+
* only checked it against what actually happened.
|
|
766
|
+
*
|
|
767
|
+
* Two badges would be one too many, so the qualifier sits inline with the risk label rather than
|
|
768
|
+
* competing with it: the verdict is still the headline, it just no longer claims to be final.
|
|
769
|
+
*/
|
|
770
|
+
function renderProvisionalBadge(session) {
|
|
771
|
+
if ((session.metadata.unreviewedHighPriority ?? []).length === 0) return "";
|
|
772
|
+
return `<span class="pr-helper-risk-provisional">Provisional</span>`;
|
|
773
|
+
}
|
|
774
|
+
function renderProvisionalReason(session) {
|
|
775
|
+
const areas = session.metadata.unreviewedHighPriority ?? [];
|
|
776
|
+
if (areas.length === 0) return "";
|
|
777
|
+
const named = areas.map((a) => escapeHtml(a)).join(", ");
|
|
778
|
+
return `<p class="pr-helper-risk-provisional-reason">The analysis plan called ${areas.length === 1 ? "this area" : "these areas"} high priority but ${areas.length === 1 ? "it was" : "they were"} not analysed in depth: ${named}. Treat the verdict above as provisional until ${areas.length === 1 ? "it has" : "they have"} been looked at.</p>`;
|
|
779
|
+
}
|
|
780
|
+
function renderOverviewTab(session, iconUrl = "") {
|
|
781
|
+
if (!session) return renderNoReviewState(iconUrl);
|
|
782
|
+
if (session.status === "generating") return renderGeneratingOverview(session, iconUrl);
|
|
783
|
+
if (session.status === "failed") return renderFailedOverview(session);
|
|
784
|
+
const result = composeReview(session);
|
|
785
|
+
if (!result) return emptyState("No analysis yet.");
|
|
786
|
+
const nextSteps = result.nextSteps.map((step) => `<li class="pr-helper-markdown">${renderMarkdown(step)}</li>`).join("") || "<li>None</li>";
|
|
787
|
+
const metrics = deriveReviewMetrics(result);
|
|
788
|
+
return `
|
|
789
|
+
<div class="pr-helper-risk-headline">
|
|
790
|
+
<div class="pr-helper-risk-headline-top">
|
|
791
|
+
<span class="pr-helper-risk-dot pr-helper-risk-dot-${result.overallRisk}"></span>
|
|
792
|
+
<span class="pr-helper-risk-headline-label">${escapeHtml(titleCase(result.overallRisk))} Risk</span>
|
|
793
|
+
${renderProvisionalBadge(session)}
|
|
794
|
+
</div>
|
|
795
|
+
${renderProvisionalReason(session)}
|
|
796
|
+
${result.recommendation ? `<div class="pr-helper-risk-headline-takeaway pr-helper-markdown">${renderMarkdown(result.recommendation)}</div>` : ""}
|
|
797
|
+
</div>
|
|
798
|
+
<div class="pr-helper-overview-stats">
|
|
799
|
+
${renderOverviewStat(metrics.sections, "Themes", "guide")}
|
|
800
|
+
${renderOverviewStat(metrics.issues, "Issues", "issues")}
|
|
801
|
+
${renderOverviewStat(metrics.questions, "Questions", "questions")}
|
|
802
|
+
${renderCoverageStat(session, metrics.files)}
|
|
803
|
+
</div>
|
|
804
|
+
<div class="pr-helper-review-summary pr-helper-markdown">${renderMarkdown(result.summary)}</div>
|
|
805
|
+
${renderSuggestedPath(result.sections, "Suggested review path")}
|
|
806
|
+
${hasExplainedSimply(session) ? `<p class="pr-helper-explain-simply-done">Explained in simple terms. See the Assistant tab.</p>` : `<button type="button" class="pr-helper-explain-simply-button">Explain in simple terms</button>`}
|
|
807
|
+
${renderReviewDetails(session)}
|
|
808
|
+
<h4>Next steps</h4>
|
|
809
|
+
<ul>${nextSteps}</ul>
|
|
810
|
+
`;
|
|
811
|
+
}
|
|
812
|
+
function renderFileLocation(file, className, line) {
|
|
813
|
+
if (!file) return "";
|
|
814
|
+
const { dir, name } = splitFilePath(file);
|
|
815
|
+
const suffix = line ? `:${line}` : "";
|
|
816
|
+
const dirLine = dir ? `<span class="pr-helper-path-dir">${escapeHtml(dir)}</span>` : "";
|
|
817
|
+
const lineAttr = line ? ` data-line="${line}"` : "";
|
|
818
|
+
return `<span class="${className}" data-file="${escapeHtml(file)}"${lineAttr} title="${escapeHtml(file + suffix)}">${dirLine}<span class="pr-helper-path-name">${escapeHtml(name + suffix)}</span></span>`;
|
|
819
|
+
}
|
|
820
|
+
function discussionDetails(session) {
|
|
821
|
+
const stats = session.metadata.discussionStats;
|
|
822
|
+
if (!stats) return [];
|
|
823
|
+
const conversation = stats.conversationComments ?? 0;
|
|
824
|
+
const decisions = stats.reviewDecisions ?? 0;
|
|
825
|
+
const reviewThreads = stats.resolvedThreads === void 0 ? Math.max(0, stats.totalThreads - conversation) : stats.unresolvedThreads + stats.resolvedThreads;
|
|
826
|
+
const parts = [];
|
|
827
|
+
if (reviewThreads > 0) {
|
|
828
|
+
const outdated = stats.outdatedThreads ?? 0;
|
|
829
|
+
const state = stats.unresolvedThreads > 0 ? `${stats.unresolvedThreads} unresolved${outdated > 0 ? `, ${outdated} on outdated lines` : ""}` : "all resolved";
|
|
830
|
+
parts.push(`${reviewThreads} ${plural(reviewThreads, "review thread")} (${state})`);
|
|
831
|
+
}
|
|
832
|
+
if (conversation > 0) parts.push(`${conversation} ${plural(conversation, "conversation comment")}`);
|
|
833
|
+
if (decisions > 0) parts.push(`${decisions} ${plural(decisions, "review decision")}`);
|
|
834
|
+
return parts;
|
|
835
|
+
}
|
|
836
|
+
function plural(count, word) {
|
|
837
|
+
return count === 1 ? word : `${word}s`;
|
|
838
|
+
}
|
|
839
|
+
function renderExistingDiscussion(ref) {
|
|
840
|
+
if (!ref) return "";
|
|
841
|
+
const state = ref.status === "resolved" ? "thread since marked resolved" : "thread still unresolved";
|
|
842
|
+
const label = `<strong class="pr-helper-issue-discussion-lead">${escapeHtml(ref.relation === "related" ? "Related discussion" : "Already raised")}</strong>${escapeHtml(` by @${ref.raisedBy} · ${state}`)}`;
|
|
843
|
+
const body = ref.url ? `<a class="pr-helper-issue-discussion-link" href="${escapeHtml(ref.url)}" data-discussion-url="${escapeHtml(ref.url)}" rel="noopener noreferrer">${label}</a>` : label;
|
|
844
|
+
return `<p class="pr-helper-issue-discussion pr-helper-issue-discussion-${escapeHtml(ref.status)}">${body}</p>`;
|
|
845
|
+
}
|
|
846
|
+
function buildSectionLookup(result) {
|
|
847
|
+
const map = /* @__PURE__ */ new Map();
|
|
848
|
+
for (const section of result?.sections ?? []) map.set(section.id, section);
|
|
849
|
+
return map;
|
|
850
|
+
}
|
|
851
|
+
var SECTION_ATTENTION_LABELS = {
|
|
852
|
+
high: "High Priority",
|
|
853
|
+
medium: "Medium Priority",
|
|
854
|
+
low: "Low Priority"
|
|
855
|
+
};
|
|
856
|
+
var WEIGHT_MINUTE_RANGES = {
|
|
857
|
+
1: [2, 4],
|
|
858
|
+
2: [4, 7],
|
|
859
|
+
3: [7, 12],
|
|
860
|
+
4: [12, 18],
|
|
861
|
+
5: [18, 25]
|
|
862
|
+
};
|
|
863
|
+
function estimateMinutesForWeight(weight) {
|
|
864
|
+
return WEIGHT_MINUTE_RANGES[weight];
|
|
865
|
+
}
|
|
866
|
+
function roundedMinutes(sections) {
|
|
867
|
+
const total = sections.reduce((sum, s) => {
|
|
868
|
+
const [min, max] = estimateMinutesForWeight(s.reviewWeight);
|
|
869
|
+
return sum + (min + max) / 2;
|
|
870
|
+
}, 0);
|
|
871
|
+
return Math.round(total / 5) * 5;
|
|
872
|
+
}
|
|
873
|
+
function estimateTotalMinutes(sections) {
|
|
874
|
+
return Math.max(5, roundedMinutes(sections));
|
|
875
|
+
}
|
|
876
|
+
function nextRecommendedSection(sections, completed) {
|
|
877
|
+
const outstanding = sections.filter((s) => !completed.has(s.id));
|
|
878
|
+
if (outstanding.length === 0) return null;
|
|
879
|
+
return outstanding.find((s) => s.recommendedStartingPoint) ?? outstanding[0];
|
|
880
|
+
}
|
|
881
|
+
function deriveSectionStatuses(sections, completed) {
|
|
882
|
+
const active = nextRecommendedSection(sections, completed);
|
|
883
|
+
return new Map(sections.map((s) => [s.id, completed.has(s.id) ? "completed" : s.id === active?.id ? "active" : "pending"]));
|
|
884
|
+
}
|
|
885
|
+
function remainingMinutes(sections, completed) {
|
|
886
|
+
const outstanding = sections.filter((s) => !completed.has(s.id));
|
|
887
|
+
if (outstanding.length === 0) return 0;
|
|
888
|
+
return Math.max(1, roundedMinutes(outstanding));
|
|
889
|
+
}
|
|
890
|
+
var GUIDE_PATH_HEADING = "Buddy's suggested review path";
|
|
891
|
+
function renderSuggestedPath(sections, heading) {
|
|
892
|
+
if (sections.length === 0) return "";
|
|
893
|
+
const items = sections.map((s) => `<li>${escapeHtml(s.title)}</li>`).join("");
|
|
894
|
+
return `
|
|
895
|
+
<div class="pr-helper-suggested-path">
|
|
896
|
+
<h4 class="pr-helper-suggested-path-title">${escapeHtml(heading)}</h4>
|
|
897
|
+
<ol class="pr-helper-suggested-path-list">${items}</ol>
|
|
898
|
+
<p class="pr-helper-suggested-path-time">Estimated review time ≈${estimateTotalMinutes(sections)} minutes</p>
|
|
899
|
+
</div>
|
|
900
|
+
`;
|
|
901
|
+
}
|
|
902
|
+
function renderGuideProgressSummary(sections, completed) {
|
|
903
|
+
const total = sections.length;
|
|
904
|
+
const done = sections.filter((s) => completed.has(s.id)).length;
|
|
905
|
+
if (done === 0) return renderSuggestedPath(sections, GUIDE_PATH_HEADING);
|
|
906
|
+
const counter = `<p class="pr-helper-progress-counter"><span class="pr-helper-progress-check" aria-hidden="true">✓</span>${done} of ${total} theme${total === 1 ? "" : "s"} completed</p>`;
|
|
907
|
+
const bar = `
|
|
908
|
+
<div class="pr-helper-progress-bar" role="progressbar" aria-valuenow="${done}" aria-valuemin="0" aria-valuemax="${total}">
|
|
909
|
+
<div class="pr-helper-progress-bar-fill" style="width: ${Math.round(done / total * 100)}%"></div>
|
|
910
|
+
</div>
|
|
911
|
+
`;
|
|
912
|
+
const next = nextRecommendedSection(sections, completed);
|
|
913
|
+
if (!next) return `
|
|
914
|
+
<div class="pr-helper-suggested-path pr-helper-suggested-path-complete">
|
|
915
|
+
<h4 class="pr-helper-suggested-path-title">Review complete</h4>
|
|
916
|
+
${counter}
|
|
917
|
+
${bar}
|
|
918
|
+
<p class="pr-helper-suggested-path-time">Every theme reviewed. Check the Issues tab before you submit.</p>
|
|
919
|
+
</div>
|
|
920
|
+
`;
|
|
921
|
+
return `
|
|
922
|
+
<div class="pr-helper-suggested-path">
|
|
923
|
+
<h4 class="pr-helper-suggested-path-title">${escapeHtml(GUIDE_PATH_HEADING)}</h4>
|
|
924
|
+
${counter}
|
|
925
|
+
${bar}
|
|
926
|
+
<p class="pr-helper-progress-next"><span class="pr-helper-progress-next-label">Next recommended:</span> ${escapeHtml(next.title)}</p>
|
|
927
|
+
<p class="pr-helper-suggested-path-time">Estimated remaining ≈${remainingMinutes(sections, completed)} minutes</p>
|
|
928
|
+
</div>
|
|
929
|
+
`;
|
|
930
|
+
}
|
|
931
|
+
function renderGuideSectionCard(section, counts, activeGuidedSectionId, status, activeBadgeLabel) {
|
|
932
|
+
const files = section.files.map((f) => {
|
|
933
|
+
const focus = f.focus ? `<div class="pr-helper-guide-file-focus">${renderInlineText(f.focus)}</div>` : "";
|
|
934
|
+
const { dir, name } = splitFilePath(f.path);
|
|
935
|
+
const dirLine = dir ? `<span class="pr-helper-path-dir">${escapeHtml(dir)}</span>` : "";
|
|
936
|
+
return `<li class="pr-helper-guide-file" data-file="${escapeHtml(f.path)}" data-guide-section="${escapeHtml(section.id)}" title="${escapeHtml(f.path)}">${dirLine}<span class="pr-helper-path-name">${escapeHtml(name)}</span>${focus}</li>`;
|
|
937
|
+
}).join("") || "<li class=\"pr-helper-review-empty\">No files listed.</li>";
|
|
938
|
+
const checklist = section.checklist.map((item) => `<li>${renderInlineText(item)}</li>`).join("") || "<li>None</li>";
|
|
939
|
+
const [minMinutes, maxMinutes] = estimateMinutesForWeight(section.reviewWeight);
|
|
940
|
+
const statParts = [`${section.files.length} file${section.files.length === 1 ? "" : "s"}`];
|
|
941
|
+
if (counts.issues > 0) statParts.push(`${counts.issues} issue${counts.issues === 1 ? "" : "s"}`);
|
|
942
|
+
if (counts.questions > 0) statParts.push(`${counts.questions} question${counts.questions === 1 ? "" : "s"}`);
|
|
943
|
+
statParts.push(`~${minMinutes}-${maxMinutes} min`);
|
|
944
|
+
const isActive = activeGuidedSectionId === section.id;
|
|
945
|
+
const isCompleted = status === "completed";
|
|
946
|
+
const check = isCompleted ? `<span class="pr-helper-guide-check" aria-hidden="true">✓</span>` : "";
|
|
947
|
+
const statusBadge = isCompleted ? `<span class="pr-helper-guide-badge pr-helper-completed-badge">Completed</span>` : status === "active" ? `<span class="pr-helper-guide-badge pr-helper-start-here-badge">${escapeHtml(activeBadgeLabel)}</span>` : "";
|
|
948
|
+
const undo = isCompleted ? `<button type="button" class="pr-helper-guide-uncomplete" data-guide-uncomplete="${escapeHtml(section.id)}">Mark as not reviewed</button>` : "";
|
|
949
|
+
return `
|
|
950
|
+
<section class="pr-helper-guide-section${isCompleted ? " pr-helper-guide-section-completed" : ""}" id="pr-helper-guide-section-${escapeHtml(section.id)}" data-guide-section-card="${escapeHtml(section.id)}">
|
|
951
|
+
<button type="button" class="pr-helper-guide-section-header" data-guide-toggle="${escapeHtml(section.id)}">
|
|
952
|
+
<div class="pr-helper-guide-section-badges">
|
|
953
|
+
${check}
|
|
954
|
+
${statusBadge}
|
|
955
|
+
<span class="pr-helper-guide-badge pr-helper-attention-badge pr-helper-attention-${section.reviewAttention}">${escapeHtml(SECTION_ATTENTION_LABELS[section.reviewAttention])}</span>
|
|
956
|
+
</div>
|
|
957
|
+
<div class="pr-helper-guide-section-header-top">
|
|
958
|
+
<span class="pr-helper-guide-disclosure">▼</span>
|
|
959
|
+
<span class="pr-helper-guide-section-title">${escapeHtml(section.title)}</span>
|
|
960
|
+
</div>
|
|
961
|
+
<div class="pr-helper-guide-meta-row" title="Review weight ${section.reviewWeight}/5">${escapeHtml(statParts.join(" · "))}</div>
|
|
962
|
+
</button>
|
|
963
|
+
<div class="pr-helper-guide-section-body">
|
|
964
|
+
<p class="pr-helper-guide-summary">${renderInlineText(section.oneLineSummary)}</p>
|
|
965
|
+
${section.purpose ? `<p class="pr-helper-guide-purpose">${renderInlineText(section.purpose)}</p>` : ""}
|
|
966
|
+
<h5>Files</h5>
|
|
967
|
+
<ul class="pr-helper-guide-files">${files}</ul>
|
|
968
|
+
${section.whyReview ? `<h5>Why review this</h5><p class="pr-helper-guide-why">${renderInlineText(section.whyReview)}</p>` : ""}
|
|
969
|
+
<h5>Review checklist</h5>
|
|
970
|
+
<ul class="pr-helper-guide-checklist">${checklist}</ul>
|
|
971
|
+
<div class="pr-helper-guide-section-actions">
|
|
972
|
+
<button type="button" class="pr-helper-guide-start-cta" data-guide-start="${escapeHtml(section.id)}">
|
|
973
|
+
${isActive ? "Resume guided review →" : isCompleted ? "Review again →" : "Start guided review →"}
|
|
974
|
+
</button>
|
|
975
|
+
${undo}
|
|
976
|
+
</div>
|
|
977
|
+
</div>
|
|
978
|
+
</section>
|
|
979
|
+
`;
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* Areas the plan named but did not read — the Guide's second half, and deliberately not mixed
|
|
983
|
+
* into its first.
|
|
984
|
+
*
|
|
985
|
+
* The invariant this renders: **Guide themes are based on reviewed code; suggested themes
|
|
986
|
+
* are identified from the manifest only.** A section can tell the reviewer what a change does
|
|
987
|
+
* because the model read it. These cannot — nobody read them — so they carry a heading that says
|
|
988
|
+
* so, no findings, and no implication that they have been assessed.
|
|
989
|
+
*
|
|
990
|
+
* They exist because the alternative is worse. A capped review used to end with 432 files simply
|
|
991
|
+
* absent; naming what was left and why is what turns the end of the review into the start of the
|
|
992
|
+
* reviewer's own work.
|
|
993
|
+
*/
|
|
994
|
+
/**
|
|
995
|
+
* The action that turns a suggested area into an analysed one.
|
|
996
|
+
*
|
|
997
|
+
* Phrased as work the product does — "Analyse Infrastructure changes" — never as a place the
|
|
998
|
+
* analysis stopped. The label names the area rather than a count, because "analyse 18 files" is a
|
|
999
|
+
* chore and "analyse the deployment configuration" is a decision the reviewer can actually make.
|
|
1000
|
+
*
|
|
1001
|
+
* Offered only when `paths` is present. Sessions stored before that field carry a file count but
|
|
1002
|
+
* nothing to scope a follow-up with, and an action that cannot run is worse than no action.
|
|
1003
|
+
*/
|
|
1004
|
+
function renderAreaAction(area, busyLabel) {
|
|
1005
|
+
if (!area.paths?.length) return "";
|
|
1006
|
+
if (busyLabel === area.title) return `
|
|
1007
|
+
<div class="pr-helper-suggested-area-running">
|
|
1008
|
+
<span class="pr-helper-suggested-area-busy">Analysing…</span>
|
|
1009
|
+
<button class="pr-helper-suggested-area-cancel" data-follow-up-cancel>Cancel</button>
|
|
1010
|
+
</div>
|
|
1011
|
+
`;
|
|
1012
|
+
const disabled = busyLabel !== null;
|
|
1013
|
+
return `
|
|
1014
|
+
<button class="pr-helper-suggested-area-action" data-follow-up-area="${escapeHtml(area.title)}"${disabled ? " disabled" : ""}>
|
|
1015
|
+
${area.partiallyAnalysed ? "Analyse the rest of" : "Analyse"} ${escapeHtml(area.title)}
|
|
1016
|
+
</button>
|
|
1017
|
+
`;
|
|
1018
|
+
}
|
|
1019
|
+
function renderSuggestedAreas(session, busyLabel) {
|
|
1020
|
+
const areas = composeReview(session)?.suggestedAreas ?? [];
|
|
1021
|
+
const notNamed = session.metadata.fileCoverage?.notNamedCount ?? 0;
|
|
1022
|
+
if (areas.length === 0 && notNamed === 0) return "";
|
|
1023
|
+
const card = (area) => `
|
|
1024
|
+
<div class="pr-helper-suggested-area">
|
|
1025
|
+
<div class="pr-helper-suggested-area-head">
|
|
1026
|
+
<span class="pr-helper-suggested-area-title">${escapeHtml(area.title)}</span>
|
|
1027
|
+
<span class="pr-helper-suggested-area-count">${area.fileCount} file${area.fileCount === 1 ? "" : "s"}</span>
|
|
1028
|
+
</div>
|
|
1029
|
+
${area.why ? `<p class="pr-helper-suggested-area-why">${escapeHtml(area.why)}</p>` : ""}
|
|
1030
|
+
${area.riskNote ? `<p class="pr-helper-suggested-area-risk">Watch for: ${escapeHtml(area.riskNote)}</p>` : ""}
|
|
1031
|
+
${renderAreaAction(area, busyLabel)}
|
|
1032
|
+
</div>
|
|
1033
|
+
`;
|
|
1034
|
+
const unfinished = areas.filter((area) => area.partiallyAnalysed);
|
|
1035
|
+
const suggested = areas.filter((area) => !area.partiallyAnalysed);
|
|
1036
|
+
const remainder = notNamed > 0 ? `<p class="pr-helper-suggested-area-remainder">${notNamed} further changed file${notNamed === 1 ? " was" : "s were"} not placed in any theme. See the Files tab for the full list.</p>` : "";
|
|
1037
|
+
return `${unfinished.length === 0 ? "" : `
|
|
1038
|
+
<div class="pr-helper-suggested-areas">
|
|
1039
|
+
<h4 class="pr-helper-suggested-areas-heading">Unfinished themes</h4>
|
|
1040
|
+
<p class="pr-helper-suggested-areas-intro">The analysis plan chose ${unfinished.length === 1 ? "this theme" : "these themes"} for in-depth analysis and the file ceiling stopped it partway. What is listed below is the remainder; the part that was read has ${unfinished.length === 1 ? "a theme" : "themes"} above.</p>
|
|
1041
|
+
${unfinished.map(card).join("")}
|
|
1042
|
+
</div>
|
|
1043
|
+
`}${suggested.length === 0 && notNamed === 0 ? "" : `
|
|
1044
|
+
<div class="pr-helper-suggested-areas">
|
|
1045
|
+
<h4 class="pr-helper-suggested-areas-heading">Suggested themes</h4>
|
|
1046
|
+
<p class="pr-helper-suggested-areas-intro">Identified from the changed-file list, not analysed. Nothing below has been read, so none of it has been assessed.</p>
|
|
1047
|
+
${suggested.map(card).join("")}
|
|
1048
|
+
${remainder}
|
|
1049
|
+
</div>
|
|
1050
|
+
`}`;
|
|
1051
|
+
}
|
|
1052
|
+
function renderGuideTab(session, activeGuidedSectionId = null, followUpInFlight = null) {
|
|
1053
|
+
if (!session) return emptyState("Generate an analysis to see the Guide.");
|
|
1054
|
+
if (session.status === "generating") return emptyState("Analysis in progress…");
|
|
1055
|
+
if (session.status === "failed") return emptyState("No analysis available.");
|
|
1056
|
+
const result = composeReview(session);
|
|
1057
|
+
const sections = result?.sections ?? [];
|
|
1058
|
+
if (sections.length === 0) return emptyState("Guide not available for this analysis.");
|
|
1059
|
+
const completed = new Set(session.completedSectionIds ?? []);
|
|
1060
|
+
const statuses = deriveSectionStatuses(sections, completed);
|
|
1061
|
+
const activeBadgeLabel = [...statuses.values()].includes("completed") ? "Continue" : "Start here";
|
|
1062
|
+
const cards = sections.map((s) => {
|
|
1063
|
+
return renderGuideSectionCard(s, {
|
|
1064
|
+
issues: result?.issues.filter((i) => i.sectionId === s.id).length ?? 0,
|
|
1065
|
+
questions: result?.questions.filter((q) => q.sectionId === s.id).length ?? 0
|
|
1066
|
+
}, activeGuidedSectionId, statuses.get(s.id) ?? "pending", activeBadgeLabel);
|
|
1067
|
+
}).join("");
|
|
1068
|
+
return `
|
|
1069
|
+
<div class="pr-helper-guide">
|
|
1070
|
+
${renderGuideProgressSummary(sections, completed)}
|
|
1071
|
+
<div class="pr-helper-guide-sections">${cards}</div>
|
|
1072
|
+
${renderSuggestedAreas(session, followUpInFlight)}
|
|
1073
|
+
</div>
|
|
1074
|
+
`;
|
|
1075
|
+
}
|
|
1076
|
+
function renderSectionTag(sectionId, sections) {
|
|
1077
|
+
if (!sectionId) return "";
|
|
1078
|
+
const section = sections.get(sectionId);
|
|
1079
|
+
if (!section) return "";
|
|
1080
|
+
return `<button type="button"${pillAttrs("pr-helper-section-tag", SECTION_TAG_TIP)} data-guide-jump="${escapeHtml(section.id)}">${escapeHtml(section.title)}</button>`;
|
|
1081
|
+
}
|
|
1082
|
+
/**
|
|
1083
|
+
* How sure the model is that an issue is real — deliberately not shown when it is sure.
|
|
1084
|
+
*
|
|
1085
|
+
* Severity already earns a badge on every card, and a second always-present badge would turn the
|
|
1086
|
+
* header into a row of labels the eye stops reading. Confidence only carries information when it
|
|
1087
|
+
* is *not* high, so that is the only case that gets ink: "medium confidence" and "low confidence"
|
|
1088
|
+
* are qualifications, and a qualification nobody notices is the same as not making it.
|
|
1089
|
+
*
|
|
1090
|
+
* Absent means the model did not state one, which renders as nothing rather than a guessed middle.
|
|
1091
|
+
*/
|
|
1092
|
+
function renderIssueConfidence(confidence) {
|
|
1093
|
+
if (!confidence || confidence === "high") return "";
|
|
1094
|
+
return `<span${pillAttrs(`pr-helper-issue-confidence pr-helper-issue-confidence-${confidence}`, confidenceTip(confidence))}>${escapeHtml(confidence)} confidence</span>`;
|
|
1095
|
+
}
|
|
1096
|
+
/**
|
|
1097
|
+
* What became of a finding when the pull request moved on. Absent on an ordinary analysis, which
|
|
1098
|
+
* has no prior state to report — those cards render exactly as they always have.
|
|
1099
|
+
*
|
|
1100
|
+
* Placed first among the badges, before the section tag and severity: on a reconciled analysis this
|
|
1101
|
+
* is the axis the reviewer is scanning for. They already know what the findings were; what they
|
|
1102
|
+
* came back to learn is which ones the author dealt with.
|
|
1103
|
+
*/
|
|
1104
|
+
function renderFindingStatus(status) {
|
|
1105
|
+
if (!status) return "";
|
|
1106
|
+
return `<span${pillAttrs(`pr-helper-finding-status pr-helper-finding-status-${status}`, findingStatusTip(status))}>${escapeHtml(findingStatusLabel(status))}</span>`;
|
|
1107
|
+
}
|
|
1108
|
+
/**
|
|
1109
|
+
* The same pill, for a question.
|
|
1110
|
+
*
|
|
1111
|
+
* A separate function rather than a widened one, because the two draw from different vocabularies:
|
|
1112
|
+
* a finding is `resolved` and a question is `answered`, which are different events. Nothing fixed
|
|
1113
|
+
* the question; somebody now knows the answer.
|
|
1114
|
+
*/
|
|
1115
|
+
function renderQuestionStatus(status) {
|
|
1116
|
+
if (!status) return "";
|
|
1117
|
+
return `<span${pillAttrs(`pr-helper-finding-status pr-helper-finding-status-${status === "answered" ? "resolved" : status}`, questionStatusTip(status))}>${escapeHtml(questionStatusLabel(status))}</span>`;
|
|
1118
|
+
}
|
|
1119
|
+
/** The model's one-line reason for a status, when it gave one. */
|
|
1120
|
+
function renderReconciliationNote(note) {
|
|
1121
|
+
if (!note?.trim()) return "";
|
|
1122
|
+
return `<p class="pr-helper-reconciliation-note">${renderInlineText(note)}</p>`;
|
|
1123
|
+
}
|
|
1124
|
+
/**
|
|
1125
|
+
* Escalation to a repository-aware coding agent, on the findings that might actually need one.
|
|
1126
|
+
*
|
|
1127
|
+
* Only issues and questions carry it. Those are the two things a reviewer can be *uncertain*
|
|
1128
|
+
* about, and uncertainty is the only state a repository investigation resolves — the Overview,
|
|
1129
|
+
* the Guide and the Files tab describe what the change is, which the diff already settled.
|
|
1130
|
+
*
|
|
1131
|
+
* Visible rather than tucked into a menu: if the escalation is worth building it is worth
|
|
1132
|
+
* seeing, and a reviewer who has to discover it will not reach for it at the moment they are
|
|
1133
|
+
* stuck. Outlined rather than filled — see sidebar.css.
|
|
1134
|
+
*
|
|
1135
|
+
* Text only, no leading icon. Every other action in the sidebar (Jump to file, Mark answered,
|
|
1136
|
+
* Explain, Analyse, Copy) is a bare label, and an emoji here read as decoration on a control
|
|
1137
|
+
* whose neighbours have none rather than as emphasis.
|
|
1138
|
+
*/
|
|
1139
|
+
function renderInvestigateButton(attribute, id, label) {
|
|
1140
|
+
return `<button type="button" class="pr-helper-investigate-btn" ${attribute}="${escapeHtml(id)}" title="Generate a prompt to investigate this with your repository-aware AI">${escapeHtml(label)}</button>`;
|
|
1141
|
+
}
|
|
1142
|
+
/**
|
|
1143
|
+
* The two halves of the investigation loop, side by side on the finding they concern.
|
|
1144
|
+
*
|
|
1145
|
+
* Outbound ("Investigate Repository") generates the prompt; inbound ("Add Investigation") brings
|
|
1146
|
+
* the agent's answer back. Pairing them here rather than putting the inbound action somewhere
|
|
1147
|
+
* general is the whole mental model: the finding is the entry point, the reviewer goes away, and
|
|
1148
|
+
* they return to the same place. A generic "add context" control elsewhere in the sidebar would
|
|
1149
|
+
* make the round trip something the reviewer has to assemble for themselves.
|
|
1150
|
+
*
|
|
1151
|
+
* State comes from the **newest** record for this finding, because records accumulate — a reviewer
|
|
1152
|
+
* who has just pressed "Investigate Again" is mid-investigation whatever earlier rounds concluded.
|
|
1153
|
+
* "Add Investigation" names what the reviewer is doing; "Paste Results" would name the mechanism,
|
|
1154
|
+
* which the modal explains once they are in it.
|
|
1155
|
+
*/
|
|
1156
|
+
function renderInvestigationActions(kind, id, records, investigatingId) {
|
|
1157
|
+
const attribute = kind === "issue" ? "data-investigate-issue" : "data-investigate-question";
|
|
1158
|
+
const addAttribute = kind === "issue" ? "data-add-investigation-issue" : "data-add-investigation-question";
|
|
1159
|
+
const newest = newestInvestigationForFinding(records, kind, id);
|
|
1160
|
+
if (investigatingId && newest?.id === investigatingId) return `${renderInvestigateButton(attribute, id, "Investigate Again")}<span class="pr-helper-investigate-applying" role="status">Applying…</span>`;
|
|
1161
|
+
if (!newest) return renderInvestigateButton(attribute, id, "Investigate Repository");
|
|
1162
|
+
const inbound = newest.state === "applied" ? "<span class=\"pr-helper-investigate-added\" aria-label=\"Investigation added\">Investigation Added ✓</span>" : `<button type="button" class="pr-helper-investigate-btn pr-helper-investigate-btn-add" ${addAttribute}="${escapeHtml(id)}" title="Paste the result from your repository-aware AI">Add Investigation</button>`;
|
|
1163
|
+
return `${renderInvestigateButton(attribute, id, "Investigate Again")}${inbound}`;
|
|
1164
|
+
}
|
|
1165
|
+
/**
|
|
1166
|
+
* One line per applied investigation, oldest first.
|
|
1167
|
+
*
|
|
1168
|
+
* All of them, not just the most recent: a finding investigated twice was shaped by two pieces of
|
|
1169
|
+
* evidence, and showing only the latest would quietly retire the first from the record while its
|
|
1170
|
+
* effect on the analysis remains.
|
|
1171
|
+
*/
|
|
1172
|
+
function renderInvestigationProvenance(kind, id, records, now) {
|
|
1173
|
+
const applied = investigationsForFinding(records, kind, id).filter((r) => r.state === "applied");
|
|
1174
|
+
if (!applied.length) return "";
|
|
1175
|
+
return applied.map((record) => `
|
|
1176
|
+
<p class="pr-helper-investigation-provenance">
|
|
1177
|
+
<span class="pr-helper-investigation-provenance-label">Repository investigation</span>
|
|
1178
|
+
<span class="pr-helper-investigation-provenance-agent">${escapeHtml(record.agent ?? "Unknown agent")}</span>
|
|
1179
|
+
<span class="pr-helper-investigation-provenance-time">${escapeHtml(formatRelativeTime(record.appliedAt ?? record.requestedAt, now))}</span>
|
|
1180
|
+
<button type="button" class="pr-helper-investigation-view" data-view-investigation="${escapeHtml(record.id)}">View investigation</button>
|
|
1181
|
+
</p>
|
|
1182
|
+
`).join("");
|
|
1183
|
+
}
|
|
1184
|
+
/**
|
|
1185
|
+
* What a repository investigation concluded about this finding.
|
|
1186
|
+
*
|
|
1187
|
+
* Sits beside renderFindingStatus rather than replacing it: the two axes answer different questions
|
|
1188
|
+
* and a finding may carry both, so `[Still open] [Revised]` is a coherent and useful pair rather
|
|
1189
|
+
* than a contradiction to be resolved in the renderer.
|
|
1190
|
+
*/
|
|
1191
|
+
function renderInvestigationImpact(impact) {
|
|
1192
|
+
if (!impact) return "";
|
|
1193
|
+
return `<span${pillAttrs(`pr-helper-investigation-impact pr-helper-investigation-impact-${impact}`, investigationImpactTip(impact))}>${escapeHtml(investigationImpactLabel(impact))}</span>`;
|
|
1194
|
+
}
|
|
1195
|
+
/**
|
|
1196
|
+
* `major → minor`, when an investigation moved a grade.
|
|
1197
|
+
*
|
|
1198
|
+
* Rendered only on an actual change, because priorSeverity/priorConfidence are only set on one (see
|
|
1199
|
+
* applyReassessment). Showing where a value came from matters more here than anywhere else in the
|
|
1200
|
+
* product: a reviewer who saw "major" yesterday and "minor" today, with no arrow, has been given a
|
|
1201
|
+
* different answer with no indication that anything moved it.
|
|
1202
|
+
*/
|
|
1203
|
+
function renderSeverityRevision(prior, current) {
|
|
1204
|
+
if (!prior || prior === current) return "";
|
|
1205
|
+
return `<p class="pr-helper-investigation-revision"><span class="pr-helper-investigation-revision-from">${escapeHtml(prior)}</span> → <span class="pr-helper-investigation-revision-to">${escapeHtml(current)}</span></p>`;
|
|
1206
|
+
}
|
|
1207
|
+
/** The model's one-line reason for an impact, when it gave one. */
|
|
1208
|
+
function renderInvestigationNote(note) {
|
|
1209
|
+
if (!note?.trim()) return "";
|
|
1210
|
+
return `<p class="pr-helper-investigation-note">${renderInlineText(note)}</p>`;
|
|
1211
|
+
}
|
|
1212
|
+
function renderIssuesTab(session, investigatingId) {
|
|
1213
|
+
if (!session) return emptyState("Generate an analysis to see issues.");
|
|
1214
|
+
if (session.status === "generating") return emptyState("Analysis in progress…");
|
|
1215
|
+
if (session.status === "failed") return emptyState("No analysis available.");
|
|
1216
|
+
const result = composeReview(session);
|
|
1217
|
+
if (!result || result.issues.length === 0) return emptyState("No issues found.");
|
|
1218
|
+
const sections = buildSectionLookup(result);
|
|
1219
|
+
const records = session.investigations;
|
|
1220
|
+
const now = Date.now();
|
|
1221
|
+
return result.issues.map((issue) => `
|
|
1222
|
+
<div class="pr-helper-issue-card pr-helper-severity-${issue.severity}${issue.reconciliationStatus ? ` pr-helper-issue-${issue.reconciliationStatus}` : ""}${issue.investigationImpact ? ` pr-helper-issue-investigation-${issue.investigationImpact}` : ""}" data-finding-card="${escapeHtml(issue.id)}">
|
|
1223
|
+
<div class="pr-helper-issue-header">
|
|
1224
|
+
<p class="pr-helper-issue-title">${renderInlineText(issue.title)}</p>
|
|
1225
|
+
<div class="pr-helper-issue-badges">
|
|
1226
|
+
${renderFindingStatus(issue.reconciliationStatus)}
|
|
1227
|
+
${renderInvestigationImpact(issue.investigationImpact)}
|
|
1228
|
+
${renderSectionTag(issue.sectionId, sections)}
|
|
1229
|
+
${renderIssueConfidence(issue.confidence)}
|
|
1230
|
+
<span${pillAttrs("pr-helper-severity-badge", severityTip(issue.severity))}>${escapeHtml(issue.severity)}</span>
|
|
1231
|
+
</div>
|
|
1232
|
+
</div>
|
|
1233
|
+
${renderSeverityRevision(issue.priorSeverity, issue.severity)}
|
|
1234
|
+
<p class="pr-helper-issue-desc">${renderInlineText(issue.description)}</p>
|
|
1235
|
+
${renderReconciliationNote(issue.reconciliationNote)}
|
|
1236
|
+
${renderInvestigationNote(issue.investigationNote)}
|
|
1237
|
+
${renderExistingDiscussion(issue.existingDiscussion)}
|
|
1238
|
+
${renderFileLocation(issue.file, "pr-helper-issue-loc", issue.line)}
|
|
1239
|
+
${renderInvestigationProvenance("issue", issue.id, records, now)}
|
|
1240
|
+
<div class="pr-helper-issue-actions">
|
|
1241
|
+
${renderInvestigationActions("issue", issue.id, records, investigatingId)}
|
|
1242
|
+
</div>
|
|
1243
|
+
</div>
|
|
1244
|
+
`).join("");
|
|
1245
|
+
}
|
|
1246
|
+
function renderQuestionCard(q, sections, showTag, answered, records, now = Date.now(), investigatingId, options) {
|
|
1247
|
+
const importanceBadge = q.importance ? `<span${pillAttrs(`pr-helper-question-importance-badge pr-helper-question-importance-${q.importance}`, importanceTip(q.importance))}>${escapeHtml(q.importance)}</span>` : "";
|
|
1248
|
+
const jumpLink = renderQuestionJump(q.file, options);
|
|
1249
|
+
const answerToggle = `<button type="button" class="pr-helper-question-answer-toggle" data-question-answered="${escapeHtml(q.id)}" data-answered="${answered}">${answered ? "Mark as unanswered" : "Mark answered"}</button>`;
|
|
1250
|
+
const investigationAnswer = q.investigationAnswer?.trim() ? `<h5>Answer from investigation</h5><p class="pr-helper-issue-desc pr-helper-investigation-answer">${renderInlineText(q.investigationAnswer)}</p>` : "";
|
|
1251
|
+
const reconciliationAnswer = q.reconciliationAnswer?.trim() ? `<h5>Answered by the new changes</h5><p class="pr-helper-issue-desc pr-helper-investigation-answer">${renderInlineText(q.reconciliationAnswer)}</p>` : "";
|
|
1252
|
+
return `
|
|
1253
|
+
<div class="pr-helper-question-card${answered ? " pr-helper-question-card-answered" : ""}${q.investigationImpact ? ` pr-helper-issue-investigation-${q.investigationImpact}` : ""}" data-finding-card="${escapeHtml(q.id)}">
|
|
1254
|
+
<div class="pr-helper-issue-header">
|
|
1255
|
+
<p class="pr-helper-issue-title">${renderInlineText(q.question)}</p>
|
|
1256
|
+
<div class="pr-helper-issue-badges">
|
|
1257
|
+
${renderQuestionStatus(q.reconciliationStatus)}
|
|
1258
|
+
${renderInvestigationImpact(q.investigationImpact)}
|
|
1259
|
+
${showTag ? renderSectionTag(q.sectionId, sections) : ""}
|
|
1260
|
+
${importanceBadge}
|
|
1261
|
+
</div>
|
|
1262
|
+
</div>
|
|
1263
|
+
${q.whyThisMatters ? `<h5>Why this matters</h5><p class="pr-helper-issue-desc">${renderInlineText(q.whyThisMatters)}</p>` : ""}
|
|
1264
|
+
${reconciliationAnswer}
|
|
1265
|
+
${renderReconciliationNote(q.reconciliationNote)}
|
|
1266
|
+
${investigationAnswer}
|
|
1267
|
+
${renderInvestigationNote(q.investigationNote)}
|
|
1268
|
+
${q.file ? `<h5>Files</h5>${renderFileLocation(q.file, "pr-helper-issue-loc", void 0)}` : ""}
|
|
1269
|
+
${renderInvestigationProvenance("question", q.id, records, now)}
|
|
1270
|
+
<div class="pr-helper-question-actions">
|
|
1271
|
+
${jumpLink}
|
|
1272
|
+
<div class="pr-helper-question-actions-group">
|
|
1273
|
+
${renderInvestigationActions("question", q.id, records, investigatingId)}
|
|
1274
|
+
${answerToggle}
|
|
1275
|
+
</div>
|
|
1276
|
+
</div>
|
|
1277
|
+
</div>
|
|
1278
|
+
`;
|
|
1279
|
+
}
|
|
1280
|
+
/**
|
|
1281
|
+
* The counter's contents, split out so a caller that toggles one question can refresh it without
|
|
1282
|
+
* re-rendering the tab.
|
|
1283
|
+
*
|
|
1284
|
+
* The extension redraws the whole Questions tab on every toggle and never needed this. The
|
|
1285
|
+
* plugin's workspace updates the card in place, so its counter sat frozen at "2 of 2 unanswered"
|
|
1286
|
+
* while a card visibly turned green above it. Exported rather than reimplemented there, because
|
|
1287
|
+
* two places wording the same count differently is how "All 5 questions answered" and "0 of 5
|
|
1288
|
+
* unanswered" end up on the same product.
|
|
1289
|
+
*/
|
|
1290
|
+
function questionsProgressBody(total, outstanding) {
|
|
1291
|
+
return outstanding === 0 ? `<span class="pr-helper-questions-progress-check" aria-hidden="true">✓</span><span class="pr-helper-questions-progress-count">All ${total} questions answered</span>` : `<span class="pr-helper-questions-progress-count">${outstanding} of ${total} unanswered</span>`;
|
|
1292
|
+
}
|
|
1293
|
+
function renderQuestionsProgress(questions, answered) {
|
|
1294
|
+
const total = questions.length;
|
|
1295
|
+
if (total < 2) return "";
|
|
1296
|
+
const outstanding = questions.filter((q) => !answered.has(q.id)).length;
|
|
1297
|
+
return `<div class="pr-helper-questions-progress" role="status">${questionsProgressBody(total, outstanding)}</div>`;
|
|
1298
|
+
}
|
|
1299
|
+
function renderQuestionJump(file, options) {
|
|
1300
|
+
if (!file || options?.suppressFileJump) return "";
|
|
1301
|
+
return `<button type="button" class="pr-helper-question-jump" data-file="${escapeHtml(file)}">Jump to file →</button>`;
|
|
1302
|
+
}
|
|
1303
|
+
function renderQuestionsTab(session, investigatingId, options) {
|
|
1304
|
+
if (!session) return emptyState("Generate an analysis to see open questions.");
|
|
1305
|
+
if (session.status === "generating") return emptyState("Analysis in progress…");
|
|
1306
|
+
if (session.status === "failed") return emptyState("No analysis available.");
|
|
1307
|
+
const result = composeReview(session);
|
|
1308
|
+
if (!result || result.questions.length === 0) return emptyState("No open questions.");
|
|
1309
|
+
const sections = buildSectionLookup(result);
|
|
1310
|
+
const answered = new Set(session.answeredQuestionIds ?? []);
|
|
1311
|
+
const progress = renderQuestionsProgress(result.questions, answered);
|
|
1312
|
+
const records = session.investigations;
|
|
1313
|
+
const now = Date.now();
|
|
1314
|
+
if (result.sections.length === 0) return `${progress}${result.questions.map((q) => renderQuestionCard(q, sections, true, answered.has(q.id), records, now, investigatingId, options)).join("")}`;
|
|
1315
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
1316
|
+
const ungrouped = [];
|
|
1317
|
+
for (const q of result.questions) if (q.sectionId && sections.has(q.sectionId)) {
|
|
1318
|
+
const list = grouped.get(q.sectionId) ?? [];
|
|
1319
|
+
list.push(q);
|
|
1320
|
+
grouped.set(q.sectionId, list);
|
|
1321
|
+
} else ungrouped.push(q);
|
|
1322
|
+
return `${progress}${result.sections.filter((s) => grouped.has(s.id)).map((s) => `
|
|
1323
|
+
<div class="pr-helper-question-group">
|
|
1324
|
+
<h5 class="pr-helper-question-group-title">${escapeHtml(s.title)}</h5>
|
|
1325
|
+
${grouped.get(s.id).map((q) => renderQuestionCard(q, sections, false, answered.has(q.id), records, now, investigatingId, options)).join("")}
|
|
1326
|
+
</div>
|
|
1327
|
+
`).join("")}${ungrouped.map((q) => renderQuestionCard(q, sections, false, answered.has(q.id), records, now, investigatingId, options)).join("")}`;
|
|
1328
|
+
}
|
|
1329
|
+
//#endregion
|
|
1330
|
+
//#region ../../packages/review-harness/src/workspace/file_links.ts
|
|
1331
|
+
/**
|
|
1332
|
+
* Where a file lives on the pull request, when there is one.
|
|
1333
|
+
*
|
|
1334
|
+
* There used to be a "Jump to file" button beside a question that did exactly what clicking the
|
|
1335
|
+
* file name does, which is open the file in this workspace. Two controls, one behaviour, and the
|
|
1336
|
+
* second one quietly teaching the reviewer that the first was worth ignoring. The useful second
|
|
1337
|
+
* destination is the pull request itself, where the file sits next to its comments and its review
|
|
1338
|
+
* controls.
|
|
1339
|
+
*
|
|
1340
|
+
* Shared because three surfaces need the same answer: the rendered page, the file viewer's route,
|
|
1341
|
+
* and the Files tab. A second copy of this decision is how one of them ends up linking somewhere
|
|
1342
|
+
* else.
|
|
1343
|
+
*/
|
|
1344
|
+
function buildPrFileLink(attached, path) {
|
|
1345
|
+
if (!attached) return null;
|
|
1346
|
+
const { pullRequest } = attached;
|
|
1347
|
+
if (pullRequest.fileUrlTemplate) return {
|
|
1348
|
+
href: fillFileUrlTemplate(pullRequest.fileUrlTemplate, path),
|
|
1349
|
+
label: "Open in pull request",
|
|
1350
|
+
precise: true
|
|
1351
|
+
};
|
|
1352
|
+
return {
|
|
1353
|
+
href: pullRequest.filesUrl || pullRequest.url,
|
|
1354
|
+
label: "Open the pull request",
|
|
1355
|
+
precise: false
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
1358
|
+
//#endregion
|
|
1359
|
+
//#region ../../packages/review-harness/src/workspace/pr_context_view.ts
|
|
1360
|
+
/**
|
|
1361
|
+
* Everything that was imported from the pull request, shown as it was imported.
|
|
1362
|
+
*
|
|
1363
|
+
* This view is not a convenience. Every "already raised" line in a review rests on a conversation
|
|
1364
|
+
* the daemon never fetched and cannot verify — Claude Code produced the payload, using the
|
|
1365
|
+
* developer's own tooling, and the daemon has no way to check it against the real pull request.
|
|
1366
|
+
* What makes that claim honest rather than merely asserted is that the reviewer can read the
|
|
1367
|
+
* threads themselves and follow each one back to the comment it came from. A finding attributed
|
|
1368
|
+
* to a conversation that never happened is one click from being caught here.
|
|
1369
|
+
*
|
|
1370
|
+
* So: complete, in the order the processor bucketed it, with every permalink intact. Nothing is
|
|
1371
|
+
* summarised and nothing is hidden behind a "show more" — the budget trimming that happens
|
|
1372
|
+
* upstream is about what fits in a prompt, and has nothing to do with what a reviewer may read.
|
|
1373
|
+
*/
|
|
1374
|
+
/**
|
|
1375
|
+
* The four buckets, in the order a reviewer needs them.
|
|
1376
|
+
*
|
|
1377
|
+
* Unresolved first because it is the live disagreement; outdated-unresolved next because the
|
|
1378
|
+
* anchor moved but the concern did not; conversation then resolved. This is the processor's own
|
|
1379
|
+
* priority order, not a second opinion about it.
|
|
1380
|
+
*/
|
|
1381
|
+
var BUCKETS = [
|
|
1382
|
+
{
|
|
1383
|
+
key: "unresolved",
|
|
1384
|
+
title: "Unresolved",
|
|
1385
|
+
note: "Open threads on the current code."
|
|
1386
|
+
},
|
|
1387
|
+
{
|
|
1388
|
+
key: "unresolvedOutdated",
|
|
1389
|
+
title: "Unresolved, code has moved",
|
|
1390
|
+
note: "Still open, but written against a revision that has since changed."
|
|
1391
|
+
},
|
|
1392
|
+
{
|
|
1393
|
+
key: "conversation",
|
|
1394
|
+
title: "Conversation",
|
|
1395
|
+
note: "The main discussion, not anchored to a line."
|
|
1396
|
+
},
|
|
1397
|
+
{
|
|
1398
|
+
key: "resolved",
|
|
1399
|
+
title: "Resolved",
|
|
1400
|
+
note: "Marked resolved. Not proof the underlying problem is fixed."
|
|
1401
|
+
}
|
|
1402
|
+
];
|
|
1403
|
+
function renderPrContextView(view, attribution, refresh) {
|
|
1404
|
+
if (!view) return renderNothingConnected();
|
|
1405
|
+
const { pullRequest, discussion } = view.prContext;
|
|
1406
|
+
const { processed } = view;
|
|
1407
|
+
const buckets = BUCKETS.map((bucket) => renderBucket(bucket.title, bucket.note, processed[bucket.key])).join("");
|
|
1408
|
+
return `<h3 class="prb-doc-title">PR context</h3>
|
|
1409
|
+
${renderProvenance(pullRequest, discussion.fetchedAt, processed)}
|
|
1410
|
+
${renderRefresh(refresh)}
|
|
1411
|
+
${renderAttribution(attribution, processed)}
|
|
1412
|
+
${discussion.truncated ? renderTruncation() : ""}
|
|
1413
|
+
${renderDecisions(processed)}
|
|
1414
|
+
${processed.all.length === 0 ? "<p class=\"prb-pr-empty\">This pull request has no comments yet.</p>" : buckets}`;
|
|
1415
|
+
}
|
|
1416
|
+
/**
|
|
1417
|
+
* Re-reading the threads, and what the last re-read found.
|
|
1418
|
+
*
|
|
1419
|
+
* The narrowest useful action in the product, and the copy has to keep it narrow. This corrects
|
|
1420
|
+
* what the review already claims about other people's threads — who raised one, whether it is
|
|
1421
|
+
* still open — and it cannot produce a finding, because deciding whether a new comment changes
|
|
1422
|
+
* anything is judgement and judgement is a fresh review. A reviewer who pressed this expecting a
|
|
1423
|
+
* re-review would be worse off than one who never saw the button.
|
|
1424
|
+
*/
|
|
1425
|
+
function renderRefresh(refresh) {
|
|
1426
|
+
if (!refresh?.enabled) return "";
|
|
1427
|
+
const last = refresh.last;
|
|
1428
|
+
return `<div class="prb-pr-refresh">
|
|
1429
|
+
<button type="button" data-pr-recheck>Re-check the conversation</button>
|
|
1430
|
+
<span class="prb-pr-refresh-note">
|
|
1431
|
+
Fetches every thread again and updates what this review says about them. If someone has
|
|
1432
|
+
replied or resolved a thread since the review ran, the "Already raised" notes on the Issues
|
|
1433
|
+
view will be corrected to match, and any note pointing at a thread that has been deleted will
|
|
1434
|
+
be removed. Your code is not looked at again, so no findings are added, changed or removed.
|
|
1435
|
+
</span>
|
|
1436
|
+
${last ? `<p class="prb-pr-refresh-last" data-ok="${last.ok}">
|
|
1437
|
+
Last re-checked ${escapeHtml(relativeTime$1(last.at))}. ${escapeHtml(last.message)}
|
|
1438
|
+
${last.attributionsRestated ? escapeHtml(` ${last.attributionsRestated} finding${last.attributionsRestated === 1 ? "" : "s"} had its thread's state corrected.`.replace("its", last.attributionsRestated === 1 ? "its" : "their")) : ""}
|
|
1439
|
+
${last.attributionsWithdrawn ? escapeHtml(` ${last.attributionsWithdrawn} claim${last.attributionsWithdrawn === 1 ? "" : "s"} withdrawn: the thread it cited is no longer in the conversation.`) : ""}
|
|
1440
|
+
</p>` : ""}
|
|
1441
|
+
</div>`;
|
|
1442
|
+
}
|
|
1443
|
+
/**
|
|
1444
|
+
* How much of this review the conversation actually touched.
|
|
1445
|
+
*
|
|
1446
|
+
* Without this the page has a hole exactly where the feature's own promise sits. A reviewer who
|
|
1447
|
+
* reads the threads here and then sees no "already raised" line anywhere cannot tell whether the
|
|
1448
|
+
* review compared its findings against this conversation and matched none, or never compared them
|
|
1449
|
+
* at all — and those call for completely different responses. Zero matches is an ordinary,
|
|
1450
|
+
* frequent outcome; it is silence about zero matches that is the problem.
|
|
1451
|
+
*
|
|
1452
|
+
* Counted from the findings themselves, so it can never disagree with what the Issues view shows.
|
|
1453
|
+
*/
|
|
1454
|
+
function renderAttribution(attribution, processed) {
|
|
1455
|
+
if (!attribution || processed.all.length === 0) return "";
|
|
1456
|
+
const { findings, matched } = attribution;
|
|
1457
|
+
if (findings === 0) return "";
|
|
1458
|
+
return `<p class="prb-pr-attribution" data-matched="${matched}">${escapeHtml(matched === 0 ? `None of the ${findings} finding${findings === 1 ? "" : "s"} in this review matched a thread here. The comparison was made; nothing lined up.` : `${matched} of ${findings} finding${findings === 1 ? "" : "s"} in this review ${matched === 1 ? "is" : "are"} matched to a thread here. Each one says so on the Issues view.`)}</p>`;
|
|
1459
|
+
}
|
|
1460
|
+
/**
|
|
1461
|
+
* Said plainly, because the alternative is a reviewer assuming their findings were checked
|
|
1462
|
+
* against a conversation that was never read. "No comments" and "never looked" are different
|
|
1463
|
+
* facts and this is the page that has to tell them apart.
|
|
1464
|
+
*/
|
|
1465
|
+
function renderNothingConnected() {
|
|
1466
|
+
return `<h3 class="prb-doc-title">PR context</h3>
|
|
1467
|
+
<p class="prb-pr-empty">
|
|
1468
|
+
No pull request discussion was brought into this review, so none of the findings have been
|
|
1469
|
+
checked against what other reviewers have already said.
|
|
1470
|
+
</p>
|
|
1471
|
+
<p class="prb-pr-empty">
|
|
1472
|
+
That usually means the branch does not have a pull request open yet, or that the tools on this
|
|
1473
|
+
machine could not sign in to fetch one. It does not mean the pull request is free of comments.
|
|
1474
|
+
Nobody looked.
|
|
1475
|
+
</p>`;
|
|
1476
|
+
}
|
|
1477
|
+
function renderProvenance(pullRequest, fetchedAt, processed) {
|
|
1478
|
+
const { stats } = processed;
|
|
1479
|
+
const parts = [
|
|
1480
|
+
`${stats.totalThreads} thread${stats.totalThreads === 1 ? "" : "s"}`,
|
|
1481
|
+
`${stats.totalComments} comment${stats.totalComments === 1 ? "" : "s"}`,
|
|
1482
|
+
`${stats.unresolvedCount} unresolved`
|
|
1483
|
+
];
|
|
1484
|
+
return `<p class="prb-pr-provenance">
|
|
1485
|
+
<a href="${escapeHtml(pullRequest.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(pullRequest.provider)} #${pullRequest.number}</a>
|
|
1486
|
+
${pullRequest.title ? `<span class="prb-pr-title">${escapeHtml(pullRequest.title)}</span>` : ""}
|
|
1487
|
+
<span class="prb-pr-counts">${parts.join(" · ")}</span>
|
|
1488
|
+
<time datetime="${new Date(fetchedAt).toISOString()}">imported ${escapeHtml(relativeTime$1(fetchedAt))}</time>
|
|
1489
|
+
<span class="prb-pr-asof">This is a snapshot taken at that moment, not a live view. Anything that has happened on the pull request since then, including replies and threads being resolved, will not show up here until you re-check it.</span>
|
|
1490
|
+
</p>`;
|
|
1491
|
+
}
|
|
1492
|
+
/** Disclosed rather than absorbed: a review working from a partial record should say which part. */
|
|
1493
|
+
function renderTruncation() {
|
|
1494
|
+
return `<p class="prb-pr-truncated">
|
|
1495
|
+
This discussion arrived incomplete. Whatever fetched it reported that it could not retrieve
|
|
1496
|
+
everything, so some threads are missing from the list below. The review did not see those
|
|
1497
|
+
threads either, so treat its coverage of the discussion as partial.
|
|
1498
|
+
</p>`;
|
|
1499
|
+
}
|
|
1500
|
+
/**
|
|
1501
|
+
* The verdicts people left, drawn the same way their threads are.
|
|
1502
|
+
*
|
|
1503
|
+
* These used to be a bare list while every thread beside them was a card, so the one section
|
|
1504
|
+
* carrying a reviewer's overall position on the change looked like a footnote next to the
|
|
1505
|
+
* line-level nitpicks. Same card, same author line, same body treatment. A verdict is at least as
|
|
1506
|
+
* important as a comment on line 317.
|
|
1507
|
+
*/
|
|
1508
|
+
function renderDecisions(processed) {
|
|
1509
|
+
if (processed.decisions.length === 0) return "";
|
|
1510
|
+
const cards = processed.decisions.map((decision) => `<article class="prb-pr-thread prb-pr-decision">
|
|
1511
|
+
<header>
|
|
1512
|
+
<span class="prb-pr-author">@${escapeHtml(decision.reviewer)}${decision.reviewerIsBot ? " (bot)" : ""}</span>
|
|
1513
|
+
<span class="prb-pr-verdict" data-verdict="${escapeHtml(decision.verdict)}">${escapeHtml(verdictLabel(decision.verdict))}</span>
|
|
1514
|
+
${decision.timestamp ? `<time datetime="${escapeHtml(decision.timestamp)}">${escapeHtml(relativeTime$1(Date.parse(decision.timestamp)))}</time>` : ""}
|
|
1515
|
+
</header>
|
|
1516
|
+
${decision.body ? renderBody(decision.body) : "<p class=\"prb-pr-nobody\">No comment left with this review.</p>"}
|
|
1517
|
+
</article>`).join("");
|
|
1518
|
+
return `<section class="prb-pr-bucket">
|
|
1519
|
+
<h4>Reviews <span class="prb-pr-bucket-count">${processed.decisions.length}</span></h4>
|
|
1520
|
+
<p class="prb-pr-bucket-note">Where each reviewer landed on the change as a whole.</p>
|
|
1521
|
+
${cards}
|
|
1522
|
+
</section>`;
|
|
1523
|
+
}
|
|
1524
|
+
/**
|
|
1525
|
+
* Someone else's words, rendered as they wrote them.
|
|
1526
|
+
*
|
|
1527
|
+
* People write review comments in Markdown, so a comment that quotes a stack trace or a snippet
|
|
1528
|
+
* was arriving as one unbroken wall of text with stray backticks in it. That is the comment at its
|
|
1529
|
+
* least readable, in the one place the reviewer most needs to read it carefully.
|
|
1530
|
+
*
|
|
1531
|
+
* `renderMarkdown` escapes before it parses, so nothing in a comment can become markup this page
|
|
1532
|
+
* did not emit, and its href allow-list means a link is only ever a link. It was written for model
|
|
1533
|
+
* output injected into github.com, which is a harsher setting than this one.
|
|
1534
|
+
*/
|
|
1535
|
+
function renderBody(body) {
|
|
1536
|
+
return `<div class="prb-pr-comment-body prb-pr-md">${renderMarkdown(body)}</div>`;
|
|
1537
|
+
}
|
|
1538
|
+
function renderBucket(title, note, threads) {
|
|
1539
|
+
if (threads.length === 0) return "";
|
|
1540
|
+
return `<section class="prb-pr-bucket">
|
|
1541
|
+
<h4>${escapeHtml(title)} <span class="prb-pr-bucket-count">${threads.length}</span></h4>
|
|
1542
|
+
<p class="prb-pr-bucket-note">${escapeHtml(note)}</p>
|
|
1543
|
+
${threads.map(renderThread).join("")}
|
|
1544
|
+
</section>`;
|
|
1545
|
+
}
|
|
1546
|
+
function renderThread(thread) {
|
|
1547
|
+
const location = thread.location ? `<span class="prb-pr-loc">${escapeHtml(thread.location.file)}${thread.location.line !== null ? `:${thread.location.line}${thread.location.lineIsOriginal ? " (original)" : ""}` : ""}</span>` : "";
|
|
1548
|
+
return `<article class="prb-pr-thread" data-thread-id="${escapeHtml(thread.id)}">
|
|
1549
|
+
<header>
|
|
1550
|
+
${location}
|
|
1551
|
+
${thread.outdated ? "<span class=\"prb-pr-flag\">outdated</span>" : ""}
|
|
1552
|
+
${thread.resolved ? "<span class=\"prb-pr-flag\">resolved</span>" : ""}
|
|
1553
|
+
</header>
|
|
1554
|
+
${thread.comments.map(renderComment).join("")}
|
|
1555
|
+
${thread.truncatedCommentCount > 0 ? `<p class="prb-pr-truncated">${thread.truncatedCommentCount} further comment${thread.truncatedCommentCount === 1 ? "" : "s"} in this thread were not imported.</p>` : ""}
|
|
1556
|
+
</article>`;
|
|
1557
|
+
}
|
|
1558
|
+
function renderComment(comment) {
|
|
1559
|
+
const who = `@${comment.author}${comment.authorIsBot ? " (bot)" : ""}`;
|
|
1560
|
+
return `<div class="prb-pr-comment">
|
|
1561
|
+
<p class="prb-pr-comment-meta">
|
|
1562
|
+
${comment.url ? `<a href="${escapeHtml(comment.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(who)}</a>` : escapeHtml(who)}
|
|
1563
|
+
${comment.timestamp ? `<time datetime="${escapeHtml(comment.timestamp)}">${escapeHtml(relativeTime$1(Date.parse(comment.timestamp)))}</time>` : ""}
|
|
1564
|
+
</p>
|
|
1565
|
+
${renderBody(comment.body)}
|
|
1566
|
+
</div>`;
|
|
1567
|
+
}
|
|
1568
|
+
var VERDICT_LABELS = {
|
|
1569
|
+
approved: "approved",
|
|
1570
|
+
changes_requested: "requested changes",
|
|
1571
|
+
commented: "commented",
|
|
1572
|
+
dismissed: "dismissed"
|
|
1573
|
+
};
|
|
1574
|
+
function verdictLabel(verdict) {
|
|
1575
|
+
return VERDICT_LABELS[verdict] ?? verdict;
|
|
1576
|
+
}
|
|
1577
|
+
//#endregion
|
|
1578
|
+
//#region ../../packages/review-harness/src/workspace/update_view.ts
|
|
1579
|
+
/**
|
|
1580
|
+
* The Update review control, and what it says afterwards.
|
|
1581
|
+
*
|
|
1582
|
+
* Two pieces of copy that have to carry the same idea from opposite ends. Before: what pressing
|
|
1583
|
+
* this will do, in enough detail that a reviewer can decide whether to spend the minutes. After:
|
|
1584
|
+
* what it did, in numbers they can act on without opening anything.
|
|
1585
|
+
*
|
|
1586
|
+
* The one thing neither may do is describe the machinery. A reviewer does not want to know that a
|
|
1587
|
+
* fast-forward preceded a reconciliation. They want to know that the review they are reading is
|
|
1588
|
+
* about the code they have, and what changed in it while they were away.
|
|
1589
|
+
*/
|
|
1590
|
+
/**
|
|
1591
|
+
* The primary action on a moved branch.
|
|
1592
|
+
*
|
|
1593
|
+
* A button rather than a command to copy, and that is a deliberate reversal of what the freshness
|
|
1594
|
+
* strip used to offer. The old reasoning was that a re-analysis is minutes of work that belongs in
|
|
1595
|
+
* the session where the reviewer can watch it, which is true of a *fresh review* and was quietly
|
|
1596
|
+
* being applied to everything. This is not a fresh review: it is bounded, it reads only the new
|
|
1597
|
+
* commits, it keeps the reviewer's place, and it refuses rather than improvising whenever the
|
|
1598
|
+
* working tree would need a judgement call. That is a button.
|
|
1599
|
+
*
|
|
1600
|
+
* The fresh review stays on offer beside it, because a branch rebuilt from scratch genuinely needs
|
|
1601
|
+
* one, but behind a disclosure rather than in the open. It is the rarer answer, and a raw slash
|
|
1602
|
+
* command sitting on the panel is the implementation showing through the product: the reviewer has
|
|
1603
|
+
* to read and dismiss it every time to get to the button they actually want.
|
|
1604
|
+
*/
|
|
1605
|
+
function renderUpdateButton() {
|
|
1606
|
+
return `<button type="button" class="prb-update-btn" data-review-update>Update review</button>`;
|
|
1607
|
+
}
|
|
1608
|
+
/**
|
|
1609
|
+
* What pressing it does, and the way out for someone who wants a different thing entirely.
|
|
1610
|
+
*
|
|
1611
|
+
* Separate from the button rather than wrapped with it, because the panel lays out its own rows:
|
|
1612
|
+
* the controls sit on one line and this sits under them. Trying to do that with flex ordering
|
|
1613
|
+
* inside a wrapper put the sentence after `Dismiss`, which is exactly where it does not belong.
|
|
1614
|
+
*/
|
|
1615
|
+
function renderUpdateAside(command) {
|
|
1616
|
+
return {
|
|
1617
|
+
alt: `<details class="prb-update-alt">
|
|
1618
|
+
<summary>Start fresh instead</summary>
|
|
1619
|
+
<span>A brand new review, with its own findings and its own path through the change. Run <code>${escapeHtml(command)}</code> in your session.<button type="button" class="prb-fresh-quiet" data-copy-command="${escapeHtml(command)}">Copy</button></span>
|
|
1620
|
+
</details>`,
|
|
1621
|
+
explain: `<p class="prb-update-explain">Reanalyses the new commits and re-reads the pull request conversation. Your progress and Buddy AI history are kept.</p>`
|
|
1622
|
+
};
|
|
1623
|
+
}
|
|
1624
|
+
/**
|
|
1625
|
+
* What the last update did, on the review it did it to.
|
|
1626
|
+
*
|
|
1627
|
+
* Shown after the reload that follows an update, and only then: it reports an event rather than a
|
|
1628
|
+
* state, so it is dismissible and does not come back. A stop is shown with the same weight as a
|
|
1629
|
+
* success, because "nothing happened and here is why" is the outcome most likely to be missed and
|
|
1630
|
+
* the only one the reviewer has to act on.
|
|
1631
|
+
*/
|
|
1632
|
+
function renderUpdateOutcome(outcome) {
|
|
1633
|
+
if (!outcome) return "";
|
|
1634
|
+
return `<div class="prb-update-result" data-state="${outcome.ok ? "done" : outcome.stop === "refused" ? "refused" : "stopped"}" data-update-result role="status">
|
|
1635
|
+
<div class="prb-update-result-body">
|
|
1636
|
+
<p><strong>${escapeHtml(outcome.ok ? "Review updated" : outcome.stop === "refused" ? "There was nothing to update against" : "The review was not updated")}</strong> ${escapeHtml(outcome.message)}</p>
|
|
1637
|
+
${renderFootnotes(outcome)}
|
|
1638
|
+
</div>
|
|
1639
|
+
<button type="button" class="prb-fresh-dismiss" data-update-close>Close</button>
|
|
1640
|
+
</div>`;
|
|
1641
|
+
}
|
|
1642
|
+
/**
|
|
1643
|
+
* The two things that can go quietly half-wrong, said out loud.
|
|
1644
|
+
*
|
|
1645
|
+
* Both are cases where the headline is a success and part of the work did not happen. A reviewer
|
|
1646
|
+
* who is told "Review updated" and not told the conversation could not be re-read will read the
|
|
1647
|
+
* "already raised by @sarah" lines as current, which is exactly the confident staleness the
|
|
1648
|
+
* freshness strip exists to prevent.
|
|
1649
|
+
*/
|
|
1650
|
+
function renderFootnotes(outcome) {
|
|
1651
|
+
const notes = [];
|
|
1652
|
+
if (outcome.fetchFailed) notes.push("The remote could not be reached, so this used the commits your copy of the repository already had.");
|
|
1653
|
+
if (outcome.conversationOk === false) notes.push("The pull request conversation could not be re-read, so the notes about who raised what are as old as the review.");
|
|
1654
|
+
if (!notes.length) return "";
|
|
1655
|
+
return `<p class="prb-update-footnote">${notes.map((note) => escapeHtml(note)).join(" ")}</p>`;
|
|
1656
|
+
}
|
|
1657
|
+
//#endregion
|
|
1658
|
+
//#region ../../packages/review-harness/src/workspace/freshness_view.ts
|
|
1659
|
+
/**
|
|
1660
|
+
* Freshness, said in sentences.
|
|
1661
|
+
*
|
|
1662
|
+
* Rendered by one function shared between the server and the page's own client, because this is
|
|
1663
|
+
* the second renderer of a thing that already exists on the server and the codebase has been
|
|
1664
|
+
* bitten by that before — "two viewers would drift the first time one of them was improved". The
|
|
1665
|
+
* server paints it on load; the client repaints it after a re-check; both call this.
|
|
1666
|
+
*
|
|
1667
|
+
* Nothing here decides what the reviewer should do. It reports three comparisons and, where it
|
|
1668
|
+
* cannot make one, says so — including the third, which it can never make. The reason that matters
|
|
1669
|
+
* is the one the whole module is against: a review that goes stale silently is more dangerous than
|
|
1670
|
+
* one that admits it does not know, because the reader supplies the confidence themselves.
|
|
1671
|
+
*/
|
|
1672
|
+
/**
|
|
1673
|
+
* What the reviewer runs to get a brand new review of the change.
|
|
1674
|
+
*
|
|
1675
|
+
* Still handed over rather than run from here, and the boundary is real: a fresh review is a fresh
|
|
1676
|
+
* set of findings, a fresh path through the change, and questions it may need answering along the
|
|
1677
|
+
* way. That belongs in the session where the analyser lives and the reviewer can watch it.
|
|
1678
|
+
*
|
|
1679
|
+
* Its own command rather than `/prreviewbuddy:review`, which now hands back the review of this
|
|
1680
|
+
* branch that already exists rather than making a second one. That is the safe default for the
|
|
1681
|
+
* command someone types most often, and it makes this string the only way to ask for another
|
|
1682
|
+
* review on purpose — which is exactly what the reviewer reading it is trying to do.
|
|
1683
|
+
*
|
|
1684
|
+
* What *is* run from here is the update, which is a different thing wearing similar clothes. It
|
|
1685
|
+
* reads only the commits since this review was made, it keeps the review and everything marked in
|
|
1686
|
+
* it, and it refuses rather than improvising whenever the working tree would need a judgement call.
|
|
1687
|
+
* See `update_view.ts`, which offers both side by side.
|
|
1688
|
+
*/
|
|
1689
|
+
var REANALYSE_COMMAND = "/prreviewbuddy:fresh-review";
|
|
1690
|
+
/** Short summary for the header control. The reviewer should not have to open a panel to worry. */
|
|
1691
|
+
function freshnessLabel(report) {
|
|
1692
|
+
if (!report) return {
|
|
1693
|
+
label: "Check for updates",
|
|
1694
|
+
state: "unknown"
|
|
1695
|
+
};
|
|
1696
|
+
if (report.code.state === "changed") return {
|
|
1697
|
+
label: report.code.rewritten ? "Code rewritten" : "Code changed",
|
|
1698
|
+
state: "changed"
|
|
1699
|
+
};
|
|
1700
|
+
if (report.checkout.state === "changed") return {
|
|
1701
|
+
label: "Checkout differs",
|
|
1702
|
+
state: "changed"
|
|
1703
|
+
};
|
|
1704
|
+
if (report.code.state === "current") return {
|
|
1705
|
+
label: "Up to date",
|
|
1706
|
+
state: "current"
|
|
1707
|
+
};
|
|
1708
|
+
return {
|
|
1709
|
+
label: "Freshness unknown",
|
|
1710
|
+
state: "unknown"
|
|
1711
|
+
};
|
|
1712
|
+
}
|
|
1713
|
+
/**
|
|
1714
|
+
* The banner under the header, or nothing.
|
|
1715
|
+
*
|
|
1716
|
+
* Silent when every comparison came back clean and the reviewer has not asked, because a strip
|
|
1717
|
+
* that is always there is a strip nobody reads — and the one time it says something new is the
|
|
1718
|
+
* time it has to be noticed.
|
|
1719
|
+
*/
|
|
1720
|
+
/**
|
|
1721
|
+
* The panel under the header: one status, one consequence, one primary action.
|
|
1722
|
+
*
|
|
1723
|
+
* This used to be a stack of independent sentence-rows, one per comparison, each with its own
|
|
1724
|
+
* amber dot and its own paragraph. Three comparisons meant three warnings of equal weight, so a
|
|
1725
|
+
* rewritten branch and a checkout that is merely behind shouted equally loudly, the action was
|
|
1726
|
+
* buried in the middle of the prose, and the whole thing pushed the review it describes off the
|
|
1727
|
+
* screen. Everything below is one restructure of that into a hierarchy.
|
|
1728
|
+
*
|
|
1729
|
+
* The code comparison is the status. Everything else is a footnote to it, because everything else
|
|
1730
|
+
* is only interesting once you know whether the findings still describe the code. The header chip
|
|
1731
|
+
* carries the same status in two words; this panel exists to say what it means and offer the way
|
|
1732
|
+
* out, so it does not repeat the chip's job.
|
|
1733
|
+
*/
|
|
1734
|
+
function renderFreshness(report, options = {}) {
|
|
1735
|
+
if (!report) return "";
|
|
1736
|
+
const alarming = report.code.state === "changed" || report.checkout.state === "changed";
|
|
1737
|
+
const lost = report.code.reason === "missing-commit";
|
|
1738
|
+
const dismissed = options.dismissedSha !== void 0 && options.dismissedSha === report.code.headSha;
|
|
1739
|
+
if (!options.open && (!(alarming || lost) || dismissed)) return "";
|
|
1740
|
+
const status = codeStatus(report.code);
|
|
1741
|
+
const facts = [...status.facts, `checked ${escapeHtml(relativeTime$1(report.checkedAt))}`];
|
|
1742
|
+
const notes = [checkoutNote(report.checkout), options.open ? conversationNote$1(report, options.actions === true) : ""].filter((note) => note !== "");
|
|
1743
|
+
return `<div class="prb-fresh" data-state="${alarming || lost ? "changed" : "quiet"}" role="status">
|
|
1744
|
+
<div class="prb-fresh-head">
|
|
1745
|
+
<span class="prb-fresh-dot" data-freshness="${status.state}" aria-hidden="true"></span>
|
|
1746
|
+
<strong class="prb-fresh-title">${escapeHtml(status.title)}</strong>
|
|
1747
|
+
<span class="prb-fresh-facts">${facts.join(" · ")}</span>
|
|
1748
|
+
</div>
|
|
1749
|
+
${status.consequence ? `<p class="prb-fresh-say">${status.consequence}</p>` : ""}
|
|
1750
|
+
${renderActions(report, options, alarming || lost)}
|
|
1751
|
+
${notes.length === 0 ? "" : `<div class="prb-fresh-notes">${notes.map((note) => `<p>${note}</p>`).join("")}</div>`}
|
|
1752
|
+
</div>`;
|
|
1753
|
+
}
|
|
1754
|
+
/**
|
|
1755
|
+
* One primary action, then the two that only adjust the panel itself.
|
|
1756
|
+
*
|
|
1757
|
+
* `Update review` is the only control here that changes the review, so it is the only one wearing
|
|
1758
|
+
* the brand colour. `Check again` and `Dismiss` are quiet text buttons: as equal-weight boxes they
|
|
1759
|
+
* competed with it, which is what made the area read as busy rather than as a decision.
|
|
1760
|
+
*/
|
|
1761
|
+
function renderActions(report, options, alarming) {
|
|
1762
|
+
const offerUpdate = options.actions && alarming;
|
|
1763
|
+
const dismiss = alarming ? `<button type="button" class="prb-fresh-quiet" data-fresh-dismiss="${escapeHtml(report.code.headSha ?? "")}">Dismiss</button>` : `<button type="button" class="prb-fresh-quiet" data-fresh-close>Close</button>`;
|
|
1764
|
+
const aside = offerUpdate ? renderUpdateAside(REANALYSE_COMMAND) : {
|
|
1765
|
+
alt: "",
|
|
1766
|
+
explain: ""
|
|
1767
|
+
};
|
|
1768
|
+
return `<div class="prb-fresh-actions">
|
|
1769
|
+
${offerUpdate ? renderUpdateButton() : ""}
|
|
1770
|
+
<button type="button" class="prb-fresh-quiet" data-fresh-recheck>Check again</button>
|
|
1771
|
+
${dismiss}
|
|
1772
|
+
${aside.alt}
|
|
1773
|
+
</div>
|
|
1774
|
+
${aside.explain}`;
|
|
1775
|
+
}
|
|
1776
|
+
function files(n, verb) {
|
|
1777
|
+
return n === null ? [] : [`${n} file${n === 1 ? "" : "s"} ${verb}`];
|
|
1778
|
+
}
|
|
1779
|
+
/** Seven characters is how everyone reads a commit, and the full sha is on the element for copying. */
|
|
1780
|
+
function short(sha) {
|
|
1781
|
+
return sha ? `<code title="${escapeHtml(sha)}">${escapeHtml(sha.slice(0, 7))}</code>` : "";
|
|
1782
|
+
}
|
|
1783
|
+
/** The reviewed commit, which is the one fact worth repeating on every variant. */
|
|
1784
|
+
function reviewed(sha) {
|
|
1785
|
+
return sha ? [`reviewed ${short(sha)}`] : [];
|
|
1786
|
+
}
|
|
1787
|
+
function codeStatus(code) {
|
|
1788
|
+
if (code.state === "current") return {
|
|
1789
|
+
state: "current",
|
|
1790
|
+
title: "Up to date",
|
|
1791
|
+
facts: reviewed(code.reviewedSha),
|
|
1792
|
+
consequence: ""
|
|
1793
|
+
};
|
|
1794
|
+
if (code.state === "changed") {
|
|
1795
|
+
if (code.rewritten) return {
|
|
1796
|
+
state: "changed",
|
|
1797
|
+
title: "Code rewritten",
|
|
1798
|
+
facts: [...reviewed(code.reviewedSha), ...files(code.changedFiles, "differ")],
|
|
1799
|
+
consequence: "The branch was rebased, amended or reset, so the reviewed commit is no longer on it. Every finding below describes the older code."
|
|
1800
|
+
};
|
|
1801
|
+
const commits = code.newCommits === null ? [] : [`${code.newCommits} new commit${code.newCommits === 1 ? "" : "s"}`];
|
|
1802
|
+
return {
|
|
1803
|
+
state: "changed",
|
|
1804
|
+
title: "Code changed",
|
|
1805
|
+
facts: [
|
|
1806
|
+
...reviewed(code.reviewedSha),
|
|
1807
|
+
...commits,
|
|
1808
|
+
...files(code.changedFiles, "changed")
|
|
1809
|
+
],
|
|
1810
|
+
consequence: "Findings below describe the reviewed commit, and the lines they cite may no longer be those lines."
|
|
1811
|
+
};
|
|
1812
|
+
}
|
|
1813
|
+
switch (code.reason) {
|
|
1814
|
+
case "uncommitted": return {
|
|
1815
|
+
state: "unknown",
|
|
1816
|
+
title: "Cannot be compared",
|
|
1817
|
+
facts: reviewed(code.reviewedSha),
|
|
1818
|
+
consequence: "This review read uncommitted work and nothing recorded what the working tree held, so whether the files still match cannot be told from here."
|
|
1819
|
+
};
|
|
1820
|
+
case "missing-commit": return {
|
|
1821
|
+
state: "unknown",
|
|
1822
|
+
title: "Reviewed commit is gone",
|
|
1823
|
+
facts: reviewed(code.reviewedSha),
|
|
1824
|
+
consequence: "It is not in this repository any more, after a force-push or a branch rebuilt from scratch, so nothing can be measured against it."
|
|
1825
|
+
};
|
|
1826
|
+
default: return {
|
|
1827
|
+
state: "unknown",
|
|
1828
|
+
title: "Cannot be compared",
|
|
1829
|
+
facts: [],
|
|
1830
|
+
consequence: "Git could not be read in this checkout."
|
|
1831
|
+
};
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
/**
|
|
1835
|
+
* The checkout, demoted to a footnote.
|
|
1836
|
+
*
|
|
1837
|
+
* It was a second amber warning carrying the same weight as the code status, which is the wrong
|
|
1838
|
+
* ranking: a checkout that is behind changes nothing about whether the findings are true, it only
|
|
1839
|
+
* explains why the conversation reads the way it does. Muted, one line, inside the same panel.
|
|
1840
|
+
*/
|
|
1841
|
+
function checkoutNote(checkout) {
|
|
1842
|
+
if (checkout.reason === "no-pull-request") return "";
|
|
1843
|
+
if (checkout.reason === "no-head-sha" || checkout.reason === "git-failed") return "Your checkout cannot be compared against the pull request.";
|
|
1844
|
+
const untouched = "Updating leaves your checkout alone.";
|
|
1845
|
+
switch (checkout.relation) {
|
|
1846
|
+
case "same": return "";
|
|
1847
|
+
case "checkout-behind": return `Your checkout is behind the pull request head ${short(checkout.prHeadSha)}, recorded at import. ${untouched}`;
|
|
1848
|
+
case "checkout-ahead": return `Your checkout is ahead of the pull request head ${short(checkout.prHeadSha)} recorded at import, so the conversation below describes earlier code. ${untouched}`;
|
|
1849
|
+
case "diverged": return `Your checkout and the pull request have diverged, measured against ${short(checkout.prHeadSha)} at import. ${untouched}`;
|
|
1850
|
+
default: return "The relationship between your checkout and the pull request could not be worked out.";
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
/**
|
|
1854
|
+
* When the conversation was last read, and the offer to read it again.
|
|
1855
|
+
*
|
|
1856
|
+
* The wording changed with the thing it describes. This used to say the page "has no connection to
|
|
1857
|
+
* the site your pull request lives on, so someone has to go and look", which was true while the
|
|
1858
|
+
* only way to fetch a conversation was to ask a model to go and do it. A forge adapter drives the
|
|
1859
|
+
* CLI the reviewer has already signed in to now, so re-checking takes a couple of seconds and the
|
|
1860
|
+
* sentence calling it impossible had outlived the limitation it described.
|
|
1861
|
+
*
|
|
1862
|
+
* Still safe to run from here for the reason it always was: re-reading threads can only correct
|
|
1863
|
+
* the state of claims that already exist, never produce a finding.
|
|
1864
|
+
*/
|
|
1865
|
+
function conversationNote$1(report, actions) {
|
|
1866
|
+
if (!report.conversation.attached) return "";
|
|
1867
|
+
const when = escapeHtml(relativeTime$1(report.conversation.importedAt));
|
|
1868
|
+
if (!actions) return `The pull request conversation was read ${when}.`;
|
|
1869
|
+
return `The pull request conversation was read ${when}. Re-checking fetches every thread again and corrects the “already raised” notes on your findings, leaving the findings themselves alone.<button type="button" class="prb-fresh-quiet" data-pr-recheck>Re-check the conversation</button>`;
|
|
1870
|
+
}
|
|
1871
|
+
//#endregion
|
|
1872
|
+
//#region ../../packages/review-harness/src/workspace/job_view.ts
|
|
1873
|
+
/**
|
|
1874
|
+
* The workspace while its review is still being made, and what to do about it if it fails.
|
|
1875
|
+
*
|
|
1876
|
+
* A reviewer can now open a link before the review behind it exists: the review runs in a spawned
|
|
1877
|
+
* process against an isolated checkout, and this is minutes long by design. A silent wait was
|
|
1878
|
+
* reported as a hang once already in this product, on a step that took far less time than this
|
|
1879
|
+
* one does, so every state here says which phase it is in and how long it has been going rather
|
|
1880
|
+
* than leaving a blank space where the review will eventually appear.
|
|
1881
|
+
*
|
|
1882
|
+
* A conversation that could not be fetched is not folded in here as a failure: it is a line on a
|
|
1883
|
+
* review that finished, not a state this module renders while the job is still working.
|
|
1884
|
+
*/
|
|
1885
|
+
/** What the reviewer reads while each phase runs. The only copy visible during the wait. */
|
|
1886
|
+
var PHASE_COPY = {
|
|
1887
|
+
preparing: "Preparing an isolated checkout. This can take a minute on a large repository.",
|
|
1888
|
+
context: "Reading what changed.",
|
|
1889
|
+
conversation: "Reading the pull request conversation.",
|
|
1890
|
+
analysing: "Analysing the change."
|
|
1891
|
+
};
|
|
1892
|
+
/**
|
|
1893
|
+
* The same phases, as a noun, for the retry sentence.
|
|
1894
|
+
*
|
|
1895
|
+
* "picks up from the analysis" reads as an answer to "will this redo work"; "picks up from the
|
|
1896
|
+
* analysing" does not, which is why this is a separate table from `PHASE_COPY` rather than a
|
|
1897
|
+
* reuse of it.
|
|
1898
|
+
*/
|
|
1899
|
+
var PHASE_NOUN = {
|
|
1900
|
+
preparing: "checkout",
|
|
1901
|
+
context: "context reading",
|
|
1902
|
+
conversation: "conversation reading",
|
|
1903
|
+
analysing: "analysis"
|
|
1904
|
+
};
|
|
1905
|
+
function isWorkPhase(phase) {
|
|
1906
|
+
return phase === "preparing" || phase === "context" || phase === "conversation" || phase === "analysing";
|
|
1907
|
+
}
|
|
1908
|
+
/** How long a phase has been running, in words a reviewer reads as reassurance rather than alarm. */
|
|
1909
|
+
function elapsedFor(startedAt) {
|
|
1910
|
+
const minutes = Math.round((Date.now() - startedAt) / 6e4);
|
|
1911
|
+
if (minutes < 1) return "under a minute";
|
|
1912
|
+
return `${minutes} minute${minutes === 1 ? "" : "s"}`;
|
|
1913
|
+
}
|
|
1914
|
+
/**
|
|
1915
|
+
* What to say about a conversation that is not attached, and when to say nothing at all.
|
|
1916
|
+
*
|
|
1917
|
+
* Two of the six outcomes are silence. An attached conversation speaks for itself, on the review.
|
|
1918
|
+
* A change that is not on a pull request has nothing missing from it, and a permanent note saying
|
|
1919
|
+
* so on every branch review would be the house style's own bad habit: a status strip that is only
|
|
1920
|
+
* ever true.
|
|
1921
|
+
*
|
|
1922
|
+
* The other four all change what the "already raised" notes on this review can claim, which is
|
|
1923
|
+
* exactly the thing a reviewer would otherwise assume they had.
|
|
1924
|
+
*/
|
|
1925
|
+
function conversationNote(outcome) {
|
|
1926
|
+
switch (outcome.kind) {
|
|
1927
|
+
case "attached":
|
|
1928
|
+
case "no-request": return "";
|
|
1929
|
+
case "unsupported-forge": return outcome.host ? `PR Review Buddy does not read pull request conversation on ${outcome.host} yet, so this review is of the code alone.` : "This repository has no remote to look a pull request up on, so this review is of the code alone.";
|
|
1930
|
+
case "cli-missing": return `This review was made without the pull request conversation, because \`${outcome.cli}\` is not installed.`;
|
|
1931
|
+
case "not-authenticated": return `This review was made without the pull request conversation, because \`${outcome.cli}\` is not signed in. Run \`${outcome.cli} auth login\` and update the review to read it.`;
|
|
1932
|
+
case "failed": return `This review was made without the pull request conversation attached. ${outcome.detail}`;
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
/**
|
|
1936
|
+
* The workspace page above the review: what the job is doing, or nothing at all.
|
|
1937
|
+
*
|
|
1938
|
+
* Nothing is rendered once the job has finished cleanly, because a permanent status strip on a
|
|
1939
|
+
* complete review is exactly the noise this house style avoids elsewhere. The one exception is a
|
|
1940
|
+
* conversation that could not be attached: that is worth a line even on a review that otherwise
|
|
1941
|
+
* succeeded, because it changes what the "already raised" notes on this review can claim.
|
|
1942
|
+
*/
|
|
1943
|
+
function renderJobState(job) {
|
|
1944
|
+
if (!job) return "";
|
|
1945
|
+
if (job.phase === "failed" && job.failure) {
|
|
1946
|
+
const noun = isWorkPhase(job.failure.phase) ? PHASE_NOUN[job.failure.phase] : job.failure.phase;
|
|
1947
|
+
return `<div class="prb-job" data-job-phase="failed" role="status">
|
|
1948
|
+
<p><strong>Review paused.</strong> ${escapeHtml(job.failure.message)}</p>
|
|
1949
|
+
<button type="button" class="prb-job-retry" data-job-retry>Retry</button>
|
|
1950
|
+
<p class="prb-job-note">Retry picks up from the ${escapeHtml(noun)}, not from the start.</p>
|
|
1951
|
+
</div>`;
|
|
1952
|
+
}
|
|
1953
|
+
if (job.phase === "done") {
|
|
1954
|
+
const note = job.conversation ? conversationNote(job.conversation) : "";
|
|
1955
|
+
if (!note) return "";
|
|
1956
|
+
return `<div class="prb-job" data-job-phase="done" role="status">
|
|
1957
|
+
<p>${escapeHtml(note)}</p>
|
|
1958
|
+
</div>`;
|
|
1959
|
+
}
|
|
1960
|
+
const copy = isWorkPhase(job.phase) ? PHASE_COPY[job.phase] : "";
|
|
1961
|
+
const progress = job.progress ? ` <span class="prb-job-progress">${escapeHtml(job.progress)}</span>` : "";
|
|
1962
|
+
return `<div class="prb-job" data-job-phase="${escapeHtml(job.phase)}" role="status" aria-live="polite">
|
|
1963
|
+
<p><span class="prb-spinner" aria-hidden="true"></span> ${escapeHtml(copy)}${progress}</p>
|
|
1964
|
+
<p class="prb-job-elapsed">Running for ${escapeHtml(elapsedFor(job.startedAt))}.</p>
|
|
1965
|
+
</div>`;
|
|
1966
|
+
}
|
|
1967
|
+
/**
|
|
1968
|
+
* What the page's poll asks for, and what the client needs to decide whether to keep asking.
|
|
1969
|
+
*
|
|
1970
|
+
* `'none'` covers a workspace with no job at all, which is an ordinary state for anything made
|
|
1971
|
+
* before jobs existed, or a review whose job record has since been swept.
|
|
1972
|
+
*/
|
|
1973
|
+
function jobStatus(job) {
|
|
1974
|
+
if (!job) return {
|
|
1975
|
+
phase: "none",
|
|
1976
|
+
progress: "",
|
|
1977
|
+
failed: false
|
|
1978
|
+
};
|
|
1979
|
+
const progress = job.phase === "failed" ? job.failure?.message ?? "" : job.progress ?? (isWorkPhase(job.phase) ? PHASE_COPY[job.phase] : "");
|
|
1980
|
+
return {
|
|
1981
|
+
phase: job.phase,
|
|
1982
|
+
progress,
|
|
1983
|
+
failed: job.phase === "failed"
|
|
1984
|
+
};
|
|
1985
|
+
}
|
|
1986
|
+
//#endregion
|
|
1987
|
+
//#region ../../packages/review-harness/src/workspace/page.ts
|
|
1988
|
+
function renderWorkspace(view) {
|
|
1989
|
+
const { session, changeSet } = view;
|
|
1990
|
+
const result = session.result;
|
|
1991
|
+
const sections = result?.sections ?? [];
|
|
1992
|
+
const title = changeSet.data.pullRequest.title;
|
|
1993
|
+
/**
|
|
1994
|
+
* Freshness is a claim about findings, so a review with no findings has none to make.
|
|
1995
|
+
*
|
|
1996
|
+
* "The code has moved on, the findings below describe that commit" said on a review that stopped
|
|
1997
|
+
* before producing a finding describes nothing, and the Update review button beside it offers to
|
|
1998
|
+
* reconcile a review against itself. The CLI already refuses this; the page was showing it.
|
|
1999
|
+
*
|
|
2000
|
+
* Decided once here rather than at each of the three places it is drawn, because the header pill,
|
|
2001
|
+
* the `data-report` the client repaints from, and the banner itself have to agree. Suppressing
|
|
2002
|
+
* only the markup would leave the pill able to paint it straight back.
|
|
2003
|
+
*/
|
|
2004
|
+
const freshness = result ? view.freshness : void 0;
|
|
2005
|
+
return `<!doctype html>
|
|
2006
|
+
<html lang="en">
|
|
2007
|
+
<head>
|
|
2008
|
+
<meta charset="utf-8">
|
|
2009
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
2010
|
+
<title>${escapeHtml(changeSet.isLinkedWorktree ? `${changeSet.checkoutName}: ${title}` : title)} · PR Review Buddy</title>
|
|
2011
|
+
<link rel="icon" type="image/png" href="/assets/icon128.png">
|
|
2012
|
+
<link rel="stylesheet" href="/assets/sidebar.css">
|
|
2013
|
+
<link rel="stylesheet" href="/assets/shell.css">
|
|
2014
|
+
</head>
|
|
2015
|
+
<body data-api-base="${escapeHtml(view.apiBase)}"${view.token ? ` data-token="${escapeHtml(view.token)}"` : ""}${view.editor ? ` data-editor-label="${escapeHtml(view.editor.label)}" data-editor-template="${escapeHtml(view.editor.template)}"` : ""}${view.repoPath ? ` data-repo-path="${escapeHtml(view.repoPath)}"` : ""} data-explain-prompt="${escapeHtml(EXPLAIN_SIMPLY_PROMPT)}">
|
|
2016
|
+
${renderHeader(view, freshness)}
|
|
2017
|
+
${renderJobState(view.job ?? null)}
|
|
2018
|
+
<div class="prb-fresh-region" data-fresh-region${freshness ? ` data-report="${escapeHtml(JSON.stringify(freshness))}"` : ""}${view.dismissedFreshnessSha ? ` data-dismissed-sha="${escapeHtml(view.dismissedFreshnessSha)}"` : ""}>${renderFreshness(freshness ?? null, {
|
|
2019
|
+
dismissedSha: view.dismissedFreshnessSha,
|
|
2020
|
+
actions: view.token !== void 0
|
|
2021
|
+
})}</div>
|
|
2022
|
+
${renderUpdateOutcome(view.lastUpdate)}
|
|
2023
|
+
${renderLineageNotice(view)}
|
|
2024
|
+
${result ? renderReviewBody(view, sections) : ""}
|
|
2025
|
+
${view.token ? renderShutdownDialog() : ""}
|
|
2026
|
+
${view.token ? renderPromptDialog() : ""}
|
|
2027
|
+
<script type="module" src="/assets/workspace.js"><\/script>
|
|
2028
|
+
</body>
|
|
2029
|
+
</html>`;
|
|
2030
|
+
}
|
|
2031
|
+
/**
|
|
2032
|
+
* Everything the review actually shows: the rail, the document, the assistant.
|
|
2033
|
+
*
|
|
2034
|
+
* Pulled out of `renderWorkspace` so it can be skipped whole rather than rendered around a null
|
|
2035
|
+
* result. A review with no result yet has no sections, no issues, no files worth listing — every
|
|
2036
|
+
* one of those panes would draw as empty rather than as absent, and a reviewer cannot tell "this
|
|
2037
|
+
* finished with nothing to say" from "this has not run yet" by looking at an empty list.
|
|
2038
|
+
*/
|
|
2039
|
+
function renderReviewBody(view, sections) {
|
|
2040
|
+
const { session } = view;
|
|
2041
|
+
const result = session.result;
|
|
2042
|
+
return `<div class="prb-body"${view.askWidth ? ` style="--ask-width: ${Math.round(view.askWidth)}px"` : ""}>
|
|
2043
|
+
${renderRail(view, sections)}
|
|
2044
|
+
<main class="prb-doc">
|
|
2045
|
+
<div class="pr-helper-sidebar prb-pane">
|
|
2046
|
+
${renderDisclosure(view)}
|
|
2047
|
+
<section class="prb-view" data-view="overview">${renderOverviewTab(session)}</section>
|
|
2048
|
+
<section class="prb-view" data-view="guide" hidden>${renderGuideTab(session, null, null)}</section>
|
|
2049
|
+
<section class="prb-view" data-view="issues" hidden>
|
|
2050
|
+
<h3 class="prb-doc-title">Issues</h3>
|
|
2051
|
+
${renderAttributionAge(view)}
|
|
2052
|
+
${renderIssuesTab(session, null)}
|
|
2053
|
+
</section>
|
|
2054
|
+
<section class="prb-view" data-view="questions" hidden>
|
|
2055
|
+
<h3 class="prb-doc-title">Questions</h3>
|
|
2056
|
+
${renderQuestionsTab(session, null, { suppressFileJump: true })}
|
|
2057
|
+
</section>
|
|
2058
|
+
<section class="prb-view" data-view="files" hidden>
|
|
2059
|
+
<h3 class="prb-doc-title">Files</h3>
|
|
2060
|
+
${renderFiles(view, sections)}
|
|
2061
|
+
</section>
|
|
2062
|
+
<section class="prb-view" data-view="prcontext" hidden>
|
|
2063
|
+
${renderPrContextView(view.prContext ?? null, {
|
|
2064
|
+
findings: result?.issues.length ?? 0,
|
|
2065
|
+
matched: (result?.issues ?? []).filter((issue) => issue.existingDiscussion).length
|
|
2066
|
+
}, {
|
|
2067
|
+
enabled: view.token !== void 0 && view.prContext !== void 0,
|
|
2068
|
+
last: view.lastPrRefresh
|
|
2069
|
+
})}
|
|
2070
|
+
</section>
|
|
2071
|
+
</div>
|
|
2072
|
+
</main>
|
|
2073
|
+
${renderAsk(view, sections)}
|
|
2074
|
+
</div>`;
|
|
2075
|
+
}
|
|
2076
|
+
/**
|
|
2077
|
+
* When the "already raised" claims below were last true.
|
|
2078
|
+
*
|
|
2079
|
+
* Every attribution on this view carries a thread's state — "still unresolved", "since marked
|
|
2080
|
+
* resolved" — and that state was frozen the moment the conversation was imported. Nothing
|
|
2081
|
+
* re-checks it, and nothing here can: the daemon reaches no forge. So a card can go on telling
|
|
2082
|
+
* the reviewer that Sergei's thread is open an hour after Sergei closed it.
|
|
2083
|
+
*
|
|
2084
|
+
* Every other kind of staleness in this product degrades into vagueness. This one degrades into a
|
|
2085
|
+
* confident, specific, wrong claim about what a named colleague currently thinks, which is worth
|
|
2086
|
+
* one line above the list rather than an explanation the reviewer has to go and find.
|
|
2087
|
+
*
|
|
2088
|
+
* Only where a conversation was attached: there are no such claims to qualify otherwise.
|
|
2089
|
+
*/
|
|
2090
|
+
function renderAttributionAge(view) {
|
|
2091
|
+
if (!view.prContext) return "";
|
|
2092
|
+
return `<p class="prb-doc-note">Any “already raised” note below describes the pull request as it stood ${escapeHtml(relativeTime$1(view.prContext.prContext.discussion.fetchedAt))}, when its discussion was fetched. That includes whether each thread was still open. If someone has replied or resolved a thread since then, this page has no way of knowing. Use <b>Re-check the conversation</b> on the PR context view to bring it up to date.</p>`;
|
|
2093
|
+
}
|
|
2094
|
+
/**
|
|
2095
|
+
* The link out to this file on the pull request.
|
|
2096
|
+
*
|
|
2097
|
+
* Wraps the shared builder in `file_links.ts`, which the file viewer's own route uses too so the
|
|
2098
|
+
* two cannot disagree about where a file lives.
|
|
2099
|
+
*/
|
|
2100
|
+
function prFileLink(view, path) {
|
|
2101
|
+
return buildPrFileLink(view.prContext?.prContext, path);
|
|
2102
|
+
}
|
|
2103
|
+
/**
|
|
2104
|
+
* Whose work this is, beside the branch it is on.
|
|
2105
|
+
*
|
|
2106
|
+
* Here rather than in the Overview because it is part of what identifies the change, and a
|
|
2107
|
+
* reviewer wants it before they read a line rather than after. Absent when nothing recorded it,
|
|
2108
|
+
* which is an ordinary state for a review made before this existed.
|
|
2109
|
+
*/
|
|
2110
|
+
function renderAuthor(view) {
|
|
2111
|
+
const author = describeAuthorship(view.changeSet, view.prContext?.prContext.pullRequest);
|
|
2112
|
+
if (!author) return "";
|
|
2113
|
+
const title = author.title ? ` title="${escapeHtml(author.title)}"` : "";
|
|
2114
|
+
const handle = author.handle ? `<span class="prb-author-handle">@${escapeHtml(author.handle)}</span>` : "";
|
|
2115
|
+
const body = `<span class="prb-author-name">${escapeHtml(author.label)}</span>${handle}`;
|
|
2116
|
+
return author.url ? `<a class="prb-author" href="${escapeHtml(author.url)}" target="_blank" rel="noopener noreferrer"${title}>${body}</a>` : `<span class="prb-author"${title}>${body}</span>`;
|
|
2117
|
+
}
|
|
2118
|
+
/**
|
|
2119
|
+
* "You are reading review 1 of 3", when that is true and only then.
|
|
2120
|
+
*
|
|
2121
|
+
* Silent on the latest review, which is the overwhelming majority of page loads: a strip that
|
|
2122
|
+
* appears on every review is a strip nobody reads, and the one time this has something to say is
|
|
2123
|
+
* the time it has to be noticed.
|
|
2124
|
+
*
|
|
2125
|
+
* The link goes to the index and carries no token of its own. That is the whole reason this is a
|
|
2126
|
+
* sentence and a link rather than a dropdown: the index is the only page that holds every review's
|
|
2127
|
+
* credential, and a reviewer arriving there has their own way in.
|
|
2128
|
+
*/
|
|
2129
|
+
function renderLineageNotice(view) {
|
|
2130
|
+
const lineage = view.lineage;
|
|
2131
|
+
if (!lineage || lineage.position >= lineage.total) return "";
|
|
2132
|
+
const newer = lineage.total - lineage.position;
|
|
2133
|
+
return `<div class="prb-lineage" role="status">
|
|
2134
|
+
<p><strong>You are reading review ${lineage.position} of ${lineage.total}.</strong> ${newer === 1 ? "A newer review" : `${newer} newer reviews`} of this branch ${newer === 1 ? "exists" : "exist"}, so the findings below may already have been answered. Nothing here is out of bounds, but the current one is somewhere else.</p>
|
|
2135
|
+
<a class="prb-lineage-link" href="/">See every review of this branch</a>
|
|
2136
|
+
</div>`;
|
|
2137
|
+
}
|
|
2138
|
+
function renderHeader(view, freshness) {
|
|
2139
|
+
const { changeSet } = view;
|
|
2140
|
+
const additions = changeSet.data.files.reduce((sum, f) => sum + f.additions, 0);
|
|
2141
|
+
const deletions = changeSet.data.files.reduce((sum, f) => sum + f.deletions, 0);
|
|
2142
|
+
const fileCount = changeSet.data.files.length;
|
|
2143
|
+
return `<header class="prb-header">
|
|
2144
|
+
<span class="prb-wordmark"><b>pr</b>review<b>buddy</b></span>
|
|
2145
|
+
<span class="prb-change-ref" title="${escapeHtml(changeSet.repoPath)}">
|
|
2146
|
+
${changeSet.isLinkedWorktree ? `<span class="prb-checkout">${escapeHtml(changeSet.checkoutName)}</span>` : ""}
|
|
2147
|
+
${view.prContext ? `<a class="prb-pr-chip" href="${escapeHtml(view.prContext.prContext.pullRequest.url)}" target="_blank" rel="noopener noreferrer" title="${escapeHtml(view.prContext.prContext.pullRequest.title ?? "")}">#${view.prContext.prContext.pullRequest.number}</a>` : ""}
|
|
2148
|
+
<strong>${escapeHtml(changeSet.headRef)}</strong>
|
|
2149
|
+
<span class="prb-arrow" aria-label="compared against">→</span>
|
|
2150
|
+
<span>${escapeHtml(changeSet.baseRef)}</span>
|
|
2151
|
+
${changeSet.includesUncommitted ? "<span class=\"prb-file-status\">+ working tree</span>" : ""}
|
|
2152
|
+
${renderAuthor(view)}
|
|
2153
|
+
</span>
|
|
2154
|
+
<div class="prb-header-stats">
|
|
2155
|
+
<span>${fileCount} file${fileCount === 1 ? "" : "s"}</span>
|
|
2156
|
+
<span><span class="prb-add">+${additions}</span> <span class="prb-del">−${deletions}</span></span>
|
|
2157
|
+
${view.token && freshness ? (() => {
|
|
2158
|
+
const { label, state } = freshnessLabel(freshness ?? null);
|
|
2159
|
+
return `<button type="button" class="prb-fresh-check" data-fresh-check data-state="${state}" aria-expanded="false" title="How this review stands against the branch as it is now">${escapeHtml(label)}</button>`;
|
|
2160
|
+
})() : ""}
|
|
2161
|
+
${view.token ? `<button type="button" class="prb-complete" data-complete aria-pressed="${view.complete === true}">${view.complete ? "Reviewed" : "Mark review complete"}</button>` : ""}
|
|
2162
|
+
<button type="button" class="prb-ask-toggle" data-ask-toggle aria-expanded="false">Buddy AI</button>
|
|
2163
|
+
</div>
|
|
2164
|
+
</header>`;
|
|
2165
|
+
}
|
|
2166
|
+
/**
|
|
2167
|
+
* Everything below the review: where else to go, and what to do to the server.
|
|
2168
|
+
*
|
|
2169
|
+
* These are in the rail rather than the header because they are not about this review. The header
|
|
2170
|
+
* answers "what am I looking at" — branch, size, whether I am done with it, and Buddy AI — and
|
|
2171
|
+
* anything global sitting up there competed with that. Leaving the review is navigation, and it
|
|
2172
|
+
* belongs with the other navigation.
|
|
2173
|
+
*
|
|
2174
|
+
* Stopping the server is separated from the links because it is operational and it ends every open
|
|
2175
|
+
* review, not just this one. The build sits beside it: it is provenance, wanted when something is
|
|
2176
|
+
* wrong, and that is the same corner as the feedback link that carries it.
|
|
2177
|
+
*
|
|
2178
|
+
* The same feedback form the extension uses, so replies land in one place rather than two. What
|
|
2179
|
+
* it carries is decided in `version.ts` — version, client, OS — and deliberately not the
|
|
2180
|
+
* repository or its path.
|
|
2181
|
+
*/
|
|
2182
|
+
function renderRailFoot(view) {
|
|
2183
|
+
return `<div class="prb-rail-group prb-rail-foot">
|
|
2184
|
+
${view.token ? `<a class="prb-rail-item" href="/">
|
|
2185
|
+
<span class="prb-rail-order">‹</span>
|
|
2186
|
+
<span class="prb-rail-label"><span class="prb-rail-title">All reviews</span></span>
|
|
2187
|
+
</a>` : ""}
|
|
2188
|
+
<a class="prb-rail-item" href="${escapeHtml(feedbackUrl())}" target="_blank" rel="noopener noreferrer">
|
|
2189
|
+
<span class="prb-rail-order"></span>
|
|
2190
|
+
<span class="prb-rail-label"><span class="prb-rail-title">Send feedback</span></span>
|
|
2191
|
+
</a>
|
|
2192
|
+
</div>
|
|
2193
|
+
<div class="prb-rail-group prb-rail-ops">
|
|
2194
|
+
${view.token ? `<button type="button" class="prb-rail-item prb-rail-danger" data-shutdown>
|
|
2195
|
+
<span class="prb-rail-order"></span>
|
|
2196
|
+
<span class="prb-rail-label"><span class="prb-rail-title">Stop server</span></span>
|
|
2197
|
+
</button>` : ""}
|
|
2198
|
+
<p class="prb-build">v${escapeHtml(BUILD_VERSION)}</p>
|
|
2199
|
+
</div>`;
|
|
2200
|
+
}
|
|
2201
|
+
/**
|
|
2202
|
+
* The review path, nested under the Overview.
|
|
2203
|
+
*
|
|
2204
|
+
* The themes are the Overview made walkable, not a second place to go, so the rail says that by
|
|
2205
|
+
* containing them rather than by listing them alongside it. Collapsing is a plain button beside
|
|
2206
|
+
* the Overview rather than a <details>: the row has to stay a link to the Overview, and a control
|
|
2207
|
+
* inside a <summary> is toggled by the browser as well as by us in some engines.
|
|
2208
|
+
*
|
|
2209
|
+
* Themes are numbered because the guide genuinely is an ordered walkthrough — `order` is the
|
|
2210
|
+
* model's answer to "what sequence should a reviewer take through this change" — so the number
|
|
2211
|
+
* tells the reader something they need. It is not a decorative index.
|
|
2212
|
+
*/
|
|
2213
|
+
function renderRail(view, sections) {
|
|
2214
|
+
const result = view.session.result;
|
|
2215
|
+
const issues = result?.issues ?? [];
|
|
2216
|
+
const questions = result?.questions ?? [];
|
|
2217
|
+
return `<nav class="prb-rail" aria-label="Review path">
|
|
2218
|
+
<div class="prb-rail-group">
|
|
2219
|
+
<div class="prb-rail-row">
|
|
2220
|
+
<button type="button" class="prb-rail-item" data-view-nav="overview" aria-current="true">
|
|
2221
|
+
<span class="prb-rail-order"></span>
|
|
2222
|
+
<span class="prb-rail-label"><span class="prb-rail-title">Overview</span></span>
|
|
2223
|
+
</button>
|
|
2224
|
+
<button type="button" class="prb-rail-twisty" data-rail-toggle aria-expanded="true" aria-controls="prb-rail-path" aria-label="Collapse the review path">
|
|
2225
|
+
<svg viewBox="0 0 12 12" aria-hidden="true" focusable="false"><path d="M3 4.5 6 8l3-3.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
2226
|
+
</button>
|
|
2227
|
+
</div>
|
|
2228
|
+
<div class="prb-rail-path" id="prb-rail-path">
|
|
2229
|
+
<p class="prb-rail-heading">Review path</p>
|
|
2230
|
+
${[...sections].sort((a, b) => a.order - b.order).map((section, index) => {
|
|
2231
|
+
const sectionIssues = issues.filter((issue) => issue.sectionId === section.id).length;
|
|
2232
|
+
const note = section.recommendedStartingPoint ? "<span class=\"prb-rail-start\">Start here</span>" : `<span class="prb-rail-note">${section.files.length} file${section.files.length === 1 ? "" : "s"}</span>`;
|
|
2233
|
+
return `<button type="button" class="prb-rail-item" data-goto-section="${escapeHtml(section.id)}" data-theme-index="${index}" data-recommended-start="${section.recommendedStartingPoint}">
|
|
2234
|
+
<span class="prb-rail-order">${index + 1}</span>
|
|
2235
|
+
<span class="prb-rail-label">
|
|
2236
|
+
<span class="prb-rail-title">${escapeHtml(section.title)}</span>
|
|
2237
|
+
${note}
|
|
2238
|
+
</span>
|
|
2239
|
+
${sectionIssues > 0 ? `<span class="prb-rail-count" title="${sectionIssues} issue${sectionIssues === 1 ? "" : "s"} in this theme">${sectionIssues}</span>` : ""}
|
|
2240
|
+
<span class="prb-attention" data-attention="${escapeHtml(section.reviewAttention)}" title="Needs ${escapeHtml(section.reviewAttention)} attention"></span>
|
|
2241
|
+
</button>`;
|
|
2242
|
+
}).join("") || "<p class=\"prb-rail-heading\">No themes</p>"}
|
|
2243
|
+
</div>
|
|
2244
|
+
</div>
|
|
2245
|
+
<div class="prb-rail-group">
|
|
2246
|
+
${railLink("issues", "Issues", issues.length)}
|
|
2247
|
+
${railLink("questions", "Questions", questions.length)}
|
|
2248
|
+
${railLink("files", "Files", view.changeSet.data.files.length)}
|
|
2249
|
+
${view.prContext ? railLink("prcontext", "PR context", view.prContext.processed.stats.totalThreads) : railLink("prcontext", "PR context", null, "No pull request was connected to this review")}
|
|
2250
|
+
</div>
|
|
2251
|
+
${renderRailFoot(view)}
|
|
2252
|
+
</nav>`;
|
|
2253
|
+
}
|
|
2254
|
+
/** A null count renders no badge, for the case where zero would be a claim rather than a total. */
|
|
2255
|
+
function railLink(view, label, count, note) {
|
|
2256
|
+
return `<button type="button" class="prb-rail-item" data-view-nav="${view}"${note ? ` title="${escapeHtml(note)}"` : ""}>
|
|
2257
|
+
<span class="prb-rail-order"></span>
|
|
2258
|
+
<span class="prb-rail-label"><span class="prb-rail-title">${escapeHtml(label)}</span></span>
|
|
2259
|
+
${count === null ? "" : `<span class="prb-rail-count" title="${count} ${escapeHtml(label.toLowerCase())}">${count}</span>`}
|
|
2260
|
+
</button>`;
|
|
2261
|
+
}
|
|
2262
|
+
/**
|
|
2263
|
+
* What the harness corrected, stated above the review rather than buried in a details element.
|
|
2264
|
+
* A guide quietly repaired is a guide trusted more than it deserves.
|
|
2265
|
+
*
|
|
2266
|
+
* Corrections only. There is deliberately nothing here about how much of the change travelled
|
|
2267
|
+
* inline: that is provenance, it belongs in Analysis details on the Overview, and putting it in a
|
|
2268
|
+
* box under this heading would restate a limit that no longer exists as though it did.
|
|
2269
|
+
*/
|
|
2270
|
+
function renderDisclosure(view) {
|
|
2271
|
+
const { audit } = view;
|
|
2272
|
+
const notes = [];
|
|
2273
|
+
if (audit.unknownPaths.length > 0) notes.push(`${audit.unknownPaths.length} referenced path${audit.unknownPaths.length === 1 ? "" : "s"} could not be found in this change and ${audit.unknownPaths.length === 1 ? "was" : "were"} removed: ` + audit.unknownPaths.map((path) => `<code>${escapeHtml(path)}</code>`).join(", ") + ".");
|
|
2274
|
+
if (audit.relocatedFindings > 0) notes.push(`${audit.relocatedFindings} finding${audit.relocatedFindings === 1 ? "" : "s"} pointed at a file that is not in this change. The finding is kept; its location is not.`);
|
|
2275
|
+
if (notes.length === 0) return "";
|
|
2276
|
+
return `<aside class="prb-disclosure">
|
|
2277
|
+
<strong>What the harness corrected</strong>
|
|
2278
|
+
<ul>${notes.map((note) => `<li>${note}</li>`).join("")}</ul>
|
|
2279
|
+
</aside>`;
|
|
2280
|
+
}
|
|
2281
|
+
/**
|
|
2282
|
+
* Every changed file, split by whether the guide sends the reviewer there.
|
|
2283
|
+
*
|
|
2284
|
+
* The split used to be analysed / not analysed, which was the truth under a file ceiling and is a
|
|
2285
|
+
* lie without one: nothing here was beyond the analysis's reach. What the two groups now record
|
|
2286
|
+
* is a priority the model set after reading, so the second group is a "later, if you want to",
|
|
2287
|
+
* not a gap the reviewer has to go and close.
|
|
2288
|
+
*/
|
|
2289
|
+
/**
|
|
2290
|
+
* The Files tab: the escape hatch from the guide.
|
|
2291
|
+
*
|
|
2292
|
+
* Everywhere else in the workspace the code shown is the code some finding is *about* — "here is
|
|
2293
|
+
* the evidence for this claim". This tab answers the other question a reviewer asks, which no
|
|
2294
|
+
* guide can anticipate: "let me look at that file myself". So it lists every changed file, groups
|
|
2295
|
+
* them by the theme that owns them, and expands them in place.
|
|
2296
|
+
*
|
|
2297
|
+
* The viewer is not a second implementation — every row carries `data-file`, which is the same
|
|
2298
|
+
* attribute the guide's file rows, an issue's location and a question's jump button carry, so all
|
|
2299
|
+
* four go through one code path. Two viewers would drift the first time one of them was improved.
|
|
2300
|
+
*/
|
|
2301
|
+
function renderFiles(view, sections) {
|
|
2302
|
+
const files = view.changeSet.data.files;
|
|
2303
|
+
const byPath = new Map(files.map((file) => [file.name, file]));
|
|
2304
|
+
const references = collectReferences(view, sections);
|
|
2305
|
+
const claimed = /* @__PURE__ */ new Set();
|
|
2306
|
+
const groups = sections.map((section) => {
|
|
2307
|
+
const sectionFiles = section.files.map((file) => byPath.get(file.path)).filter((file) => {
|
|
2308
|
+
if (!file || claimed.has(file.name)) return false;
|
|
2309
|
+
claimed.add(file.name);
|
|
2310
|
+
return true;
|
|
2311
|
+
});
|
|
2312
|
+
return fileGroup(view, `${section.order}. ${section.title}`, sectionFiles, references);
|
|
2313
|
+
}).join("");
|
|
2314
|
+
const unclaimed = files.filter((file) => !claimed.has(file.name));
|
|
2315
|
+
return `<div class="prb-file-search">
|
|
2316
|
+
<input type="search" data-file-filter placeholder="Search files…" aria-label="Filter files by path" autocomplete="off" spellcheck="false">
|
|
2317
|
+
<span class="prb-file-search-count" data-file-count>${files.length} file${files.length === 1 ? "" : "s"}</span>
|
|
2318
|
+
</div>
|
|
2319
|
+
${groups}
|
|
2320
|
+
${fileGroup(view, "Not in the review path", unclaimed, references)}
|
|
2321
|
+
<p class="prb-file-none" data-file-none hidden>No file matches that.</p>`;
|
|
2322
|
+
}
|
|
2323
|
+
/**
|
|
2324
|
+
* Which findings point at each file.
|
|
2325
|
+
*
|
|
2326
|
+
* Worth showing on the row because it is the one thing the file list cannot say for itself: a file
|
|
2327
|
+
* with four issues against it and a file with none look identical by path, status and line count.
|
|
2328
|
+
*/
|
|
2329
|
+
function collectReferences(view, sections) {
|
|
2330
|
+
const result = /* @__PURE__ */ new Map();
|
|
2331
|
+
const owner = /* @__PURE__ */ new Map();
|
|
2332
|
+
for (const section of sections) for (const file of section.files) if (!owner.has(file.path)) owner.set(file.path, section.id);
|
|
2333
|
+
const titles = new Map(sections.map((section) => [section.id, `${section.order}. ${section.title}`]));
|
|
2334
|
+
const entry = (path) => {
|
|
2335
|
+
const existing = result.get(path);
|
|
2336
|
+
if (existing) return existing;
|
|
2337
|
+
const created = {
|
|
2338
|
+
issues: 0,
|
|
2339
|
+
questions: 0,
|
|
2340
|
+
otherSections: []
|
|
2341
|
+
};
|
|
2342
|
+
result.set(path, created);
|
|
2343
|
+
return created;
|
|
2344
|
+
};
|
|
2345
|
+
const note = (path, sectionId, kind) => {
|
|
2346
|
+
if (!path) return;
|
|
2347
|
+
const record = entry(path);
|
|
2348
|
+
record[kind] += 1;
|
|
2349
|
+
const title = sectionId && sectionId !== owner.get(path) ? titles.get(sectionId) : void 0;
|
|
2350
|
+
if (title && !record.otherSections.includes(title)) record.otherSections.push(title);
|
|
2351
|
+
};
|
|
2352
|
+
for (const issue of view.session.result?.issues ?? []) note(issue.file, issue.sectionId, "issues");
|
|
2353
|
+
for (const question of view.session.result?.questions ?? []) note(question.file, question.sectionId, "questions");
|
|
2354
|
+
return result;
|
|
2355
|
+
}
|
|
2356
|
+
function fileGroup(view, heading, files, references) {
|
|
2357
|
+
if (files.length === 0) return "";
|
|
2358
|
+
const rows = files.map((file) => fileRow(view, file, references.get(file.name))).join("");
|
|
2359
|
+
return `<div class="prb-file-group" data-file-group>
|
|
2360
|
+
<h4 data-group-heading="${escapeHtml(heading)}" data-group-total="${files.length}">${escapeHtml(heading)} · ${files.length}</h4>${rows}
|
|
2361
|
+
</div>`;
|
|
2362
|
+
}
|
|
2363
|
+
function fileRow(view, file, refs) {
|
|
2364
|
+
const { dir, name } = splitFilePath(file.name);
|
|
2365
|
+
const badges = [
|
|
2366
|
+
...refs?.issues ? [`<span class="prb-file-ref prb-file-ref-issue">${refs.issues} issue${refs.issues === 1 ? "" : "s"}</span>`] : [],
|
|
2367
|
+
...refs?.questions ? [`<span class="prb-file-ref">${refs.questions} question${refs.questions === 1 ? "" : "s"}</span>`] : [],
|
|
2368
|
+
...(refs?.otherSections ?? []).map((title) => `<span class="prb-file-ref">also ${escapeHtml(title)}</span>`)
|
|
2369
|
+
].join("");
|
|
2370
|
+
const readable = !(file.additions === 0 && file.deletions === 0 && !file.patch);
|
|
2371
|
+
const openEditor = !readable && view.editor ? `<button type="button" class="prb-file-open" data-open-editor="${escapeHtml(file.name)}" data-open-line="1" title="Open in ${escapeHtml(view.editor.label)}">Open in ${escapeHtml(view.editor.label)}</button>` : "";
|
|
2372
|
+
const link = readable ? null : prFileLink(view, file.name);
|
|
2373
|
+
const openPr = link ? `<a class="prb-file-open prb-file-pr" href="${escapeHtml(link.href)}" target="_blank" rel="noopener noreferrer" title="${escapeHtml(link.label)}">Pull request</a>` : "";
|
|
2374
|
+
const attributes = readable ? ` data-file="${escapeHtml(file.name)}" role="button" tabindex="0" aria-expanded="false"` : "";
|
|
2375
|
+
return `<div class="prb-file${readable ? " prb-file-expandable" : ""}"${attributes} data-search="${escapeHtml(file.name.toLowerCase())}" title="${escapeHtml(file.name)}">
|
|
2376
|
+
<span class="prb-file-status">${escapeHtml(file.status)}</span>
|
|
2377
|
+
<span class="prb-file-path">
|
|
2378
|
+
${dir ? `<span class="pr-helper-path-dir">${escapeHtml(dir)}</span>` : ""}
|
|
2379
|
+
<span class="pr-helper-path-name">${escapeHtml(name)}</span>
|
|
2380
|
+
</span>
|
|
2381
|
+
${openPr}
|
|
2382
|
+
${openEditor}
|
|
2383
|
+
${badges ? `<span class="prb-file-badges">${badges}</span>` : ""}
|
|
2384
|
+
<span class="prb-file-stat"><span class="prb-add">+${file.additions}</span> <span class="prb-del">−${file.deletions}</span></span>
|
|
2385
|
+
</div>`;
|
|
2386
|
+
}
|
|
2387
|
+
/**
|
|
2388
|
+
* Saving and editing one prompt.
|
|
2389
|
+
*
|
|
2390
|
+
* A dialog rather than an inline field: a prompt is a paragraph often enough that a one-line
|
|
2391
|
+
* input would misrepresent what belongs here, and editing needs somewhere to put Delete that is
|
|
2392
|
+
* not next to the button that inserts.
|
|
2393
|
+
*
|
|
2394
|
+
* One dialog, reused for add and for edit — `data-prompt-id` says which, and it is empty for a
|
|
2395
|
+
* new one. Two dialogs would be two places for the field wiring to drift.
|
|
2396
|
+
*/
|
|
2397
|
+
function renderPromptDialog() {
|
|
2398
|
+
return `<dialog class="prb-dialog prb-prompt-dialog" data-prompt-dialog aria-labelledby="prb-prompt-title">
|
|
2399
|
+
<h2 id="prb-prompt-title">Save a prompt</h2>
|
|
2400
|
+
<label class="prb-field">
|
|
2401
|
+
<span>Name</span>
|
|
2402
|
+
<input type="text" data-prompt-name maxlength="60" placeholder="N+1 queries">
|
|
2403
|
+
</label>
|
|
2404
|
+
<label class="prb-field">
|
|
2405
|
+
<span>Prompt</span>
|
|
2406
|
+
<textarea data-prompt-text rows="5" maxlength="2000" placeholder="Check this change for N+1 database queries."></textarea>
|
|
2407
|
+
</label>
|
|
2408
|
+
<p class="prb-dialog-reassure">Saved prompts are yours and stay on this machine. They are offered in every review, and on any text you select.</p>
|
|
2409
|
+
<div class="prb-dialog-actions">
|
|
2410
|
+
<button type="button" class="prb-dialog-danger" data-prompt-delete hidden>Delete</button>
|
|
2411
|
+
<span class="prb-dialog-spacer"></span>
|
|
2412
|
+
<button type="button" data-prompt-cancel>Cancel</button>
|
|
2413
|
+
<button type="button" class="prb-dialog-primary" data-prompt-save>Save</button>
|
|
2414
|
+
</div>
|
|
2415
|
+
</dialog>`;
|
|
2416
|
+
}
|
|
2417
|
+
/**
|
|
2418
|
+
* A real dialog rather than a button that relabels itself.
|
|
2419
|
+
*
|
|
2420
|
+
* The relabelling version had a disarm timer, and an uncancelled timer from an earlier arming
|
|
2421
|
+
* could fire after the server had already stopped and reset the label to an offer that no longer
|
|
2422
|
+
* meant anything. A dialog has no timer to race, and it has room to say what is about to happen.
|
|
2423
|
+
*/
|
|
2424
|
+
function renderShutdownDialog() {
|
|
2425
|
+
return `<dialog class="prb-dialog" data-shutdown-dialog aria-labelledby="prb-shutdown-title">
|
|
2426
|
+
<h2 id="prb-shutdown-title">Stop the workspace server?</h2>
|
|
2427
|
+
<p>One server serves every review on this machine, so <b>every open workspace stops</b>, not just this one.</p>
|
|
2428
|
+
<p class="prb-dialog-reassure">Nothing is deleted. Your reviews stay on disk for thirty days. Run <code>/prreviewbuddy:start-server</code> to start the server again and get their links back.</p>
|
|
2429
|
+
<div class="prb-dialog-actions">
|
|
2430
|
+
<button type="button" data-shutdown-cancel>Cancel</button>
|
|
2431
|
+
<button type="button" class="prb-dialog-danger" data-shutdown-confirm>Stop server</button>
|
|
2432
|
+
</div>
|
|
2433
|
+
</dialog>`;
|
|
2434
|
+
}
|
|
2435
|
+
function renderAsk(view, sections) {
|
|
2436
|
+
const suggestions = buildSuggestions(view, sections);
|
|
2437
|
+
const transcript = view.transcript ?? [];
|
|
2438
|
+
return `<aside class="prb-ask" aria-label="Ask Buddy AI">
|
|
2439
|
+
<div
|
|
2440
|
+
class="prb-ask-resize"
|
|
2441
|
+
data-ask-resize
|
|
2442
|
+
role="separator"
|
|
2443
|
+
aria-orientation="vertical"
|
|
2444
|
+
aria-label="Resize the assistant. Arrow keys adjust; Home resets."
|
|
2445
|
+
tabindex="0"
|
|
2446
|
+
></div>
|
|
2447
|
+
<div class="prb-ask-header">
|
|
2448
|
+
<h2>Ask Buddy AI</h2>
|
|
2449
|
+
<span class="prb-ask-scope" data-ask-scope>whole change</span>
|
|
2450
|
+
</div>
|
|
2451
|
+
${renderPromptBank(view.prompts ?? [])}
|
|
2452
|
+
<div class="prb-ask-log" data-ask-log>
|
|
2453
|
+
${transcript.length > 0 ? transcript.map((exchange) => `<div class="prb-msg prb-msg-user">${escapeHtml(exchange.question)}</div>
|
|
2454
|
+
<div class="prb-msg prb-msg-assistant prb-md">${renderMarkdown(exchange.answer)}</div>`).join("") : `<div class="prb-ask-empty">
|
|
2455
|
+
<p>Ask about this change. Answers come from the review, the diff and the repository on disk — not from anywhere else.</p>
|
|
2456
|
+
${suggestions.map((text) => `<button type="button" class="prb-suggestion" data-suggestion>${escapeHtml(text)}</button>`).join("")}
|
|
2457
|
+
</div>`}
|
|
2458
|
+
</div>
|
|
2459
|
+
<form class="prb-ask-form" data-ask-form>
|
|
2460
|
+
<textarea name="question" rows="1" placeholder="Ask about this change…" aria-label="Your question"></textarea>
|
|
2461
|
+
<button type="submit">Ask</button>
|
|
2462
|
+
</form>
|
|
2463
|
+
</aside>`;
|
|
2464
|
+
}
|
|
2465
|
+
/**
|
|
2466
|
+
* The reviewer's own prompts, above the conversation.
|
|
2467
|
+
*
|
|
2468
|
+
* Rendered server-side rather than fetched, so the bank is there on first paint — a list that
|
|
2469
|
+
* appears a moment after the page does reads as something that loaded, and a prompt bank is
|
|
2470
|
+
* furniture rather than content.
|
|
2471
|
+
*
|
|
2472
|
+
* The list is always present, even when empty: an empty bank with an Add button explains what
|
|
2473
|
+
* the feature is, where a hidden one leaves it undiscoverable until someone reads a changelog.
|
|
2474
|
+
*/
|
|
2475
|
+
function renderPromptBank(prompts) {
|
|
2476
|
+
return `<div class="prb-bank" data-prompt-bank data-prompts="${escapeHtml(JSON.stringify(prompts))}">
|
|
2477
|
+
<div class="prb-bank-head">
|
|
2478
|
+
<span>Your prompts</span>
|
|
2479
|
+
<button type="button" class="prb-bank-add" data-prompt-add>Add</button>
|
|
2480
|
+
</div>
|
|
2481
|
+
<div class="prb-bank-list" data-prompt-list>${prompts.map(renderPromptChip).join("")}</div>
|
|
2482
|
+
</div>`;
|
|
2483
|
+
}
|
|
2484
|
+
function renderPromptChip(prompt) {
|
|
2485
|
+
return `<span class="prb-bank-item">
|
|
2486
|
+
<button type="button" class="prb-bank-use" data-prompt-use="${escapeHtml(prompt.id)}" title="${escapeHtml(prompt.prompt)}">${escapeHtml(prompt.name)}</button>
|
|
2487
|
+
<button type="button" class="prb-bank-edit" data-prompt-edit="${escapeHtml(prompt.id)}" aria-label="Edit ${escapeHtml(prompt.name)}">✎</button>
|
|
2488
|
+
</span>`;
|
|
2489
|
+
}
|
|
2490
|
+
/**
|
|
2491
|
+
* Openers drawn from this change rather than a fixed list.
|
|
2492
|
+
*
|
|
2493
|
+
* A generic "explain this code" prompt teaches the reviewer nothing about what the assistant is
|
|
2494
|
+
* for. Naming the actual highest-severity finding and the actual starting theme shows them that it
|
|
2495
|
+
* already knows what they are looking at.
|
|
2496
|
+
*/
|
|
2497
|
+
function buildSuggestions(view, sections) {
|
|
2498
|
+
const suggestions = [];
|
|
2499
|
+
const worst = (view.session.result?.issues ?? [])[0];
|
|
2500
|
+
if (worst) suggestions.push(`Why is "${worst.title}" risky?`);
|
|
2501
|
+
const start = sections.find((section) => section.recommendedStartingPoint) ?? sections[0];
|
|
2502
|
+
if (start) suggestions.push(`Walk me through the ${start.title.toLowerCase()} changes.`);
|
|
2503
|
+
suggestions.push("Would this break existing users?");
|
|
2504
|
+
return suggestions.slice(0, 3);
|
|
2505
|
+
}
|
|
2506
|
+
//#endregion
|
|
2507
|
+
//#region ../../packages/review-harness/src/workspace/reviews_page.ts
|
|
2508
|
+
function renderReviewsPage(view) {
|
|
2509
|
+
const now = view.now ?? Date.now();
|
|
2510
|
+
const ordered = [...view.groups].sort((a, b) => Number(b.latest.pinned) - Number(a.latest.pinned) || touchedAt(b.latest) - touchedAt(a.latest));
|
|
2511
|
+
return `<!doctype html>
|
|
2512
|
+
<html lang="en">
|
|
2513
|
+
<head>
|
|
2514
|
+
<meta charset="utf-8">
|
|
2515
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
2516
|
+
<title>Reviews · PR Review Buddy</title>
|
|
2517
|
+
<link rel="icon" type="image/png" href="/assets/icon128.png">
|
|
2518
|
+
<link rel="stylesheet" href="/assets/sidebar.css">
|
|
2519
|
+
<link rel="stylesheet" href="/assets/shell.css">
|
|
2520
|
+
</head>
|
|
2521
|
+
<body class="prb-index-body">
|
|
2522
|
+
<header class="prb-header">
|
|
2523
|
+
<span class="prb-wordmark"><b>pr</b>review<b>buddy</b></span>
|
|
2524
|
+
<span class="prb-change-ref">Reviews</span>
|
|
2525
|
+
<div class="prb-header-stats">
|
|
2526
|
+
<span>${storedLabel(view.groups.reduce((total, group) => total + group.history.length, 0), ordered.length)}</span>
|
|
2527
|
+
<span class="prb-build">v${escapeHtml(BUILD_VERSION)}</span>
|
|
2528
|
+
<a class="prb-feedback" href="${escapeHtml(feedbackUrl())}" target="_blank" rel="noopener noreferrer">Send feedback</a>
|
|
2529
|
+
</div>
|
|
2530
|
+
</header>
|
|
2531
|
+
<main class="prb-index">
|
|
2532
|
+
${ordered.length === 0 ? "" : renderControls()}
|
|
2533
|
+
<div class="prb-cards" data-cards>
|
|
2534
|
+
${ordered.map((group) => renderCard(group, now)).join("")}
|
|
2535
|
+
</div>
|
|
2536
|
+
${ordered.length === 0 ? `<p class="prb-index-empty">No reviews stored yet. Ask Claude Code to review a change and one will appear here.</p>` : `<p class="prb-index-none" data-no-matches hidden>Nothing matches that.</p>`}
|
|
2537
|
+
<p class="prb-index-note">Reviews are kept for thirty days, on this machine only. Stopping the server deletes nothing, but the next start gives every review a new link, so come back to this page rather than to a bookmarked review.</p>
|
|
2538
|
+
</main>
|
|
2539
|
+
<script src="/assets/reviews.js" type="module"><\/script>
|
|
2540
|
+
</body>
|
|
2541
|
+
</html>`;
|
|
2542
|
+
}
|
|
2543
|
+
/**
|
|
2544
|
+
* Search and filters, above the grid.
|
|
2545
|
+
*
|
|
2546
|
+
* Search covers everything a person might remember about a review — the repository, the branch,
|
|
2547
|
+
* the title, the model's summary, and the paths it touched — because which of those they recall
|
|
2548
|
+
* is not predictable. The filters are the three states and nothing else; a filter per risk level
|
|
2549
|
+
* would be four more controls for a question ("show me only the risky ones") that sorting by
|
|
2550
|
+
* recency already answers badly and that scanning answers well.
|
|
2551
|
+
*/
|
|
2552
|
+
function renderControls() {
|
|
2553
|
+
return `<div class="prb-index-controls">
|
|
2554
|
+
<input type="search" class="prb-index-search" data-search placeholder="Search repo, branch, title, summary or file…" aria-label="Search reviews" spellcheck="false">
|
|
2555
|
+
<div class="prb-index-filters" role="group" aria-label="Filter reviews">
|
|
2556
|
+
<button type="button" data-filter="all" aria-pressed="true">All</button>
|
|
2557
|
+
<button type="button" data-filter="in-progress" aria-pressed="false">In progress</button>
|
|
2558
|
+
<button type="button" data-filter="complete" aria-pressed="false">Reviewed</button>
|
|
2559
|
+
</div>
|
|
2560
|
+
</div>`;
|
|
2561
|
+
}
|
|
2562
|
+
/**
|
|
2563
|
+
* "13 reviews of 9 branches", once those are different numbers.
|
|
2564
|
+
*
|
|
2565
|
+
* The count used to be the number of cards, and it still could be, but the card no longer stands
|
|
2566
|
+
* for one review. Saying "9 stored" over a page holding thirteen analyses would be quietly wrong
|
|
2567
|
+
* about the one thing this header exists to state.
|
|
2568
|
+
*/
|
|
2569
|
+
function storedLabel(reviews, branches) {
|
|
2570
|
+
if (reviews === branches) return `${reviews} stored`;
|
|
2571
|
+
return `${reviews} reviews of ${branches} branch${branches === 1 ? "" : "es"}`;
|
|
2572
|
+
}
|
|
2573
|
+
/**
|
|
2574
|
+
* One branch.
|
|
2575
|
+
*
|
|
2576
|
+
* The order down the card is the order the questions get asked: what is this (summary), where is
|
|
2577
|
+
* it (repo and branch), how big and how risky, what is left, and when was I last here. Everything
|
|
2578
|
+
* above the history line describes the **latest** review, because that is the state of this branch
|
|
2579
|
+
* as far as the product knows; the earlier ones are history and are folded away until asked for.
|
|
2580
|
+
*/
|
|
2581
|
+
function renderCard(group, now) {
|
|
2582
|
+
const review = group.latest;
|
|
2583
|
+
const status = statusOf(review);
|
|
2584
|
+
const href = `/r/${encodeURIComponent(review.id)}?t=${encodeURIComponent(review.token)}`;
|
|
2585
|
+
return `<article class="prb-card" data-card data-status="${status}" data-pinned="${review.pinned}" data-search-text="${escapeHtml(groupSearchText(group))}">
|
|
2586
|
+
<button type="button" class="prb-card-pin" data-pin="${escapeHtml(review.id)}" aria-pressed="${review.pinned}" aria-label="${review.pinned ? "Unpin" : "Pin"} this review" title="${review.pinned ? "Unpin" : "Pin to the top"}">${review.pinned ? "★" : "☆"}</button>
|
|
2587
|
+
<a class="prb-card-link" href="${escapeHtml(href)}">
|
|
2588
|
+
<div class="prb-card-head">
|
|
2589
|
+
<h2>${escapeHtml(review.title)}</h2>
|
|
2590
|
+
${renderStatus(status)}
|
|
2591
|
+
</div>
|
|
2592
|
+
${review.summary ? `<p class="prb-card-summary">${escapeHtml(review.summary)}</p>` : ""}
|
|
2593
|
+
<p class="prb-card-where">
|
|
2594
|
+
<span class="prb-card-repo">${escapeHtml(lastSegment(review.repoPath))}</span>
|
|
2595
|
+
<span class="prb-card-branch">${escapeHtml(review.branch)}</span>
|
|
2596
|
+
${review.author ? `<span class="prb-card-author">${escapeHtml(review.author)}</span>` : ""}
|
|
2597
|
+
</p>
|
|
2598
|
+
<p class="prb-card-stats">
|
|
2599
|
+
${renderRisk(review.overallRisk)}
|
|
2600
|
+
<span>${review.changedFiles} file${review.changedFiles === 1 ? "" : "s"}</span>
|
|
2601
|
+
<span class="prb-card-diffstat"><span class="prb-add">+${review.additions}</span> <span class="prb-del">−${review.deletions}</span></span>
|
|
2602
|
+
</p>
|
|
2603
|
+
<p class="prb-card-left">${escapeHtml(whatIsLeft(review))}</p>
|
|
2604
|
+
<p class="prb-card-when"><time datetime="${new Date(touchedAt(review)).toISOString()}" title="${escapeHtml(new Date(touchedAt(review)).toLocaleString())}">${escapeHtml(whenLabel(review, now))}</time></p>
|
|
2605
|
+
</a>
|
|
2606
|
+
${renderHistory(group, now)}
|
|
2607
|
+
</article>`;
|
|
2608
|
+
}
|
|
2609
|
+
/**
|
|
2610
|
+
* Every analysis of this branch, folded away.
|
|
2611
|
+
*
|
|
2612
|
+
* Absent entirely on a branch reviewed once, which is most of them: a disclosure reading
|
|
2613
|
+
* "1 review" is a control that can only ever tell you what the card already said.
|
|
2614
|
+
*
|
|
2615
|
+
* Outside the card's link rather than inside it, because a link inside a link is not a thing HTML
|
|
2616
|
+
* has, and each row is its own link into its own review with its own token.
|
|
2617
|
+
*/
|
|
2618
|
+
function renderHistory(group, now) {
|
|
2619
|
+
if (group.history.length < 2) return "";
|
|
2620
|
+
const count = group.history.length;
|
|
2621
|
+
return `<div class="prb-card-history">
|
|
2622
|
+
<button type="button" class="prb-card-history-toggle" data-history-toggle aria-expanded="false">
|
|
2623
|
+
<span class="prb-card-history-caret" aria-hidden="true">▾</span>${count} reviews
|
|
2624
|
+
</button>
|
|
2625
|
+
<ol class="prb-card-history-list" data-history-list hidden>
|
|
2626
|
+
${group.history.map((review, index) => renderHistoryRow(review, count - index, index === 0, now)).join("")}
|
|
2627
|
+
</ol>
|
|
2628
|
+
</div>`;
|
|
2629
|
+
}
|
|
2630
|
+
/**
|
|
2631
|
+
* One analysis in the list.
|
|
2632
|
+
*
|
|
2633
|
+
* The commit is on every row, including the latest, and it is the field that does the real work
|
|
2634
|
+
* here: two reviews an hour apart are told apart by what they were of, not by when they happened.
|
|
2635
|
+
* A review whose commit was never recorded shows none rather than a placeholder.
|
|
2636
|
+
*
|
|
2637
|
+
* Numbered from the oldest, so the newest has the highest number and "Review 1" is where this
|
|
2638
|
+
* branch started. The same numbering the workspace itself uses, so "Review 1 of 3" there and
|
|
2639
|
+
* "Review 1" here are the same review.
|
|
2640
|
+
*
|
|
2641
|
+
* **Only the latest row carries a workflow status.** `New`, `In progress` and `Reviewed` describe
|
|
2642
|
+
* where someone is in a piece of work, and on a superseded review that is a claim about work
|
|
2643
|
+
* nobody should be continuing: a row reading `IN PROGRESS` under a newer review of the same branch
|
|
2644
|
+
* invites exactly the wrong click. The second slot is the lineage instead, `Latest` or `Previous`,
|
|
2645
|
+
* which is the only thing about an older review that a reader needs before opening it.
|
|
2646
|
+
*/
|
|
2647
|
+
function renderHistoryRow(review, number, latest, now) {
|
|
2648
|
+
const href = `/r/${encodeURIComponent(review.id)}?t=${encodeURIComponent(review.token)}`;
|
|
2649
|
+
return `<li class="prb-card-history-row"${latest ? " data-latest" : ""}>
|
|
2650
|
+
<a href="${escapeHtml(href)}">
|
|
2651
|
+
<span class="prb-card-history-name">Review ${number} ${latest ? "<span class=\"prb-card-history-latest\">Latest</span>" : "<span class=\"prb-card-history-previous\">Previous</span>"}</span>
|
|
2652
|
+
${review.commitSha ? `<code class="prb-card-history-sha" title="${escapeHtml(review.commitSha)}">${escapeHtml(review.commitSha.slice(0, 7))}</code>` : ""}
|
|
2653
|
+
<span class="prb-card-history-when">${escapeHtml(relativeTime(review.createdAt, now))}</span>
|
|
2654
|
+
${latest ? renderStatus(statusOf(review)) : ""}
|
|
2655
|
+
</a>
|
|
2656
|
+
</li>`;
|
|
2657
|
+
}
|
|
2658
|
+
/**
|
|
2659
|
+
* What this review still has open.
|
|
2660
|
+
*
|
|
2661
|
+
* Unresolved counts, not totals: "1 unanswered question" says where the review stands, where
|
|
2662
|
+
* "1/2 questions" makes the reader do the subtraction. Nothing here is a completion metric — the
|
|
2663
|
+
* product does not track file-by-file review, so the card does not pretend it does.
|
|
2664
|
+
*/
|
|
2665
|
+
function whatIsLeft(review) {
|
|
2666
|
+
const parts = [];
|
|
2667
|
+
if (review.issues > 0) parts.push(`${review.issues} issue${review.issues === 1 ? "" : "s"}`);
|
|
2668
|
+
if (review.unansweredQuestions > 0) parts.push(`${review.unansweredQuestions} unanswered question${review.unansweredQuestions === 1 ? "" : "s"}`);
|
|
2669
|
+
else if (review.questions > 0) parts.push("all questions answered");
|
|
2670
|
+
if (review.messages > 0) parts.push(`${review.messages} Buddy AI message${review.messages === 1 ? "" : "s"}`);
|
|
2671
|
+
return parts.length > 0 ? parts.join(" · ") : "Nothing flagged";
|
|
2672
|
+
}
|
|
2673
|
+
/**
|
|
2674
|
+
* Three states, and only one of them is inferred from anything.
|
|
2675
|
+
*
|
|
2676
|
+
* `complete` is set by the reviewer pressing a button and never derived. Every signal that looks
|
|
2677
|
+
* like completion — questions ticked, issues read — means the checklist is empty, which is not
|
|
2678
|
+
* the same as being done; a reviewer can still be reading code with nothing left to tick.
|
|
2679
|
+
*/
|
|
2680
|
+
function statusOf(review) {
|
|
2681
|
+
if (review.completedAt) return "complete";
|
|
2682
|
+
return review.lastViewedAt ? "in-progress" : "new";
|
|
2683
|
+
}
|
|
2684
|
+
function renderStatus(status) {
|
|
2685
|
+
return `<span class="prb-card-status" data-status="${status}">${status === "complete" ? "Reviewed" : status === "in-progress" ? "In progress" : "New"}</span>`;
|
|
2686
|
+
}
|
|
2687
|
+
function renderRisk(risk) {
|
|
2688
|
+
if (risk === "unknown") return "";
|
|
2689
|
+
return `<span class="prb-card-risk" data-risk="${risk}">${risk} risk</span>`;
|
|
2690
|
+
}
|
|
2691
|
+
/** Last opened where that is known, created otherwise. */
|
|
2692
|
+
function touchedAt(review) {
|
|
2693
|
+
return review.lastViewedAt ?? review.createdAt;
|
|
2694
|
+
}
|
|
2695
|
+
function whenLabel(review, now) {
|
|
2696
|
+
return review.lastViewedAt ? `Opened ${relativeTime(review.lastViewedAt, now)}` : `Created ${relativeTime(review.createdAt, now)}`;
|
|
2697
|
+
}
|
|
2698
|
+
/**
|
|
2699
|
+
* Everything the search field matches, flattened into one string on the card.
|
|
2700
|
+
*
|
|
2701
|
+
* Matching happens in the browser against this attribute rather than over the rendered text: the
|
|
2702
|
+
* paths are searchable without being displayed, and a filter that walked the DOM would match the
|
|
2703
|
+
* word "issue" in a label as readily as in a title.
|
|
2704
|
+
*/
|
|
2705
|
+
function groupSearchText(group) {
|
|
2706
|
+
return group.history.map(searchText).join(" ");
|
|
2707
|
+
}
|
|
2708
|
+
function searchText(review) {
|
|
2709
|
+
return [
|
|
2710
|
+
lastSegment(review.repoPath),
|
|
2711
|
+
review.repoPath,
|
|
2712
|
+
review.branch,
|
|
2713
|
+
review.title,
|
|
2714
|
+
review.summary,
|
|
2715
|
+
review.author ?? "",
|
|
2716
|
+
...review.paths
|
|
2717
|
+
].join(" ").toLowerCase();
|
|
2718
|
+
}
|
|
2719
|
+
function lastSegment(path) {
|
|
2720
|
+
const segments = path.split("/").filter(Boolean);
|
|
2721
|
+
return segments[segments.length - 1] ?? path;
|
|
2722
|
+
}
|
|
2723
|
+
/**
|
|
2724
|
+
* "12 minutes ago" rather than a timestamp.
|
|
2725
|
+
*
|
|
2726
|
+
* What the reader is deciding is which of these is the one they were just working on, and a
|
|
2727
|
+
* relative age answers that in a glance where a clock time makes them do the subtraction. The
|
|
2728
|
+
* exact time is on the element's `title` for when several read the same.
|
|
2729
|
+
*/
|
|
2730
|
+
function relativeTime(at, now) {
|
|
2731
|
+
const seconds = Math.max(0, Math.round((now - at) / 1e3));
|
|
2732
|
+
if (seconds < 60) return "just now";
|
|
2733
|
+
const minutes = Math.round(seconds / 60);
|
|
2734
|
+
if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
|
|
2735
|
+
const hours = Math.round(minutes / 60);
|
|
2736
|
+
if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
|
|
2737
|
+
const days = Math.round(hours / 24);
|
|
2738
|
+
return `${days} day${days === 1 ? "" : "s"} ago`;
|
|
2739
|
+
}
|
|
2740
|
+
//#endregion
|
|
2741
|
+
//#region ../../packages/review-harness/src/workspace/ask.ts
|
|
2742
|
+
/** Long enough for a real look at the repository; short enough that a hang is not forever. */
|
|
2743
|
+
var ASK_TIMEOUT_MS = 18e4;
|
|
2744
|
+
var SYSTEM_PROMPT = `You are Buddy AI, PR Review Buddy's review assistant. Refer to yourself as Buddy AI on the rare occasion you need to name yourself.
|
|
2745
|
+
|
|
2746
|
+
A developer is reviewing a specific code change and is looking at a review guide you have been given below. They ask you questions while they read.
|
|
2747
|
+
|
|
2748
|
+
Answer only in relation to this change and the repository it lives in. You have the working tree, so open the files, trace callers and read history wherever that settles the question. Do not speculate about code you could have read.
|
|
2749
|
+
|
|
2750
|
+
If a question cannot be answered from this change and this repository, say so plainly and say what would answer it. Do not pad the answer out with general advice about the topic; a developer mid-review wants the specific answer or an honest "that isn't determinable from here".
|
|
2751
|
+
|
|
2752
|
+
Write in short paragraphs and keep it to what was asked. Markdown renders, so use it where it carries meaning: backticks for identifiers and paths, a fenced block for a snippet worth reading as code rather than described in a sentence, a bullet list where the answer genuinely is a list. Prose is still the default. No headings, and do not break a paragraph into bullets to look organised.`;
|
|
2753
|
+
var AskUnavailableError = class extends Error {};
|
|
2754
|
+
/**
|
|
2755
|
+
* Answer one question about the change, by running the agent in a checkout of the reviewed commit.
|
|
2756
|
+
*
|
|
2757
|
+
* Not the reviewer's own checkout, which is what this used to read. They are mid-review: the tree
|
|
2758
|
+
* has their edits in it, or a branch they switched to, or a rebase half done, and an assistant
|
|
2759
|
+
* reading that answers confidently about code the review is not about. The checkout here is pinned
|
|
2760
|
+
* to the commit the review was made from, so the answer describes the change the reviewer is
|
|
2761
|
+
* looking at, and the isolation the analysis has always had now covers the questions too.
|
|
2762
|
+
*
|
|
2763
|
+
* The agent rather than a provider API on purpose: it uses the authentication the developer already
|
|
2764
|
+
* has, needs no API key, and costs them nothing beyond their existing subscription. It also means
|
|
2765
|
+
* the assistant keeps working after the session that produced the review has closed, which is the
|
|
2766
|
+
* whole reason the workspace is served rather than written to a file.
|
|
2767
|
+
*/
|
|
2768
|
+
async function askAboutChange(workspace, question, sectionId) {
|
|
2769
|
+
const cwd = await askCheckout(workspace);
|
|
2770
|
+
const env = agentEnvOf(workspace);
|
|
2771
|
+
try {
|
|
2772
|
+
return extractAnswer(await agentFor(env).run({
|
|
2773
|
+
capability: "read-code",
|
|
2774
|
+
prompt: buildPrompt(workspace, question, sectionId, cwd),
|
|
2775
|
+
cwd,
|
|
2776
|
+
originRepoPath: workspace.repoPath,
|
|
2777
|
+
env,
|
|
2778
|
+
timeoutMs: ASK_TIMEOUT_MS
|
|
2779
|
+
}));
|
|
2780
|
+
} catch (error) {
|
|
2781
|
+
if (error instanceof AgentUnavailableError) throw new AskUnavailableError("The agent is not available here, so the assistant cannot answer. Install Claude Code, or start the workspace from a shell where `claude` runs.");
|
|
2782
|
+
throw error;
|
|
2783
|
+
}
|
|
2784
|
+
}
|
|
2785
|
+
/**
|
|
2786
|
+
* The answer, checked before it is shown.
|
|
2787
|
+
*
|
|
2788
|
+
* An agent that produced nothing is a failure with a clear cause, and rendering its empty string as
|
|
2789
|
+
* an answer would show the reviewer a blank panel and call it a reply.
|
|
2790
|
+
*/
|
|
2791
|
+
function extractAnswer(raw) {
|
|
2792
|
+
const answer = raw.trim();
|
|
2793
|
+
if (!answer) throw new Error("The assistant returned an empty answer.");
|
|
2794
|
+
return answer;
|
|
2795
|
+
}
|
|
2796
|
+
/**
|
|
2797
|
+
* Bound the assistant to this change.
|
|
2798
|
+
*
|
|
2799
|
+
* The guide and the whole diff go in every time. The theme the reviewer is currently reading is
|
|
2800
|
+
* named rather than used to filter the diff: they routinely ask "does this interact with X",
|
|
2801
|
+
* where X is in another theme, and a prompt that had quietly dropped X would answer confidently
|
|
2802
|
+
* and wrongly.
|
|
2803
|
+
*/
|
|
2804
|
+
function buildPrompt(workspace, question, sectionId, cwd) {
|
|
2805
|
+
const section = workspace.session.result?.sections.find((s) => s.id === sectionId);
|
|
2806
|
+
const recent = workspace.transcript.slice(-4);
|
|
2807
|
+
return [
|
|
2808
|
+
SYSTEM_PROMPT,
|
|
2809
|
+
"---",
|
|
2810
|
+
`CHANGE: ${workspace.changeSet.headRef} compared against ${workspace.changeSet.baseRef}, in the checkout at ${cwd}`,
|
|
2811
|
+
"---",
|
|
2812
|
+
"REVIEW GUIDE (JSON):",
|
|
2813
|
+
JSON.stringify(workspace.session.result),
|
|
2814
|
+
"---",
|
|
2815
|
+
"DIFF:",
|
|
2816
|
+
workspace.diff,
|
|
2817
|
+
...section ? ["---", `The reviewer is currently reading the "${section.title}" theme. Answer with that in view, but do not ignore the rest of the change if it bears on the question.`] : [],
|
|
2818
|
+
...recent.length > 0 ? [
|
|
2819
|
+
"---",
|
|
2820
|
+
"EARLIER IN THIS CONVERSATION:",
|
|
2821
|
+
recent.map((entry) => `Q: ${entry.question}\nA: ${entry.answer}`).join("\n\n")
|
|
2822
|
+
] : [],
|
|
2823
|
+
"---",
|
|
2824
|
+
`QUESTION: ${question}`
|
|
2825
|
+
].join("\n");
|
|
2826
|
+
}
|
|
2827
|
+
var FileOutsideRepositoryError = class extends Error {};
|
|
2828
|
+
/**
|
|
2829
|
+
* Resolve a path against the reviewed repository, refusing anything outside it.
|
|
2830
|
+
*
|
|
2831
|
+
* The token already gates every route. This is the second lock: a path escape would turn one
|
|
2832
|
+
* review's link into a read of anything the developer can read, which is a much larger failure
|
|
2833
|
+
* than an unauthorised look at a review.
|
|
2834
|
+
*/
|
|
2835
|
+
function resolveInRepository(repoPath, path) {
|
|
2836
|
+
const root = resolve(repoPath);
|
|
2837
|
+
const target = resolve(root, path);
|
|
2838
|
+
const rel = relative(root, target);
|
|
2839
|
+
if (rel.startsWith("..") || isAbsolute(rel)) throw new FileOutsideRepositoryError("That path is outside the repository.");
|
|
2840
|
+
return target;
|
|
2841
|
+
}
|
|
2842
|
+
function readFileWindow(workspace, path, options = {}) {
|
|
2843
|
+
const target = resolveInRepository(workspace.repoPath, path);
|
|
2844
|
+
const all = readFileSync(target, "utf8").split("\n");
|
|
2845
|
+
if (all.length > 0 && all[all.length - 1] === "") all.pop();
|
|
2846
|
+
const totalLines = all.length;
|
|
2847
|
+
const added = addedLinesFor(workspace, path);
|
|
2848
|
+
let from = 1;
|
|
2849
|
+
let to = Math.min(totalLines, 600);
|
|
2850
|
+
if (options.line && !options.expand) {
|
|
2851
|
+
from = Math.max(1, options.line - 12);
|
|
2852
|
+
to = Math.min(totalLines, options.line + 12);
|
|
2853
|
+
} else if (options.expand) to = Math.min(totalLines, 600);
|
|
2854
|
+
return {
|
|
2855
|
+
path,
|
|
2856
|
+
totalLines,
|
|
2857
|
+
from,
|
|
2858
|
+
to,
|
|
2859
|
+
lines: all.slice(from - 1, to),
|
|
2860
|
+
addedLines: added.filter((n) => n >= from && n <= to),
|
|
2861
|
+
truncated: to < totalLines
|
|
2862
|
+
};
|
|
2863
|
+
}
|
|
2864
|
+
/**
|
|
2865
|
+
* Which lines of the *current* file this change added, from the patch we already hold.
|
|
2866
|
+
*
|
|
2867
|
+
* Read out of the stored change set rather than by running git again: the review describes one
|
|
2868
|
+
* specific state of the working tree, and a second `git diff` could answer about a different one.
|
|
2869
|
+
*/
|
|
2870
|
+
function addedLinesFor(workspace, path) {
|
|
2871
|
+
const file = workspace.changeSet.data.files.find((entry) => entry.name === path);
|
|
2872
|
+
if (!file?.patch) return [];
|
|
2873
|
+
return addedLinesFromPatch(file.patch);
|
|
2874
|
+
}
|
|
2875
|
+
var HUNK = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/;
|
|
2876
|
+
function addedLinesFromPatch(patch) {
|
|
2877
|
+
const added = [];
|
|
2878
|
+
let lineNumber = 0;
|
|
2879
|
+
for (const line of patch.split("\n")) {
|
|
2880
|
+
const hunk = HUNK.exec(line);
|
|
2881
|
+
if (hunk) {
|
|
2882
|
+
lineNumber = Number(hunk[1]);
|
|
2883
|
+
continue;
|
|
2884
|
+
}
|
|
2885
|
+
if (lineNumber === 0) continue;
|
|
2886
|
+
if (line.startsWith("+++")) continue;
|
|
2887
|
+
if (line.startsWith("+")) {
|
|
2888
|
+
added.push(lineNumber);
|
|
2889
|
+
lineNumber += 1;
|
|
2890
|
+
} else if (line.startsWith("-") || line.startsWith("\\")) {} else lineNumber += 1;
|
|
2891
|
+
}
|
|
2892
|
+
return added;
|
|
2893
|
+
}
|
|
2894
|
+
//#endregion
|
|
2895
|
+
//#region ../../packages/review-harness/src/workspace/editor.ts
|
|
2896
|
+
/**
|
|
2897
|
+
* How to hand a file back to the reviewer's real environment.
|
|
2898
|
+
*
|
|
2899
|
+
* A review tool is a navigation layer, not an IDE. The moment someone wants to actually change
|
|
2900
|
+
* code they should leave, and the value of this button is that leaving is one click rather than a
|
|
2901
|
+
* path copied out of a citation.
|
|
2902
|
+
*
|
|
2903
|
+
* `{path}` is substituted with the absolute path, `{line}` with a 1-based line number.
|
|
2904
|
+
*/
|
|
2905
|
+
var EDITOR_TEMPLATES = {
|
|
2906
|
+
vscode: "vscode://file{path}:{line}",
|
|
2907
|
+
"vscode-insiders": "vscode-insiders://file{path}:{line}",
|
|
2908
|
+
cursor: "cursor://file{path}:{line}",
|
|
2909
|
+
windsurf: "windsurf://file{path}:{line}",
|
|
2910
|
+
zed: "zed://file{path}:{line}",
|
|
2911
|
+
sublime: "subl://open?url=file://{path}&line={line}",
|
|
2912
|
+
intellij: "idea://open?file={path}&line={line}"
|
|
2913
|
+
};
|
|
2914
|
+
var DEFAULT_EDITOR = "vscode";
|
|
2915
|
+
var LABELS = {
|
|
2916
|
+
vscode: "VS Code",
|
|
2917
|
+
"vscode-insiders": "VS Code Insiders",
|
|
2918
|
+
cursor: "Cursor",
|
|
2919
|
+
windsurf: "Windsurf",
|
|
2920
|
+
zed: "Zed",
|
|
2921
|
+
sublime: "Sublime Text",
|
|
2922
|
+
intellij: "IntelliJ"
|
|
2923
|
+
};
|
|
2924
|
+
/**
|
|
2925
|
+
* Read the configured editor. There is no way to detect one from a browser, so this is asked
|
|
2926
|
+
* rather than guessed: a button that opens the wrong application is worse than one that says
|
|
2927
|
+
* plainly which application it opens.
|
|
2928
|
+
*/
|
|
2929
|
+
function editorChoice() {
|
|
2930
|
+
const configured = readConfiguredEditor();
|
|
2931
|
+
if (configured?.includes("{path}")) return {
|
|
2932
|
+
label: "editor",
|
|
2933
|
+
template: configured
|
|
2934
|
+
};
|
|
2935
|
+
const key = configured && EDITOR_TEMPLATES[configured] ? configured : DEFAULT_EDITOR;
|
|
2936
|
+
return {
|
|
2937
|
+
label: LABELS[key] ?? key,
|
|
2938
|
+
template: EDITOR_TEMPLATES[key]
|
|
2939
|
+
};
|
|
2940
|
+
}
|
|
2941
|
+
function readConfiguredEditor() {
|
|
2942
|
+
if (process.env.PRB_EDITOR) return process.env.PRB_EDITOR;
|
|
2943
|
+
try {
|
|
2944
|
+
const config = JSON.parse(readFileSync(join(STORE_ROOT, "config.json"), "utf8"));
|
|
2945
|
+
return typeof config.editor === "string" && config.editor ? config.editor : null;
|
|
2946
|
+
} catch {
|
|
2947
|
+
return null;
|
|
2948
|
+
}
|
|
2949
|
+
}
|
|
2950
|
+
//#endregion
|
|
2951
|
+
//#region ../../packages/review-harness/src/workspace/preferences.ts
|
|
2952
|
+
/**
|
|
2953
|
+
* Workspace preferences that belong to the reviewer rather than to a review.
|
|
2954
|
+
*
|
|
2955
|
+
* Kept server-side, in their own file, for two reasons. `localStorage` is keyed by origin, and
|
|
2956
|
+
* this server takes whatever port the OS offers on each start — so a restart would silently
|
|
2957
|
+
* discard a width the reviewer had deliberately chosen, which is the one failure a preference
|
|
2958
|
+
* must not have. And `config.json` next door is hand-edited: writing to a file someone maintains
|
|
2959
|
+
* by hand reformats it under them the first time they save.
|
|
2960
|
+
*
|
|
2961
|
+
* Everything here is best-effort. A workspace that will not open because a preferences file is
|
|
2962
|
+
* unreadable would be a preference costing more than it is worth.
|
|
2963
|
+
*/
|
|
2964
|
+
var PREFERENCES_PATH = join(STORE_ROOT, "ui.json");
|
|
2965
|
+
var MAX_ASK_WIDTH = 1200;
|
|
2966
|
+
function readPreferences() {
|
|
2967
|
+
try {
|
|
2968
|
+
const parsed = JSON.parse(readFileSync(PREFERENCES_PATH, "utf8"));
|
|
2969
|
+
if (!parsed || typeof parsed !== "object") return {};
|
|
2970
|
+
const askWidth = parsed.askWidth;
|
|
2971
|
+
return typeof askWidth === "number" && Number.isFinite(askWidth) ? { askWidth: clampAskWidth(askWidth) } : {};
|
|
2972
|
+
} catch {
|
|
2973
|
+
return {};
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2976
|
+
function writeAskWidth(width) {
|
|
2977
|
+
if (!Number.isFinite(width)) return;
|
|
2978
|
+
try {
|
|
2979
|
+
mkdirSync(STORE_ROOT, { recursive: true });
|
|
2980
|
+
const next = {
|
|
2981
|
+
...readPreferences(),
|
|
2982
|
+
askWidth: clampAskWidth(width)
|
|
2983
|
+
};
|
|
2984
|
+
writeFileSync(PREFERENCES_PATH, `${JSON.stringify(next, null, 2)}\n`);
|
|
2985
|
+
} catch {}
|
|
2986
|
+
}
|
|
2987
|
+
/** Forget a stated width, so the stylesheet's proportional default applies again. */
|
|
2988
|
+
function clearAskWidth() {
|
|
2989
|
+
try {
|
|
2990
|
+
const { askWidth: _dropped, ...rest } = readPreferences();
|
|
2991
|
+
mkdirSync(STORE_ROOT, { recursive: true });
|
|
2992
|
+
writeFileSync(PREFERENCES_PATH, `${JSON.stringify(rest, null, 2)}\n`);
|
|
2993
|
+
} catch {}
|
|
2994
|
+
}
|
|
2995
|
+
/**
|
|
2996
|
+
* Absolute bounds only. The upper limit the reviewer actually feels is half the viewport, which
|
|
2997
|
+
* the page enforces because it is the only party that knows how wide the window is — this is the
|
|
2998
|
+
* far cruder job of keeping a hand-edited or stale number from producing an unusable layout.
|
|
2999
|
+
*/
|
|
3000
|
+
function clampAskWidth(width) {
|
|
3001
|
+
return Math.round(Math.min(MAX_ASK_WIDTH, Math.max(320, width)));
|
|
3002
|
+
}
|
|
3003
|
+
/** Long enough for a paragraph of standing instructions, short enough not to crowd the question. */
|
|
3004
|
+
var MAX_PROMPT_CHARS = 2e3;
|
|
3005
|
+
/**
|
|
3006
|
+
* Everything reaching this function came from a client, so nothing here is trusted.
|
|
3007
|
+
*
|
|
3008
|
+
* Entries are repaired where the repair is unambiguous — trimming, truncating — and dropped where
|
|
3009
|
+
* it is not. An entry with no name has nothing to show on a chip; an entry with no prompt has
|
|
3010
|
+
* nothing to insert. Neither is worth rejecting the whole bank over: the reviewer would lose
|
|
3011
|
+
* everything they had saved to one bad row.
|
|
3012
|
+
*/
|
|
3013
|
+
function sanitisePromptBank(value) {
|
|
3014
|
+
if (!Array.isArray(value)) return [];
|
|
3015
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3016
|
+
const clean = [];
|
|
3017
|
+
for (const entry of value) {
|
|
3018
|
+
if (!entry || typeof entry !== "object") continue;
|
|
3019
|
+
const { id, name, prompt } = entry;
|
|
3020
|
+
if (typeof id !== "string" || typeof name !== "string" || typeof prompt !== "string") continue;
|
|
3021
|
+
const trimmed = {
|
|
3022
|
+
id: id.trim(),
|
|
3023
|
+
name: name.trim().slice(0, 60),
|
|
3024
|
+
prompt: prompt.trim().slice(0, MAX_PROMPT_CHARS)
|
|
3025
|
+
};
|
|
3026
|
+
if (!trimmed.id || !trimmed.name || !trimmed.prompt) continue;
|
|
3027
|
+
if (seen.has(trimmed.id)) continue;
|
|
3028
|
+
seen.add(trimmed.id);
|
|
3029
|
+
clean.push(trimmed);
|
|
3030
|
+
if (clean.length === 50) break;
|
|
3031
|
+
}
|
|
3032
|
+
return clean;
|
|
3033
|
+
}
|
|
3034
|
+
//#endregion
|
|
3035
|
+
//#region ../../packages/review-harness/src/workspace/prompt_bank.ts
|
|
3036
|
+
/**
|
|
3037
|
+
* The reviewer's own prompts, kept on disk.
|
|
3038
|
+
*
|
|
3039
|
+
* Saved next to the workspaces rather than inside one: a prompt belongs to the person, not to the
|
|
3040
|
+
* review they happened to think of it during. The rules for what counts as a saved prompt live in
|
|
3041
|
+
* `domain/ai_review/prompt_bank` and are shared with the extension — this file is only the storage.
|
|
3042
|
+
*
|
|
3043
|
+
* Best-effort throughout, exactly as `preferences.ts` is. A bank that cannot be read must never be
|
|
3044
|
+
* a workspace that will not open.
|
|
3045
|
+
*/
|
|
3046
|
+
var PROMPTS_PATH = join(STORE_ROOT, "prompts.json");
|
|
3047
|
+
function readPromptBank() {
|
|
3048
|
+
try {
|
|
3049
|
+
return sanitisePromptBank(JSON.parse(readFileSync(PROMPTS_PATH, "utf8")));
|
|
3050
|
+
} catch {
|
|
3051
|
+
return [];
|
|
3052
|
+
}
|
|
3053
|
+
}
|
|
3054
|
+
/** Returns the bank as stored, which is not always the bank that was passed in. */
|
|
3055
|
+
function writePromptBank(bank) {
|
|
3056
|
+
const clean = sanitisePromptBank(bank);
|
|
3057
|
+
try {
|
|
3058
|
+
mkdirSync(STORE_ROOT, { recursive: true });
|
|
3059
|
+
writeFileSync(PROMPTS_PATH, `${JSON.stringify(clean, null, 2)}\n`, { mode: 384 });
|
|
3060
|
+
} catch {}
|
|
3061
|
+
return clean;
|
|
3062
|
+
}
|
|
3063
|
+
//#endregion
|
|
3064
|
+
//#region ../../packages/review-harness/src/telemetry/rollup.ts
|
|
3065
|
+
/**
|
|
3066
|
+
* Close out a workspace session by summarising its own events.
|
|
3067
|
+
*
|
|
3068
|
+
* Every number is read back out of the log rather than tracked alongside it. A rollup that keeps
|
|
3069
|
+
* its own counters can drift from the events it claims to summarise, and the resulting number
|
|
3070
|
+
* looks exactly as authoritative as a correct one — the same failure the review engine avoids by
|
|
3071
|
+
* counting what the model produced instead of letting it report its own coverage.
|
|
3072
|
+
*/
|
|
3073
|
+
function recordReviewCompleted(reviewId) {
|
|
3074
|
+
const events = readEvents(reviewId);
|
|
3075
|
+
if (events.length === 0) return;
|
|
3076
|
+
if (events.some((event) => event.event === "review_completed")) return;
|
|
3077
|
+
const opened = events.find((event) => event.event === "workspace_opened");
|
|
3078
|
+
const themesTotal = opened?.event === "workspace_opened" ? opened.themes : 0;
|
|
3079
|
+
record({
|
|
3080
|
+
event: "review_completed",
|
|
3081
|
+
reviewId,
|
|
3082
|
+
activeSeconds: sum(events, "workspace_heartbeat", (event) => event.event === "workspace_heartbeat" ? event.visibleSeconds : 0),
|
|
3083
|
+
themesOpened: distinct(events, (event) => event.event === "theme_opened" ? event.themeIndex : null),
|
|
3084
|
+
themesTotal,
|
|
3085
|
+
viewsOpened: distinct(events, (event) => event.event === "view_opened" ? event.view : null),
|
|
3086
|
+
filesOpened: events.filter((event) => event.event === "file_opened").length,
|
|
3087
|
+
editorOpened: events.some((event) => event.event === "editor_opened"),
|
|
3088
|
+
questionsAsked: events.filter((event) => event.event === "assistant_question_asked").length,
|
|
3089
|
+
assistantUsed: events.some((event) => event.event === "assistant_answered" && event.ok)
|
|
3090
|
+
});
|
|
3091
|
+
}
|
|
3092
|
+
function sum(events, name, value) {
|
|
3093
|
+
return events.filter((event) => event.event === name).reduce((total, event) => total + value(event), 0);
|
|
3094
|
+
}
|
|
3095
|
+
function distinct(events, pick) {
|
|
3096
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3097
|
+
for (const event of events) {
|
|
3098
|
+
const value = pick(event);
|
|
3099
|
+
if (value !== null) seen.add(value);
|
|
3100
|
+
}
|
|
3101
|
+
return seen.size;
|
|
3102
|
+
}
|
|
3103
|
+
//#endregion
|
|
3104
|
+
//#region ../../packages/review-harness/src/workspace/http.ts
|
|
3105
|
+
/**
|
|
3106
|
+
* Where the page's assets live, in each of the two layouts this file actually runs in.
|
|
3107
|
+
*
|
|
3108
|
+
* Bundled, it is `dist/server.js`, so the assets are one level up. In the source tree under
|
|
3109
|
+
* vitest, it is `src/workspace/http.ts`, so they are two. Both candidates are real, neither is a
|
|
3110
|
+
* guess, and resolving both is what lets the tests exercise the same code path the product uses —
|
|
3111
|
+
* the previous single-candidate version silently resolved to a directory that has never existed
|
|
3112
|
+
* (`src/static`), which is why nothing ever noticed that asset serving was untested.
|
|
3113
|
+
*/
|
|
3114
|
+
var STATIC_DIR = ["../static", "../../static"].map((candidate) => fileURLToPath(new URL(candidate, import.meta.url))).find(existsSync) ?? fileURLToPath(new URL("../static", import.meta.url));
|
|
3115
|
+
/**
|
|
3116
|
+
* The page's own assets, read into memory when the server starts.
|
|
3117
|
+
*
|
|
3118
|
+
* They used to be read from disk on each request, which quietly made a running workspace depend on
|
|
3119
|
+
* its own installation directory still being there. That is not a safe assumption for this
|
|
3120
|
+
* process: it is detached so a review outlives the session, so it equally outlives whatever
|
|
3121
|
+
* temporary directory a plugin archive was unpacked into to launch it. When that directory goes,
|
|
3122
|
+
* every asset starts 404ing while the pages keep rendering perfectly — the HTML is built from code
|
|
3123
|
+
* already in memory. The result looks exactly like broken CSS and points nowhere near the cause.
|
|
3124
|
+
*
|
|
3125
|
+
* A quarter of a megabyte, read once. Anything detached should carry what it needs.
|
|
3126
|
+
*/
|
|
3127
|
+
var ASSETS = loadAssets();
|
|
3128
|
+
function loadAssets() {
|
|
3129
|
+
const assets = /* @__PURE__ */ new Map();
|
|
3130
|
+
try {
|
|
3131
|
+
for (const name of readdirSync(STATIC_DIR)) try {
|
|
3132
|
+
assets.set(name, readFileSync(join(STATIC_DIR, name)));
|
|
3133
|
+
} catch {}
|
|
3134
|
+
} catch {}
|
|
3135
|
+
return assets;
|
|
3136
|
+
}
|
|
3137
|
+
var CONTENT_TYPES = {
|
|
3138
|
+
".css": "text/css; charset=utf-8",
|
|
3139
|
+
".js": "text/javascript; charset=utf-8",
|
|
3140
|
+
".woff2": "font/woff2",
|
|
3141
|
+
".png": "image/png"
|
|
3142
|
+
};
|
|
3143
|
+
function createWorkspaceServer(options = {}) {
|
|
3144
|
+
return createServer((req, res) => {
|
|
3145
|
+
handle(req, res, options).catch((error) => {
|
|
3146
|
+
send(res, 500, { error: error instanceof Error ? error.message : "Unknown error" });
|
|
3147
|
+
});
|
|
3148
|
+
});
|
|
3149
|
+
}
|
|
3150
|
+
async function handle(req, res, options) {
|
|
3151
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
3152
|
+
if (url.pathname === "/health") return send(res, 200, {
|
|
3153
|
+
ok: true,
|
|
3154
|
+
version: BUILD_VERSION,
|
|
3155
|
+
assets: ASSETS.size
|
|
3156
|
+
});
|
|
3157
|
+
if (url.pathname.startsWith("/assets/")) return sendAsset(res, url.pathname);
|
|
3158
|
+
if (url.pathname === "/") return sendReviewsIndex(req, res, url.searchParams.get("t"), options.indexToken);
|
|
3159
|
+
if (url.pathname === "/pin") return handlePin(req, res, options.indexToken);
|
|
3160
|
+
const match = /^\/(?:r|api)\/([0-9a-f-]{36})(\/[a-z]+)?$/.exec(url.pathname);
|
|
3161
|
+
if (!match) return send(res, 404, { error: "Not found" });
|
|
3162
|
+
const [, id, action] = match;
|
|
3163
|
+
const workspace = loadWorkspace(id);
|
|
3164
|
+
if (!workspace) return send(res, 404, { error: "No such review" });
|
|
3165
|
+
if (!tokenMatches(workspace, url.searchParams.get("t"))) return send(res, 403, { error: "This link is missing its access token." });
|
|
3166
|
+
if (url.pathname.startsWith("/r/")) {
|
|
3167
|
+
touchWorkspace(workspace);
|
|
3168
|
+
return sendHtml(res, workspace);
|
|
3169
|
+
}
|
|
3170
|
+
if (action === "/job") return send(res, 200, jobStatus(workspace.jobId ? loadJob(workspace.jobId) : null));
|
|
3171
|
+
if (action === "/retry") return handleRetry(res, workspace);
|
|
3172
|
+
if (action === "/freshness") return handleFreshness(res, workspace);
|
|
3173
|
+
if (action === "/refresh") return handleRefreshPr(res, workspace);
|
|
3174
|
+
if (action === "/update") return handleUpdate(res, workspace);
|
|
3175
|
+
if (action === "/review") return send(res, 200, workspace.session.result ?? {});
|
|
3176
|
+
if (action === "/file") return sendFile(res, workspace, url.searchParams);
|
|
3177
|
+
if (action === "/ask") return handleAsk(req, res, workspace);
|
|
3178
|
+
if (action === "/state") return handleState(req, res, workspace);
|
|
3179
|
+
if (action === "/event") return handleEvent(req, res, workspace);
|
|
3180
|
+
if (action === "/prefs") return handlePrefs(req, res);
|
|
3181
|
+
if (action === "/prompts") return handlePrompts(req, res);
|
|
3182
|
+
if (action === "/shutdown") return handleShutdown(res, options);
|
|
3183
|
+
return send(res, 404, { error: "Not found" });
|
|
3184
|
+
}
|
|
3185
|
+
/**
|
|
3186
|
+
* Constant-time, and length-checked first.
|
|
3187
|
+
*
|
|
3188
|
+
* `timingSafeEqual` throws on a length mismatch rather than returning false, so an unequal-length
|
|
3189
|
+
* token would surface as a 500 instead of a refusal.
|
|
3190
|
+
*/
|
|
3191
|
+
function tokenMatches(workspace, supplied) {
|
|
3192
|
+
return secretMatches(workspace.token, supplied);
|
|
3193
|
+
}
|
|
3194
|
+
function secretMatches(expected, supplied) {
|
|
3195
|
+
if (!supplied) return false;
|
|
3196
|
+
const a = Buffer.from(expected);
|
|
3197
|
+
const b = Buffer.from(supplied);
|
|
3198
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
3199
|
+
}
|
|
3200
|
+
/**
|
|
3201
|
+
* The list of every stored review, with a working link into each.
|
|
3202
|
+
*
|
|
3203
|
+
* Its own credential, not any review's. This page is the one place that holds all the per-review
|
|
3204
|
+
* tokens, so authorising it with one of them would make that review's token a key to the rest —
|
|
3205
|
+
* and a workspace page never receives this one, so it stays a key only to itself.
|
|
3206
|
+
*
|
|
3207
|
+
* The refusal names the command that produces a working link rather than saying "forbidden": the
|
|
3208
|
+
* likely reader is the owner of the machine arriving from a bookmark whose token is from a server
|
|
3209
|
+
* that has since restarted, and they need the way back, not a verdict.
|
|
3210
|
+
*/
|
|
3211
|
+
function sendReviewsIndex(req, res, supplied, indexToken) {
|
|
3212
|
+
if (!indexToken || !(secretMatches(indexToken, supplied) || secretMatches(indexToken, indexCookie(req)))) return send(res, 403, { error: "This link is missing its access token. Run /prreviewbuddy:start-server to get the current one." });
|
|
3213
|
+
const html = renderReviewsPage({ groups: recentReviewGroups(MAX_LISTED_BRANCHES) });
|
|
3214
|
+
res.writeHead(200, {
|
|
3215
|
+
"content-type": "text/html; charset=utf-8",
|
|
3216
|
+
"set-cookie": `${INDEX_COOKIE}=${indexToken}; Path=/; HttpOnly; SameSite=Strict`
|
|
3217
|
+
});
|
|
3218
|
+
res.end(html);
|
|
3219
|
+
}
|
|
3220
|
+
/**
|
|
3221
|
+
* Pin or unpin a review.
|
|
3222
|
+
*
|
|
3223
|
+
* Authorised by the index credential rather than the review's own, because pinning is a fact about
|
|
3224
|
+
* the list and the caller is the list. It reaches this route as the cookie: the index page holds
|
|
3225
|
+
* no token in its markup, which is the point of the cookie in the first place.
|
|
3226
|
+
*/
|
|
3227
|
+
async function handlePin(req, res, indexToken) {
|
|
3228
|
+
if (!indexToken || !secretMatches(indexToken, indexCookie(req))) return send(res, 403, { error: "Not authorised." });
|
|
3229
|
+
const body = await readJson(req);
|
|
3230
|
+
const id = typeof body?.id === "string" ? body.id : null;
|
|
3231
|
+
const workspace = id ? loadWorkspace(id) : null;
|
|
3232
|
+
if (!workspace) return send(res, 404, { error: "No such review" });
|
|
3233
|
+
workspace.pinned = body?.pinned === true;
|
|
3234
|
+
saveWorkspace(workspace);
|
|
3235
|
+
send(res, 200, { ok: true });
|
|
3236
|
+
}
|
|
3237
|
+
var INDEX_COOKIE = "prb_index";
|
|
3238
|
+
/**
|
|
3239
|
+
* The reviews index, remembered by the browser that has already proved it may see it.
|
|
3240
|
+
*
|
|
3241
|
+
* A workspace needs a way back to `/`, and it cannot carry the index token — that was the whole
|
|
3242
|
+
* point of not building a switcher: a page holding a credential for every review is a master key,
|
|
3243
|
+
* and one that then lives in a URL, in history, and in whatever reads either.
|
|
3244
|
+
*
|
|
3245
|
+
* A cookie moves that credential out of the page entirely. It is set only when someone arrives at
|
|
3246
|
+
* the index with the real token, it is `HttpOnly` so no script can read it back out, and it is
|
|
3247
|
+
* `SameSite=Strict` so another origin cannot make the browser spend it. A browser that never had
|
|
3248
|
+
* the token gets a 403 from the link, which is correct: it was never shown the list.
|
|
3249
|
+
*/
|
|
3250
|
+
function indexCookie(req) {
|
|
3251
|
+
const header = req.headers.cookie;
|
|
3252
|
+
if (!header) return null;
|
|
3253
|
+
for (const part of header.split(";")) {
|
|
3254
|
+
const [name, ...rest] = part.trim().split("=");
|
|
3255
|
+
if (name === INDEX_COOKIE) return rest.join("=");
|
|
3256
|
+
}
|
|
3257
|
+
return null;
|
|
3258
|
+
}
|
|
3259
|
+
/**
|
|
3260
|
+
* Enough to cover the reviews anyone still has in mind, and a bound on a page that is read by
|
|
3261
|
+
* scanning it. Older reviews are still on disk and still listed by the MCP tool.
|
|
3262
|
+
*/
|
|
3263
|
+
/** Branches, not reviews: a branch reviewed five times is one card and must not cost five slots. */
|
|
3264
|
+
var MAX_LISTED_BRANCHES = 50;
|
|
3265
|
+
async function sendHtml(res, workspace) {
|
|
3266
|
+
const freshness = await freshnessOrNull(workspace);
|
|
3267
|
+
const notice = workspace.lastUpdate?.acknowledged ? void 0 : workspace.lastUpdate;
|
|
3268
|
+
if (notice) {
|
|
3269
|
+
notice.acknowledged = true;
|
|
3270
|
+
saveWorkspace(workspace);
|
|
3271
|
+
}
|
|
3272
|
+
const html = renderWorkspace({
|
|
3273
|
+
session: workspace.session,
|
|
3274
|
+
changeSet: workspace.changeSet,
|
|
3275
|
+
audit: workspace.audit,
|
|
3276
|
+
apiBase: `/api/${workspace.id}`,
|
|
3277
|
+
token: workspace.token,
|
|
3278
|
+
editor: editorChoice(),
|
|
3279
|
+
repoPath: workspace.repoPath,
|
|
3280
|
+
askWidth: readPreferences().askWidth,
|
|
3281
|
+
transcript: workspace.transcript,
|
|
3282
|
+
prompts: readPromptBank(),
|
|
3283
|
+
complete: workspace.completedAt !== void 0,
|
|
3284
|
+
prContext: workspace.prContext ? {
|
|
3285
|
+
prContext: workspace.prContext,
|
|
3286
|
+
processed: processDiscussion(workspace.prContext.discussion)
|
|
3287
|
+
} : void 0,
|
|
3288
|
+
freshness: freshness ?? void 0,
|
|
3289
|
+
dismissedFreshnessSha: workspace.dismissedFreshnessSha,
|
|
3290
|
+
lastPrRefresh: workspace.lastPrRefresh,
|
|
3291
|
+
lastUpdate: notice,
|
|
3292
|
+
lineage: lineagePosition(workspace),
|
|
3293
|
+
job: workspace.jobId ? loadJob(workspace.jobId) : void 0
|
|
3294
|
+
});
|
|
3295
|
+
if (freshness) record({
|
|
3296
|
+
event: "freshness_checked",
|
|
3297
|
+
reviewId: workspace.id,
|
|
3298
|
+
source: "page_load",
|
|
3299
|
+
code: freshness.code.state,
|
|
3300
|
+
checkout: freshness.checkout.state
|
|
3301
|
+
});
|
|
3302
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
3303
|
+
res.end(html);
|
|
3304
|
+
}
|
|
3305
|
+
function sendAsset(res, pathname) {
|
|
3306
|
+
const name = pathname.slice(8);
|
|
3307
|
+
if (!/^[a-z0-9_-]+\.(css|js|woff2|png)$/i.test(name)) return send(res, 404, { error: "Not found" });
|
|
3308
|
+
const body = ASSETS.get(name);
|
|
3309
|
+
if (!body) return send(res, 404, { error: "Not found" });
|
|
3310
|
+
const ext = name.slice(name.lastIndexOf("."));
|
|
3311
|
+
res.writeHead(200, {
|
|
3312
|
+
"content-type": CONTENT_TYPES[ext] ?? "application/octet-stream",
|
|
3313
|
+
"cache-control": "no-cache"
|
|
3314
|
+
});
|
|
3315
|
+
res.end(body);
|
|
3316
|
+
}
|
|
3317
|
+
/**
|
|
3318
|
+
* A window onto a file from the reviewed repository.
|
|
3319
|
+
*
|
|
3320
|
+
* A window rather than the file, and JSON rather than text, because the caller is a review pane
|
|
3321
|
+
* showing why a file matters — not a code browser. Containment is enforced in file_view.ts.
|
|
3322
|
+
*/
|
|
3323
|
+
function sendFile(res, workspace, params) {
|
|
3324
|
+
const path = params.get("path");
|
|
3325
|
+
if (!path) return send(res, 400, { error: "No path given" });
|
|
3326
|
+
const lineParam = Number(params.get("line"));
|
|
3327
|
+
try {
|
|
3328
|
+
send(res, 200, {
|
|
3329
|
+
...readFileWindow(workspace, path, {
|
|
3330
|
+
line: Number.isFinite(lineParam) && lineParam > 0 ? lineParam : void 0,
|
|
3331
|
+
expand: params.get("expand") === "1"
|
|
3332
|
+
}),
|
|
3333
|
+
pullRequest: buildPrFileLink(workspace.prContext, path) ?? void 0
|
|
3334
|
+
});
|
|
3335
|
+
} catch (error) {
|
|
3336
|
+
if (error instanceof FileOutsideRepositoryError) return send(res, 403, { error: error.message });
|
|
3337
|
+
send(res, 404, { error: "That file is not in the working tree. This change may have deleted it." });
|
|
3338
|
+
}
|
|
3339
|
+
}
|
|
3340
|
+
/**
|
|
3341
|
+
* The three comparisons, on demand.
|
|
3342
|
+
*
|
|
3343
|
+
* Separate from the page render so a tab that has been open for an hour can ask again without
|
|
3344
|
+
* losing its place — which is the case the button exists for. Read-only and cheap: four git
|
|
3345
|
+
* plumbing commands against the local object store, no network anywhere in it.
|
|
3346
|
+
*/
|
|
3347
|
+
async function handleFreshness(res, workspace) {
|
|
3348
|
+
const report = await freshnessOrNull(workspace);
|
|
3349
|
+
if (!report) return send(res, 200, { report: null });
|
|
3350
|
+
record({
|
|
3351
|
+
event: "freshness_checked",
|
|
3352
|
+
reviewId: workspace.id,
|
|
3353
|
+
source: "button",
|
|
3354
|
+
code: report.code.state,
|
|
3355
|
+
checkout: report.checkout.state
|
|
3356
|
+
});
|
|
3357
|
+
send(res, 200, {
|
|
3358
|
+
report,
|
|
3359
|
+
dismissedSha: workspace.dismissedFreshnessSha ?? null
|
|
3360
|
+
});
|
|
3361
|
+
}
|
|
3362
|
+
/**
|
|
3363
|
+
* Re-read the pull request conversation, at the reviewer's request.
|
|
3364
|
+
*
|
|
3365
|
+
* Answers 200 with `ok: false` rather than an error status when it does not work. The page's job
|
|
3366
|
+
* here is to tell the reviewer what happened in a sentence, and a failed re-read is an outcome —
|
|
3367
|
+
* the conversation on disk is untouched and the review is exactly as usable as it was a moment
|
|
3368
|
+
* ago. An HTTP error would make the page choose between a stack trace and silence.
|
|
3369
|
+
*/
|
|
3370
|
+
async function handleRefreshPr(res, workspace) {
|
|
3371
|
+
let outcome;
|
|
3372
|
+
try {
|
|
3373
|
+
outcome = await refreshPrContext(workspace);
|
|
3374
|
+
} catch {
|
|
3375
|
+
outcome = {
|
|
3376
|
+
at: Date.now(),
|
|
3377
|
+
ok: false,
|
|
3378
|
+
message: "The pull request could not be re-read. Nothing here has changed."
|
|
3379
|
+
};
|
|
3380
|
+
}
|
|
3381
|
+
workspace.lastPrRefresh = outcome;
|
|
3382
|
+
saveWorkspace(workspace);
|
|
3383
|
+
record({
|
|
3384
|
+
event: "pr_conversation_rechecked",
|
|
3385
|
+
reviewId: workspace.id,
|
|
3386
|
+
ok: outcome.ok,
|
|
3387
|
+
changed: outcome.changes ? !isUnchanged(outcome.changes) : false,
|
|
3388
|
+
attributionsRestated: outcome.attributionsRestated ?? 0,
|
|
3389
|
+
attributionsWithdrawn: outcome.attributionsWithdrawn ?? 0
|
|
3390
|
+
});
|
|
3391
|
+
send(res, 200, outcome);
|
|
3392
|
+
}
|
|
3393
|
+
/**
|
|
3394
|
+
* Bring the whole review up to date with the checkout, at the reviewer's request.
|
|
3395
|
+
*
|
|
3396
|
+
* Answers 200 with `ok: false` when it refuses or fails, for the reason the conversation re-read
|
|
3397
|
+
* does: a refusal is an outcome the page has to explain in a sentence, and the review is exactly
|
|
3398
|
+
* as usable as it was a moment ago. An HTTP error would make the page choose between a stack trace
|
|
3399
|
+
* and silence.
|
|
3400
|
+
*
|
|
3401
|
+
* The only place this reaches disk. `updateReview` computes and mutates in memory, so a failure
|
|
3402
|
+
* before this line leaves the stored review untouched, and the snapshot it takes of the result it
|
|
3403
|
+
* replaced is written in this same save.
|
|
3404
|
+
*/
|
|
3405
|
+
async function handleUpdate(res, workspace) {
|
|
3406
|
+
let outcome;
|
|
3407
|
+
try {
|
|
3408
|
+
outcome = await updateReview(workspace);
|
|
3409
|
+
} catch (error) {
|
|
3410
|
+
outcome = {
|
|
3411
|
+
at: Date.now(),
|
|
3412
|
+
ok: false,
|
|
3413
|
+
message: error instanceof RefreshUnavailableError ? error.message : "This review could not be brought up to date. Nothing here has changed.",
|
|
3414
|
+
stop: "analysis-failed",
|
|
3415
|
+
fetchFailed: false,
|
|
3416
|
+
moved: false,
|
|
3417
|
+
fromSha: null,
|
|
3418
|
+
toSha: null
|
|
3419
|
+
};
|
|
3420
|
+
}
|
|
3421
|
+
workspace.lastUpdate = outcome;
|
|
3422
|
+
saveWorkspace(workspace);
|
|
3423
|
+
record({
|
|
3424
|
+
event: "review_updated",
|
|
3425
|
+
reviewId: workspace.id,
|
|
3426
|
+
ok: outcome.ok,
|
|
3427
|
+
stop: outcome.stop,
|
|
3428
|
+
moved: outcome.moved,
|
|
3429
|
+
resolved: outcome.counts?.resolved ?? 0,
|
|
3430
|
+
stillOpen: outcome.counts?.stillOpen ?? 0,
|
|
3431
|
+
affected: outcome.counts?.affected ?? 0,
|
|
3432
|
+
newIssues: outcome.counts?.newIssues ?? 0,
|
|
3433
|
+
questionsAnswered: outcome.counts?.questionsAnswered ?? 0
|
|
3434
|
+
});
|
|
3435
|
+
send(res, 200, outcome);
|
|
3436
|
+
}
|
|
3437
|
+
/**
|
|
3438
|
+
* Resume a job that stopped, rather than start one over.
|
|
3439
|
+
*
|
|
3440
|
+
* Fires and returns at once, exactly as starting a job does: `runJob` can take minutes, and this
|
|
3441
|
+
* route answers a button click, not a session that is waiting for the review. `runJob` records
|
|
3442
|
+
* every outcome, success or failure, on the job itself before it resolves or rejects, so there is
|
|
3443
|
+
* nothing left worth doing with a rejection here except not letting it become an unhandled one.
|
|
3444
|
+
*
|
|
3445
|
+
* Refused up front when the job is not terminal, rather than left to `runJob`'s own guard: a job
|
|
3446
|
+
* that is still running has nothing here worth retrying, and saying so is cheaper and clearer than
|
|
3447
|
+
* a 200 that quietly does nothing.
|
|
3448
|
+
*
|
|
3449
|
+
* Refused again when a driver still holds the claim, which is not the same question. A job can be
|
|
3450
|
+
* terminal and driven at once: the sweep marks a job failed after half an hour of silence, and a
|
|
3451
|
+
* long analysis that streams nothing is silent while being perfectly alive. Without this line that
|
|
3452
|
+
* job is retryable, and pressing Retry starts a second driver against the worktree the first one is
|
|
3453
|
+
* still using. `runJob` refuses it too, but only after this route has already answered 200 and told
|
|
3454
|
+
* the reviewer their retry began.
|
|
3455
|
+
*/
|
|
3456
|
+
async function handleRetry(res, workspace) {
|
|
3457
|
+
if (!workspace.jobId) return send(res, 404, { error: "No job for this review." });
|
|
3458
|
+
const job = loadJob(workspace.jobId);
|
|
3459
|
+
if (!job || !isTerminal(job) || isClaimed(job.id)) return send(res, 409, { error: "This review is still being made. Wait for it to finish before retrying." });
|
|
3460
|
+
runJob(workspace.id).catch(() => {});
|
|
3461
|
+
send(res, 200, { ok: true });
|
|
3462
|
+
}
|
|
3463
|
+
/**
|
|
3464
|
+
* Never throws.
|
|
3465
|
+
*
|
|
3466
|
+
* The checkout may have been deleted, moved, or turned into something that is not a repository
|
|
3467
|
+
* since the review was made — all of which are ordinary, and none of which is a reason to refuse
|
|
3468
|
+
* to serve a review that is sitting complete on disk. The page renders without the banner, which
|
|
3469
|
+
* reads as "not checked" rather than as "nothing has changed".
|
|
3470
|
+
*/
|
|
3471
|
+
async function freshnessOrNull(workspace) {
|
|
3472
|
+
try {
|
|
3473
|
+
return await checkFreshness(workspace);
|
|
3474
|
+
} catch {
|
|
3475
|
+
return null;
|
|
3476
|
+
}
|
|
3477
|
+
}
|
|
3478
|
+
async function handleAsk(req, res, workspace) {
|
|
3479
|
+
const body = await readJson(req);
|
|
3480
|
+
const question = typeof body?.question === "string" ? body.question.trim() : "";
|
|
3481
|
+
if (!question) return send(res, 400, { error: "No question given." });
|
|
3482
|
+
const sectionId = typeof body?.sectionId === "string" ? body.sectionId : null;
|
|
3483
|
+
record({
|
|
3484
|
+
event: "assistant_question_asked",
|
|
3485
|
+
reviewId: workspace.id,
|
|
3486
|
+
source: askSource(body?.source),
|
|
3487
|
+
scoped: sectionId !== null,
|
|
3488
|
+
turn: workspace.transcript.length + 1
|
|
3489
|
+
});
|
|
3490
|
+
const startedAt = Date.now();
|
|
3491
|
+
let answer;
|
|
3492
|
+
try {
|
|
3493
|
+
answer = await askAboutChange(workspace, question, sectionId);
|
|
3494
|
+
} catch (error) {
|
|
3495
|
+
record({
|
|
3496
|
+
event: "assistant_answered",
|
|
3497
|
+
reviewId: workspace.id,
|
|
3498
|
+
durationMs: Date.now() - startedAt,
|
|
3499
|
+
ok: false
|
|
3500
|
+
});
|
|
3501
|
+
throw error;
|
|
3502
|
+
}
|
|
3503
|
+
record({
|
|
3504
|
+
event: "assistant_answered",
|
|
3505
|
+
reviewId: workspace.id,
|
|
3506
|
+
durationMs: Date.now() - startedAt,
|
|
3507
|
+
ok: true
|
|
3508
|
+
});
|
|
3509
|
+
workspace.transcript.push({
|
|
3510
|
+
question,
|
|
3511
|
+
answer,
|
|
3512
|
+
sectionId,
|
|
3513
|
+
askedAt: Date.now()
|
|
3514
|
+
});
|
|
3515
|
+
saveWorkspace(workspace);
|
|
3516
|
+
send(res, 200, { answer });
|
|
3517
|
+
}
|
|
3518
|
+
/**
|
|
3519
|
+
* Stop serving, at the request of a workspace.
|
|
3520
|
+
*
|
|
3521
|
+
* The reply goes first and the exit follows on the next tick: a process that dies mid-response
|
|
3522
|
+
* leaves the page unable to tell "stopped" from "crashed", which are the two things the reviewer
|
|
3523
|
+
* most needs to distinguish here.
|
|
3524
|
+
*
|
|
3525
|
+
* One server serves every review, so this ends all of them. The page says so before asking.
|
|
3526
|
+
*/
|
|
3527
|
+
function handleShutdown(res, options) {
|
|
3528
|
+
send(res, 200, { ok: true });
|
|
3529
|
+
const stop = options.onShutdown ?? (() => process.exit(0));
|
|
3530
|
+
setTimeout(stop, 50).unref();
|
|
3531
|
+
}
|
|
3532
|
+
/**
|
|
3533
|
+
* Reviewer preferences, which outlive any one review.
|
|
3534
|
+
*
|
|
3535
|
+
* Stored server-side rather than in the page's `localStorage` because that is keyed by origin and
|
|
3536
|
+
* this server takes a fresh port on every start — a restart would silently forget a width the
|
|
3537
|
+
* reviewer chose deliberately. A null width means "back to the stylesheet's default".
|
|
3538
|
+
*/
|
|
3539
|
+
/**
|
|
3540
|
+
* The reviewer's saved prompts.
|
|
3541
|
+
*
|
|
3542
|
+
* The page sends the whole bank rather than a delta, and gets back the bank as stored — which is
|
|
3543
|
+
* not necessarily the bank it sent. `writePromptBank` trims, truncates and drops, so returning
|
|
3544
|
+
* the result is what stops the page from showing an entry the file does not hold.
|
|
3545
|
+
*
|
|
3546
|
+
* Global state behind a per-review route, exactly as the assistant width already is. The token
|
|
3547
|
+
* that authorises it belongs to a review, but what it protects is the machine's user.
|
|
3548
|
+
*/
|
|
3549
|
+
async function handlePrompts(req, res) {
|
|
3550
|
+
if (req.method !== "POST") return send(res, 200, { prompts: readPromptBank() });
|
|
3551
|
+
send(res, 200, { prompts: writePromptBank((await readJson(req))?.prompts) });
|
|
3552
|
+
}
|
|
3553
|
+
async function handlePrefs(req, res) {
|
|
3554
|
+
const askWidth = (await readJson(req))?.askWidth;
|
|
3555
|
+
if (typeof askWidth === "number") writeAskWidth(askWidth);
|
|
3556
|
+
else if (askWidth === null) clearAskWidth();
|
|
3557
|
+
send(res, 200, { ok: true });
|
|
3558
|
+
}
|
|
3559
|
+
async function handleState(req, res, workspace) {
|
|
3560
|
+
const body = await readJson(req);
|
|
3561
|
+
let changed = false;
|
|
3562
|
+
if (Array.isArray(body?.answeredQuestionIds)) {
|
|
3563
|
+
workspace.session.answeredQuestionIds = body.answeredQuestionIds.filter((id) => typeof id === "string");
|
|
3564
|
+
changed = true;
|
|
3565
|
+
}
|
|
3566
|
+
if (typeof body?.dismissFreshnessSha === "string" || body?.dismissFreshnessSha === null) {
|
|
3567
|
+
workspace.dismissedFreshnessSha = body.dismissFreshnessSha ?? void 0;
|
|
3568
|
+
changed = true;
|
|
3569
|
+
}
|
|
3570
|
+
if (typeof body?.complete === "boolean") {
|
|
3571
|
+
workspace.completedAt = body.complete ? Date.now() : void 0;
|
|
3572
|
+
changed = true;
|
|
3573
|
+
}
|
|
3574
|
+
if (changed) saveWorkspace(workspace);
|
|
3575
|
+
send(res, 200, {
|
|
3576
|
+
ok: true,
|
|
3577
|
+
completedAt: workspace.completedAt ?? null
|
|
3578
|
+
});
|
|
3579
|
+
}
|
|
3580
|
+
/** 1 MB is far more than any question or id list, and far less than a memory problem. */
|
|
3581
|
+
var MAX_BODY_BYTES = 1048576;
|
|
3582
|
+
async function readJson(req) {
|
|
3583
|
+
const chunks = [];
|
|
3584
|
+
let size = 0;
|
|
3585
|
+
for await (const chunk of req) {
|
|
3586
|
+
size += chunk.length;
|
|
3587
|
+
if (size > MAX_BODY_BYTES) throw new Error("Request body too large.");
|
|
3588
|
+
chunks.push(chunk);
|
|
3589
|
+
}
|
|
3590
|
+
try {
|
|
3591
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
3592
|
+
} catch {
|
|
3593
|
+
return null;
|
|
3594
|
+
}
|
|
3595
|
+
}
|
|
3596
|
+
function send(res, status, payload) {
|
|
3597
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
3598
|
+
res.end(JSON.stringify(payload));
|
|
3599
|
+
}
|
|
3600
|
+
var ASK_SOURCES = [
|
|
3601
|
+
"suggestion",
|
|
3602
|
+
"free_text",
|
|
3603
|
+
"explain_simply",
|
|
3604
|
+
"selection"
|
|
3605
|
+
];
|
|
3606
|
+
function askSource(value) {
|
|
3607
|
+
return ASK_SOURCES.includes(value) ? value : "free_text";
|
|
3608
|
+
}
|
|
3609
|
+
/**
|
|
3610
|
+
* Usage events from the page.
|
|
3611
|
+
*
|
|
3612
|
+
* Rebuilt field by field from a fixed list rather than forwarded, so the log holds only what this
|
|
3613
|
+
* file constructs. A page could otherwise post anything into the record — and the point of the
|
|
3614
|
+
* event union is that there is nowhere for a path, a title or a question to end up.
|
|
3615
|
+
*/
|
|
3616
|
+
async function handleEvent(req, res, workspace) {
|
|
3617
|
+
const body = await readJson(req);
|
|
3618
|
+
const event = toEvent(body, workspace.id);
|
|
3619
|
+
if (event) record(event);
|
|
3620
|
+
if (body?.event === "review_completed") {
|
|
3621
|
+
const trailing = toEvent({
|
|
3622
|
+
event: "workspace_heartbeat",
|
|
3623
|
+
visibleSeconds: body.trailingSeconds
|
|
3624
|
+
}, workspace.id);
|
|
3625
|
+
if (trailing && trailing.visibleSeconds > 0) record(trailing);
|
|
3626
|
+
recordReviewCompleted(workspace.id);
|
|
3627
|
+
}
|
|
3628
|
+
send(res, 200, { ok: true });
|
|
3629
|
+
}
|
|
3630
|
+
var VIEWS = [
|
|
3631
|
+
"overview",
|
|
3632
|
+
"guide",
|
|
3633
|
+
"issues",
|
|
3634
|
+
"questions",
|
|
3635
|
+
"files",
|
|
3636
|
+
"prcontext"
|
|
3637
|
+
];
|
|
3638
|
+
var ATTENTIONS = [
|
|
3639
|
+
"high",
|
|
3640
|
+
"medium",
|
|
3641
|
+
"low"
|
|
3642
|
+
];
|
|
3643
|
+
function toEvent(body, reviewId) {
|
|
3644
|
+
const int = (value) => Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
|
|
3645
|
+
switch (body?.event) {
|
|
3646
|
+
case "view_opened": {
|
|
3647
|
+
const view = VIEWS.find((candidate) => candidate === body.view);
|
|
3648
|
+
return view ? {
|
|
3649
|
+
event: "view_opened",
|
|
3650
|
+
reviewId,
|
|
3651
|
+
view
|
|
3652
|
+
} : null;
|
|
3653
|
+
}
|
|
3654
|
+
case "theme_opened": {
|
|
3655
|
+
const attention = ATTENTIONS.find((candidate) => candidate === body.attention);
|
|
3656
|
+
return attention ? {
|
|
3657
|
+
event: "theme_opened",
|
|
3658
|
+
reviewId,
|
|
3659
|
+
themeIndex: int(body.themeIndex),
|
|
3660
|
+
attention,
|
|
3661
|
+
isRecommendedStart: body.isRecommendedStart === true
|
|
3662
|
+
} : null;
|
|
3663
|
+
}
|
|
3664
|
+
case "file_opened": return {
|
|
3665
|
+
event: "file_opened",
|
|
3666
|
+
reviewId,
|
|
3667
|
+
fromFinding: body.fromFinding === true,
|
|
3668
|
+
targeted: body.targeted === true
|
|
3669
|
+
};
|
|
3670
|
+
case "editor_opened": return {
|
|
3671
|
+
event: "editor_opened",
|
|
3672
|
+
reviewId,
|
|
3673
|
+
fromFinding: body.fromFinding === true
|
|
3674
|
+
};
|
|
3675
|
+
case "question_marked_answered": return {
|
|
3676
|
+
event: "question_marked_answered",
|
|
3677
|
+
reviewId,
|
|
3678
|
+
answered: int(body.answered),
|
|
3679
|
+
total: int(body.total)
|
|
3680
|
+
};
|
|
3681
|
+
case "workspace_heartbeat": return {
|
|
3682
|
+
event: "workspace_heartbeat",
|
|
3683
|
+
reviewId,
|
|
3684
|
+
visibleSeconds: Math.min(int(body.visibleSeconds), 120)
|
|
3685
|
+
};
|
|
3686
|
+
default: return null;
|
|
3687
|
+
}
|
|
3688
|
+
}
|
|
3689
|
+
/**
|
|
3690
|
+
* The reviewer-facing half of that. A job whose driver was killed did not fail: it stopped
|
|
3691
|
+
* updating, which the page had no way to tell from working. This is the sentence that makes the
|
|
3692
|
+
* difference visible, and marking the job failed is what puts Retry on the page.
|
|
3693
|
+
*/
|
|
3694
|
+
var ABANDONED_MESSAGE = "Nothing has updated it for over half an hour, so whatever was making it is no longer running.";
|
|
3695
|
+
async function sweep(now = Date.now()) {
|
|
3696
|
+
const report = {
|
|
3697
|
+
abandoned: 0,
|
|
3698
|
+
retired: 0,
|
|
3699
|
+
orphansRemoved: 0,
|
|
3700
|
+
refusals: []
|
|
3701
|
+
};
|
|
3702
|
+
const jobs = allJobs();
|
|
3703
|
+
for (const job of jobs) {
|
|
3704
|
+
if (!isTerminal(job)) {
|
|
3705
|
+
if (markAbandoned(job, now)) report.abandoned += 1;
|
|
3706
|
+
continue;
|
|
3707
|
+
}
|
|
3708
|
+
if (now - finishedAt(job) < 216e5) continue;
|
|
3709
|
+
if (await retire(job, report)) report.retired += 1;
|
|
3710
|
+
}
|
|
3711
|
+
await removeOrphans(jobs, report);
|
|
3712
|
+
return report;
|
|
3713
|
+
}
|
|
3714
|
+
/** When this job stopped doing work. `finishedAt` predates nothing, but a record can be old. */
|
|
3715
|
+
function finishedAt(job) {
|
|
3716
|
+
return job.finishedAt ?? job.updatedAt;
|
|
3717
|
+
}
|
|
3718
|
+
/**
|
|
3719
|
+
* Record that a job stopped, unless it has moved since this pass read it.
|
|
3720
|
+
*
|
|
3721
|
+
* The reload is the whole of the safety here. Between `allJobs` and this line a driver in another
|
|
3722
|
+
* process can have written a progress line, and saving a failure over that would take a review
|
|
3723
|
+
* that is being made and tell its reviewer it stopped.
|
|
3724
|
+
*/
|
|
3725
|
+
function markAbandoned(job, now) {
|
|
3726
|
+
if (now - job.updatedAt < 18e5) return false;
|
|
3727
|
+
if (isClaimed(job.id, now)) return false;
|
|
3728
|
+
const current = loadJob(job.id);
|
|
3729
|
+
if (!current || current.updatedAt !== job.updatedAt) return false;
|
|
3730
|
+
saveJob(fail(current, ABANDONED_MESSAGE));
|
|
3731
|
+
return true;
|
|
3732
|
+
}
|
|
3733
|
+
/**
|
|
3734
|
+
* Remove a finished job's checkout, and then its record.
|
|
3735
|
+
*
|
|
3736
|
+
* In that order, and the record only if the directory went. The record is the only thing that
|
|
3737
|
+
* names the directory; deleting it first would turn a refusal into an orphan and lose the origin
|
|
3738
|
+
* repository that the removal guard needs to ask git about it.
|
|
3739
|
+
*/
|
|
3740
|
+
async function retire(job, report) {
|
|
3741
|
+
if (job.worktreePath) {
|
|
3742
|
+
const outcome = await removeWorktree({
|
|
3743
|
+
originRepoPath: job.target.originRepoPath,
|
|
3744
|
+
worktreePath: job.worktreePath
|
|
3745
|
+
});
|
|
3746
|
+
if (!outcome.removed) {
|
|
3747
|
+
report.refusals.push(outcome.message);
|
|
3748
|
+
return false;
|
|
3749
|
+
}
|
|
3750
|
+
}
|
|
3751
|
+
deleteJob(job.id);
|
|
3752
|
+
clearClaim(job.id);
|
|
3753
|
+
return true;
|
|
3754
|
+
}
|
|
3755
|
+
/**
|
|
3756
|
+
* Directories under the managed root that no job record names.
|
|
3757
|
+
*
|
|
3758
|
+
* A crash between creating a worktree and saving the record that points at it leaves exactly this.
|
|
3759
|
+
* The origin repository comes from the directory's own marker, because there is nothing else left
|
|
3760
|
+
* to ask, and that does make `removeWorktree`'s marker check tautological on this path. It is not
|
|
3761
|
+
* the guard here and was never meant to be: git is asked first and answers about a real repository,
|
|
3762
|
+
* and where git has disowned the path what remains is the managed root and `ORPHAN_MIN_AGE_MS`,
|
|
3763
|
+
* which is the ordering the removal guard has always had.
|
|
3764
|
+
*/
|
|
3765
|
+
async function removeOrphans(jobs, report) {
|
|
3766
|
+
const claimed = new Set(jobs.map((job) => job.worktreePath).filter((path) => !!path));
|
|
3767
|
+
let names;
|
|
3768
|
+
try {
|
|
3769
|
+
names = readdirSync(MANAGED_ROOT);
|
|
3770
|
+
} catch {
|
|
3771
|
+
return;
|
|
3772
|
+
}
|
|
3773
|
+
for (const name of names) {
|
|
3774
|
+
const path = join(MANAGED_ROOT, name);
|
|
3775
|
+
if (claimed.has(path)) continue;
|
|
3776
|
+
try {
|
|
3777
|
+
if (!statSync(path).isDirectory()) continue;
|
|
3778
|
+
} catch {
|
|
3779
|
+
continue;
|
|
3780
|
+
}
|
|
3781
|
+
const marker = readMarker(path);
|
|
3782
|
+
if (!marker) continue;
|
|
3783
|
+
const outcome = await removeWorktree({
|
|
3784
|
+
originRepoPath: marker.originRepoPath,
|
|
3785
|
+
worktreePath: path
|
|
3786
|
+
});
|
|
3787
|
+
if (outcome.removed) report.orphansRemoved += 1;
|
|
3788
|
+
else if (outcome.reason !== "too-young") report.refusals.push(outcome.message);
|
|
3789
|
+
}
|
|
3790
|
+
}
|
|
3791
|
+
/**
|
|
3792
|
+
* How often the pass runs.
|
|
3793
|
+
*
|
|
3794
|
+
* Chosen from what it costs to be late rather than what it costs to run. A sweep is a directory
|
|
3795
|
+
* listing and a `git worktree list` per finished job, so ten minutes is nothing; being an hour
|
|
3796
|
+
* late telling a reviewer their review stopped is not. Ten minutes puts that answer between thirty
|
|
3797
|
+
* and forty minutes after the last sign of life.
|
|
3798
|
+
*/
|
|
3799
|
+
var SWEEP_INTERVAL_MS = 6e5;
|
|
3800
|
+
/**
|
|
3801
|
+
* Run the pass on a timer, and return the stop.
|
|
3802
|
+
*
|
|
3803
|
+
* Once immediately, because a server that has just started is very often one that restarted after
|
|
3804
|
+
* whatever killed the last one, and the jobs that died with it are exactly what this finds.
|
|
3805
|
+
*
|
|
3806
|
+
* Nothing here throws outward. A sweep that fails is disk it did not reclaim, and the server it
|
|
3807
|
+
* runs inside is serving reviews, which matters more.
|
|
3808
|
+
*/
|
|
3809
|
+
function startSweeping() {
|
|
3810
|
+
const run = () => {
|
|
3811
|
+
sweep().catch(() => {});
|
|
3812
|
+
};
|
|
3813
|
+
run();
|
|
3814
|
+
const timer = setInterval(run, SWEEP_INTERVAL_MS);
|
|
3815
|
+
timer.unref();
|
|
3816
|
+
return () => clearInterval(timer);
|
|
3817
|
+
}
|
|
3818
|
+
//#endregion
|
|
3819
|
+
//#region ../../packages/review-harness/src/cli/server.ts
|
|
3820
|
+
/**
|
|
3821
|
+
* The workspace server process.
|
|
3822
|
+
*
|
|
3823
|
+
* Started detached by `ensureServer` and left running, so a workspace stays usable after the
|
|
3824
|
+
* Claude Code session that produced it has closed. One server serves every review; individual
|
|
3825
|
+
* reviews are addressed by id and gated by their own token.
|
|
3826
|
+
*/
|
|
3827
|
+
var indexToken = randomBytes(24).toString("base64url");
|
|
3828
|
+
var server = createWorkspaceServer({
|
|
3829
|
+
indexToken,
|
|
3830
|
+
onShutdown: () => process.kill(process.pid, "SIGTERM")
|
|
3831
|
+
});
|
|
3832
|
+
server.listen(0, "127.0.0.1", () => {
|
|
3833
|
+
const address = server.address();
|
|
3834
|
+
if (address === null || typeof address === "string") process.exit(1);
|
|
3835
|
+
writeServerRecord({
|
|
3836
|
+
pid: process.pid,
|
|
3837
|
+
port: address.port,
|
|
3838
|
+
startedAt: Date.now(),
|
|
3839
|
+
token: indexToken
|
|
3840
|
+
});
|
|
3841
|
+
});
|
|
3842
|
+
var stopSweeping = startSweeping();
|
|
3843
|
+
for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, () => {
|
|
3844
|
+
stopSweeping();
|
|
3845
|
+
clearServerRecord();
|
|
3846
|
+
server.close(() => process.exit(0));
|
|
3847
|
+
});
|
|
3848
|
+
//#endregion
|
|
3849
|
+
export {};
|