@graphty/visual-review 0.0.1 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,663 @@
1
+ /**
2
+ * The review page's server: the static page and a small JSON API over the downloaded captures.
3
+ *
4
+ * Every /api request carries the session token in `x-review-token`; the page reads it from the
5
+ * URL fragment `serve` prints. State-changing requests are POST only and must come from the
6
+ * served origin. Images are served only when their file is named by results.json and its bytes
7
+ * hash to the hash results.json gives, so the page shows exactly what CI compared. Decisions
8
+ * are kept in `<tmp>/state/<target>.json` until Finish, each with the hash of the image it was
9
+ * taken on, so a restart resumes them and a new CI run keeps only those whose image is unchanged.
10
+ *
11
+ * Finish runs in the background: a large seed takes minutes, longer than a browser (Safari on an
12
+ * iPad) keeps one request open. POST /api/finish starts it and GET /api/finish-status reports its
13
+ * step, then its result or error, so a reload finds the running Finish.
14
+ */
15
+
16
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
17
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { readFile } from "node:fs/promises";
19
+ import { dirname, join } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+
22
+ import { AcceptError, behindMaster, cleanReason, decisionProblem, finish } from "./accept.mjs";
23
+ import { downloadCaptures, exec, getRun, newestCiRun, openPullRequests, visualJobs } from "./github.mjs";
24
+ import { validateResults } from "./results.mjs";
25
+
26
+ const HERE = dirname(fileURLToPath(import.meta.url));
27
+ const STATIC = {
28
+ "/": ["../page/index.html", "text/html; charset=utf-8"],
29
+ "/review.js": ["../page/review.js", "text/javascript; charset=utf-8"],
30
+ "/review.css": ["../page/review.css", "text/css; charset=utf-8"],
31
+ "/pixelmatch.mjs": ["../vendor/pixelmatch.mjs", "text/javascript; charset=utf-8"],
32
+ };
33
+ const HEADERS = {
34
+ "content-security-policy":
35
+ "default-src 'self'; img-src 'self' blob:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
36
+ "x-content-type-options": "nosniff",
37
+ "referrer-policy": "no-referrer",
38
+ "cache-control": "no-store",
39
+ };
40
+ /**
41
+ * The component a story belongs to: its id before "--" (`components-overlays-tooltip--states`).
42
+ * @param {string} id the story id
43
+ * @returns {string} the component part
44
+ */
45
+ const componentOf = (id) => id.split("--")[0];
46
+
47
+ const REVIEWABLE = new Set(["changed", "moved", "new", "removed", "unstable", "failed"]);
48
+ const WRITES = new Set(["decide", "accept-all", "finish"]);
49
+
50
+ /**
51
+ * The image a decision was taken on: the capture, or for a removed item its baseline.
52
+ * @param {{ capture: string | null, baseline: string | null }} item the results.json item
53
+ * @returns {string | null} its SHA-256
54
+ */
55
+ const imageHash = (item) => item.capture ?? item.baseline ?? null;
56
+
57
+ /**
58
+ * The newest `to` hash per path in this pull request's review records at `head`. A record is
59
+ * data from the branch and only decides whether a "re-review" flag is shown.
60
+ * @param {string} repo the repository
61
+ * @param {string | null} head the captured head
62
+ * @param {number | null} pr the pull request
63
+ * @param {string} baselines the baselines directory
64
+ * @returns {Promise<Map<string, string | null>>} `to` by baseline path
65
+ */
66
+ async function earlierAccepts(repo, head, pr, baselines) {
67
+ const out = new Map();
68
+ if (pr === null || !head) {
69
+ return out;
70
+ }
71
+ const git = (args) => exec("git", args, { cwd: repo });
72
+ const names = await git(["ls-tree", "--name-only", head, `${baselines}/reviews/`]).catch(() => "");
73
+ // Record names start with their UTC time, so sorting by name applies the newest last.
74
+ for (const name of names
75
+ .split("\n")
76
+ .filter((n) => n.endsWith(".json"))
77
+ .sort()) {
78
+ const record = await git(["show", `${head}:${name}`])
79
+ .then(JSON.parse)
80
+ .catch(() => null);
81
+ if (record?.pr === pr && Array.isArray(record.items)) {
82
+ for (const item of record.items) {
83
+ out.set(item.path, item.to);
84
+ }
85
+ }
86
+ }
87
+ return out;
88
+ }
89
+
90
+ /**
91
+ * Who Finish's commit will be signed by: the git configuration of the server's own environment.
92
+ * An agent that starts the server passes on its GIT_CONFIG_* overrides, and with them its own
93
+ * signing key, so the page shows this before every Finish, with where git found the key.
94
+ * @param {string} repo the repository
95
+ * @returns {Promise<{ signs: boolean, format: string, key: string | null, keyFrom: string | null,
96
+ * author: string | null, fromEnv: boolean }>} the signing settings git will use, where the key
97
+ * is set (`file:<path>` or `command line:`, as `git config --show-origin` says), the committer,
98
+ * and whether the environment overrides git's config files
99
+ */
100
+ async function signingIdentity(repo) {
101
+ const get = (...k) => exec("git", ["config", ...k], { cwd: repo }).catch(() => "");
102
+ const [sign, format, keyLine, name, email] = await Promise.all([
103
+ get("--type=bool", "--get", "commit.gpgsign"),
104
+ get("--get", "gpg.format"),
105
+ get("--show-origin", "--get", "user.signingkey"),
106
+ get("--get", "user.name"),
107
+ get("--get", "user.email"),
108
+ ]);
109
+ const [keyFrom, key] = keyLine ? keyLine.split("\t") : [null, null];
110
+ return {
111
+ signs: sign === "true",
112
+ format: format || "openpgp",
113
+ key: key || null,
114
+ keyFrom: keyFrom || null,
115
+ author: name || email ? `${name} <${email}>` : null,
116
+ fromEnv: Boolean(process.env.GIT_CONFIG_COUNT || process.env.GIT_CONFIG_PARAMETERS),
117
+ };
118
+ }
119
+
120
+ /**
121
+ * The session token, created once so a restart keeps the owner's URL.
122
+ * @param {string} stateDir where the token file lives
123
+ * @returns {string} the token
124
+ */
125
+ export function sessionToken(stateDir) {
126
+ const file = join(stateDir, "token");
127
+ if (!existsSync(file)) {
128
+ mkdirSync(stateDir, { recursive: true });
129
+ writeFileSync(file, randomBytes(32).toString("base64url"), { mode: 0o600 });
130
+ }
131
+ return readFileSync(file, "utf8").trim();
132
+ }
133
+
134
+ /**
135
+ * Reads and validates one project's results.json.
136
+ * @param {string} dir the capture directory
137
+ * @returns {Promise<{ results: object | null, problem: string | null }>} the results, or why not
138
+ */
139
+ async function loadResults(dir) {
140
+ let results;
141
+ try {
142
+ results = JSON.parse(await readFile(join(dir, "results.json"), "utf8"));
143
+ } catch {
144
+ return { results: null, problem: "capture failed" };
145
+ }
146
+ const problems = validateResults(results);
147
+ if (problems.length > 0) {
148
+ return { results: null, problem: `invalid results.json: ${problems[0]}` };
149
+ }
150
+ const problem = results.complete ? null : `incomplete: ${results.items.length} of ${results.expected} stories`;
151
+ return { results, problem };
152
+ }
153
+
154
+ /**
155
+ * Builds the request handler.
156
+ * @param {object} options the server's settings
157
+ * @param {string} options.repo the repository accepts are committed in
158
+ * @param {Function} options.gh the gh runner
159
+ * @param {ReturnType<typeof import("./config.mjs").normalizeConfig>} options.config the
160
+ * repository's settings (visual-review.config.json)
161
+ * @param {string} options.tmp where artifacts are downloaded
162
+ * @param {string} options.token the session token
163
+ * @param {string} options.origin the origin the page is served from
164
+ * @param {number} [options.masterRun] the default branch's CI run to seed from (the target the
165
+ * page calls "master")
166
+ * @param {string} [options.results] a local directory of `<project>/results.json` instead of CI:
167
+ * a preview to look at, with no decisions and no Finish
168
+ * @param {string} [options.startCommand] the shell command that starts this server, shown so the
169
+ * owner can restart it from their own shell and sign Finish with their own key
170
+ * @returns {(req: import("node:http").IncomingMessage, res: import("node:http").ServerResponse) => void}
171
+ * the handler, for node:https in the CLI and node:http in the tests
172
+ */
173
+ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, results, startCommand = null }) {
174
+ const stateDir = join(tmp, "state");
175
+ const { projects, defaultBranch } = config;
176
+ const names = Object.keys(projects);
177
+ /** @type {Map<string, object>} targets by id: a pull request number, or "master" */
178
+ let targets = new Map();
179
+ /**
180
+ * By target and run, then by `<project>/<file>`; `bulk` marks an accept from Accept all that
181
+ * was never opened one by one, `posted` a reject an earlier Finish already commented.
182
+ * @type {Map<string, Map<string, { decision: string, reason: string | null, bulk?: true,
183
+ * posted?: true }>>}
184
+ */
185
+ const decisions = new Map();
186
+ let finishing = false;
187
+ /**
188
+ * The newest Finish, kept after it ends so a reload still shows its result.
189
+ * @type {{ id: number, target: string, pr: number | null, running: boolean, step: string | null,
190
+ * result: object | null, error: string | null } | null}
191
+ */
192
+ let job = null;
193
+ let signer = null;
194
+ const busy = (t) => finishing && job?.target === t.id;
195
+ const BUSY = "a Finish is running on this target: wait for it to end";
196
+
197
+ const itemOf = (t, key) => {
198
+ const at = key.indexOf("/");
199
+ const p = t.projects.find((x) => x.project === key.slice(0, at));
200
+ return p?.results?.items.find((i) => i.file === key.slice(at + 1));
201
+ };
202
+ const stateFile = (t) => join(stateDir, `${t.id}.json`);
203
+
204
+ const decisionsOf = (t) => {
205
+ const key = `${t.id}@${t.runId}`;
206
+ if (!decisions.has(key)) {
207
+ let saved = {};
208
+ try {
209
+ saved = JSON.parse(readFileSync(stateFile(t), "utf8"));
210
+ } catch {
211
+ // No state yet, or a broken file: start empty.
212
+ }
213
+ const mine = new Map();
214
+ for (const [k, { hash, ...d }] of Object.entries(saved)) {
215
+ const item = itemOf(t, k);
216
+ if (item && imageHash(item) === hash) {
217
+ mine.set(k, d);
218
+ }
219
+ }
220
+ decisions.set(key, mine);
221
+ }
222
+ return decisions.get(key);
223
+ };
224
+
225
+ const save = (t) => {
226
+ const out = {};
227
+ for (const [k, d] of decisionsOf(t)) {
228
+ out[k] = { ...d, hash: imageHash(itemOf(t, k)) };
229
+ }
230
+ mkdirSync(stateDir, { recursive: true });
231
+ writeFileSync(stateFile(t), JSON.stringify(out, null, 2));
232
+ };
233
+
234
+ // `problem` is what CI said (the job failed, or no artifact); results.json can add its own.
235
+ async function project(name, dir, problem) {
236
+ const loaded = dir ? await loadResults(dir) : { results: null, problem: null };
237
+ return { project: name, dir, results: loaded.results, problem: problem ?? loaded.problem, logUrl: null };
238
+ }
239
+
240
+ async function build(info, run) {
241
+ const jobs = await visualJobs(gh, run, run.attempt, names);
242
+ const downloaded = await downloadCaptures(gh, run, names, tmp);
243
+ const list = [];
244
+ for (const name of names) {
245
+ const job = jobs[name];
246
+ const failed = !downloaded[name] || job?.conclusion === "failure";
247
+ const p = await project(
248
+ name,
249
+ downloaded[name]?.dir,
250
+ failed ? (job ? "capture failed" : "no capture") : null,
251
+ );
252
+ p.logUrl = job?.url ?? run.url;
253
+ list.push(p);
254
+ }
255
+ return { ...info, runId: run.id, runAttempt: run.attempt, runUrl: run.url, projects: list };
256
+ }
257
+
258
+ async function refresh() {
259
+ const next = new Map();
260
+ if (results) {
261
+ const list = await Promise.all(
262
+ names.map((n) =>
263
+ existsSync(join(results, n, "results.json"))
264
+ ? project(n, join(results, n), null)
265
+ : project(n, null, "no capture"),
266
+ ),
267
+ );
268
+ const r = list.find((p) => p.results)?.results;
269
+ if (r) {
270
+ // Never "master" or a pull request: a preview is not a seed and has no Finish.
271
+ next.set("local", {
272
+ id: "local",
273
+ pr: null,
274
+ local: true,
275
+ title: `local preview of ${results}`,
276
+ url: null,
277
+ branch: null,
278
+ runId: r.runId,
279
+ runAttempt: r.runAttempt,
280
+ runUrl: null,
281
+ projects: list,
282
+ });
283
+ }
284
+ } else {
285
+ const prs = await openPullRequests(gh);
286
+ // Best effort: the default branch and the pull requests' branches, so the badge below
287
+ // sees what accept will see. A fork's branch is not on origin and fails its fetch, so
288
+ // the default branch is fetched alone first.
289
+ const fetch = (refs) => exec("git", ["fetch", "-q", "origin", ...refs], { cwd: repo }).catch(() => {});
290
+ const fetched = fetch([`+refs/heads/${defaultBranch}:refs/remotes/origin/${defaultBranch}`]).then(() =>
291
+ fetch(prs.map((p) => `+refs/heads/${p.branch}:refs/remotes/origin/${p.branch}`)),
292
+ );
293
+ // Every pull request at once: one after another took about 40 s for 18 of them.
294
+ const built = await Promise.all(
295
+ prs.map(async (pr) => {
296
+ const run = await newestCiRun(gh, pr.headSha, config);
297
+ const id = String(pr.number);
298
+ return run
299
+ ? build({ id, pr: pr.number, title: pr.title, url: pr.url, branch: pr.branch }, run)
300
+ : null;
301
+ }),
302
+ );
303
+ await fetched;
304
+ for (const t of built) if (t) next.set(t.id, t);
305
+ if (masterRun) {
306
+ const run = await getRun(gh, masterRun);
307
+ next.set(
308
+ "master",
309
+ await build({ id: "master", pr: null, title: defaultBranch, url: null, branch: null }, run),
310
+ );
311
+ }
312
+ }
313
+ for (const t of next.values()) {
314
+ const first = t.projects.find((p) => p.results)?.results;
315
+ t.commit = first?.commit ?? null;
316
+ t.headSha = first?.headSha ?? null;
317
+ const base = t.pr === null ? t.commit : t.headSha;
318
+ t.earlier = await earlierAccepts(repo, t.headSha, t.pr, config.baselines);
319
+ t.mergeMasterFirst = false;
320
+ for (const p of t.projects) {
321
+ if (
322
+ !t.local &&
323
+ p.results &&
324
+ base &&
325
+ (await behindMaster(repo, base, p.project, config).catch(() => true))
326
+ ) {
327
+ t.mergeMasterFirst = true;
328
+ }
329
+ }
330
+ }
331
+ signer = await signingIdentity(repo);
332
+ targets = next;
333
+ }
334
+
335
+ const summary = (t) => {
336
+ const decided = decisionsOf(t);
337
+ return {
338
+ id: t.id,
339
+ pr: t.pr,
340
+ local: t.local === true,
341
+ title: t.title,
342
+ url: t.url,
343
+ branch: t.branch,
344
+ runId: t.runId,
345
+ runAttempt: t.runAttempt,
346
+ runUrl: t.runUrl,
347
+ commit: t.commit,
348
+ headSha: t.headSha,
349
+ mergeMasterFirst: t.mergeMasterFirst,
350
+ signer,
351
+ startCommand,
352
+ projects: t.projects.map((p) => {
353
+ const counts = {};
354
+ for (const item of p.results?.items ?? []) {
355
+ counts[item.status] = (counts[item.status] ?? 0) + 1;
356
+ }
357
+ const reviewable = (p.results?.items ?? []).filter((i) => REVIEWABLE.has(i.status)).length;
358
+ const prefix = `${p.project}/`;
359
+ const mine = [...decided].filter(([k]) => k.startsWith(prefix));
360
+ return {
361
+ project: p.project,
362
+ problem: p.problem,
363
+ logUrl: p.logUrl,
364
+ counts,
365
+ reviewable,
366
+ decided: mine.length,
367
+ undecided: reviewable - mine.length,
368
+ notOpened: mine.filter(([, d]) => d.bulk).length,
369
+ acceptable: acceptable(t, p.project),
370
+ local: p.results?.local ?? null,
371
+ };
372
+ }),
373
+ };
374
+ };
375
+
376
+ // A local preview (--results, or any capture not made by CI) is only looked at: no decision is
377
+ // taken on it and it has no Finish, since Finish accepts only CI captures.
378
+ const isLocal = (t, name) =>
379
+ t.local === true || Boolean(t.projects.find((x) => x.project === name)?.results?.local);
380
+ const acceptable = (t, name) =>
381
+ !isLocal(t, name) && (t.pr !== null || projects[name].seedFromDefaultBranch === true);
382
+ const LOCAL = "is a local preview: nothing is decided on it; only CI captures of a pushed commit are";
383
+
384
+ async function targetOf(id) {
385
+ if (!targets.has(id)) {
386
+ await refresh();
387
+ }
388
+ return targets.get(id);
389
+ }
390
+
391
+ async function projectOf(id, name) {
392
+ const t = await targetOf(id);
393
+ const p = t?.projects.find((x) => x.project === name);
394
+ return p?.results ? { t, p } : {};
395
+ }
396
+
397
+ const routes = {
398
+ "GET /api/prs": async () => {
399
+ await refresh();
400
+ return [200, { targets: [...targets.values()].map(summary) }];
401
+ },
402
+ // One target's counts without refetching from GitHub, for Finish's confirmation.
403
+ "GET /api/target": async ([id]) => {
404
+ const t = await targetOf(id);
405
+ return t ? [200, summary(t)] : [404, { error: "no such target" }];
406
+ },
407
+ "GET /api/pr": async ([id, name]) => {
408
+ const { t, p } = await projectOf(id, name);
409
+ if (!p) {
410
+ return [404, { error: "no such capture" }];
411
+ }
412
+ const { items, ...meta } = p.results;
413
+ const prefix = `${name}/`;
414
+ const mine = [...decisionsOf(t)].filter(([k]) => k.startsWith(prefix));
415
+ return [
416
+ 200,
417
+ {
418
+ target: summary(t),
419
+ project: name,
420
+ acceptable: acceptable(t, name),
421
+ results: meta,
422
+ items: items.map((i) => {
423
+ const to = t.earlier.get(`${config.baselines}/${name}/${i.file}`);
424
+ return to !== undefined && to !== i.baseline ? { ...i, reReview: true } : i;
425
+ }),
426
+ decisions: Object.fromEntries(mine.map(([k, v]) => [k.slice(prefix.length), v])),
427
+ },
428
+ ];
429
+ },
430
+ "GET /api/img": async ([id, name, kind, file]) => {
431
+ const { p } = await projectOf(id, name);
432
+ const item = p?.results.items.find((i) => i.file === file);
433
+ const hash = item && { capture: item.capture, baseline: item.baseline }[kind];
434
+ if (!hash) {
435
+ return [404, { error: "no such image" }];
436
+ }
437
+ // A moved item's baseline is its capture's bytes, so the artifact holds only the capture.
438
+ const own = kind === "capture" || item.baseline === item.capture;
439
+ const bytes = await readFile(own ? join(p.dir, file) : join(p.dir, "baselines", file)).catch(() => null);
440
+ if (!bytes || createHash("sha256").update(bytes).digest("hex") !== hash) {
441
+ return [409, { error: `${file} does not match results.json` }];
442
+ }
443
+ return [200, bytes, "image/png"];
444
+ },
445
+ "POST /api/decide": async (_, body) => {
446
+ const { p, t } = await projectOf(String(body.id), body.project);
447
+ const item = p?.results.items.find((i) => i.file === body.file);
448
+ if (!item) {
449
+ return [404, { error: "no such item" }];
450
+ }
451
+ const key = `${body.project}/${body.file}`;
452
+ if (busy(t)) {
453
+ return [409, { error: BUSY }];
454
+ }
455
+ if (isLocal(t, body.project)) {
456
+ return [403, { error: `${body.project} ${LOCAL}` }];
457
+ }
458
+ if (body.decision === null) {
459
+ decisionsOf(t).delete(key);
460
+ save(t);
461
+ return [200, { ok: true }];
462
+ }
463
+ if (body.decision !== "reject" && !acceptable(t, body.project)) {
464
+ return [
465
+ 403,
466
+ {
467
+ error: `${body.project} is not seeded from ${defaultBranch}; its first review is on a pull request`,
468
+ },
469
+ ];
470
+ }
471
+ const reason = cleanReason(body.reason);
472
+ const problem = decisionProblem(item, body.decision, reason);
473
+ if (problem) {
474
+ return [problem.status, { error: problem.message }];
475
+ }
476
+ // Nothing silently reverses a decision: changing one takes an explicit Undo first. The
477
+ // same decision again is allowed (opening an item Accept all decided re-sends it).
478
+ const before = decisionsOf(t).get(key);
479
+ if (before && (before.decision !== body.decision || before.reason !== reason)) {
480
+ const done = { accept: "accepted", reject: "rejected", exclude: "excluded" }[before.decision];
481
+ return [409, { error: `${body.file} is already ${done}: Undo it first to change it` }];
482
+ }
483
+ decisionsOf(t).set(key, { decision: body.decision, reason });
484
+ save(t);
485
+ return [200, { ok: true }];
486
+ },
487
+ "POST /api/accept-all": async (_, body) => {
488
+ const { p, t } = await projectOf(String(body.id), body.project);
489
+ if (!p) {
490
+ return [404, { error: "no such capture" }];
491
+ }
492
+ if (busy(t)) {
493
+ return [409, { error: BUSY }];
494
+ }
495
+ if (!acceptable(t, body.project)) {
496
+ return [
497
+ 403,
498
+ {
499
+ error: `${body.project} cannot be accepted here (a local preview, or not seeded from ${defaultBranch})`,
500
+ },
501
+ ];
502
+ }
503
+ // With `component`, only that component's stories: the story id before "--".
504
+ const inScope = (item) => typeof body.component !== "string" || componentOf(item.id) === body.component;
505
+ const mine = decisionsOf(t);
506
+ let accepted = 0;
507
+ for (const item of p.results.items) {
508
+ const key = `${body.project}/${item.file}`;
509
+ if (inScope(item) && !mine.has(key) && !decisionProblem(item, "accept", null)) {
510
+ mine.set(key, { decision: "accept", reason: null, bulk: true });
511
+ accepted++;
512
+ }
513
+ }
514
+ save(t);
515
+ return [200, { accepted }];
516
+ },
517
+ "POST /api/finish": async (_, body) => {
518
+ const t = await targetOf(String(body.id));
519
+ if (!t) {
520
+ return [404, { error: "no such target" }];
521
+ }
522
+ if (t.local) {
523
+ return [403, { error: "a local preview has no Finish" }];
524
+ }
525
+ if (finishing) {
526
+ return [409, { error: "a Finish is already running" }];
527
+ }
528
+ const mine = decisionsOf(t);
529
+ // A reject already posted by an earlier Finish stays shown as rejected, not posted again.
530
+ const list = [...mine]
531
+ .filter(([, v]) => !v.posted)
532
+ .map(([k, v]) => {
533
+ const at = k.indexOf("/");
534
+ return { project: k.slice(0, at), file: k.slice(at + 1), decision: v.decision, reason: v.reason };
535
+ });
536
+ const captures = Object.fromEntries(
537
+ t.projects.filter((p) => p.results).map((p) => [p.project, { dir: p.dir, results: p.results }]),
538
+ );
539
+ const undecided = summary(t).projects.reduce((n, p) => n + p.undecided, 0);
540
+ finishing = true;
541
+ job = {
542
+ id: (job?.id ?? 0) + 1,
543
+ target: t.id,
544
+ pr: t.pr,
545
+ running: true,
546
+ step: "starting",
547
+ result: null,
548
+ error: null,
549
+ };
550
+ runFinish(job, t, mine, {
551
+ repo,
552
+ gh,
553
+ target: { pr: t.pr, branch: t.branch },
554
+ projects: captures,
555
+ decisions: list,
556
+ undecided,
557
+ config,
558
+ });
559
+ return [202, { job }];
560
+ },
561
+ "GET /api/finish-status": async () => [200, { job }],
562
+ };
563
+
564
+ /**
565
+ * Runs one Finish to its end, recording its steps and outcome on `j`.
566
+ * @param {object} j the job
567
+ * @param {object} t the target
568
+ * @param {Map<string, object>} mine the target's decisions
569
+ * @param {object} input finish's input
570
+ */
571
+ async function runFinish(j, t, mine, input) {
572
+ try {
573
+ j.result = await finish({ ...input, progress: (step) => (j.step = step) });
574
+ // Rejects stay, keyed by image hash, so an unchanged rejected capture on the next
575
+ // CI run still reads as rejected rather than undecided.
576
+ for (const [k, v] of mine) {
577
+ if (v.decision === "reject") {
578
+ v.posted = true;
579
+ } else {
580
+ mine.delete(k);
581
+ }
582
+ }
583
+ save(t);
584
+ } catch (err) {
585
+ if (err instanceof AcceptError && err.committed) {
586
+ // The accepts are on the branch; keep only the rejects, so Finish again only comments.
587
+ for (const [k, v] of mine) {
588
+ if (v.decision !== "reject") {
589
+ mine.delete(k);
590
+ }
591
+ }
592
+ save(t);
593
+ }
594
+ j.error = err.message;
595
+ } finally {
596
+ j.running = false;
597
+ j.step = null;
598
+ finishing = false;
599
+ }
600
+ }
601
+
602
+ const tokenOk = (given) => {
603
+ const a = Buffer.from(String(given ?? ""));
604
+ const b = Buffer.from(token);
605
+ return a.length === b.length && timingSafeEqual(a, b);
606
+ };
607
+
608
+ function send(res, status, body, type = "application/json") {
609
+ const data = type === "application/json" ? JSON.stringify(body) : body;
610
+ res.writeHead(status, { ...HEADERS, "content-type": type });
611
+ res.end(data);
612
+ }
613
+
614
+ async function readBody(req) {
615
+ let size = 0;
616
+ const chunks = [];
617
+ for await (const chunk of req) {
618
+ size += chunk.length;
619
+ if (size > 65536) {
620
+ throw new Error("body too large");
621
+ }
622
+ chunks.push(chunk);
623
+ }
624
+ const body = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
625
+ if (typeof body !== "object" || body === null) {
626
+ throw new Error("body must be an object");
627
+ }
628
+ return body;
629
+ }
630
+
631
+ return async (req, res) => {
632
+ try {
633
+ const url = new URL(req.url, origin);
634
+ if (!url.pathname.startsWith("/api/")) {
635
+ const entry = req.method === "GET" && Object.hasOwn(STATIC, url.pathname) ? STATIC[url.pathname] : null;
636
+ return entry
637
+ ? send(res, 200, await readFile(join(HERE, entry[0])), entry[1])
638
+ : send(res, 404, { error: "not found" });
639
+ }
640
+ if (!tokenOk(req.headers["x-review-token"])) {
641
+ return send(res, 401, { error: "missing or wrong session token: open the URL serve printed" });
642
+ }
643
+ const [, , route, ...args] = url.pathname.split("/").map(decodeURIComponent);
644
+ const writes = WRITES.has(route);
645
+ const handler = routes[`${req.method} /api/${route}`];
646
+ if (!handler) {
647
+ return send(res, writes || Object.hasOwn(routes, `GET /api/${route}`) ? 405 : 404, {
648
+ error: "not allowed",
649
+ });
650
+ }
651
+ if (writes && req.headers.origin !== origin) {
652
+ return send(res, 403, { error: "foreign origin" });
653
+ }
654
+ const [status, body, type] = await handler(args, writes ? await readBody(req) : undefined);
655
+ return send(res, status, body, type);
656
+ } catch (err) {
657
+ // A malformed request (bad JSON, bad escape, oversized body) is the client's; the rest,
658
+ // gh and git failures included, is ours.
659
+ const client = err instanceof SyntaxError || err instanceof URIError || err.message.startsWith("body ");
660
+ return send(res, client ? 400 : 500, { error: err.message });
661
+ }
662
+ };
663
+ }
@@ -0,0 +1,19 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Visual review</title>
7
+ <link rel="stylesheet" href="review.css" />
8
+ <script type="module" src="review.js"></script>
9
+ </head>
10
+ <body>
11
+ <header>
12
+ <button type="button" id="home" class="link">Visual review</button>
13
+ <nav id="crumbs"></nav>
14
+ <span id="status" role="status"></span>
15
+ <button type="button" id="copy-link" title="Copy a link to this screen">Copy link</button>
16
+ </header>
17
+ <main id="app"><p>Loading...</p></main>
18
+ </body>
19
+ </html>