@graphty/visual-review 0.2.0 → 0.2.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.
- package/README.md +39 -11
- package/capture/capture.mjs +7 -0
- package/package.json +1 -1
- package/trusted/cli.mjs +1 -0
- package/trusted/gate.mjs +7 -1
- package/trusted/lib/accept.mjs +66 -27
- package/trusted/lib/config.mjs +4 -3
- package/trusted/lib/github.mjs +100 -20
- package/trusted/lib/serve.mjs +444 -110
- package/trusted/page/review.css +19 -0
- package/trusted/page/review.js +326 -97
package/trusted/lib/serve.mjs
CHANGED
|
@@ -6,21 +6,26 @@
|
|
|
6
6
|
* served origin. Images are served only when their file is named by results.json and its bytes
|
|
7
7
|
* hash to the hash results.json gives, so the page shows exactly what CI compared. Decisions
|
|
8
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
|
|
9
|
+
* taken on, so a restart resumes them and a new CI run or attempt shows only those whose image is
|
|
10
|
+
* unchanged. The file is the only copy: every request reads it and every change rewrites it, so a
|
|
11
|
+
* failed write changes nothing, and a decision whose item is missing from this run (a project
|
|
12
|
+
* still capturing, a download that failed) is kept for when it comes back.
|
|
10
13
|
*
|
|
11
14
|
* Finish runs in the background: a large seed takes minutes, longer than a browser (Safari on an
|
|
12
15
|
* 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.
|
|
16
|
+
* step, then its result or error, so a reload finds the running Finish. The job is also written
|
|
17
|
+
* to `<tmp>/state/finish.json`, so a server restarted during a Finish says it was interrupted.
|
|
14
18
|
*/
|
|
15
19
|
|
|
16
20
|
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
17
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
18
22
|
import { readFile } from "node:fs/promises";
|
|
19
23
|
import { dirname, join } from "node:path";
|
|
20
24
|
import { fileURLToPath } from "node:url";
|
|
21
25
|
|
|
22
26
|
import { AcceptError, behindMaster, cleanReason, decisionProblem, finish } from "./accept.mjs";
|
|
23
27
|
import { downloadCaptures, exec, getRun, newestCiRun, openPullRequests, visualJobs } from "./github.mjs";
|
|
28
|
+
import { CONFIG_FILE } from "./config.mjs";
|
|
24
29
|
import { validateResults } from "./results.mjs";
|
|
25
30
|
|
|
26
31
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
@@ -46,6 +51,12 @@ const componentOf = (id) => id.split("--")[0];
|
|
|
46
51
|
|
|
47
52
|
const REVIEWABLE = new Set(["changed", "moved", "new", "unseeded", "removed", "unstable", "failed"]);
|
|
48
53
|
const WRITES = new Set(["decide", "accept-all", "finish"]);
|
|
54
|
+
// How long a page load waits for a run's captures to download before it lists the target as
|
|
55
|
+
// downloading; the download goes on, and a reload picks it up.
|
|
56
|
+
const PATIENCE = 1000;
|
|
57
|
+
// An unknown target id refreshes from GitHub at most this often: a closed pull request's open
|
|
58
|
+
// grid asks for dozens of images at once.
|
|
59
|
+
const UNKNOWN_REFRESH = 60000;
|
|
49
60
|
|
|
50
61
|
/**
|
|
51
62
|
* The image a decision was taken on: the capture, or for a removed item its baseline.
|
|
@@ -124,13 +135,27 @@ async function signingIdentity(repo) {
|
|
|
124
135
|
*/
|
|
125
136
|
export function sessionToken(stateDir) {
|
|
126
137
|
const file = join(stateDir, "token");
|
|
127
|
-
|
|
138
|
+
// An empty file (a full disk on the first start) would be an empty token: write a new one.
|
|
139
|
+
if (!existsSync(file) || readFileSync(file, "utf8").trim() === "") {
|
|
128
140
|
mkdirSync(stateDir, { recursive: true });
|
|
129
141
|
writeFileSync(file, randomBytes(32).toString("base64url"), { mode: 0o600 });
|
|
130
142
|
}
|
|
131
143
|
return readFileSync(file, "utf8").trim();
|
|
132
144
|
}
|
|
133
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Writes a JSON file through a sibling renamed into place, so a kill or a full disk mid-write
|
|
148
|
+
* never leaves half a file.
|
|
149
|
+
* @param {string} file the file
|
|
150
|
+
* @param {unknown} value what to write
|
|
151
|
+
*/
|
|
152
|
+
function writeJson(file, value) {
|
|
153
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
154
|
+
const part = `${file}.tmp`;
|
|
155
|
+
writeFileSync(part, JSON.stringify(value, null, 2));
|
|
156
|
+
renameSync(part, file);
|
|
157
|
+
}
|
|
158
|
+
|
|
134
159
|
/**
|
|
135
160
|
* Reads and validates one project's results.json.
|
|
136
161
|
* @param {string} dir the capture directory
|
|
@@ -167,29 +192,59 @@ async function loadResults(dir) {
|
|
|
167
192
|
* a preview to look at, with no decisions and no Finish
|
|
168
193
|
* @param {string} [options.startCommand] the shell command that starts this server, shown so the
|
|
169
194
|
* owner can restart it from their own shell and sign Finish with their own key
|
|
195
|
+
* @param {boolean} [options.warm] start downloading every target's captures right away
|
|
170
196
|
* @returns {(req: import("node:http").IncomingMessage, res: import("node:http").ServerResponse) => void}
|
|
171
197
|
* the handler, for node:https in the CLI and node:http in the tests
|
|
172
198
|
*/
|
|
173
|
-
export function createApp({ repo, gh, config, tmp, token, origin, masterRun, results, startCommand = null }) {
|
|
199
|
+
export function createApp({ repo, gh, config, tmp, token, origin, masterRun, results, startCommand = null, warm }) {
|
|
174
200
|
const stateDir = join(tmp, "state");
|
|
175
201
|
const { projects, defaultBranch } = config;
|
|
176
202
|
const names = Object.keys(projects);
|
|
177
203
|
/** @type {Map<string, object>} targets by id: a pull request number, or "master" */
|
|
178
204
|
let targets = new Map();
|
|
179
|
-
/**
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
* posted?: true }>>}
|
|
184
|
-
*/
|
|
185
|
-
const decisions = new Map();
|
|
205
|
+
/** What the last refresh could not read from GitHub (the pull request list, a git fetch). */
|
|
206
|
+
let listWarnings = [];
|
|
207
|
+
/** Why a target's saved decisions were set aside, by target id. */
|
|
208
|
+
const stateProblems = new Map();
|
|
186
209
|
let finishing = false;
|
|
210
|
+
const jobFile = join(stateDir, "finish.json");
|
|
187
211
|
/**
|
|
188
212
|
* The newest Finish, kept after it ends so a reload still shows its result.
|
|
189
|
-
* @type {{ id: number, target: string, pr: number | null,
|
|
190
|
-
* result: object | null, error: string | null
|
|
213
|
+
* @type {{ id: number, target: string, pr: number | null, branch: string | null, running: boolean,
|
|
214
|
+
* step: string | null, result: object | null, error: string | null, warnings: string[],
|
|
215
|
+
* interrupted?: true } | null}
|
|
191
216
|
*/
|
|
192
217
|
let job = null;
|
|
218
|
+
try {
|
|
219
|
+
const saved = JSON.parse(readFileSync(jobFile, "utf8"));
|
|
220
|
+
if (saved?.running) {
|
|
221
|
+
const where = saved.branch ?? "the visual/seed-* branch";
|
|
222
|
+
job = {
|
|
223
|
+
...saved,
|
|
224
|
+
running: false,
|
|
225
|
+
step: null,
|
|
226
|
+
interrupted: true,
|
|
227
|
+
warnings: saved.warnings ?? [],
|
|
228
|
+
error:
|
|
229
|
+
`the server stopped while this Finish was at "${saved.step}", so it never ended: check ` +
|
|
230
|
+
`whether ${where} on origin has its commit before pressing Finish again`,
|
|
231
|
+
};
|
|
232
|
+
console.error(`visual-review: ${job.error}`);
|
|
233
|
+
}
|
|
234
|
+
} catch {
|
|
235
|
+
// No Finish ran yet.
|
|
236
|
+
}
|
|
237
|
+
const persist = (j) => {
|
|
238
|
+
try {
|
|
239
|
+
writeJson(jobFile, j);
|
|
240
|
+
} catch (err) {
|
|
241
|
+
const warning = `could not save the Finish job (a restart would not report it): ${err.message}`;
|
|
242
|
+
if (!j.warnings.includes(warning)) {
|
|
243
|
+
j.warnings.push(warning);
|
|
244
|
+
console.error(`visual-review: ${warning}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
};
|
|
193
248
|
let signer = null;
|
|
194
249
|
const busy = (t) => finishing && job?.target === t.id;
|
|
195
250
|
const BUSY = "a Finish is running on this target: wait for it to end";
|
|
@@ -200,35 +255,87 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
|
|
|
200
255
|
return p?.results?.items.find((i) => i.file === key.slice(at + 1));
|
|
201
256
|
};
|
|
202
257
|
const stateFile = (t) => join(stateDir, `${t.id}.json`);
|
|
258
|
+
const warned = new Set();
|
|
259
|
+
const warnOnce = (message) => {
|
|
260
|
+
if (!warned.has(message)) {
|
|
261
|
+
warned.add(message);
|
|
262
|
+
console.error(`visual-review: ${message}`);
|
|
263
|
+
}
|
|
264
|
+
};
|
|
203
265
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
266
|
+
/**
|
|
267
|
+
* A target's saved decisions by `<project>/<file>`, each with the hash of the image it was
|
|
268
|
+
* taken on; `bulk` marks an accept from Accept all that was never opened one by one, `posted`
|
|
269
|
+
* a reject an earlier Finish already commented. A file that is not a decisions file is moved
|
|
270
|
+
* aside, never overwritten, so the owner can still recover what it held.
|
|
271
|
+
* @param {object} t the target
|
|
272
|
+
* @returns {Record<string, { decision: string, reason: string | null, hash: string | null,
|
|
273
|
+
* bulk?: true, posted?: true }>} the decisions
|
|
274
|
+
*/
|
|
275
|
+
const readState = (t) => {
|
|
276
|
+
const file = stateFile(t);
|
|
277
|
+
let saved;
|
|
278
|
+
try {
|
|
279
|
+
saved = JSON.parse(readFileSync(file, "utf8"));
|
|
280
|
+
} catch (err) {
|
|
281
|
+
if (err.code === "ENOENT") {
|
|
282
|
+
return {};
|
|
283
|
+
}
|
|
284
|
+
if (!(err instanceof SyntaxError)) {
|
|
285
|
+
throw err;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (typeof saved !== "object" || saved === null || Array.isArray(saved)) {
|
|
289
|
+
const aside = `${file}.unreadable-${Date.now()}`;
|
|
290
|
+
renameSync(file, aside);
|
|
291
|
+
stateProblems.set(t.id, `the saved decisions were unreadable; the file was moved to ${aside}`);
|
|
292
|
+
console.error(`visual-review: ${file} is not a decisions file: moved it to ${aside}`);
|
|
293
|
+
return {};
|
|
294
|
+
}
|
|
295
|
+
for (const [k, d] of Object.entries(saved)) {
|
|
296
|
+
if (typeof d !== "object" || d === null || typeof d.decision !== "string") {
|
|
297
|
+
warnOnce(`${file}: ignoring the malformed entry ${JSON.stringify(k)}`);
|
|
298
|
+
delete saved[k];
|
|
219
299
|
}
|
|
220
|
-
decisions.set(key, mine);
|
|
221
300
|
}
|
|
222
|
-
return
|
|
301
|
+
return saved;
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Changes a target's saved decisions. The file is read, changed and written in one go, so a
|
|
306
|
+
* failed write changes nothing and a second server on the same directory is not overwritten.
|
|
307
|
+
* ponytail: no lock between processes; two writes in the same millisecond can still race.
|
|
308
|
+
* @param {object} t the target
|
|
309
|
+
* @param {(saved: Record<string, object>) => void} change edits the decisions in place
|
|
310
|
+
*/
|
|
311
|
+
const update = (t, change) => {
|
|
312
|
+
const saved = readState(t);
|
|
313
|
+
change(saved);
|
|
314
|
+
writeJson(stateFile(t), saved);
|
|
223
315
|
};
|
|
224
316
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
317
|
+
/**
|
|
318
|
+
* The decisions that apply to this run: those whose item is in it, with the image the decision
|
|
319
|
+
* was taken on, and still decidable that way. The others stay in the file.
|
|
320
|
+
* @param {object} t the target
|
|
321
|
+
* @returns {Map<string, { decision: string, reason: string | null, bulk?: true, posted?: true }>}
|
|
322
|
+
* by `<project>/<file>`
|
|
323
|
+
*/
|
|
324
|
+
const decisionsOf = (t) => {
|
|
325
|
+
const mine = new Map();
|
|
326
|
+
for (const [k, { hash, ...d }] of Object.entries(readState(t))) {
|
|
327
|
+
const item = itemOf(t, k);
|
|
328
|
+
const project = k.slice(0, k.indexOf("/"));
|
|
329
|
+
if (
|
|
330
|
+
item &&
|
|
331
|
+
imageHash(item) === hash &&
|
|
332
|
+
!decisionProblem(item, d.decision, d.reason ?? null) &&
|
|
333
|
+
(d.decision === "reject" || acceptable(t, project))
|
|
334
|
+
) {
|
|
335
|
+
mine.set(k, d);
|
|
336
|
+
}
|
|
229
337
|
}
|
|
230
|
-
|
|
231
|
-
writeFileSync(stateFile(t), JSON.stringify(out, null, 2));
|
|
338
|
+
return mine;
|
|
232
339
|
};
|
|
233
340
|
|
|
234
341
|
// `problem` is what CI said (the job failed, or no artifact); results.json can add its own.
|
|
@@ -239,23 +346,72 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
|
|
|
239
346
|
|
|
240
347
|
async function build(info, run) {
|
|
241
348
|
const jobs = await visualJobs(gh, run, run.attempt, names);
|
|
242
|
-
const
|
|
349
|
+
const others = [];
|
|
350
|
+
const downloads = downloadCaptures(gh, run, names, tmp, others);
|
|
351
|
+
downloads.catch(() => {}); // A download still running after PATIENCE fails on a later refresh.
|
|
352
|
+
const downloaded = await Promise.race([
|
|
353
|
+
downloads,
|
|
354
|
+
new Promise((resolve) => setTimeout(resolve, PATIENCE, null).unref()),
|
|
355
|
+
]);
|
|
356
|
+
if (!downloaded) {
|
|
357
|
+
return blank(info, "downloading the captures: reload in a moment", run);
|
|
358
|
+
}
|
|
243
359
|
const list = [];
|
|
244
360
|
for (const name of names) {
|
|
245
361
|
const job = jobs[name];
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
362
|
+
const got = downloaded[name];
|
|
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);
|
|
252
374
|
p.logUrl = job?.url ?? run.url;
|
|
253
375
|
list.push(p);
|
|
254
376
|
}
|
|
255
|
-
|
|
377
|
+
const warnings = others.map(
|
|
378
|
+
(p) =>
|
|
379
|
+
`the run captured ${p}, which this server's ${CONFIG_FILE} does not list: serve from a checkout that has it`,
|
|
380
|
+
);
|
|
381
|
+
return { ...info, runId: run.id, runAttempt: run.attempt, runUrl: run.url, projects: list, warnings };
|
|
256
382
|
}
|
|
257
383
|
|
|
258
|
-
|
|
384
|
+
const retryLater = (what, message) => `${what}: ${message.split("\n")[0]}; reload the page to retry`;
|
|
385
|
+
|
|
386
|
+
// A target with no capture to show: GitHub would not give it to us (after gh's retries), CI has
|
|
387
|
+
// not run yet, or its download is still going. The other targets still load.
|
|
388
|
+
const blank = (info, problem, run = null) => ({
|
|
389
|
+
...info,
|
|
390
|
+
runId: run?.id ?? null,
|
|
391
|
+
runAttempt: run?.attempt ?? null,
|
|
392
|
+
runUrl: run?.url ?? null,
|
|
393
|
+
projects: names.map((n) => ({ project: n, dir: null, results: null, problem, logUrl: run?.url ?? null })),
|
|
394
|
+
warnings: [],
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
// A target that loaded before keeps what it showed when a later refresh of it fails.
|
|
398
|
+
const keptOr = (info, err) => {
|
|
399
|
+
const old = targets.get(info.id);
|
|
400
|
+
return old?.runId
|
|
401
|
+
? { ...old, warnings: [retryLater("could not refresh", err.message)] }
|
|
402
|
+
: blank(info, retryLater("failed to load", err.message));
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
// Concurrent callers (the startup refresh, the page's first request) share one refresh.
|
|
406
|
+
let refreshing = null;
|
|
407
|
+
let refreshedAt = 0;
|
|
408
|
+
const refresh = () =>
|
|
409
|
+
(refreshing ??= load().finally(() => {
|
|
410
|
+
refreshing = null;
|
|
411
|
+
refreshedAt = Date.now();
|
|
412
|
+
}));
|
|
413
|
+
|
|
414
|
+
async function load() {
|
|
259
415
|
const next = new Map();
|
|
260
416
|
if (results) {
|
|
261
417
|
const list = await Promise.all(
|
|
@@ -282,31 +438,78 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
|
|
|
282
438
|
});
|
|
283
439
|
}
|
|
284
440
|
} else {
|
|
285
|
-
|
|
441
|
+
// When the list of pull requests cannot be read, the ones listed before stay, and the
|
|
442
|
+
// master seed still loads.
|
|
443
|
+
listWarnings = [];
|
|
444
|
+
const kept = [...targets.values()].filter((t) => t.pr !== null);
|
|
445
|
+
const prs = await openPullRequests(gh).catch((err) => {
|
|
446
|
+
if (!masterRun && kept.length === 0) {
|
|
447
|
+
throw err;
|
|
448
|
+
}
|
|
449
|
+
console.error(`visual-review: pull requests not listed: ${err.message}`);
|
|
450
|
+
const warning = retryLater("could not list the pull requests", err.message);
|
|
451
|
+
listWarnings.push(warning);
|
|
452
|
+
for (const t of kept) {
|
|
453
|
+
next.set(t.id, { ...t, warnings: [warning] });
|
|
454
|
+
}
|
|
455
|
+
return [];
|
|
456
|
+
});
|
|
286
457
|
// Best effort: the default branch and the pull requests' branches, so the badge below
|
|
287
|
-
// sees what accept will see.
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
458
|
+
// sees what accept will see. Skipped while a Finish runs, which fetches and pushes too.
|
|
459
|
+
const fetch = (refs) =>
|
|
460
|
+
exec("git", ["fetch", "-q", "origin", ...refs], {
|
|
461
|
+
cwd: repo,
|
|
462
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
|
463
|
+
});
|
|
464
|
+
const ref = (b) => `+refs/heads/${b}:refs/remotes/origin/${b}`;
|
|
465
|
+
const logFetch = (what) => (err) => {
|
|
466
|
+
console.error(`visual-review: git fetch of ${what} failed: ${err.message}`);
|
|
467
|
+
listWarnings.push(`git fetch of ${what} failed, so "merge master first" may be wrong: ${err.message}`);
|
|
468
|
+
};
|
|
469
|
+
const fetched = (async () => {
|
|
470
|
+
if (finishing) {
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
await fetch([ref(defaultBranch)]).catch(logFetch(defaultBranch));
|
|
474
|
+
// One fetch for every branch; one missing on origin (a fork's, a deleted one) fails
|
|
475
|
+
// them all, so then one at a time.
|
|
476
|
+
if (prs.length > 0) {
|
|
477
|
+
await fetch(prs.map((p) => ref(p.branch))).catch(async () => {
|
|
478
|
+
for (const p of prs) {
|
|
479
|
+
await fetch([ref(p.branch)]).catch(logFetch(`${p.branch} (#${p.number})`));
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
})();
|
|
293
484
|
// Every pull request at once: one after another took about 40 s for 18 of them.
|
|
294
485
|
const built = await Promise.all(
|
|
295
486
|
prs.map(async (pr) => {
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
:
|
|
487
|
+
const info = {
|
|
488
|
+
id: String(pr.number),
|
|
489
|
+
pr: pr.number,
|
|
490
|
+
title: pr.title,
|
|
491
|
+
url: pr.url,
|
|
492
|
+
branch: pr.branch,
|
|
493
|
+
};
|
|
494
|
+
try {
|
|
495
|
+
const run = await newestCiRun(gh, pr.headSha, config);
|
|
496
|
+
return run
|
|
497
|
+
? await build(info, run)
|
|
498
|
+
: blank(info, `waiting for CI on ${pr.headSha.slice(0, 10)}`);
|
|
499
|
+
} catch (err) {
|
|
500
|
+
return keptOr(info, err);
|
|
501
|
+
}
|
|
301
502
|
}),
|
|
302
503
|
);
|
|
303
504
|
await fetched;
|
|
304
|
-
for (const t of built)
|
|
505
|
+
for (const t of built) next.set(t.id, t);
|
|
305
506
|
if (masterRun) {
|
|
306
|
-
const
|
|
507
|
+
const info = { id: "master", pr: null, title: defaultBranch, url: null, branch: null };
|
|
307
508
|
next.set(
|
|
308
509
|
"master",
|
|
309
|
-
await
|
|
510
|
+
await getRun(gh, masterRun)
|
|
511
|
+
.then((run) => build(info, run))
|
|
512
|
+
.catch((err) => keptOr(info, err)),
|
|
310
513
|
);
|
|
311
514
|
}
|
|
312
515
|
}
|
|
@@ -316,24 +519,56 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
|
|
|
316
519
|
t.headSha = first?.headSha ?? null;
|
|
317
520
|
const base = t.pr === null ? t.commit : t.headSha;
|
|
318
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
|
+
));
|
|
319
529
|
t.mergeMasterFirst = false;
|
|
320
530
|
for (const p of t.projects) {
|
|
321
|
-
if (
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
) {
|
|
327
|
-
t.mergeMasterFirst = true;
|
|
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
|
+
}
|
|
328
536
|
}
|
|
329
537
|
}
|
|
330
538
|
}
|
|
331
539
|
signer = await signingIdentity(repo);
|
|
332
540
|
targets = next;
|
|
541
|
+
prune();
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Deletes the downloads of runs no target shows any more (never state/). Not while a Finish
|
|
545
|
+
// reads its captures, nor after a refresh that could not load some target.
|
|
546
|
+
function prune() {
|
|
547
|
+
const runs = new Set([...targets.values()].map((t) => t.runId));
|
|
548
|
+
if (results || finishing || runs.has(null) || !existsSync(tmp)) {
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
for (const d of readdirSync(tmp)) {
|
|
552
|
+
const run = /^(\d+)-\d+$/.exec(d)?.[1];
|
|
553
|
+
if (run && !runs.has(Number(run))) {
|
|
554
|
+
rmSync(join(tmp, d), { recursive: true, force: true });
|
|
555
|
+
}
|
|
556
|
+
}
|
|
333
557
|
}
|
|
334
558
|
|
|
335
559
|
const summary = (t) => {
|
|
336
|
-
const
|
|
560
|
+
const warnings = [...(t.warnings ?? [])];
|
|
561
|
+
let decided = new Map();
|
|
562
|
+
try {
|
|
563
|
+
decided = decisionsOf(t);
|
|
564
|
+
} catch (err) {
|
|
565
|
+
// One unreadable state file never fails the whole list.
|
|
566
|
+
console.error(`visual-review: ${stateFile(t)}: ${err.message}`);
|
|
567
|
+
warnings.push(`could not read the saved decisions: ${err.message}`);
|
|
568
|
+
}
|
|
569
|
+
if (stateProblems.has(t.id)) {
|
|
570
|
+
warnings.push(stateProblems.get(t.id));
|
|
571
|
+
}
|
|
337
572
|
return {
|
|
338
573
|
id: t.id,
|
|
339
574
|
pr: t.pr,
|
|
@@ -347,6 +582,7 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
|
|
|
347
582
|
commit: t.commit,
|
|
348
583
|
headSha: t.headSha,
|
|
349
584
|
mergeMasterFirst: t.mergeMasterFirst,
|
|
585
|
+
warnings,
|
|
350
586
|
signer,
|
|
351
587
|
startCommand,
|
|
352
588
|
projects: t.projects.map((p) => {
|
|
@@ -382,7 +618,7 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
|
|
|
382
618
|
const LOCAL = "is a local preview: nothing is decided on it; only CI captures of a pushed commit are";
|
|
383
619
|
|
|
384
620
|
async function targetOf(id) {
|
|
385
|
-
if (!targets.has(id)) {
|
|
621
|
+
if (!targets.has(id) && (refreshing || Date.now() - refreshedAt > UNKNOWN_REFRESH)) {
|
|
386
622
|
await refresh();
|
|
387
623
|
}
|
|
388
624
|
return targets.get(id);
|
|
@@ -391,21 +627,39 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
|
|
|
391
627
|
async function projectOf(id, name) {
|
|
392
628
|
const t = await targetOf(id);
|
|
393
629
|
const p = t?.projects.find((x) => x.project === name);
|
|
394
|
-
return p?.results ?
|
|
630
|
+
return { t, p: p?.results ? p : undefined };
|
|
395
631
|
}
|
|
396
632
|
|
|
633
|
+
const gone = (id) => [
|
|
634
|
+
404,
|
|
635
|
+
{
|
|
636
|
+
error: `${id === "master" ? defaultBranch : `#${id}`} is not listed any more (closed, or it could not be loaded): reload the list`,
|
|
637
|
+
},
|
|
638
|
+
];
|
|
639
|
+
|
|
640
|
+
// Whether a write is about the capture the page shows: `hash` (the image), or `runId` and
|
|
641
|
+
// `runAttempt`. A page that sends neither is not checked.
|
|
642
|
+
const sameCapture = (t, body, item) =>
|
|
643
|
+
body.hash !== undefined
|
|
644
|
+
? body.hash === imageHash(item)
|
|
645
|
+
: body.runId === undefined || (body.runId === t.runId && body.runAttempt === t.runAttempt);
|
|
646
|
+
const CHANGED = "the capture changed since the page loaded it (a new CI run or attempt): reload the page";
|
|
647
|
+
|
|
397
648
|
const routes = {
|
|
398
649
|
"GET /api/prs": async () => {
|
|
399
650
|
await refresh();
|
|
400
|
-
return [200, { targets: [...targets.values()].map(summary) }];
|
|
651
|
+
return [200, { targets: [...targets.values()].map(summary), warning: listWarnings.join("\n") || null }];
|
|
401
652
|
},
|
|
402
653
|
// One target's counts without refetching from GitHub, for Finish's confirmation.
|
|
403
654
|
"GET /api/target": async ([id]) => {
|
|
404
655
|
const t = await targetOf(id);
|
|
405
|
-
return t ? [200, summary(t)] :
|
|
656
|
+
return t ? [200, summary(t)] : gone(id);
|
|
406
657
|
},
|
|
407
658
|
"GET /api/pr": async ([id, name]) => {
|
|
408
659
|
const { t, p } = await projectOf(id, name);
|
|
660
|
+
if (!t) {
|
|
661
|
+
return gone(id);
|
|
662
|
+
}
|
|
409
663
|
if (!p) {
|
|
410
664
|
return [404, { error: "no such capture" }];
|
|
411
665
|
}
|
|
@@ -428,7 +682,10 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
|
|
|
428
682
|
];
|
|
429
683
|
},
|
|
430
684
|
"GET /api/img": async ([id, name, kind, file]) => {
|
|
431
|
-
const { p } = await projectOf(id, name);
|
|
685
|
+
const { t, p } = await projectOf(id, name);
|
|
686
|
+
if (!t) {
|
|
687
|
+
return gone(id);
|
|
688
|
+
}
|
|
432
689
|
const item = p?.results.items.find((i) => i.file === file);
|
|
433
690
|
const hash = item && { capture: item.capture, baseline: item.baseline }[kind];
|
|
434
691
|
if (!hash) {
|
|
@@ -436,14 +693,27 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
|
|
|
436
693
|
}
|
|
437
694
|
// A moved item's baseline is its capture's bytes, so the artifact holds only the capture.
|
|
438
695
|
const own = kind === "capture" || item.baseline === item.capture;
|
|
439
|
-
const
|
|
696
|
+
const path = own ? join(p.dir, file) : join(p.dir, "baselines", file);
|
|
697
|
+
const bytes = await readFile(path).catch(() => null);
|
|
440
698
|
if (!bytes || createHash("sha256").update(bytes).digest("hex") !== hash) {
|
|
441
|
-
|
|
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
|
+
];
|
|
442
709
|
}
|
|
443
710
|
return [200, bytes, "image/png"];
|
|
444
711
|
},
|
|
445
712
|
"POST /api/decide": async (_, body) => {
|
|
446
713
|
const { p, t } = await projectOf(String(body.id), body.project);
|
|
714
|
+
if (!t) {
|
|
715
|
+
return gone(String(body.id));
|
|
716
|
+
}
|
|
447
717
|
const item = p?.results.items.find((i) => i.file === body.file);
|
|
448
718
|
if (!item) {
|
|
449
719
|
return [404, { error: "no such item" }];
|
|
@@ -456,8 +726,22 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
|
|
|
456
726
|
return [403, { error: `${body.project} ${LOCAL}` }];
|
|
457
727
|
}
|
|
458
728
|
if (body.decision === null) {
|
|
459
|
-
|
|
460
|
-
|
|
729
|
+
update(t, (saved) => {
|
|
730
|
+
delete saved[key];
|
|
731
|
+
});
|
|
732
|
+
return [200, { ok: true }];
|
|
733
|
+
}
|
|
734
|
+
if (!sameCapture(t, body, item)) {
|
|
735
|
+
return [409, { error: `${body.file}: ${CHANGED}` }];
|
|
736
|
+
}
|
|
737
|
+
if (body.opened === true) {
|
|
738
|
+
// Opening an item Accept all decided marks it opened. It never decides anything,
|
|
739
|
+
// so a stale page cannot bring back a decision undone or finished meanwhile.
|
|
740
|
+
if (decisionsOf(t).get(key)?.bulk) {
|
|
741
|
+
update(t, (saved) => {
|
|
742
|
+
delete saved[key].bulk;
|
|
743
|
+
});
|
|
744
|
+
}
|
|
461
745
|
return [200, { ok: true }];
|
|
462
746
|
}
|
|
463
747
|
if (body.decision !== "reject" && !acceptable(t, body.project)) {
|
|
@@ -480,15 +764,22 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
|
|
|
480
764
|
const done = { accept: "accepted", reject: "rejected", exclude: "excluded" }[before.decision];
|
|
481
765
|
return [409, { error: `${body.file} is already ${done}: Undo it first to change it` }];
|
|
482
766
|
}
|
|
483
|
-
|
|
484
|
-
|
|
767
|
+
update(t, (saved) => {
|
|
768
|
+
saved[key] = { decision: body.decision, reason, hash: imageHash(item) };
|
|
769
|
+
});
|
|
485
770
|
return [200, { ok: true }];
|
|
486
771
|
},
|
|
487
772
|
"POST /api/accept-all": async (_, body) => {
|
|
488
773
|
const { p, t } = await projectOf(String(body.id), body.project);
|
|
774
|
+
if (!t) {
|
|
775
|
+
return gone(String(body.id));
|
|
776
|
+
}
|
|
489
777
|
if (!p) {
|
|
490
778
|
return [404, { error: "no such capture" }];
|
|
491
779
|
}
|
|
780
|
+
if (!sameCapture(t, { runId: body.runId, runAttempt: body.runAttempt }, null)) {
|
|
781
|
+
return [409, { error: CHANGED }];
|
|
782
|
+
}
|
|
492
783
|
if (busy(t)) {
|
|
493
784
|
return [409, { error: BUSY }];
|
|
494
785
|
}
|
|
@@ -504,20 +795,21 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
|
|
|
504
795
|
const inScope = (item) => typeof body.component !== "string" || componentOf(item.id) === body.component;
|
|
505
796
|
const mine = decisionsOf(t);
|
|
506
797
|
let accepted = 0;
|
|
507
|
-
|
|
508
|
-
const
|
|
509
|
-
|
|
510
|
-
mine.
|
|
511
|
-
|
|
798
|
+
update(t, (saved) => {
|
|
799
|
+
for (const item of p.results.items) {
|
|
800
|
+
const key = `${body.project}/${item.file}`;
|
|
801
|
+
if (inScope(item) && !mine.has(key) && !decisionProblem(item, "accept", null)) {
|
|
802
|
+
saved[key] = { decision: "accept", reason: null, bulk: true, hash: imageHash(item) };
|
|
803
|
+
accepted++;
|
|
804
|
+
}
|
|
512
805
|
}
|
|
513
|
-
}
|
|
514
|
-
save(t);
|
|
806
|
+
});
|
|
515
807
|
return [200, { accepted }];
|
|
516
808
|
},
|
|
517
809
|
"POST /api/finish": async (_, body) => {
|
|
518
810
|
const t = await targetOf(String(body.id));
|
|
519
811
|
if (!t) {
|
|
520
|
-
return
|
|
812
|
+
return gone(String(body.id));
|
|
521
813
|
}
|
|
522
814
|
if (t.local) {
|
|
523
815
|
return [403, { error: "a local preview has no Finish" }];
|
|
@@ -542,18 +834,25 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
|
|
|
542
834
|
id: (job?.id ?? 0) + 1,
|
|
543
835
|
target: t.id,
|
|
544
836
|
pr: t.pr,
|
|
837
|
+
branch: t.branch,
|
|
545
838
|
running: true,
|
|
546
839
|
step: "starting",
|
|
547
840
|
result: null,
|
|
548
841
|
error: null,
|
|
842
|
+
warnings: [],
|
|
549
843
|
};
|
|
550
|
-
|
|
844
|
+
persist(job);
|
|
845
|
+
console.log(
|
|
846
|
+
`visual-review: Finish of ${finishLabel(t)} started: ${list.length} decisions, ${undecided} undecided`,
|
|
847
|
+
);
|
|
848
|
+
runFinish(job, t, list, {
|
|
551
849
|
repo,
|
|
552
850
|
gh,
|
|
553
851
|
target: { pr: t.pr, branch: t.branch },
|
|
554
852
|
projects: captures,
|
|
555
853
|
decisions: list,
|
|
556
854
|
undecided,
|
|
855
|
+
unloaded: t.projects.filter((p) => !p.results).map((p) => p.project),
|
|
557
856
|
config,
|
|
558
857
|
});
|
|
559
858
|
return [202, { job }];
|
|
@@ -562,47 +861,74 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
|
|
|
562
861
|
};
|
|
563
862
|
|
|
564
863
|
/**
|
|
565
|
-
* Runs one Finish to its end, recording its steps and outcome on `j
|
|
864
|
+
* Runs one Finish to its end, recording its steps and outcome on `j` (and in the log). It
|
|
865
|
+
* never throws: nothing awaits it.
|
|
566
866
|
* @param {object} j the job
|
|
567
867
|
* @param {object} t the target
|
|
568
|
-
* @param {
|
|
868
|
+
* @param {{ project: string, file: string, decision: string }[]} sent the decisions it applies
|
|
569
869
|
* @param {object} input finish's input
|
|
570
870
|
*/
|
|
571
|
-
async function runFinish(j, t,
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
871
|
+
async function runFinish(j, t, sent, input) {
|
|
872
|
+
// Drops the accepts and exclusions Finish pushed; rejects stay, keyed by image hash, so an
|
|
873
|
+
// unchanged rejected capture on the next CI run still reads as rejected, not undecided.
|
|
874
|
+
const clear = (posted) =>
|
|
875
|
+
update(t, (saved) => {
|
|
876
|
+
for (const { project, file, decision } of sent) {
|
|
877
|
+
const k = `${project}/${file}`;
|
|
878
|
+
if (decision !== "reject") {
|
|
879
|
+
delete saved[k];
|
|
880
|
+
} else if (posted && saved[k]) {
|
|
881
|
+
saved[k].posted = true;
|
|
882
|
+
}
|
|
581
883
|
}
|
|
884
|
+
});
|
|
885
|
+
try {
|
|
886
|
+
j.result = await finish({
|
|
887
|
+
...input,
|
|
888
|
+
progress: (step) => {
|
|
889
|
+
j.step = step;
|
|
890
|
+
persist(j);
|
|
891
|
+
},
|
|
892
|
+
});
|
|
893
|
+
try {
|
|
894
|
+
clear(true);
|
|
895
|
+
} catch (err) {
|
|
896
|
+
// What was pushed and posted stands; the decisions it applied are still saved.
|
|
897
|
+
j.warnings.push(`pushed and posted, but the decisions could not be cleared: ${err.message}`);
|
|
898
|
+
console.error(`visual-review: Finish of ${finishLabel(t)}: ${j.warnings.at(-1)}`);
|
|
582
899
|
}
|
|
583
|
-
|
|
900
|
+
const r = j.result;
|
|
901
|
+
const status = r.statusError ? `; status not posted: ${r.statusError}` : "";
|
|
902
|
+
console.log(
|
|
903
|
+
`visual-review: Finish of ${finishLabel(t)} done: commit ${r.commit ?? "none"}, ${r.rejects} rejects${status}`,
|
|
904
|
+
);
|
|
584
905
|
} catch (err) {
|
|
585
|
-
if (err instanceof AcceptError && err.committed) {
|
|
906
|
+
if (err instanceof AcceptError && err.committed && !err.pullRequestMissing) {
|
|
586
907
|
// The accepts are on the branch; keep only the rejects, so Finish again only comments.
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
}
|
|
908
|
+
try {
|
|
909
|
+
clear(false);
|
|
910
|
+
} catch (e) {
|
|
911
|
+
j.warnings.push(`the accepts pushed could not be cleared from the decisions: ${e.message}`);
|
|
912
|
+
console.error(`visual-review: Finish of ${finishLabel(t)}: ${j.warnings.at(-1)}`);
|
|
591
913
|
}
|
|
592
|
-
save(t);
|
|
593
914
|
}
|
|
594
|
-
|
|
915
|
+
// Refused before anything ran, the message says it all; later, it names the step.
|
|
916
|
+
j.error = ["starting", "checking"].includes(j.step) ? err.message : `${j.step}: ${err.message}`;
|
|
917
|
+
console.error(`visual-review: Finish of ${finishLabel(t)} failed: ${j.error}`);
|
|
595
918
|
} finally {
|
|
596
919
|
j.running = false;
|
|
597
920
|
j.step = null;
|
|
598
921
|
finishing = false;
|
|
922
|
+
persist(j);
|
|
599
923
|
}
|
|
600
924
|
}
|
|
601
925
|
|
|
926
|
+
const finishLabel = (t) => (t.pr === null ? "the master seed" : `#${t.pr}`);
|
|
927
|
+
|
|
602
928
|
const tokenOk = (given) => {
|
|
603
929
|
const a = Buffer.from(String(given ?? ""));
|
|
604
930
|
const b = Buffer.from(token);
|
|
605
|
-
return a.length === b.length && timingSafeEqual(a, b);
|
|
931
|
+
return b.length > 0 && a.length === b.length && timingSafeEqual(a, b);
|
|
606
932
|
};
|
|
607
933
|
|
|
608
934
|
function send(res, status, body, type = "application/json") {
|
|
@@ -628,6 +954,11 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
|
|
|
628
954
|
return body;
|
|
629
955
|
}
|
|
630
956
|
|
|
957
|
+
if (warm) {
|
|
958
|
+
// Download the captures now, so they are on disk before the page first asks.
|
|
959
|
+
refresh().catch((err) => console.error(`visual-review: startup refresh failed: ${err.message}`));
|
|
960
|
+
}
|
|
961
|
+
|
|
631
962
|
return async (req, res) => {
|
|
632
963
|
try {
|
|
633
964
|
const url = new URL(req.url, origin);
|
|
@@ -657,6 +988,9 @@ export function createApp({ repo, gh, config, tmp, token, origin, masterRun, res
|
|
|
657
988
|
// A malformed request (bad JSON, bad escape, oversized body) is the client's; the rest,
|
|
658
989
|
// gh and git failures included, is ours.
|
|
659
990
|
const client = err instanceof SyntaxError || err instanceof URIError || err.message.startsWith("body ");
|
|
991
|
+
if (!client) {
|
|
992
|
+
console.error(`visual-review: ${req.method} ${req.url} failed: ${err.message}`);
|
|
993
|
+
}
|
|
660
994
|
return send(res, client ? 400 : 500, { error: err.message });
|
|
661
995
|
}
|
|
662
996
|
};
|