@graphty/visual-review 0.1.3 → 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 +74 -32
- package/capture/capture.mjs +11 -1
- package/package.json +1 -1
- package/templates/visual-review.yml +4 -3
- package/trusted/cli.mjs +1 -0
- package/trusted/gate.mjs +68 -33
- package/trusted/lib/accept.mjs +67 -28
- package/trusted/lib/config.mjs +7 -3
- package/trusted/lib/github.mjs +100 -20
- package/trusted/lib/serve.mjs +445 -111
- package/trusted/page/review.css +19 -0
- package/trusted/page/review.js +341 -116
package/trusted/lib/accept.mjs
CHANGED
|
@@ -26,16 +26,19 @@ import { isLfsPointer } from "./compare.mjs";
|
|
|
26
26
|
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
27
27
|
|
|
28
28
|
/** The statuses an item can be accepted or rejected in; unstable and failed are only excluded. */
|
|
29
|
-
const DECIDABLE = new Set(["changed", "moved", "new", "removed"]);
|
|
29
|
+
const DECIDABLE = new Set(["changed", "moved", "new", "unseeded", "removed"]);
|
|
30
30
|
const EXCLUDABLE = new Set([...DECIDABLE, "unstable", "failed"]);
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
33
|
* Refused decisions and failed git commands; the message is shown to the owner as is.
|
|
34
34
|
* `committed` is set when the accepts were already pushed and only the reject comment failed,
|
|
35
|
-
* so the caller drops the accepts and keeps the rejects for a retry that only comments.
|
|
35
|
+
* so the caller drops the accepts and keeps the rejects for a retry that only comments. On
|
|
36
|
+
* master, `pullRequestMissing` is set too when opening the seed's pull request failed: the caller
|
|
37
|
+
* keeps the accepts, and the retry opens the pull request for the branch already pushed.
|
|
36
38
|
*/
|
|
37
39
|
export class AcceptError extends Error {
|
|
38
40
|
committed = null;
|
|
41
|
+
pullRequestMissing = false;
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
/**
|
|
@@ -48,6 +51,7 @@ export class AcceptError extends Error {
|
|
|
48
51
|
* with it, so the hooks path points nowhere. Signing is left as the repository configures it, so
|
|
49
52
|
* the commit carries the owner's identity and signature. GIT_LFS_SKIP_SMUDGE keeps the worktree's
|
|
50
53
|
* checkout from downloading every baseline image: untouched baselines stay pointer files there.
|
|
54
|
+
* GIT_TERMINAL_PROMPT=0 makes a credential prompt fail instead of waiting on a terminal.
|
|
51
55
|
* @param {string} cwd the repository or worktree
|
|
52
56
|
* @param {string[]} args git's arguments
|
|
53
57
|
* @param {string} [input] stdin
|
|
@@ -57,7 +61,7 @@ const git = (cwd, args, input) =>
|
|
|
57
61
|
exec("git", ["-c", "core.hooksPath=/dev/null", ...args], {
|
|
58
62
|
cwd,
|
|
59
63
|
input,
|
|
60
|
-
env: { ...process.env, HUSKY: "0", GIT_LFS_SKIP_SMUDGE: "1" },
|
|
64
|
+
env: { ...process.env, HUSKY: "0", GIT_LFS_SKIP_SMUDGE: "1", GIT_TERMINAL_PROMPT: "0" },
|
|
61
65
|
});
|
|
62
66
|
|
|
63
67
|
/** Images per `git lfs push --object-id`, so a large seed reports its upload as it goes. */
|
|
@@ -185,6 +189,7 @@ function check(projects, decisions) {
|
|
|
185
189
|
* @param {{ project: string, file: string, decision: string, reason: string | null }[]} input.decisions
|
|
186
190
|
* what the owner decided: accept, reject or exclude (checked here)
|
|
187
191
|
* @param {number} [input.undecided] how many reviewable items are left undecided, for the status
|
|
192
|
+
* @param {string[]} [input.unloaded] the projects whose capture did not load, so nobody reviewed them
|
|
188
193
|
* @param {Date} [input.now] the review time
|
|
189
194
|
* @param {(step: string) => void} [input.progress] told each step as it starts, for the page
|
|
190
195
|
* @param {ReturnType<typeof import("./config.mjs").normalizeConfig>} input.config the settings
|
|
@@ -200,6 +205,7 @@ export async function finish({
|
|
|
200
205
|
projects,
|
|
201
206
|
decisions,
|
|
202
207
|
undecided = 0,
|
|
208
|
+
unloaded = [],
|
|
203
209
|
now = new Date(),
|
|
204
210
|
progress = () => {},
|
|
205
211
|
config,
|
|
@@ -220,12 +226,22 @@ export async function finish({
|
|
|
220
226
|
({ commit, branch } = await commitAccepts({ repo, target, accepts, first, now, progress, config }));
|
|
221
227
|
if (isMaster) {
|
|
222
228
|
progress("opening the pull request");
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
+
try {
|
|
230
|
+
pullRequest = await createPullRequest(gh, {
|
|
231
|
+
title: `${config.commitPrefix}: seed visual baselines`,
|
|
232
|
+
head: branch,
|
|
233
|
+
base: config.defaultBranch,
|
|
234
|
+
body: seedBody(first, accepts, rejects, config.defaultBranch),
|
|
235
|
+
});
|
|
236
|
+
} catch (err) {
|
|
237
|
+
const e = new AcceptError(
|
|
238
|
+
`${branch} was pushed as ${commit.slice(0, 10)}, but opening its pull request failed: ` +
|
|
239
|
+
`${err.message}. Press Finish again to open it.`,
|
|
240
|
+
);
|
|
241
|
+
e.committed = commit;
|
|
242
|
+
e.pullRequestMissing = true;
|
|
243
|
+
throw e;
|
|
244
|
+
}
|
|
229
245
|
}
|
|
230
246
|
}
|
|
231
247
|
if (rejects.length > 0) {
|
|
@@ -259,10 +275,10 @@ export async function finish({
|
|
|
259
275
|
// none. A failure here does not undo what was pushed and posted; the page shows it.
|
|
260
276
|
const accepted = accepts.filter((a) => a.decision === "accept").length;
|
|
261
277
|
const excluded = accepts.length - accepted;
|
|
262
|
-
const state = rejects.length > 0 ? "failure" : undecided > 0 ? "pending" : "success";
|
|
278
|
+
const state = rejects.length > 0 ? "failure" : undecided > 0 || unloaded.length > 0 ? "pending" : "success";
|
|
263
279
|
const status =
|
|
264
280
|
`Reviewed: ${accepted} accepted, ${rejects.length} rejected, ${excluded} excluded, ` +
|
|
265
|
-
`${undecided} left undecided`;
|
|
281
|
+
`${undecided} left undecided${unloaded.length > 0 ? `, not loaded: ${unloaded.join(", ")}` : ""}`;
|
|
266
282
|
let statusError = null;
|
|
267
283
|
progress("posting the status");
|
|
268
284
|
try {
|
|
@@ -287,7 +303,11 @@ export async function finish({
|
|
|
287
303
|
*/
|
|
288
304
|
async function commitAccepts({ repo, target, accepts, first, now, progress, config }) {
|
|
289
305
|
const { baselines, defaultBranch } = config;
|
|
290
|
-
|
|
306
|
+
// Finish fetches into refs of its own, never the remote-tracking refs a page reload fetches
|
|
307
|
+
// into at the same time (two fetches of one ref fail on its lock).
|
|
308
|
+
const own = (b) => `refs/visual-review/origin/${b}`;
|
|
309
|
+
const fetch = (b) => git(repo, ["fetch", "-q", "--no-write-fetch-head", "origin", `+refs/heads/${b}:${own(b)}`]);
|
|
310
|
+
const tracking = own(defaultBranch);
|
|
291
311
|
const isMaster = target.pr === null;
|
|
292
312
|
const lfs = await lfsProblem(repo);
|
|
293
313
|
if (lfs) {
|
|
@@ -319,19 +339,27 @@ async function commitAccepts({ repo, target, accepts, first, now, progress, conf
|
|
|
319
339
|
}
|
|
320
340
|
}
|
|
321
341
|
|
|
322
|
-
await
|
|
342
|
+
await fetch(defaultBranch);
|
|
323
343
|
if (isMaster) {
|
|
324
344
|
if ((await git(repo, ["ls-remote", "--heads", "origin", branch])) !== "") {
|
|
345
|
+
// A seed this tool pushed whose pull request failed to open is used as it is.
|
|
346
|
+
// ponytail: assumes the decisions did not change since that push; compare the trees if
|
|
347
|
+
// they can.
|
|
348
|
+
await fetch(branch);
|
|
349
|
+
const [subject, parent] = (await git(repo, ["log", "-1", "--format=%s%n%P", own(branch)])).split("\n");
|
|
350
|
+
if (subject === `${config.commitPrefix}: seed visual baselines` && parent === base) {
|
|
351
|
+
return { commit: await git(repo, ["rev-parse", own(branch)]), branch };
|
|
352
|
+
}
|
|
325
353
|
throw new AcceptError(`${branch} already exists on origin: merge or delete it first`);
|
|
326
354
|
}
|
|
327
355
|
} else {
|
|
328
|
-
await
|
|
329
|
-
if ((await git(repo, ["rev-parse",
|
|
356
|
+
await fetch(branch);
|
|
357
|
+
if ((await git(repo, ["rev-parse", own(branch)])) !== base) {
|
|
330
358
|
throw new AcceptError("capture is stale, wait for CI: the branch has moved past the captured head");
|
|
331
359
|
}
|
|
332
360
|
}
|
|
333
361
|
for (const project of new Set(accepts.map((a) => a.project))) {
|
|
334
|
-
if (await behindMaster(repo, base, project, config)) {
|
|
362
|
+
if (await behindMaster(repo, base, project, config, tracking)) {
|
|
335
363
|
throw new AcceptError(
|
|
336
364
|
`merge ${defaultBranch} into the branch first: ${defaultBranch} has newer ${project} baselines`,
|
|
337
365
|
);
|
|
@@ -406,12 +434,22 @@ async function commitAccepts({ repo, target, accepts, first, now, progress, conf
|
|
|
406
434
|
progress("uploading images to LFS (checking the commit has them all)");
|
|
407
435
|
await git(tree, ["lfs", "push", "origin", "HEAD"]);
|
|
408
436
|
progress("pushing");
|
|
409
|
-
await git(tree, ["push", "-q", "--no-verify", "origin", `HEAD:refs/heads/${branch}`])
|
|
437
|
+
await git(tree, ["push", "-q", "--no-verify", "origin", `HEAD:refs/heads/${branch}`]).catch((err) => {
|
|
438
|
+
// git's own advice ("git pull") is wrong here: the capture no longer matches the branch.
|
|
439
|
+
throw /\[rejected\].*\((fetch first|non-fast-forward)\)/.test(err.message)
|
|
440
|
+
? new AcceptError(
|
|
441
|
+
"capture is stale, wait for CI: the branch moved past the captured head while Finish ran; nothing was pushed",
|
|
442
|
+
)
|
|
443
|
+
: err;
|
|
444
|
+
});
|
|
410
445
|
return { commit: await git(tree, ["rev-parse", "HEAD"]), branch };
|
|
411
446
|
} catch (err) {
|
|
412
447
|
throw err instanceof AcceptError ? err : new AcceptError(err.message);
|
|
413
448
|
} finally {
|
|
414
|
-
|
|
449
|
+
// A cleanup failure must not hide what was pushed; the next Finish removes the tree.
|
|
450
|
+
await removeWorktree(repo, tree).catch((err) =>
|
|
451
|
+
console.error(`visual-review: could not remove the accept worktree ${tree}: ${err.message}`),
|
|
452
|
+
);
|
|
415
453
|
}
|
|
416
454
|
}
|
|
417
455
|
|
|
@@ -421,17 +459,17 @@ async function commitAccepts({ repo, target, accepts, first, now, progress, conf
|
|
|
421
459
|
* @param {string} head the captured head
|
|
422
460
|
* @param {string} project the project id
|
|
423
461
|
* @param {{ defaultBranch: string, baselines: string }} config the settings
|
|
462
|
+
* @param {string} [tracking] the fetched default branch
|
|
424
463
|
* @returns {Promise<boolean>} true when the branch must merge the default branch before an accept
|
|
425
464
|
*/
|
|
426
|
-
export async function behindMaster(
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
]);
|
|
465
|
+
export async function behindMaster(
|
|
466
|
+
repo,
|
|
467
|
+
head,
|
|
468
|
+
project,
|
|
469
|
+
{ defaultBranch, baselines },
|
|
470
|
+
tracking = `refs/remotes/origin/${defaultBranch}`,
|
|
471
|
+
) {
|
|
472
|
+
const newest = await git(repo, ["log", "-1", "--format=%H", tracking, "--", `${baselines}/${project}/`]);
|
|
435
473
|
return newest !== "" && !(await gitOk(repo, ["merge-base", "--is-ancestor", newest, head]));
|
|
436
474
|
}
|
|
437
475
|
|
|
@@ -487,7 +525,8 @@ async function put(path, data) {
|
|
|
487
525
|
|
|
488
526
|
async function removeWorktree(repo, tree) {
|
|
489
527
|
if (existsSync(tree)) {
|
|
490
|
-
|
|
528
|
+
// Twice: a server killed during `worktree add` leaves the tree locked ("initializing").
|
|
529
|
+
await git(repo, ["worktree", "remove", "--force", "--force", tree]);
|
|
491
530
|
}
|
|
492
531
|
await git(repo, ["worktree", "prune"]);
|
|
493
532
|
}
|
package/trusted/lib/config.mjs
CHANGED
|
@@ -21,8 +21,9 @@ const DEFAULTS = {
|
|
|
21
21
|
issueLabels: ["bug"],
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
-
// Project ids name artifacts, jobs, directories and regular expressions, so they stay plain
|
|
25
|
-
|
|
24
|
+
// Project ids name artifacts, jobs, directories and regular expressions, so they stay plain; and
|
|
25
|
+
// results.json allows only these.
|
|
26
|
+
const PROJECT_ID = /^[a-z0-9][a-z0-9-]*$/;
|
|
26
27
|
// A path inside the repository: relative, no "..", no backslashes.
|
|
27
28
|
const REPO_PATH = /^(?!\/)(?!.*(^|\/)\.\.(\/|$))[^\\]+$/;
|
|
28
29
|
|
|
@@ -72,7 +73,7 @@ export function normalizeConfig(input) {
|
|
|
72
73
|
for (const [id, p] of Object.entries(projects)) {
|
|
73
74
|
const where = `projects.${id}`;
|
|
74
75
|
if (!PROJECT_ID.test(id)) {
|
|
75
|
-
fail(`${where}: a project id is letters, digits
|
|
76
|
+
fail(`${where}: a project id is lowercase letters, digits and "-"`);
|
|
76
77
|
}
|
|
77
78
|
if (typeof p !== "object" || p === null) {
|
|
78
79
|
fail(`${where} must be an object`);
|
|
@@ -90,6 +91,9 @@ export function normalizeConfig(input) {
|
|
|
90
91
|
if (p.seedFromDefaultBranch !== undefined && typeof p.seedFromDefaultBranch !== "boolean") {
|
|
91
92
|
fail(`${where}.seedFromDefaultBranch must be true or false`);
|
|
92
93
|
}
|
|
94
|
+
if (p.gate !== undefined) {
|
|
95
|
+
fail(`${where}.gate is not a setting: every project is gated, and every story needs an approved baseline`);
|
|
96
|
+
}
|
|
93
97
|
let waitFor = null;
|
|
94
98
|
if (p.waitFor !== undefined && p.waitFor !== null) {
|
|
95
99
|
const w = p.waitFor;
|
package/trusted/lib/github.mjs
CHANGED
|
@@ -10,22 +10,29 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { execFile } from "node:child_process";
|
|
13
|
-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync } from "node:fs";
|
|
14
|
-
import { dirname, join } from "node:path";
|
|
13
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync } from "node:fs";
|
|
14
|
+
import { basename, dirname, join } from "node:path";
|
|
15
15
|
|
|
16
16
|
import { validateResults } from "./results.mjs";
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
|
-
* Runs a program and resolves with its trimmed stdout.
|
|
19
|
+
* Runs a program and resolves with its trimmed stdout. A program that runs longer than
|
|
20
|
+
* VISUAL_REVIEW_TIMEOUT_MS (10 minutes by default) is killed: a gh or git call that stalls on a
|
|
21
|
+
* dead network, or waits on a prompt nobody sees, would otherwise hold the page or Finish forever.
|
|
20
22
|
* @param {string} cmd the program
|
|
21
23
|
* @param {string[]} args its arguments
|
|
22
24
|
* @param {{ cwd?: string, input?: string, env?: object }} [options] stdin and environment
|
|
23
25
|
* @returns {Promise<string>} stdout; rejects with an Error whose message is the program's stderr
|
|
24
26
|
*/
|
|
25
27
|
export function exec(cmd, args, { cwd, input, env } = {}) {
|
|
28
|
+
const timeout = Number(process.env.VISUAL_REVIEW_TIMEOUT_MS) || 600000;
|
|
26
29
|
return new Promise((resolve, reject) => {
|
|
27
|
-
|
|
28
|
-
|
|
30
|
+
// SIGTERM (the default) lets git remove its lock files as it exits.
|
|
31
|
+
const options = { cwd, env, encoding: /** @type {const} */ ("utf8"), maxBuffer: 64 << 20, timeout };
|
|
32
|
+
const child = execFile(cmd, args, options, (err, out, stderr) => {
|
|
33
|
+
if (err?.killed) {
|
|
34
|
+
reject(new Error(`${cmd} ${args.join(" ")} timed out after ${timeout / 1000} s and was stopped`));
|
|
35
|
+
} else if (err) {
|
|
29
36
|
reject(new Error(stderr.trim() || err.message));
|
|
30
37
|
} else {
|
|
31
38
|
resolve(out.trim());
|
|
@@ -42,7 +49,40 @@ export function exec(cmd, args, { cwd, input, env } = {}) {
|
|
|
42
49
|
* @param {string} cwd the repository
|
|
43
50
|
* @returns {(args: string[], input?: string) => Promise<string>} runs gh and returns its stdout
|
|
44
51
|
*/
|
|
45
|
-
export const ghRunner = (cwd) => (args, input) => exec("gh", args, { cwd, input });
|
|
52
|
+
export const ghRunner = (cwd) => withRetries((args, input) => exec("gh", args, { cwd, input }));
|
|
53
|
+
|
|
54
|
+
// How gh reports a network that failed (DNS, a dropped or refused connection, a transfer cut
|
|
55
|
+
// short, a call exec stopped) or a GitHub server error. A 4xx, a missing artifact or any other gh
|
|
56
|
+
// error is real and is never retried.
|
|
57
|
+
const TRANSIENT =
|
|
58
|
+
/could not resolve host|no such host|error connecting to|connection (reset|refused|timed out)|i\/o timeout|TLS handshake timeout|HTTP 5\d\d|unexpected EOF|GOAWAY|stream error|context deadline exceeded|timed out after/i;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Retries a gh runner's calls that failed on the network, after each delay in turn. A write
|
|
62
|
+
* (`--input`) is never retried: GitHub may have applied it before the connection dropped. Every
|
|
63
|
+
* failure and retry is logged to stderr, so the server's log shows what happened.
|
|
64
|
+
* @param {(args: string[], input?: string) => Promise<string>} gh the gh runner
|
|
65
|
+
* @param {number[]} [delays] milliseconds before each retry
|
|
66
|
+
* @returns {(args: string[], input?: string) => Promise<string>} the retrying runner
|
|
67
|
+
*/
|
|
68
|
+
export const withRetries =
|
|
69
|
+
(gh, delays = [2000, 5000, 15000]) =>
|
|
70
|
+
async (args, input) => {
|
|
71
|
+
for (let i = 0; ; i++) {
|
|
72
|
+
try {
|
|
73
|
+
return await gh(args, input);
|
|
74
|
+
} catch (err) {
|
|
75
|
+
const retry = i < delays.length && !args.includes("--input") && TRANSIENT.test(err.message);
|
|
76
|
+
// gh's arguments never hold a token (gh keeps its own login), so they are logged whole.
|
|
77
|
+
const next = retry ? `; retrying in ${delays[i] / 1000} s` : "";
|
|
78
|
+
console.error(`visual-review: gh ${args.join(" ")} failed${next}: ${err.message}`);
|
|
79
|
+
if (!retry) {
|
|
80
|
+
throw err;
|
|
81
|
+
}
|
|
82
|
+
await new Promise((resolve) => setTimeout(resolve, delays[i]));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
};
|
|
46
86
|
|
|
47
87
|
const api = async (gh, path) => JSON.parse(await gh(["api", path]));
|
|
48
88
|
|
|
@@ -108,7 +148,8 @@ export async function visualJobs(gh, run, attempt, projects) {
|
|
|
108
148
|
const visual = jobs.filter((j) => /^visual\b/.test(j.name));
|
|
109
149
|
return Object.fromEntries(
|
|
110
150
|
projects.map((p) => {
|
|
111
|
-
|
|
151
|
+
// The exact name: "visual (graphty-element)" also contains "graphty".
|
|
152
|
+
const job = visual.find((j) => j.name === `visual (${p})`);
|
|
112
153
|
return [p, job && { conclusion: job.conclusion, url: job.html_url }];
|
|
113
154
|
}),
|
|
114
155
|
);
|
|
@@ -129,14 +170,28 @@ const downloading = new Map();
|
|
|
129
170
|
* @returns {Promise<void>} settles when `dir` is complete, or the artifact had no results.json
|
|
130
171
|
*/
|
|
131
172
|
function download(gh, runId, name, dir) {
|
|
132
|
-
if (existsSync(join(dir, "results.json"))) {
|
|
133
|
-
|
|
173
|
+
if (!downloading.has(dir) && existsSync(join(dir, "results.json"))) {
|
|
174
|
+
try {
|
|
175
|
+
JSON.parse(readFileSync(join(dir, "results.json"), "utf8"));
|
|
176
|
+
return Promise.resolve();
|
|
177
|
+
} catch (err) {
|
|
178
|
+
// Damaged on this disk (CI uploads only results.json it wrote): download it again.
|
|
179
|
+
console.error(
|
|
180
|
+
`visual-review: ${join(dir, "results.json")} is unreadable, downloading again: ${err.message}`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
134
183
|
}
|
|
135
184
|
if (!downloading.has(dir)) {
|
|
136
185
|
const done = (async () => {
|
|
137
|
-
// A directory without results.json is left over from before downloads were atomic
|
|
186
|
+
// A directory without results.json is left over from before downloads were atomic,
|
|
187
|
+
// and a .part- sibling from a download a killed server never finished.
|
|
138
188
|
rmSync(dir, { recursive: true, force: true });
|
|
139
189
|
mkdirSync(dirname(dir), { recursive: true });
|
|
190
|
+
for (const f of readdirSync(dirname(dir))) {
|
|
191
|
+
if (f.startsWith(`${basename(dir)}.part-`)) {
|
|
192
|
+
rmSync(join(dirname(dir), f), { recursive: true, force: true });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
140
195
|
const part = mkdtempSync(`${dir}.part-`);
|
|
141
196
|
try {
|
|
142
197
|
await gh(["run", "download", String(runId), "-n", name, "-D", part]);
|
|
@@ -155,31 +210,51 @@ function download(gh, runId, name, dir) {
|
|
|
155
210
|
/**
|
|
156
211
|
* Downloads each project's capture artifact from the highest attempt that uploaded one, into
|
|
157
212
|
* `<tmp>/<run>-<attempt>/<project>/`. An artifact already downloaded is not fetched again, and
|
|
158
|
-
* concurrent calls for the same one share a single download.
|
|
213
|
+
* concurrent calls for the same one share a single download. A failed download fails only its
|
|
214
|
+
* project, which the next call tries again. An expired artifact is used from disk when it was
|
|
215
|
+
* downloaded before.
|
|
159
216
|
* @param {Function} gh the gh runner
|
|
160
217
|
* @param {{ id: number }} run the run
|
|
161
218
|
* @param {string[]} projects project ids
|
|
162
219
|
* @param {string} tmp the download root
|
|
163
|
-
* @
|
|
164
|
-
*
|
|
220
|
+
* @param {string[]} [others] receives the projects the run captured that are not in `projects`
|
|
221
|
+
* @returns {Promise<Record<string, { dir: string | null, attempt: number, error?: string,
|
|
222
|
+
* expired?: true } | null>>} null for a project with no artifact; `error` (and no `dir`) when
|
|
223
|
+
* its download failed; `expired` (and no `dir`) when GitHub deleted it and it is not on disk
|
|
165
224
|
*/
|
|
166
|
-
export async function downloadCaptures(gh, run, projects, tmp) {
|
|
225
|
+
export async function downloadCaptures(gh, run, projects, tmp, others = []) {
|
|
167
226
|
const { artifacts } = await api(gh, `repos/{owner}/{repo}/actions/runs/${run.id}/artifacts?per_page=100`);
|
|
168
|
-
|
|
227
|
+
for (const a of artifacts) {
|
|
228
|
+
const p = /^visual-(.+)-\d+$/.exec(a.name)?.[1];
|
|
229
|
+
if (p && !projects.includes(p) && !others.includes(p)) {
|
|
230
|
+
others.push(p);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/** @type {Record<string, { dir: string | null, attempt: number, error?: string, expired?: true } | null>} */
|
|
169
234
|
const out = {};
|
|
170
235
|
for (const project of projects) {
|
|
171
236
|
const pattern = new RegExp(`^visual-${project}-(\\d+)$`);
|
|
172
237
|
const newest = artifacts
|
|
173
|
-
.filter((a) =>
|
|
174
|
-
.map((a) => ({ name: a.name, attempt: Number(pattern.exec(a.name)[1]) }))
|
|
238
|
+
.filter((a) => pattern.test(a.name))
|
|
239
|
+
.map((a) => ({ name: a.name, expired: a.expired, attempt: Number(pattern.exec(a.name)[1]) }))
|
|
175
240
|
.sort((a, b) => b.attempt - a.attempt)[0];
|
|
176
241
|
if (!newest) {
|
|
177
242
|
out[project] = null;
|
|
178
243
|
continue;
|
|
179
244
|
}
|
|
180
245
|
const dir = join(tmp, `${run.id}-${newest.attempt}`, project);
|
|
181
|
-
|
|
182
|
-
|
|
246
|
+
if (newest.expired) {
|
|
247
|
+
out[project] = existsSync(join(dir, "results.json"))
|
|
248
|
+
? { dir, attempt: newest.attempt }
|
|
249
|
+
: { dir: null, attempt: newest.attempt, expired: true };
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
await download(gh, run.id, newest.name, dir);
|
|
254
|
+
out[project] = { dir, attempt: newest.attempt };
|
|
255
|
+
} catch (err) {
|
|
256
|
+
out[project] = { dir: null, attempt: newest.attempt, error: err.message };
|
|
257
|
+
}
|
|
183
258
|
}
|
|
184
259
|
return out;
|
|
185
260
|
}
|
|
@@ -205,7 +280,12 @@ export async function newestMasterCapture(gh, project, tmp, { workflow, defaultB
|
|
|
205
280
|
if (!run) {
|
|
206
281
|
continue;
|
|
207
282
|
}
|
|
208
|
-
const
|
|
283
|
+
const got = (await downloadCaptures(gh, run, [project], tmp))[project];
|
|
284
|
+
if (got?.error) {
|
|
285
|
+
// Not skipped: an older capture would be compared instead of this one.
|
|
286
|
+
throw new Error(got.error);
|
|
287
|
+
}
|
|
288
|
+
const dir = got?.dir;
|
|
209
289
|
let results = null;
|
|
210
290
|
try {
|
|
211
291
|
results = dir ? JSON.parse(readFileSync(join(dir, "results.json"), "utf8")) : null;
|