@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/README.md
CHANGED
|
@@ -147,10 +147,12 @@ Per project:
|
|
|
147
147
|
| `seedFromDefaultBranch` | `true` | `false`: the project's first baselines are accepted on a pull request, not seeded from the default branch |
|
|
148
148
|
| `waitFor` | none | After a story renders, call `method()` on every element matching `selector` and wait for the promise it returns, for a component that keeps drawing after Storybook says it is done. A console line containing `failOnConsole` fails the story |
|
|
149
149
|
|
|
150
|
-
Project ids are letters, digits
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
150
|
+
Project ids are lowercase letters, digits and `-` (results.json allows no others), and so are
|
|
151
|
+
the names of Chromatic modes, which a capture refuses before it starts. Every project is gated;
|
|
152
|
+
there is no setting that turns the gate off, and a config that sets `gate` is refused. The pull
|
|
153
|
+
request gate reads the config as it is on the base branch, so a pull request cannot move
|
|
154
|
+
`baselines` out from under it or drop a project from the gate; a project that a pull request adds
|
|
155
|
+
to its own config is gated too.
|
|
154
156
|
|
|
155
157
|
## The GitHub Actions workflows
|
|
156
158
|
|
|
@@ -222,10 +224,10 @@ token is kept in the work directory, so the URL stays valid across restarts; del
|
|
|
222
224
|
|
|
223
225
|
The address always names the screen you are on, after the token: the targets list; a pull
|
|
224
226
|
request (or master) and project with the grid's filter and text; or one story with its view,
|
|
225
|
-
zoom, changed box and
|
|
226
|
-
`#token=...&target=123&project=web&filter=undecided&item=button--primary.dark.png&view=flash&zoom=2&box=on&blink=off`.
|
|
227
|
-
A link's `box` and `
|
|
228
|
-
|
|
227
|
+
zoom, changed box, blink and Spotlight flash, for example
|
|
228
|
+
`#token=...&target=123&project=web&filter=undecided&item=button--primary.dark.png&view=flash&zoom=2&box=on&blink=off&flash=off`.
|
|
229
|
+
A link's `box`, `blink` and `flash` apply to the page it opens; the choice this browser remembers
|
|
230
|
+
for B, L and F in Spotlight is left as it was.
|
|
229
231
|
Opening that address, in another tab or on another device, opens the same screen. **Copy link**
|
|
230
232
|
at the top right copies it. The link carries your session token, so it works on your iPad the way
|
|
231
233
|
the printed URL does; keep it to yourself as you would that URL. All of it sits after `#`, which a
|
|
@@ -250,6 +252,10 @@ starts the same server from your own shell.
|
|
|
250
252
|
pull request. Merge the default branch into the pull request's branch (by merge, never
|
|
251
253
|
rebase) and wait for CI.
|
|
252
254
|
- **capture failed**: the `visual` job produced no results. Re-run that job in GitHub Actions.
|
|
255
|
+
- **CI still running**, **waiting for CI**, **downloading the captures**: there is nothing
|
|
256
|
+
to review yet; reload the page in a moment.
|
|
257
|
+
- **artifact expired**: GitHub deleted the capture after 30 days and it was never
|
|
258
|
+
downloaded here. Re-run the `visual` job.
|
|
253
259
|
- **incomplete: N of M stories**: the capture stopped part way. Re-run the job.
|
|
254
260
|
- **not seeded from master**: this project is not reviewed on the default branch
|
|
255
261
|
(`"seedFromDefaultBranch": false` in the config); its first baselines are accepted on a pull
|
|
@@ -295,7 +301,9 @@ starts the same server from your own shell.
|
|
|
295
301
|
scroll it was opened at; **Highlight**, the changed pixels in solid red laid over both images
|
|
296
302
|
themselves, in both panes, where **Blink** (L) flashes the red pixels on and off at Flash's
|
|
297
303
|
pace (remembered in this browser); and **Spotlight**, the new image dimmed everywhere except around the changed pixels
|
|
298
|
-
(each grown by 10 image pixels), which finds a one-pixel change
|
|
304
|
+
(each grown by 10 image pixels), which finds a one-pixel change, where **Spotlight flash** (F
|
|
305
|
+
in Spotlight) shows the spotlighted baseline and the spotlighted new image one after the other
|
|
306
|
+
at Flash's pace, the pane's label saying which (remembered in this browser). Flash, Highlight and
|
|
299
307
|
Spotlight need two images; on a new or removed story they are off and the page says why
|
|
300
308
|
("New story, no baseline", "Only one image: this story was removed"). Badges here:
|
|
301
309
|
**size changed** (in image pixels), **flaky** (the two captures differed, then matched), and
|
|
@@ -326,11 +334,12 @@ Seed them from the default branch (below), or accept them on the pull request.
|
|
|
326
334
|
| Key | Action |
|
|
327
335
|
| ------------ | ------------------------------------------------------------------------------ |
|
|
328
336
|
| J / K | Next / previous item of this pass (decided items stay in it) |
|
|
329
|
-
| A | Accept an undecided item
|
|
337
|
+
| A | Accept an undecided item, once both its images are shown |
|
|
330
338
|
| R | Reject an undecided item (asks for a reason, then Enter) |
|
|
331
339
|
| E | Exclude an undecided item (asks for a reason, then Enter, then a confirmation) |
|
|
332
340
|
| U | Undo the item's decision (on the grid: each tile's Undo button) |
|
|
333
341
|
| F | Flash between baseline and new; F again returns to side by side |
|
|
342
|
+
| F | In Spotlight: flash the spotlighted baseline and new, or stop flashing |
|
|
334
343
|
| H | Highlight changed pixels; H again returns to side by side |
|
|
335
344
|
| S | Spotlight the changes; S again returns to side by side |
|
|
336
345
|
| Z | Next zoom: fit to screen, real size, 2x, 4x, 8x, then fit again |
|
|
@@ -344,6 +353,8 @@ Seed them from the default branch (below), or accept them on the pull request.
|
|
|
344
353
|
|
|
345
354
|
No key reverses a decision. A, R and E do nothing on an item that is already decided, and say
|
|
346
355
|
so; to change a decision, press U (or the Undo button) first. The same key twice never undoes.
|
|
356
|
+
A held A, R, E or U decides once, and a double click on a decision button decides only the item
|
|
357
|
+
it was clicked on, never the next one.
|
|
347
358
|
|
|
348
359
|
## What each decision does
|
|
349
360
|
|
|
@@ -360,7 +371,9 @@ so; to change a decision, press U (or the Undo button) first. The same key twice
|
|
|
360
371
|
runner), re-run the `visual` job instead, since the newest attempt replaces the old results.
|
|
361
372
|
- **Undo** (U, or a tile's Undo on the grid) clears a decision before Finish; it is the only way
|
|
362
373
|
to change one. The grid also undoes a whole component or project, after a second press.
|
|
363
|
-
Decisions are kept across server restarts.
|
|
374
|
+
Decisions are kept across server restarts. A decision applies only to the image it was taken
|
|
375
|
+
on: when a new run or a re-run attempt captures that item differently, it is undecided again
|
|
376
|
+
(the old decision stays saved, and comes back if the image does).
|
|
364
377
|
- After Finish, accepts and exclusions are cleared; rejects stay, marked as already posted, and
|
|
365
378
|
still show as rejected on the next CI run while the capture is unchanged. Finish does not post
|
|
366
379
|
them twice. They live in the work directory's `state/` (the config's `workDir`), not in the
|
|
@@ -598,6 +611,21 @@ PNGs move: a settings file (`<old id>.json`) is not renamed; rename it in the sa
|
|
|
598
611
|
other `core.hooksPath`), that hook is not installed: call `git lfs pre-push "$@"` from your own
|
|
599
612
|
pre-push hook. `git push --no-verify` skips the upload too; after one that carried baselines,
|
|
600
613
|
run `git lfs push origin <branch>`.
|
|
614
|
+
- **download failed / failed to load: ...; reload the page to retry.** `serve` starts
|
|
615
|
+
downloading every capture as soon as it starts, and retries a gh call that fails on the network
|
|
616
|
+
(DNS, a dropped connection, a GitHub 5xx) three times over about 20 seconds; it logs each failed
|
|
617
|
+
call and each retry to stderr. A project whose download still fails shows "download failed", a
|
|
618
|
+
pull request (or the default branch's run) GitHub would not answer for shows "failed to load",
|
|
619
|
+
or, when it loaded before, keeps what it showed with "could not refresh", and everything else
|
|
620
|
+
loads as usual. Reload the page to try again; captures already downloaded are kept, and a
|
|
621
|
+
damaged one is downloaded again.
|
|
622
|
+
- **A gh or git call hangs.** Every gh and git call `serve` and Finish make is stopped after 10
|
|
623
|
+
minutes (`VISUAL_REVIEW_TIMEOUT_MS` sets another limit, in milliseconds), and git never waits
|
|
624
|
+
for a credential prompt. A stopped Finish names the step it was on and keeps your decisions.
|
|
625
|
+
- **"the server stopped while this Finish was at ..."** The server restarted during a Finish.
|
|
626
|
+
Look at the branch on origin to see whether its commit was pushed before pressing Finish again.
|
|
627
|
+
- **"... was pushed as ..., but opening its pull request failed"** (the seed). Press Finish
|
|
628
|
+
again: it opens the pull request for the branch already pushed.
|
|
601
629
|
- **capture failed / no capture** on a target. The `visual` job produced no results. Open its
|
|
602
630
|
log from the page and re-run the job. **incomplete: N of M stories**: the job stopped part way
|
|
603
631
|
(a timeout); re-run it.
|
package/capture/capture.mjs
CHANGED
|
@@ -154,6 +154,13 @@ export function storySettings(parameters, file) {
|
|
|
154
154
|
name,
|
|
155
155
|
globals: Object.fromEntries(Object.entries(m).filter(([k]) => k !== "disable")),
|
|
156
156
|
}));
|
|
157
|
+
// A mode names files and results.json items, which allow only these: refused before any story
|
|
158
|
+
// is captured, not when results.json is checked at the end.
|
|
159
|
+
for (const { name } of modes) {
|
|
160
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(name) || name.length > 50) {
|
|
161
|
+
throw new Error(`Chromatic mode "${name}": a mode name is lowercase letters, digits and "-", at most 50`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
157
164
|
const where = byFile ? "settings file" : "story's parameters";
|
|
158
165
|
return {
|
|
159
166
|
disableSnapshot,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@graphty/visual-review",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Self-hosted visual review of Storybook stories: capture in GitHub Actions, keep the baselines in git (Git LFS), accept or reject each change in a local page, and gate pull requests",
|
|
5
5
|
"author": "Adam Powers <apowers@ato.ms>",
|
|
6
6
|
"type": "module",
|
package/trusted/cli.mjs
CHANGED
|
@@ -214,6 +214,7 @@ async function serve(args) {
|
|
|
214
214
|
origin,
|
|
215
215
|
masterRun,
|
|
216
216
|
results: values.results && resolve(values.results),
|
|
217
|
+
warm: true,
|
|
217
218
|
// What the owner types to run this server from their own shell, so Finish signs with
|
|
218
219
|
// their key rather than the environment of whoever started it (an agent, say).
|
|
219
220
|
startCommand:
|
package/trusted/gate.mjs
CHANGED
|
@@ -63,7 +63,13 @@ export function newestResults(dir) {
|
|
|
63
63
|
continue;
|
|
64
64
|
}
|
|
65
65
|
const file = join(dir, name, "results.json");
|
|
66
|
-
|
|
66
|
+
let results = null;
|
|
67
|
+
try {
|
|
68
|
+
results = JSON.parse(readFileSync(file, "utf8"));
|
|
69
|
+
} catch {
|
|
70
|
+
// Missing or not JSON: counted as missing, like any invalid results.json.
|
|
71
|
+
}
|
|
72
|
+
out[project] = { attempt, results };
|
|
67
73
|
}
|
|
68
74
|
return out;
|
|
69
75
|
}
|
package/trusted/lib/accept.mjs
CHANGED
|
@@ -32,10 +32,13 @@ const EXCLUDABLE = new Set([...DECIDABLE, "unstable", "failed"]);
|
|
|
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`);
|
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;
|