@graphty/visual-review 0.2.1 → 0.2.2
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 +424 -148
- package/package.json +1 -1
- package/templates/visual-review.yml +4 -2
- package/trusted/gate.mjs +239 -41
- package/trusted/lib/accept.mjs +413 -94
- package/trusted/lib/approval.mjs +300 -0
- package/trusted/lib/github.mjs +96 -29
- package/trusted/lib/serve.mjs +850 -101
- package/trusted/lib/thumbs.mjs +149 -0
- package/trusted/page/index.html +20 -6
- package/trusted/page/passkey.js +94 -0
- package/trusted/page/review.css +915 -102
- package/trusted/page/review.js +3062 -1044
package/trusted/lib/serve.mjs
CHANGED
|
@@ -15,18 +15,60 @@
|
|
|
15
15
|
* iPad) keeps one request open. POST /api/finish starts it and GET /api/finish-status reports its
|
|
16
16
|
* step, then its result or error, so a reload finds the running Finish. The job is also written
|
|
17
17
|
* to `<tmp>/state/finish.json`, so a server restarted during a Finish says it was interrupted.
|
|
18
|
+
*
|
|
19
|
+
* Passkeys: once any key is known (passkeys.json on the fetched default branch, or one this server
|
|
20
|
+
* registered whose pull request is not merged yet, `<tmp>/state/passkeys-pending.json`), Finish
|
|
21
|
+
* needs the owner's approval. A pending key approves only while the default branch holds none: once
|
|
22
|
+
* it does, only its keys count, as in the gate, so a key registered later (by anyone who can reach
|
|
23
|
+
* this page) approves nothing until its pull request merges. POST /api/finish-prepare builds the
|
|
24
|
+
* record and returns its hash as the WebAuthn challenge; POST /api/finish takes the approval,
|
|
25
|
+
* checks it, and commits exactly that record. The server decides whether approval is required, so
|
|
26
|
+
* leaving it out never skips it. The rpId is the host name of the page's own origin.
|
|
27
|
+
*
|
|
28
|
+
* The list of targets is cached: `GET /api/prs?cached=1` answers at once with the cache, its age,
|
|
29
|
+
* the progress of a refresh, why the last one failed and any network retry under way, and
|
|
30
|
+
* `?refresh=1` starts one in the background, so the page never waits on GitHub to change screens.
|
|
31
|
+
* The list is also kept in `<tmp>/state/list.json`, so a restarted server shows it at once and
|
|
32
|
+
* refreshes it behind. With `warm` the server refreshes at start and every two minutes, so the
|
|
33
|
+
* captures of a CI run that just finished are downloaded before the owner opens them. A target
|
|
34
|
+
* whose captures are still downloading is listed with `downloading: true` and its progress, and
|
|
35
|
+
* rebuilt in the cache when they land; `GET /api/pr/...` of a project still downloading answers
|
|
36
|
+
* 202 and moves its download to the front of the queue. What GitHub says about a finished run
|
|
37
|
+
* (its jobs and artifacts) is asked once and kept beside its downloads. Grid tiles load
|
|
38
|
+
* thumbnails (`GET /api/thumb/...`), scaled down in worker threads as soon as a capture lands and
|
|
39
|
+
* kept on disk.
|
|
18
40
|
*/
|
|
19
41
|
|
|
20
42
|
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
21
43
|
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
22
44
|
import { readFile } from "node:fs/promises";
|
|
23
|
-
import { dirname, join } from "node:path";
|
|
45
|
+
import { basename, dirname, join } from "node:path";
|
|
24
46
|
import { fileURLToPath } from "node:url";
|
|
25
47
|
|
|
26
|
-
import {
|
|
27
|
-
|
|
48
|
+
import {
|
|
49
|
+
AcceptError,
|
|
50
|
+
behindMaster,
|
|
51
|
+
cleanReason,
|
|
52
|
+
commitStatus,
|
|
53
|
+
decisionProblem,
|
|
54
|
+
finish,
|
|
55
|
+
prepareRecord,
|
|
56
|
+
proposeKey,
|
|
57
|
+
} from "./accept.mjs";
|
|
58
|
+
import { PASSKEYS_FILE, parsePasskeys, recordHash, verifyApproval, verifyRegistration } from "./approval.mjs";
|
|
59
|
+
import {
|
|
60
|
+
downloadCaptures,
|
|
61
|
+
exec,
|
|
62
|
+
getRun,
|
|
63
|
+
hurry,
|
|
64
|
+
newestCiRun,
|
|
65
|
+
openPullRequests,
|
|
66
|
+
retrying,
|
|
67
|
+
visualJobs,
|
|
68
|
+
} from "./github.mjs";
|
|
28
69
|
import { CONFIG_FILE } from "./config.mjs";
|
|
29
70
|
import { validateResults } from "./results.mjs";
|
|
71
|
+
import { scaled } from "./thumbs.mjs";
|
|
30
72
|
|
|
31
73
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
32
74
|
const STATIC = {
|
|
@@ -34,6 +76,7 @@ const STATIC = {
|
|
|
34
76
|
"/review.js": ["../page/review.js", "text/javascript; charset=utf-8"],
|
|
35
77
|
"/review.css": ["../page/review.css", "text/css; charset=utf-8"],
|
|
36
78
|
"/pixelmatch.mjs": ["../vendor/pixelmatch.mjs", "text/javascript; charset=utf-8"],
|
|
79
|
+
"/passkey.js": ["../page/passkey.js", "text/javascript; charset=utf-8"],
|
|
37
80
|
};
|
|
38
81
|
const HEADERS = {
|
|
39
82
|
"content-security-policy":
|
|
@@ -50,13 +93,25 @@ const HEADERS = {
|
|
|
50
93
|
const componentOf = (id) => id.split("--")[0];
|
|
51
94
|
|
|
52
95
|
const REVIEWABLE = new Set(["changed", "moved", "new", "unseeded", "removed", "unstable", "failed"]);
|
|
53
|
-
const WRITES = new Set(["decide", "accept-all", "finish"]);
|
|
96
|
+
const WRITES = new Set(["decide", "accept-all", "finish", "finish-prepare", "passkey-challenge", "register"]);
|
|
97
|
+
// A registration challenge is good for one use within this long; a prepared approval for this long.
|
|
98
|
+
const CHALLENGE_MS = 5 * 60000;
|
|
99
|
+
const APPROVAL_MS = 10 * 60000;
|
|
54
100
|
// How long a page load waits for a run's captures to download before it lists the target as
|
|
55
101
|
// downloading; the download goes on, and a reload picks it up.
|
|
56
102
|
const PATIENCE = 1000;
|
|
57
103
|
// An unknown target id refreshes from GitHub at most this often: a closed pull request's open
|
|
58
104
|
// grid asks for dozens of images at once.
|
|
59
105
|
const UNKNOWN_REFRESH = 60000;
|
|
106
|
+
// With `warm`, the list is refreshed this often, so a CI run that finished meanwhile is downloaded
|
|
107
|
+
// before the owner opens it.
|
|
108
|
+
const POLL = 2 * 60000;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The newest gh call waiting to retry after a network failure, for the page.
|
|
112
|
+
* @returns {{ error: string, attempt: number, of: number, until: number } | null} it, or null
|
|
113
|
+
*/
|
|
114
|
+
const networkTrouble = () => [...retrying].at(-1) ?? null;
|
|
60
115
|
|
|
61
116
|
/**
|
|
62
117
|
* The image a decision was taken on: the capture, or for a removed item its baseline.
|
|
@@ -246,6 +301,71 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
|
|
|
246
301
|
}
|
|
247
302
|
};
|
|
248
303
|
let signer = null;
|
|
304
|
+
const rpId = new URL(origin).hostname;
|
|
305
|
+
const pendingFile = join(stateDir, "passkeys-pending.json");
|
|
306
|
+
/** Registration challenges (base64url) and when each expires. */
|
|
307
|
+
const challenges = new Map();
|
|
308
|
+
/**
|
|
309
|
+
* The record each target's Finish was prepared with, waiting for its approval.
|
|
310
|
+
* @type {Map<string, { challenge: string, record: object, work: object, digest: string,
|
|
311
|
+
* expires: number }>}
|
|
312
|
+
*/
|
|
313
|
+
const approvals = new Map();
|
|
314
|
+
// Fails closed: only a missing file means no pending key; any other failure throws.
|
|
315
|
+
const readPending = () => {
|
|
316
|
+
let text;
|
|
317
|
+
try {
|
|
318
|
+
text = readFileSync(pendingFile, "utf8");
|
|
319
|
+
} catch (err) {
|
|
320
|
+
if (err.code === "ENOENT") {
|
|
321
|
+
return [];
|
|
322
|
+
}
|
|
323
|
+
throw err;
|
|
324
|
+
}
|
|
325
|
+
try {
|
|
326
|
+
return parsePasskeys(text);
|
|
327
|
+
} catch (err) {
|
|
328
|
+
throw new Error(`${pendingFile} is invalid: ${err.message}`);
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
/**
|
|
332
|
+
* The keys an approval may come from: the fetched default branch's passkeys.json, and the
|
|
333
|
+
* keys this server registered that it does not hold yet. A git failure or an invalid file
|
|
334
|
+
* throws: approval is never skipped because a key could not be read. `trusted` is what may
|
|
335
|
+
* approve: the default branch's keys, or the pending ones while it has none.
|
|
336
|
+
* @returns {Promise<{ main: object[], pending: object[], trusted: object[] }>} the keys
|
|
337
|
+
*/
|
|
338
|
+
async function knownKeys() {
|
|
339
|
+
const ref = `refs/remotes/origin/${defaultBranch}`;
|
|
340
|
+
const git = (args) => exec("git", args, { cwd: repo });
|
|
341
|
+
const listed = await git(["ls-tree", "--name-only", ref, "--", PASSKEYS_FILE]);
|
|
342
|
+
const text = listed === "" ? null : await git(["show", `${ref}:${PASSKEYS_FILE}`]);
|
|
343
|
+
let main = [];
|
|
344
|
+
try {
|
|
345
|
+
main = text === null ? [] : parsePasskeys(text);
|
|
346
|
+
} catch (err) {
|
|
347
|
+
throw new Error(`${PASSKEYS_FILE} on ${defaultBranch} is invalid: ${err.message}`);
|
|
348
|
+
}
|
|
349
|
+
const ids = new Set(main.map((k) => k.id));
|
|
350
|
+
const pending = readPending().filter((k) => !ids.has(k.id));
|
|
351
|
+
return { main, pending, trusted: main.length > 0 ? main : pending };
|
|
352
|
+
}
|
|
353
|
+
/** The passkey state the targets screen shows, read when the target list refreshes. */
|
|
354
|
+
let passkeyState = { main: [], pending: [], problem: null };
|
|
355
|
+
const readPasskeys = async () => {
|
|
356
|
+
if (results) {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
try {
|
|
360
|
+
passkeyState = { ...(await knownKeys()), problem: null };
|
|
361
|
+
} catch (err) {
|
|
362
|
+
passkeyState = { main: [], pending: [], problem: err.message };
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
/** An open pull request that registers a passkey, waiting for the owner to merge it. */
|
|
366
|
+
let passkeyPr = null;
|
|
367
|
+
/** The passkey pull request this server opened: listed until GitHub's list or the file has it. */
|
|
368
|
+
let passkeyOpened = null;
|
|
249
369
|
const busy = (t) => finishing && job?.target === t.id;
|
|
250
370
|
const BUSY = "a Finish is running on this target: wait for it to end";
|
|
251
371
|
|
|
@@ -341,37 +461,120 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
|
|
|
341
461
|
// `problem` is what CI said (the job failed, or no artifact); results.json can add its own.
|
|
342
462
|
async function project(name, dir, problem) {
|
|
343
463
|
const loaded = dir ? await loadResults(dir) : { results: null, problem: null };
|
|
464
|
+
if (loaded.results) {
|
|
465
|
+
prewarm(dir, loaded.results);
|
|
466
|
+
}
|
|
344
467
|
return { project: name, dir, results: loaded.results, problem: problem ?? loaded.problem, logUrl: null };
|
|
345
468
|
}
|
|
346
469
|
|
|
470
|
+
/**
|
|
471
|
+
* The gh runner for one finished run: what GitHub says about it (its jobs, its artifacts) no
|
|
472
|
+
* longer changes, so each answer is asked once and kept on disk beside the run's downloads,
|
|
473
|
+
* and pruned with them. A refresh then asks GitHub only for the pull requests and their newest
|
|
474
|
+
* runs. ponytail: an artifact that expires later is still listed as there; its download fails
|
|
475
|
+
* and says so, which only matters for a capture never downloaded in its 90 days.
|
|
476
|
+
* @param {{ id: number, attempt: number }} run the finished run
|
|
477
|
+
* @returns {Function} the gh runner
|
|
478
|
+
*/
|
|
479
|
+
const kept = (run) => async (args, input) => {
|
|
480
|
+
if (args[0] !== "api") {
|
|
481
|
+
return gh(args, input);
|
|
482
|
+
}
|
|
483
|
+
const name = createHash("sha256").update(args.join(" ")).digest("hex").slice(0, 16);
|
|
484
|
+
const file = join(tmp, `${run.id}-${run.attempt}`, "gh", `${name}.json`);
|
|
485
|
+
const saved = await readFile(file, "utf8").catch(() => null);
|
|
486
|
+
if (saved !== null) {
|
|
487
|
+
return saved;
|
|
488
|
+
}
|
|
489
|
+
const answer = await gh(args, input);
|
|
490
|
+
try {
|
|
491
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
492
|
+
writeFileSync(`${file}.tmp`, answer);
|
|
493
|
+
renameSync(`${file}.tmp`, file);
|
|
494
|
+
} catch (err) {
|
|
495
|
+
warnOnce(`could not keep GitHub's answers in ${dirname(file)}: ${err.message}`);
|
|
496
|
+
}
|
|
497
|
+
return answer;
|
|
498
|
+
};
|
|
499
|
+
|
|
347
500
|
async function build(info, run) {
|
|
348
|
-
const
|
|
501
|
+
const g = run.status === "completed" ? kept(run) : gh;
|
|
502
|
+
const jobs = await visualJobs(g, run, run.attempt, names);
|
|
349
503
|
const others = [];
|
|
350
|
-
|
|
504
|
+
// Each project's capture as its download lands; `partial` is the target listed while the
|
|
505
|
+
// rest are still downloading, whose rows fill in one by one. `download` counts the
|
|
506
|
+
// artifacts and their bytes, for the page's progress.
|
|
507
|
+
const landed = new Map();
|
|
508
|
+
const download = { done: 0, total: names.length, bytesDone: 0, bytes: null, startedAt: Date.now() };
|
|
509
|
+
let sizes = {};
|
|
510
|
+
let partial = null;
|
|
511
|
+
const fill = async (name) => {
|
|
512
|
+
const p = await project(name, landed.get(name)?.dir, problemOf(name, landed.get(name), run, jobs));
|
|
513
|
+
p.logUrl = jobs[name]?.url ?? run.url;
|
|
514
|
+
p.bytes = sizes[name] ?? null;
|
|
515
|
+
partial.projects[names.indexOf(name)] = p;
|
|
516
|
+
await decorate(partial);
|
|
517
|
+
};
|
|
518
|
+
const downloads = downloadCaptures(
|
|
519
|
+
g,
|
|
520
|
+
run,
|
|
521
|
+
names,
|
|
522
|
+
tmp,
|
|
523
|
+
others,
|
|
524
|
+
(name, got) => {
|
|
525
|
+
landed.set(name, got);
|
|
526
|
+
if (got) {
|
|
527
|
+
download.done++;
|
|
528
|
+
download.bytesDone += got.bytes;
|
|
529
|
+
}
|
|
530
|
+
if (partial) {
|
|
531
|
+
fill(name).catch((err) => console.error(`visual-review: ${name} of ${info.id}: ${err.message}`));
|
|
532
|
+
}
|
|
533
|
+
},
|
|
534
|
+
(planned) => {
|
|
535
|
+
sizes = planned;
|
|
536
|
+
download.total = Object.keys(planned).length;
|
|
537
|
+
download.bytes = Object.values(planned).reduce((a, b) => a + b, 0);
|
|
538
|
+
},
|
|
539
|
+
);
|
|
351
540
|
downloads.catch(() => {}); // A download still running after PATIENCE fails on a later refresh.
|
|
352
541
|
const downloaded = await Promise.race([
|
|
353
542
|
downloads,
|
|
354
543
|
new Promise((resolve) => setTimeout(resolve, PATIENCE, null).unref()),
|
|
355
544
|
]);
|
|
356
545
|
if (!downloaded) {
|
|
357
|
-
|
|
546
|
+
// Listed as downloading, and rebuilt in the cache when the download lands, so the page
|
|
547
|
+
// (which asks again every few seconds) fills the rows in without a refresh.
|
|
548
|
+
downloads
|
|
549
|
+
.then(async () => {
|
|
550
|
+
await refreshing?.catch(() => {});
|
|
551
|
+
const shown = targets.get(info.id);
|
|
552
|
+
if (shown?.downloading && shown.runId === run.id && shown.runAttempt === run.attempt) {
|
|
553
|
+
const built = await build(info, run);
|
|
554
|
+
await decorate(built);
|
|
555
|
+
if (targets.get(info.id) === shown) {
|
|
556
|
+
targets.set(info.id, built);
|
|
557
|
+
save();
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
})
|
|
561
|
+
.catch((err) => console.error(`visual-review: rebuilding ${info.id} failed: ${err.message}`));
|
|
562
|
+
partial = blank(info, null, run);
|
|
563
|
+
partial.downloading = true;
|
|
564
|
+
partial.download = download;
|
|
565
|
+
for (const p of partial.projects) {
|
|
566
|
+
p.downloading = true;
|
|
567
|
+
p.bytes = sizes[p.project] ?? null;
|
|
568
|
+
}
|
|
569
|
+
for (const name of landed.keys()) {
|
|
570
|
+
await fill(name);
|
|
571
|
+
}
|
|
572
|
+
return partial;
|
|
358
573
|
}
|
|
359
574
|
const list = [];
|
|
360
575
|
for (const name of names) {
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
let problem = null;
|
|
364
|
-
if (got?.error) {
|
|
365
|
-
problem = retryLater("download failed", got.error);
|
|
366
|
-
} else if (got?.expired) {
|
|
367
|
-
problem = "artifact expired: re-run the visual job";
|
|
368
|
-
} else if (!got && run.status !== "completed" && job?.conclusion !== "failure") {
|
|
369
|
-
problem = "CI still running; reload when it finishes";
|
|
370
|
-
} else if (!got || job?.conclusion === "failure") {
|
|
371
|
-
problem = job ? "capture failed" : "no capture";
|
|
372
|
-
}
|
|
373
|
-
const p = await project(name, got?.dir, problem);
|
|
374
|
-
p.logUrl = job?.url ?? run.url;
|
|
576
|
+
const p = await project(name, downloaded[name]?.dir, problemOf(name, downloaded[name], run, jobs));
|
|
577
|
+
p.logUrl = jobs[name]?.url ?? run.url;
|
|
375
578
|
list.push(p);
|
|
376
579
|
}
|
|
377
580
|
const warnings = others.map(
|
|
@@ -381,6 +584,24 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
|
|
|
381
584
|
return { ...info, runId: run.id, runAttempt: run.attempt, runUrl: run.url, projects: list, warnings };
|
|
382
585
|
}
|
|
383
586
|
|
|
587
|
+
// Why a project's capture is missing, from its download and its CI job; null when it is there.
|
|
588
|
+
function problemOf(name, got, run, jobs) {
|
|
589
|
+
const job = jobs[name];
|
|
590
|
+
if (got?.error) {
|
|
591
|
+
return retryLater("download failed", got.error);
|
|
592
|
+
}
|
|
593
|
+
if (got?.expired) {
|
|
594
|
+
return "artifact expired: re-run the visual job";
|
|
595
|
+
}
|
|
596
|
+
if (!got && run.status !== "completed" && job?.conclusion !== "failure") {
|
|
597
|
+
return "CI still running; reload when it finishes";
|
|
598
|
+
}
|
|
599
|
+
if (!got || job?.conclusion === "failure") {
|
|
600
|
+
return job ? "capture failed" : "no capture";
|
|
601
|
+
}
|
|
602
|
+
return null;
|
|
603
|
+
}
|
|
604
|
+
|
|
384
605
|
const retryLater = (what, message) => `${what}: ${message.split("\n")[0]}; reload the page to retry`;
|
|
385
606
|
|
|
386
607
|
// A target with no capture to show: GitHub would not give it to us (after gh's retries), CI has
|
|
@@ -405,11 +626,32 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
|
|
|
405
626
|
// Concurrent callers (the startup refresh, the page's first request) share one refresh.
|
|
406
627
|
let refreshing = null;
|
|
407
628
|
let refreshedAt = 0;
|
|
629
|
+
let loadedOnce = false;
|
|
630
|
+
/** Why the last refresh failed as a whole (no list at all), for the page; null once one works. */
|
|
631
|
+
let failure = null;
|
|
632
|
+
/** What the running refresh is doing, for the page: a step, a count, and when it began. */
|
|
633
|
+
let progress = null;
|
|
634
|
+
const step = (name, done = null, total = null) => {
|
|
635
|
+
if (progress) {
|
|
636
|
+
Object.assign(progress, { step: name, done, total });
|
|
637
|
+
}
|
|
638
|
+
};
|
|
408
639
|
const refresh = () =>
|
|
409
|
-
(refreshing ??=
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
640
|
+
(refreshing ??= (async () => {
|
|
641
|
+
progress = { step: "starting", done: null, total: null, startedAt: Date.now() };
|
|
642
|
+
try {
|
|
643
|
+
await load();
|
|
644
|
+
loadedOnce = true;
|
|
645
|
+
failure = null;
|
|
646
|
+
} catch (err) {
|
|
647
|
+
failure = err.message.split("\n")[0];
|
|
648
|
+
throw err;
|
|
649
|
+
} finally {
|
|
650
|
+
refreshing = null;
|
|
651
|
+
refreshedAt = Date.now();
|
|
652
|
+
progress = null;
|
|
653
|
+
}
|
|
654
|
+
})());
|
|
413
655
|
|
|
414
656
|
async function load() {
|
|
415
657
|
const next = new Map();
|
|
@@ -442,6 +684,7 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
|
|
|
442
684
|
// master seed still loads.
|
|
443
685
|
listWarnings = [];
|
|
444
686
|
const kept = [...targets.values()].filter((t) => t.pr !== null);
|
|
687
|
+
step("listing pull requests");
|
|
445
688
|
const prs = await openPullRequests(gh).catch((err) => {
|
|
446
689
|
if (!masterRun && kept.length === 0) {
|
|
447
690
|
throw err;
|
|
@@ -482,6 +725,8 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
|
|
|
482
725
|
}
|
|
483
726
|
})();
|
|
484
727
|
// Every pull request at once: one after another took about 40 s for 18 of them.
|
|
728
|
+
let found = 0;
|
|
729
|
+
step("finding CI runs", 0, prs.length);
|
|
485
730
|
const built = await Promise.all(
|
|
486
731
|
prs.map(async (pr) => {
|
|
487
732
|
const info = {
|
|
@@ -498,12 +743,25 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
|
|
|
498
743
|
: blank(info, `waiting for CI on ${pr.headSha.slice(0, 10)}`);
|
|
499
744
|
} catch (err) {
|
|
500
745
|
return keptOr(info, err);
|
|
746
|
+
} finally {
|
|
747
|
+
step("finding CI runs", ++found, prs.length);
|
|
501
748
|
}
|
|
502
749
|
}),
|
|
503
750
|
);
|
|
751
|
+
step("fetching branches");
|
|
504
752
|
await fetched;
|
|
753
|
+
await readPasskeys();
|
|
754
|
+
// GitHub's list can lag a pull request just opened: keep it a while, until its key is merged.
|
|
755
|
+
const opened =
|
|
756
|
+
passkeyOpened &&
|
|
757
|
+
Date.now() - passkeyOpened.at < 5 * 60000 &&
|
|
758
|
+
!passkeyState.main.some((k) => k.id === passkeyOpened.id)
|
|
759
|
+
? passkeyOpened.number
|
|
760
|
+
: null;
|
|
761
|
+
passkeyPr = prs.find((p) => p.branch.startsWith("visual/passkey-"))?.number ?? opened;
|
|
505
762
|
for (const t of built) next.set(t.id, t);
|
|
506
763
|
if (masterRun) {
|
|
764
|
+
step(`reading ${defaultBranch}'s CI run`);
|
|
507
765
|
const info = { id: "master", pr: null, title: defaultBranch, url: null, branch: null };
|
|
508
766
|
next.set(
|
|
509
767
|
"master",
|
|
@@ -513,34 +771,109 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
|
|
|
513
771
|
);
|
|
514
772
|
}
|
|
515
773
|
}
|
|
774
|
+
step("checking the baselines", 0, next.size);
|
|
775
|
+
let checked = 0;
|
|
516
776
|
for (const t of next.values()) {
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
t.headSha = first?.headSha ?? null;
|
|
520
|
-
const base = t.pr === null ? t.commit : t.headSha;
|
|
521
|
-
t.earlier = await earlierAccepts(repo, t.headSha, t.pr, config.baselines);
|
|
522
|
-
// null: unknown, when the captured head was never fetched or git fails.
|
|
523
|
-
const known =
|
|
524
|
-
base !== null &&
|
|
525
|
-
(await exec("git", ["cat-file", "-e", `${base}^{commit}`], { cwd: repo }).then(
|
|
526
|
-
() => true,
|
|
527
|
-
() => false,
|
|
528
|
-
));
|
|
529
|
-
t.mergeMasterFirst = false;
|
|
530
|
-
for (const p of t.projects) {
|
|
531
|
-
if (!t.local && p.results && base) {
|
|
532
|
-
const behind = known ? await behindMaster(repo, base, p.project, config).catch(() => null) : null;
|
|
533
|
-
if (behind !== false && t.mergeMasterFirst !== true) {
|
|
534
|
-
t.mergeMasterFirst = behind;
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
}
|
|
777
|
+
await decorate(t);
|
|
778
|
+
step("checking the baselines", ++checked, next.size);
|
|
538
779
|
}
|
|
539
780
|
signer = await signingIdentity(repo);
|
|
540
781
|
targets = next;
|
|
782
|
+
save();
|
|
541
783
|
prune();
|
|
542
784
|
}
|
|
543
785
|
|
|
786
|
+
const listFile = join(stateDir, "list.json");
|
|
787
|
+
/**
|
|
788
|
+
* Keeps the list of targets on disk, without the captures' results (results.json is read again
|
|
789
|
+
* from each capture's directory), so a restarted server shows it before GitHub answers.
|
|
790
|
+
*/
|
|
791
|
+
function save() {
|
|
792
|
+
if (results) {
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
try {
|
|
796
|
+
writeJson(listFile, {
|
|
797
|
+
at: Date.now(),
|
|
798
|
+
targets: [...targets.values()].map((t) => ({
|
|
799
|
+
...t,
|
|
800
|
+
earlier: [...(t.earlier ?? [])],
|
|
801
|
+
projects: t.projects.map((p) => ({ ...p, results: undefined })),
|
|
802
|
+
})),
|
|
803
|
+
});
|
|
804
|
+
} catch (err) {
|
|
805
|
+
const warning = `could not keep the list of targets (a restart would wait for GitHub): ${err.message}`;
|
|
806
|
+
console.error(`visual-review: ${warning}`);
|
|
807
|
+
if (!listWarnings.includes(warning)) {
|
|
808
|
+
listWarnings.push(warning);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* The list a previous run of this server kept, shown until the first refresh replaces it. A
|
|
815
|
+
* capture whose directory is gone is listed as downloading: that refresh downloads it again.
|
|
816
|
+
*/
|
|
817
|
+
async function restore() {
|
|
818
|
+
let saved;
|
|
819
|
+
try {
|
|
820
|
+
saved = JSON.parse(readFileSync(listFile, "utf8"));
|
|
821
|
+
} catch {
|
|
822
|
+
return; // Never saved, or unreadable: the first refresh builds the list.
|
|
823
|
+
}
|
|
824
|
+
try {
|
|
825
|
+
const next = new Map();
|
|
826
|
+
for (const t of saved.targets) {
|
|
827
|
+
const list = [];
|
|
828
|
+
for (const p of t.projects) {
|
|
829
|
+
const there = p.dir && existsSync(join(p.dir, "results.json"));
|
|
830
|
+
const loaded = there ? await project(p.project, p.dir, p.problem) : null;
|
|
831
|
+
list.push(
|
|
832
|
+
p.dir && !loaded?.results
|
|
833
|
+
? { ...p, dir: null, results: null, problem: null, downloading: true }
|
|
834
|
+
: { ...p, results: loaded?.results ?? null },
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
const downloading = t.downloading === true || list.some((p) => p.downloading);
|
|
838
|
+
next.set(t.id, { ...t, earlier: new Map(t.earlier), projects: list, downloading });
|
|
839
|
+
}
|
|
840
|
+
signer = await signingIdentity(repo);
|
|
841
|
+
await readPasskeys();
|
|
842
|
+
if (!loadedOnce) {
|
|
843
|
+
targets = next;
|
|
844
|
+
loadedOnce = true;
|
|
845
|
+
refreshedAt = saved.at;
|
|
846
|
+
}
|
|
847
|
+
} catch (err) {
|
|
848
|
+
console.error(`visual-review: ignoring the kept list in ${listFile}: ${err.message}`);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// A target's captured commits, its earlier accepts and whether it is behind the default branch.
|
|
853
|
+
async function decorate(t) {
|
|
854
|
+
const first = t.projects.find((p) => p.results)?.results;
|
|
855
|
+
t.commit = first?.commit ?? null;
|
|
856
|
+
t.headSha = first?.headSha ?? null;
|
|
857
|
+
const base = t.pr === null ? t.commit : t.headSha;
|
|
858
|
+
t.earlier = await earlierAccepts(repo, t.headSha, t.pr, config.baselines);
|
|
859
|
+
// null: unknown, when the captured head was never fetched or git fails.
|
|
860
|
+
const known =
|
|
861
|
+
base !== null &&
|
|
862
|
+
(await exec("git", ["cat-file", "-e", `${base}^{commit}`], { cwd: repo }).then(
|
|
863
|
+
() => true,
|
|
864
|
+
() => false,
|
|
865
|
+
));
|
|
866
|
+
t.mergeMasterFirst = false;
|
|
867
|
+
for (const p of t.projects) {
|
|
868
|
+
if (!t.local && p.results && base) {
|
|
869
|
+
const behind = known ? await behindMaster(repo, base, p.project, config).catch(() => null) : null;
|
|
870
|
+
if (behind !== false && t.mergeMasterFirst !== true) {
|
|
871
|
+
t.mergeMasterFirst = behind;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
544
877
|
// Deletes the downloads of runs no target shows any more (never state/). Not while a Finish
|
|
545
878
|
// reads its captures, nor after a refresh that could not load some target.
|
|
546
879
|
function prune() {
|
|
@@ -582,9 +915,40 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
|
|
|
582
915
|
commit: t.commit,
|
|
583
916
|
headSha: t.headSha,
|
|
584
917
|
mergeMasterFirst: t.mergeMasterFirst,
|
|
918
|
+
downloading: t.downloading === true,
|
|
919
|
+
// While downloading: how many artifacts and bytes have landed of how many, and since when
|
|
920
|
+
// (server time). `bytes` is null until GitHub has listed the artifacts.
|
|
921
|
+
download: t.downloading
|
|
922
|
+
? { done: 0, total: names.length, bytesDone: 0, bytes: null, startedAt: null, ...t.download }
|
|
923
|
+
: null,
|
|
924
|
+
defaultBranch,
|
|
925
|
+
// Decisions the next Finish would publish (a reject an earlier Finish posted is not).
|
|
926
|
+
unpublished: [...decided.values()].filter((d) => !d.posted).length,
|
|
585
927
|
warnings,
|
|
586
928
|
signer,
|
|
587
929
|
startCommand,
|
|
930
|
+
// What the targets screen and Finish's header say about the passkey. The Finish routes
|
|
931
|
+
// read the keys again themselves: this is for display only.
|
|
932
|
+
passkey: {
|
|
933
|
+
rpId,
|
|
934
|
+
required: passkeyState.problem !== null || passkeyState.main.length + passkeyState.pending.length > 0,
|
|
935
|
+
keys: [
|
|
936
|
+
...passkeyState.main.map(({ id, label, rpId: host }) => ({
|
|
937
|
+
id,
|
|
938
|
+
label,
|
|
939
|
+
rpId: host,
|
|
940
|
+
pending: false,
|
|
941
|
+
})),
|
|
942
|
+
...passkeyState.pending.map(({ id, label, rpId: host }) => ({
|
|
943
|
+
id,
|
|
944
|
+
label,
|
|
945
|
+
rpId: host,
|
|
946
|
+
pending: true,
|
|
947
|
+
})),
|
|
948
|
+
],
|
|
949
|
+
problem: passkeyState.problem,
|
|
950
|
+
waiting: passkeyPr,
|
|
951
|
+
},
|
|
588
952
|
projects: t.projects.map((p) => {
|
|
589
953
|
const counts = {};
|
|
590
954
|
for (const item of p.results?.items ?? []) {
|
|
@@ -596,11 +960,16 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
|
|
|
596
960
|
return {
|
|
597
961
|
project: p.project,
|
|
598
962
|
problem: p.problem,
|
|
963
|
+
downloading: p.downloading === true,
|
|
964
|
+
// The size of the project's artifact, while it downloads.
|
|
965
|
+
bytes: p.downloading ? (p.bytes ?? null) : null,
|
|
599
966
|
logUrl: p.logUrl,
|
|
600
967
|
counts,
|
|
601
968
|
reviewable,
|
|
602
969
|
decided: mine.length,
|
|
603
970
|
undecided: reviewable - mine.length,
|
|
971
|
+
// Accepts and exclusions an earlier Finish pushed, waiting for a new CI run.
|
|
972
|
+
finished: mine.filter(([, d]) => d.posted && d.decision !== "reject").length,
|
|
604
973
|
notOpened: mine.filter(([, d]) => d.bulk).length,
|
|
605
974
|
acceptable: acceptable(t, p.project),
|
|
606
975
|
local: p.results?.local ?? null,
|
|
@@ -645,21 +1014,244 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
|
|
|
645
1014
|
: body.runId === undefined || (body.runId === t.runId && body.runAttempt === t.runAttempt);
|
|
646
1015
|
const CHANGED = "the capture changed since the page loaded it (a new CI run or attempt): reload the page";
|
|
647
1016
|
|
|
1017
|
+
/**
|
|
1018
|
+
* The decisions a Finish of `t` would apply now: every one but the rejects an earlier Finish
|
|
1019
|
+
* already posted, in a fixed order, and a digest of them that changes when any of them does.
|
|
1020
|
+
* @param {object} t the target
|
|
1021
|
+
* @returns {{ list: { project: string, file: string, decision: string, reason: string | null }[],
|
|
1022
|
+
* digest: string }} the decisions and their digest
|
|
1023
|
+
*/
|
|
1024
|
+
const finishList = (t) => {
|
|
1025
|
+
const list = [...decisionsOf(t)]
|
|
1026
|
+
.filter(([, v]) => !v.posted)
|
|
1027
|
+
.sort(([a], [b]) => (a < b ? -1 : 1))
|
|
1028
|
+
.map(([k, v]) => {
|
|
1029
|
+
const at = k.indexOf("/");
|
|
1030
|
+
return { project: k.slice(0, at), file: k.slice(at + 1), decision: v.decision, reason: v.reason };
|
|
1031
|
+
});
|
|
1032
|
+
return { list, digest: createHash("sha256").update(JSON.stringify(list)).digest("hex") };
|
|
1033
|
+
};
|
|
1034
|
+
const CHANGED_SINCE = "Decisions changed since this sheet opened: check the summary again.";
|
|
1035
|
+
|
|
1036
|
+
// What Finish's sheet states before it runs: what it commits and posts, and the status it sets.
|
|
1037
|
+
// The passkey's challenge comes from POST /api/finish-prepare.
|
|
1038
|
+
const finishPreview = (t) => {
|
|
1039
|
+
const { list, digest } = finishList(t);
|
|
1040
|
+
const s = summary(t);
|
|
1041
|
+
const count = (d) => list.filter((x) => x.decision === d).length;
|
|
1042
|
+
const unloaded = t.projects.filter((p) => !p.results).map((p) => p.project);
|
|
1043
|
+
const undecided = s.projects.reduce((n, p) => n + p.undecided, 0);
|
|
1044
|
+
const notes = list
|
|
1045
|
+
.filter((x) => x.reason !== null && x.decision !== "exclude")
|
|
1046
|
+
.map((x) => ({ decision: x.decision, project: x.project, file: x.file, note: x.reason }));
|
|
1047
|
+
return {
|
|
1048
|
+
accepts: count("accept"),
|
|
1049
|
+
excludes: count("exclude"),
|
|
1050
|
+
rejects: count("reject"),
|
|
1051
|
+
acceptNotes: notes.filter((n) => n.decision === "accept").length,
|
|
1052
|
+
notes,
|
|
1053
|
+
notOpened: s.projects.reduce((n, p) => n + p.notOpened, 0),
|
|
1054
|
+
undecided: s.projects
|
|
1055
|
+
.filter((p) => p.undecided > 0)
|
|
1056
|
+
.map((p) => ({ project: p.project, undecided: p.undecided })),
|
|
1057
|
+
unloaded,
|
|
1058
|
+
status: commitStatus({
|
|
1059
|
+
accepted: count("accept"),
|
|
1060
|
+
rejected: count("reject"),
|
|
1061
|
+
excluded: count("exclude"),
|
|
1062
|
+
undecided,
|
|
1063
|
+
unloaded,
|
|
1064
|
+
}),
|
|
1065
|
+
digest,
|
|
1066
|
+
};
|
|
1067
|
+
};
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* What a Finish of this target applies: its decisions (a reject an earlier Finish posted stays
|
|
1071
|
+
* shown as rejected, never posted again), their digest, the captures, and what is left
|
|
1072
|
+
* undecided or unloaded.
|
|
1073
|
+
* @param {object} t the target
|
|
1074
|
+
* @returns {{ list: object[], digest: string, captures: object, undecided: number,
|
|
1075
|
+
* unloaded: string[] }} the work
|
|
1076
|
+
*/
|
|
1077
|
+
const finishWork = (t) => ({
|
|
1078
|
+
...finishList(t),
|
|
1079
|
+
captures: capturesOf(t),
|
|
1080
|
+
undecided: summary(t).projects.reduce((n, p) => n + p.undecided, 0),
|
|
1081
|
+
unloaded: t.projects.filter((p) => !p.results).map((p) => p.project),
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
const capturesOf = (t) =>
|
|
1085
|
+
Object.fromEntries(
|
|
1086
|
+
t.projects.filter((p) => p.results).map((p) => [p.project, { dir: p.dir, results: p.results }]),
|
|
1087
|
+
);
|
|
1088
|
+
|
|
1089
|
+
/** Thumbnails being made, by the file they are kept in, and whether a tile is waiting for one. */
|
|
1090
|
+
const making = new Map();
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* An image's thumbnail, made in a worker thread unless it is on disk already. A tile waiting
|
|
1094
|
+
* for one (`urgent`) goes ahead of those made in advance, even of its own made in advance.
|
|
1095
|
+
* @param {{ path: string, hash: string, file: string, dir: string }} where the image
|
|
1096
|
+
* @param {boolean} urgent a tile is waiting for it
|
|
1097
|
+
* @returns {Promise<Array<unknown>>} the answer: the PNG, or why not
|
|
1098
|
+
*/
|
|
1099
|
+
function thumbOf(where, urgent) {
|
|
1100
|
+
const kept = join(tmp, "thumbs", `${where.hash}.png`);
|
|
1101
|
+
const was = making.get(kept);
|
|
1102
|
+
if (was && (was.urgent || !urgent)) {
|
|
1103
|
+
return was.done;
|
|
1104
|
+
}
|
|
1105
|
+
const done = (async () => {
|
|
1106
|
+
// Made in advance, it was just found missing: reading here, for a thousand items at once,
|
|
1107
|
+
// would fill Node's file thread pool and stall every other request's file reads.
|
|
1108
|
+
const cached = urgent ? await readFile(kept).catch(() => null) : null;
|
|
1109
|
+
if (cached) {
|
|
1110
|
+
return [200, cached, "image/png"];
|
|
1111
|
+
}
|
|
1112
|
+
let small;
|
|
1113
|
+
try {
|
|
1114
|
+
small = await scaled(async () => {
|
|
1115
|
+
const full = await readImage(where);
|
|
1116
|
+
if (full[0] !== 200) {
|
|
1117
|
+
throw Object.assign(new Error(full[1].error), { answer: full });
|
|
1118
|
+
}
|
|
1119
|
+
return full[1];
|
|
1120
|
+
}, urgent);
|
|
1121
|
+
} catch (err) {
|
|
1122
|
+
if (err.answer) {
|
|
1123
|
+
return err.answer;
|
|
1124
|
+
}
|
|
1125
|
+
throw err;
|
|
1126
|
+
}
|
|
1127
|
+
// Its own temporary name: the advance copy and a tile's may be written at once.
|
|
1128
|
+
const part = `${kept}.${urgent ? "tile" : "ahead"}.tmp`;
|
|
1129
|
+
try {
|
|
1130
|
+
mkdirSync(dirname(kept), { recursive: true });
|
|
1131
|
+
writeFileSync(part, small);
|
|
1132
|
+
renameSync(part, kept);
|
|
1133
|
+
} catch (err) {
|
|
1134
|
+
warnOnce(`could not keep thumbnails in ${dirname(kept)}: ${err.message}`);
|
|
1135
|
+
}
|
|
1136
|
+
return [200, small, "image/png"];
|
|
1137
|
+
})().finally(() => {
|
|
1138
|
+
if (making.get(kept)?.done === done) {
|
|
1139
|
+
making.delete(kept);
|
|
1140
|
+
}
|
|
1141
|
+
});
|
|
1142
|
+
making.set(kept, { urgent, done });
|
|
1143
|
+
return done;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* Makes, in the background, the grid thumbnail of every item of a capture that needs a
|
|
1148
|
+
* decision, so a grid opens with its tiles ready.
|
|
1149
|
+
* @param {string} dir the capture's directory
|
|
1150
|
+
* @param {{ items: object[] }} r its results.json
|
|
1151
|
+
*/
|
|
1152
|
+
function prewarm(dir, r) {
|
|
1153
|
+
for (const item of r.items) {
|
|
1154
|
+
const kind = item.capture ? "capture" : "baseline";
|
|
1155
|
+
const hash = item[kind];
|
|
1156
|
+
if (REVIEWABLE.has(item.status) && hash && !existsSync(join(tmp, "thumbs", `${hash}.png`))) {
|
|
1157
|
+
const own = kind === "capture" || item.baseline === item.capture;
|
|
1158
|
+
const path = own ? join(dir, item.file) : join(dir, "baselines", item.file);
|
|
1159
|
+
thumbOf({ path, hash, file: item.file, dir }, false).catch((err) =>
|
|
1160
|
+
warnOnce(`could not make the thumbnail of ${path}: ${err.message}`),
|
|
1161
|
+
);
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// The unpublished count after a write, so the page's Finish button stays current.
|
|
1167
|
+
const unpublishedOf = (t) => finishList(t).list.length;
|
|
1168
|
+
|
|
1169
|
+
/**
|
|
1170
|
+
* Where a target's image is on this disk, by results.json.
|
|
1171
|
+
* @param {string} id the target
|
|
1172
|
+
* @param {string} name the project
|
|
1173
|
+
* @param {string} kind "capture" or "baseline"
|
|
1174
|
+
* @param {string} file the image's file name
|
|
1175
|
+
* @returns {Promise<Array<unknown> | { path: string, hash: string, file: string, dir: string }>}
|
|
1176
|
+
* where the image is, or the answer to send instead
|
|
1177
|
+
*/
|
|
1178
|
+
async function imageOf(id, name, kind, file) {
|
|
1179
|
+
const { t, p } = await projectOf(id, name);
|
|
1180
|
+
if (!t) {
|
|
1181
|
+
return gone(id);
|
|
1182
|
+
}
|
|
1183
|
+
const item = p?.results.items.find((i) => i.file === file);
|
|
1184
|
+
const hash = item && { capture: item.capture, baseline: item.baseline }[kind];
|
|
1185
|
+
if (!hash) {
|
|
1186
|
+
return [404, { error: "no such image" }];
|
|
1187
|
+
}
|
|
1188
|
+
// A moved item's baseline is its capture's bytes, so the artifact holds only the capture.
|
|
1189
|
+
const own = kind === "capture" || item.baseline === item.capture;
|
|
1190
|
+
const path = own ? join(p.dir, file) : join(p.dir, "baselines", file);
|
|
1191
|
+
return { path, hash, file, dir: p.dir };
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
async function readImage({ path, hash, file, dir }) {
|
|
1195
|
+
const bytes = await readFile(path).catch(() => null);
|
|
1196
|
+
if (!bytes || createHash("sha256").update(bytes).digest("hex") !== hash) {
|
|
1197
|
+
// CI hashed the bytes it uploaded, so the copy on this disk is damaged: drop it,
|
|
1198
|
+
// and the next reload downloads it again.
|
|
1199
|
+
console.error(
|
|
1200
|
+
`visual-review: ${path} does not match results.json: downloading it again on the next reload`,
|
|
1201
|
+
);
|
|
1202
|
+
rmSync(join(dir, "results.json"), { force: true });
|
|
1203
|
+
return [409, { error: `${file}: the downloaded copy is damaged; reload the page to download it again` }];
|
|
1204
|
+
}
|
|
1205
|
+
return [200, bytes, "image/png"];
|
|
1206
|
+
}
|
|
1207
|
+
|
|
648
1208
|
const routes = {
|
|
649
|
-
"GET /api/prs": async () => {
|
|
650
|
-
|
|
651
|
-
|
|
1209
|
+
"GET /api/prs": async (_, __, query) => {
|
|
1210
|
+
// ?cached=1 answers at once (starting the first load if there is none); ?refresh=1
|
|
1211
|
+
// also starts a refresh. Neither waits for GitHub. After a first load that failed, only
|
|
1212
|
+
// ?refresh=1 (the page's Retry) tries again.
|
|
1213
|
+
await restored;
|
|
1214
|
+
const cachedOnly = query?.get("cached") === "1" || query?.get("refresh") === "1";
|
|
1215
|
+
if (cachedOnly) {
|
|
1216
|
+
if (query.get("refresh") === "1" || (!loadedOnce && failure === null)) {
|
|
1217
|
+
refresh().catch((err) => console.error(`visual-review: refresh failed: ${err.message}`));
|
|
1218
|
+
}
|
|
1219
|
+
} else {
|
|
1220
|
+
await refresh();
|
|
1221
|
+
}
|
|
1222
|
+
return [
|
|
1223
|
+
200,
|
|
1224
|
+
{
|
|
1225
|
+
targets: loadedOnce || !cachedOnly ? [...targets.values()].map(summary) : null,
|
|
1226
|
+
warning: listWarnings.join("\n") || null,
|
|
1227
|
+
defaultBranch,
|
|
1228
|
+
updatedAt: refreshedAt || null,
|
|
1229
|
+
now: Date.now(),
|
|
1230
|
+
refreshing: progress,
|
|
1231
|
+
// Why the last refresh got no list at all; a gh call waiting to retry.
|
|
1232
|
+
error: failure,
|
|
1233
|
+
network: networkTrouble(),
|
|
1234
|
+
},
|
|
1235
|
+
];
|
|
652
1236
|
},
|
|
653
|
-
// One target's counts without refetching from GitHub,
|
|
654
|
-
"GET /api/target": async ([id]) => {
|
|
1237
|
+
// One target's counts without refetching from GitHub; with ?finish=1, what Finish would do.
|
|
1238
|
+
"GET /api/target": async ([id], _, query) => {
|
|
655
1239
|
const t = await targetOf(id);
|
|
656
|
-
|
|
1240
|
+
if (!t) {
|
|
1241
|
+
return gone(id);
|
|
1242
|
+
}
|
|
1243
|
+
return [200, query?.get("finish") === "1" ? { ...summary(t), finish: finishPreview(t) } : summary(t)];
|
|
657
1244
|
},
|
|
658
1245
|
"GET /api/pr": async ([id, name]) => {
|
|
659
1246
|
const { t, p } = await projectOf(id, name);
|
|
660
1247
|
if (!t) {
|
|
661
1248
|
return gone(id);
|
|
662
1249
|
}
|
|
1250
|
+
if (t.projects.find((x) => x.project === name)?.downloading) {
|
|
1251
|
+
// The reviewer is waiting for this one: it downloads next. The page asks again.
|
|
1252
|
+
hurry((dir) => basename(dir) === name && dir.startsWith(join(tmp, `${t.runId}-`)));
|
|
1253
|
+
return [202, { downloading: true, target: summary(t), network: networkTrouble(), now: Date.now() }];
|
|
1254
|
+
}
|
|
663
1255
|
if (!p) {
|
|
664
1256
|
return [404, { error: "no such capture" }];
|
|
665
1257
|
}
|
|
@@ -682,32 +1274,16 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
|
|
|
682
1274
|
];
|
|
683
1275
|
},
|
|
684
1276
|
"GET /api/img": async ([id, name, kind, file]) => {
|
|
685
|
-
const
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
const
|
|
691
|
-
if (
|
|
692
|
-
return
|
|
693
|
-
}
|
|
694
|
-
// A moved item's baseline is its capture's bytes, so the artifact holds only the capture.
|
|
695
|
-
const own = kind === "capture" || item.baseline === item.capture;
|
|
696
|
-
const path = own ? join(p.dir, file) : join(p.dir, "baselines", file);
|
|
697
|
-
const bytes = await readFile(path).catch(() => null);
|
|
698
|
-
if (!bytes || createHash("sha256").update(bytes).digest("hex") !== hash) {
|
|
699
|
-
// CI hashed the bytes it uploaded, so the copy on this disk is damaged: drop it,
|
|
700
|
-
// and the next reload downloads it again.
|
|
701
|
-
console.error(
|
|
702
|
-
`visual-review: ${path} does not match results.json: downloading it again on the next reload`,
|
|
703
|
-
);
|
|
704
|
-
rmSync(join(p.dir, "results.json"), { force: true });
|
|
705
|
-
return [
|
|
706
|
-
409,
|
|
707
|
-
{ error: `${file}: the downloaded copy is damaged; reload the page to download it again` },
|
|
708
|
-
];
|
|
1277
|
+
const where = await imageOf(id, name, kind, file);
|
|
1278
|
+
return Array.isArray(where) ? where : readImage(where);
|
|
1279
|
+
},
|
|
1280
|
+
// The same image scaled down for a grid tile, made once and kept by the image's hash.
|
|
1281
|
+
"GET /api/thumb": async ([id, name, kind, file]) => {
|
|
1282
|
+
const where = await imageOf(id, name, kind, file);
|
|
1283
|
+
if (Array.isArray(where)) {
|
|
1284
|
+
return where;
|
|
709
1285
|
}
|
|
710
|
-
return
|
|
1286
|
+
return thumbOf(where, true);
|
|
711
1287
|
},
|
|
712
1288
|
"POST /api/decide": async (_, body) => {
|
|
713
1289
|
const { p, t } = await projectOf(String(body.id), body.project);
|
|
@@ -729,7 +1305,7 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
|
|
|
729
1305
|
update(t, (saved) => {
|
|
730
1306
|
delete saved[key];
|
|
731
1307
|
});
|
|
732
|
-
return [200, { ok: true }];
|
|
1308
|
+
return [200, { ok: true, unpublished: unpublishedOf(t) }];
|
|
733
1309
|
}
|
|
734
1310
|
if (!sameCapture(t, body, item)) {
|
|
735
1311
|
return [409, { error: `${body.file}: ${CHANGED}` }];
|
|
@@ -767,7 +1343,7 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
|
|
|
767
1343
|
update(t, (saved) => {
|
|
768
1344
|
saved[key] = { decision: body.decision, reason, hash: imageHash(item) };
|
|
769
1345
|
});
|
|
770
|
-
return [200, { ok: true }];
|
|
1346
|
+
return [200, { ok: true, unpublished: unpublishedOf(t) }];
|
|
771
1347
|
},
|
|
772
1348
|
"POST /api/accept-all": async (_, body) => {
|
|
773
1349
|
const { p, t } = await projectOf(String(body.id), body.project);
|
|
@@ -804,9 +1380,87 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
|
|
|
804
1380
|
}
|
|
805
1381
|
}
|
|
806
1382
|
});
|
|
807
|
-
return [200, { accepted }];
|
|
1383
|
+
return [200, { accepted, unpublished: unpublishedOf(t) }];
|
|
808
1384
|
},
|
|
809
|
-
"
|
|
1385
|
+
"GET /api/passkeys": async () => {
|
|
1386
|
+
const { main, pending } = await knownKeys();
|
|
1387
|
+
const show = (k, isPending) => ({ id: k.id, rpId: k.rpId, label: k.label ?? null, pending: isPending });
|
|
1388
|
+
return [
|
|
1389
|
+
200,
|
|
1390
|
+
{
|
|
1391
|
+
rpId,
|
|
1392
|
+
keys: [...main.map((k) => show(k, false)), ...pending.map((k) => show(k, true))],
|
|
1393
|
+
required: main.length + pending.length > 0,
|
|
1394
|
+
},
|
|
1395
|
+
];
|
|
1396
|
+
},
|
|
1397
|
+
"POST /api/passkey-challenge": async () => {
|
|
1398
|
+
if (results) {
|
|
1399
|
+
return [403, { error: "a local preview registers no passkey" }];
|
|
1400
|
+
}
|
|
1401
|
+
const now = Date.now();
|
|
1402
|
+
for (const [c, expires] of challenges) {
|
|
1403
|
+
if (expires < now) {
|
|
1404
|
+
challenges.delete(c);
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
const challenge = randomBytes(32).toString("base64url");
|
|
1408
|
+
challenges.set(challenge, now + CHALLENGE_MS);
|
|
1409
|
+
const { main, pending } = await knownKeys();
|
|
1410
|
+
return [
|
|
1411
|
+
200,
|
|
1412
|
+
{
|
|
1413
|
+
challenge,
|
|
1414
|
+
userId: randomBytes(16).toString("base64url"),
|
|
1415
|
+
rpId,
|
|
1416
|
+
known: [...main, ...pending].map((k) => k.id),
|
|
1417
|
+
},
|
|
1418
|
+
];
|
|
1419
|
+
},
|
|
1420
|
+
"POST /api/register": async (_, body) => {
|
|
1421
|
+
const expires = challenges.get(body.challenge);
|
|
1422
|
+
challenges.delete(body.challenge);
|
|
1423
|
+
if (!expires || expires < Date.now()) {
|
|
1424
|
+
return [409, { error: "the registration expired or was already used: press Register passkey again" }];
|
|
1425
|
+
}
|
|
1426
|
+
const why = verifyRegistration(body, { challenge: body.challenge, origin, rpId });
|
|
1427
|
+
if (why) {
|
|
1428
|
+
return [400, { error: `the passkey was not accepted: ${why}` }];
|
|
1429
|
+
}
|
|
1430
|
+
const { main, pending } = await knownKeys();
|
|
1431
|
+
if ([...main, ...pending].some((k) => k.id === body.credentialId)) {
|
|
1432
|
+
return [409, { error: "this passkey is already registered" }];
|
|
1433
|
+
}
|
|
1434
|
+
if (finishing) {
|
|
1435
|
+
return [409, { error: "a Finish is running: register when it ends" }];
|
|
1436
|
+
}
|
|
1437
|
+
const label = typeof body.label === "string" ? body.label.replace(/\s+/g, " ").trim().slice(0, 60) : "";
|
|
1438
|
+
const entry = {
|
|
1439
|
+
id: body.credentialId,
|
|
1440
|
+
publicKey: body.publicKey,
|
|
1441
|
+
rpId,
|
|
1442
|
+
label: label || "passkey",
|
|
1443
|
+
registeredAt: new Date().toISOString(),
|
|
1444
|
+
};
|
|
1445
|
+
let proposed;
|
|
1446
|
+
try {
|
|
1447
|
+
proposed = await proposeKey({ repo, gh, entry, config });
|
|
1448
|
+
} catch (err) {
|
|
1449
|
+
if (err instanceof AcceptError) {
|
|
1450
|
+
return [409, { error: err.message }];
|
|
1451
|
+
}
|
|
1452
|
+
throw err;
|
|
1453
|
+
}
|
|
1454
|
+
writeJson(pendingFile, { version: 1, keys: [...readPending(), entry] });
|
|
1455
|
+
passkeyPr = Number(proposed.pullRequest.split("/").pop()) || passkeyPr;
|
|
1456
|
+
passkeyOpened = { number: passkeyPr, id: entry.id, at: Date.now() };
|
|
1457
|
+
await readPasskeys();
|
|
1458
|
+
console.log(
|
|
1459
|
+
`visual-review: registered passkey ${entry.id}; merge ${proposed.pullRequest} to enforce approvals`,
|
|
1460
|
+
);
|
|
1461
|
+
return [200, { entry, ...proposed }];
|
|
1462
|
+
},
|
|
1463
|
+
"POST /api/finish-prepare": async (_, body) => {
|
|
810
1464
|
const t = await targetOf(String(body.id));
|
|
811
1465
|
if (!t) {
|
|
812
1466
|
return gone(String(body.id));
|
|
@@ -817,18 +1471,103 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
|
|
|
817
1471
|
if (finishing) {
|
|
818
1472
|
return [409, { error: "a Finish is already running" }];
|
|
819
1473
|
}
|
|
820
|
-
const
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
1474
|
+
const { trusted: keys } = await knownKeys();
|
|
1475
|
+
if (keys.length === 0) {
|
|
1476
|
+
approvals.delete(t.id);
|
|
1477
|
+
return [200, { required: false }];
|
|
1478
|
+
}
|
|
1479
|
+
const here = keys.filter((k) => k.rpId === rpId);
|
|
1480
|
+
if (here.length === 0) {
|
|
1481
|
+
const hosts = [...new Set(keys.map((k) => k.rpId))].join(", ");
|
|
1482
|
+
return [
|
|
1483
|
+
409,
|
|
1484
|
+
{
|
|
1485
|
+
error: `no passkey is registered for ${rpId}: serve this page from the host your passkey is for (${hosts}), or register one here`,
|
|
1486
|
+
},
|
|
1487
|
+
];
|
|
1488
|
+
}
|
|
1489
|
+
const work = finishWork(t);
|
|
1490
|
+
let prepared;
|
|
1491
|
+
try {
|
|
1492
|
+
prepared = await prepareRecord({
|
|
1493
|
+
repo,
|
|
1494
|
+
target: { pr: t.pr, branch: t.branch },
|
|
1495
|
+
projects: work.captures,
|
|
1496
|
+
decisions: work.list,
|
|
1497
|
+
now: new Date(),
|
|
1498
|
+
config,
|
|
827
1499
|
});
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
1500
|
+
} catch (err) {
|
|
1501
|
+
if (err instanceof AcceptError) {
|
|
1502
|
+
return [409, { error: err.message }];
|
|
1503
|
+
}
|
|
1504
|
+
throw err;
|
|
1505
|
+
}
|
|
1506
|
+
const challenge = recordHash(prepared.record).toString("base64url");
|
|
1507
|
+
approvals.set(t.id, {
|
|
1508
|
+
challenge,
|
|
1509
|
+
record: prepared.record,
|
|
1510
|
+
work,
|
|
1511
|
+
digest: work.digest,
|
|
1512
|
+
expires: Date.now() + APPROVAL_MS,
|
|
1513
|
+
});
|
|
1514
|
+
return [
|
|
1515
|
+
200,
|
|
1516
|
+
{
|
|
1517
|
+
required: true,
|
|
1518
|
+
challenge,
|
|
1519
|
+
rpId,
|
|
1520
|
+
allowCredentials: here.map((k) => k.id),
|
|
1521
|
+
record: prepared.record,
|
|
1522
|
+
accepts: prepared.accepts,
|
|
1523
|
+
excludes: prepared.excludes,
|
|
1524
|
+
rejects: prepared.rejects,
|
|
1525
|
+
},
|
|
1526
|
+
];
|
|
1527
|
+
},
|
|
1528
|
+
"POST /api/finish": async (_, body) => {
|
|
1529
|
+
const t = await targetOf(String(body.id));
|
|
1530
|
+
if (!t) {
|
|
1531
|
+
return gone(String(body.id));
|
|
1532
|
+
}
|
|
1533
|
+
if (t.local) {
|
|
1534
|
+
return [403, { error: "a local preview has no Finish" }];
|
|
1535
|
+
}
|
|
1536
|
+
if (finishing) {
|
|
1537
|
+
return [409, { error: "a Finish is already running" }];
|
|
1538
|
+
}
|
|
1539
|
+
let work = finishWork(t);
|
|
1540
|
+
// The page's sheet stated what Finish would do; a decision changed since (in another
|
|
1541
|
+
// tab, say) would make that statement wrong.
|
|
1542
|
+
if (body.digest !== undefined && body.digest !== work.digest) {
|
|
1543
|
+
return [409, { error: CHANGED_SINCE }];
|
|
1544
|
+
}
|
|
1545
|
+
let approval = null;
|
|
1546
|
+
const { main, trusted } = await knownKeys();
|
|
1547
|
+
if (trusted.length > 0) {
|
|
1548
|
+
if (!body.approval) {
|
|
1549
|
+
return [400, { error: "this Finish needs your passkey approval (Face ID): press Finish again" }];
|
|
1550
|
+
}
|
|
1551
|
+
const p = approvals.get(t.id);
|
|
1552
|
+
if (!p || p.challenge !== body.challenge || p.expires < Date.now() || p.digest !== work.digest) {
|
|
1553
|
+
return [
|
|
1554
|
+
409,
|
|
1555
|
+
{
|
|
1556
|
+
error: "the approval is stale (made on another tab, expired, or the decisions changed): press Finish again",
|
|
1557
|
+
},
|
|
1558
|
+
];
|
|
1559
|
+
}
|
|
1560
|
+
const record = { ...p.record, approval: body.approval };
|
|
1561
|
+
const why = verifyApproval(record, trusted, { origin });
|
|
1562
|
+
if (why) {
|
|
1563
|
+
return [403, { error: `the approval was refused: ${why}` }];
|
|
1564
|
+
}
|
|
1565
|
+
approvals.delete(t.id);
|
|
1566
|
+
// The decisions the approved record was built from, not a fresh read of the state.
|
|
1567
|
+
work = p.work;
|
|
1568
|
+
approval = { record, pendingKeys: main.length > 0 ? [] : trusted, origin };
|
|
1569
|
+
}
|
|
1570
|
+
const { list, captures, undecided } = work;
|
|
832
1571
|
finishing = true;
|
|
833
1572
|
job = {
|
|
834
1573
|
id: (job?.id ?? 0) + 1,
|
|
@@ -852,8 +1591,9 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
|
|
|
852
1591
|
projects: captures,
|
|
853
1592
|
decisions: list,
|
|
854
1593
|
undecided,
|
|
855
|
-
unloaded:
|
|
1594
|
+
unloaded: work.unloaded,
|
|
856
1595
|
config,
|
|
1596
|
+
...(approval && { approval, now: new Date(approval.record.reviewedAt) }),
|
|
857
1597
|
});
|
|
858
1598
|
return [202, { job }];
|
|
859
1599
|
},
|
|
@@ -869,15 +1609,15 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
|
|
|
869
1609
|
* @param {object} input finish's input
|
|
870
1610
|
*/
|
|
871
1611
|
async function runFinish(j, t, sent, input) {
|
|
872
|
-
//
|
|
873
|
-
//
|
|
1612
|
+
// Marks what Finish published, so no later Finish publishes it again. The accepts and
|
|
1613
|
+
// exclusions it pushed stay shown as finished until a new CI run replaces this capture (their
|
|
1614
|
+
// hash then no longer matches, or the item is unchanged); rejects stay keyed by image hash,
|
|
1615
|
+
// so an unchanged rejected capture on the next CI run still reads as rejected.
|
|
874
1616
|
const clear = (posted) =>
|
|
875
1617
|
update(t, (saved) => {
|
|
876
1618
|
for (const { project, file, decision } of sent) {
|
|
877
1619
|
const k = `${project}/${file}`;
|
|
878
|
-
if (decision !== "reject") {
|
|
879
|
-
delete saved[k];
|
|
880
|
-
} else if (posted && saved[k]) {
|
|
1620
|
+
if (saved[k] && (decision !== "reject" || posted)) {
|
|
881
1621
|
saved[k].posted = true;
|
|
882
1622
|
}
|
|
883
1623
|
}
|
|
@@ -954,9 +1694,14 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
|
|
|
954
1694
|
return body;
|
|
955
1695
|
}
|
|
956
1696
|
|
|
1697
|
+
// The list a previous run of the server kept: read before any request for the list is answered.
|
|
1698
|
+
const restored = warm && !results ? restore() : Promise.resolve();
|
|
957
1699
|
if (warm) {
|
|
958
|
-
// Download the captures now,
|
|
959
|
-
|
|
1700
|
+
// Download the captures now, and again every POLL so that a CI run that finished meanwhile
|
|
1701
|
+
// is on disk before the page first asks for it.
|
|
1702
|
+
const failed = (when) => (err) => console.error(`visual-review: ${when} refresh failed: ${err.message}`);
|
|
1703
|
+
restored.then(() => refresh().catch(failed("startup")));
|
|
1704
|
+
setInterval(() => refresh().catch(failed("background")), POLL).unref();
|
|
960
1705
|
}
|
|
961
1706
|
|
|
962
1707
|
return async (req, res) => {
|
|
@@ -982,7 +1727,11 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
|
|
|
982
1727
|
if (writes && req.headers.origin !== origin) {
|
|
983
1728
|
return send(res, 403, { error: "foreign origin" });
|
|
984
1729
|
}
|
|
985
|
-
const [status, body, type] = await handler(
|
|
1730
|
+
const [status, body, type] = await handler(
|
|
1731
|
+
args,
|
|
1732
|
+
writes ? await readBody(req) : undefined,
|
|
1733
|
+
url.searchParams,
|
|
1734
|
+
);
|
|
986
1735
|
return send(res, status, body, type);
|
|
987
1736
|
} catch (err) {
|
|
988
1737
|
// A malformed request (bad JSON, bad escape, oversized body) is the client's; the rest,
|