@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.
@@ -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
- pullRequest = await createPullRequest(gh, {
224
- title: `${config.commitPrefix}: seed visual baselines`,
225
- head: branch,
226
- base: config.defaultBranch,
227
- body: seedBody(first, accepts, rejects, config.defaultBranch),
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
- const tracking = `refs/remotes/origin/${defaultBranch}`;
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 git(repo, ["fetch", "-q", "origin", `+refs/heads/${defaultBranch}:${tracking}`]);
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 git(repo, ["fetch", "-q", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`]);
329
- if ((await git(repo, ["rev-parse", `refs/remotes/origin/${branch}`])) !== base) {
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
- await removeWorktree(repo, tree);
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(repo, head, project, { defaultBranch, baselines }) {
427
- const newest = await git(repo, [
428
- "log",
429
- "-1",
430
- "--format=%H",
431
- `refs/remotes/origin/${defaultBranch}`,
432
- "--",
433
- `${baselines}/${project}/`,
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
- await git(repo, ["worktree", "remove", "--force", tree]);
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
  }
@@ -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
- const PROJECT_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
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, ".", "_" and "-"`);
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;
@@ -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
- const child = execFile(cmd, args, { cwd, env, encoding: "utf8", maxBuffer: 64 << 20 }, (err, out, stderr) => {
28
- if (err) {
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
- const job = visual.find((j) => j.name.includes(p));
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
- return Promise.resolve();
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
- * @returns {Promise<Record<string, { dir: string, attempt: number } | null>>} null for a project
164
- * with no unexpired artifact
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
- /** @type {Record<string, { dir: string, attempt: number } | null>} */
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) => !a.expired && pattern.test(a.name))
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
- await download(gh, run.id, newest.name, dir);
182
- out[project] = { dir, attempt: newest.attempt };
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 dir = (await downloadCaptures(gh, run, [project], tmp))[project]?.dir;
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;