@graphty/visual-review 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,13 +13,20 @@
13
13
  * never raw PNGs, and the LFS objects must reach GitHub before the commit does; with hooks
14
14
  * switched off nothing else uploads them, so this module checks git-lfs is set up, checks every
15
15
  * committed PNG is a pointer, and runs `git lfs push` before `git push`.
16
+ *
17
+ * Once a passkey is known, the record is version 2 and approved: `prepareRecord` builds it before
18
+ * any worktree exists, the owner's device signs its hash, and Finish commits only a record that
19
+ * hashes to exactly what was signed, after checking the approval once more with the gate's own
20
+ * code (approval.mjs). `proposeKey` opens the pull request that registers a passkey.
16
21
  */
17
22
 
23
+ import { execFile } from "node:child_process";
18
24
  import { createHash } from "node:crypto";
19
25
  import { existsSync } from "node:fs";
20
26
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
21
27
  import { dirname, join } from "node:path";
22
28
 
29
+ import { PASSKEYS_FILE, parsePasskeys, recordHash, verifyApproval } from "./approval.mjs";
23
30
  import { commentOnPullRequest, createIssue, createPullRequest, exec, postStatus } from "./github.mjs";
24
31
  import { isLfsPointer } from "./compare.mjs";
25
32
 
@@ -32,10 +39,13 @@ const EXCLUDABLE = new Set([...DECIDABLE, "unstable", "failed"]);
32
39
  /**
33
40
  * Refused decisions and failed git commands; the message is shown to the owner as is.
34
41
  * `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.
42
+ * so the caller drops the accepts and keeps the rejects for a retry that only comments. On
43
+ * master, `pullRequestMissing` is set too when opening the seed's pull request failed: the caller
44
+ * keeps the accepts, and the retry opens the pull request for the branch already pushed.
36
45
  */
37
46
  export class AcceptError extends Error {
38
47
  committed = null;
48
+ pullRequestMissing = false;
39
49
  }
40
50
 
41
51
  /**
@@ -48,6 +58,7 @@ export class AcceptError extends Error {
48
58
  * with it, so the hooks path points nowhere. Signing is left as the repository configures it, so
49
59
  * the commit carries the owner's identity and signature. GIT_LFS_SKIP_SMUDGE keeps the worktree's
50
60
  * checkout from downloading every baseline image: untouched baselines stay pointer files there.
61
+ * GIT_TERMINAL_PROMPT=0 makes a credential prompt fail instead of waiting on a terminal.
51
62
  * @param {string} cwd the repository or worktree
52
63
  * @param {string[]} args git's arguments
53
64
  * @param {string} [input] stdin
@@ -57,7 +68,7 @@ const git = (cwd, args, input) =>
57
68
  exec("git", ["-c", "core.hooksPath=/dev/null", ...args], {
58
69
  cwd,
59
70
  input,
60
- env: { ...process.env, HUSKY: "0", GIT_LFS_SKIP_SMUDGE: "1" },
71
+ env: { ...process.env, HUSKY: "0", GIT_LFS_SKIP_SMUDGE: "1", GIT_TERMINAL_PROMPT: "0" },
61
72
  });
62
73
 
63
74
  /** Images per `git lfs push --object-id`, so a large seed reports its upload as it goes. */
@@ -185,13 +196,20 @@ function check(projects, decisions) {
185
196
  * @param {{ project: string, file: string, decision: string, reason: string | null }[]} input.decisions
186
197
  * what the owner decided: accept, reject or exclude (checked here)
187
198
  * @param {number} [input.undecided] how many reviewable items are left undecided, for the status
199
+ * @param {string[]} [input.unloaded] the projects whose capture did not load, so nobody reviewed them
188
200
  * @param {Date} [input.now] the review time
189
201
  * @param {(step: string) => void} [input.progress] told each step as it starts, for the page
190
202
  * @param {ReturnType<typeof import("./config.mjs").normalizeConfig>} input.config the settings
203
+ * @param {{ record: object, pendingKeys?: object[], origin: string } | null} [input.approval] the
204
+ * record prepareRecord built, with the owner's `approval`, the keys registered but not yet on
205
+ * the default branch, and the page's origin; without it the record is version 1 (no key known)
191
206
  * @returns {Promise<{ commit: string | null, branch: string | null, pullRequest: string | null,
192
- * issue: string | null, rejects: number, status: string | null, statusError: string | null }>}
193
- * what was pushed and posted (`issue`: master's rejects; `status`: the commit status's
194
- * description, or `statusError` when posting it failed)
207
+ * issue: string | null, rejects: number, acceptNotes: number, state: string, status: string | null,
208
+ * statusError: string | null, commentError: string | null }>} what was pushed and posted
209
+ * (`issue`: master's rejects; `acceptNotes`: the accept notes published, in the comment or the
210
+ * seed pull request's description; `state` and `status`: the commit status's state and
211
+ * description, or `statusError` when posting it failed; `commentError` when a comment holding
212
+ * only accept notes failed after the accepts were pushed)
195
213
  */
196
214
  export async function finish({
197
215
  repo,
@@ -200,9 +218,11 @@ export async function finish({
200
218
  projects,
201
219
  decisions,
202
220
  undecided = 0,
221
+ unloaded = [],
203
222
  now = new Date(),
204
223
  progress = () => {},
205
224
  config,
225
+ approval = null,
206
226
  }) {
207
227
  progress("checking");
208
228
  const { accepts, rejects } = check(projects, decisions);
@@ -211,29 +231,64 @@ export async function finish({
211
231
  throw new AcceptError("nothing decided");
212
232
  }
213
233
  const isMaster = target.pr === null;
234
+ const notes = accepts.filter((a) => a.decision === "accept" && a.reason !== null);
214
235
 
215
236
  let commit = null;
237
+ let commentError = null;
238
+ let record = null;
216
239
  let branch = target.branch;
217
240
  let pullRequest = null;
218
241
  let issue = null;
219
242
  if (accepts.length > 0) {
220
- ({ commit, branch } = await commitAccepts({ repo, target, accepts, first, now, progress, config }));
243
+ ({ commit, branch, record } = await commitAccepts({
244
+ repo,
245
+ target,
246
+ accepts,
247
+ rejects,
248
+ first,
249
+ now,
250
+ progress,
251
+ config,
252
+ approval,
253
+ }));
221
254
  if (isMaster) {
222
255
  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
- });
256
+ try {
257
+ pullRequest = await createPullRequest(gh, {
258
+ title: `${config.commitPrefix}: seed visual baselines`,
259
+ head: branch,
260
+ base: config.defaultBranch,
261
+ body: seedBody(first, accepts, rejects, notes, config.defaultBranch),
262
+ });
263
+ } catch (err) {
264
+ const e = new AcceptError(
265
+ `${branch} was pushed as ${commit.slice(0, 10)}, but opening its pull request failed: ` +
266
+ `${err.message}. Press Finish again to open it.`,
267
+ );
268
+ e.committed = commit;
269
+ e.pullRequestMissing = true;
270
+ throw e;
271
+ }
229
272
  }
230
273
  }
231
- if (rejects.length > 0) {
274
+ // On a pull request, one comment holds the rejects and the accept notes; on master the accept
275
+ // notes are in the seed pull request's description, and only rejects open the issue.
276
+ const comment = isMaster ? rejects.length > 0 : rejects.length > 0 || notes.length > 0;
277
+ if (comment) {
232
278
  try {
233
279
  // Master has no pull request to comment on: its rejects are stories that do not look
234
280
  // right yet, so they become one issue an agent can pick up.
235
- progress(isMaster ? "opening the issue for the rejects" : "posting the rejects");
236
- const body = rejectComment(target.pr, first, rejects, config.defaultBranch);
281
+ progress(isMaster ? "opening the issue for the rejects" : "posting the comment");
282
+ // A committed record is named, not copied: a large one would pass GitHub's 65,536
283
+ // character limit. A session of rejects alone commits nothing, so its record goes here.
284
+ const body = rejectComment(
285
+ target.pr,
286
+ first,
287
+ rejects,
288
+ isMaster ? [] : notes,
289
+ config.defaultBranch,
290
+ commit === null ? { record: approval?.record } : { recordFile: record, commit },
291
+ );
237
292
  if (isMaster) {
238
293
  issue = await createIssue(gh, {
239
294
  title: `Visual review: ${rejects.length} ${rejects.length === 1 ? "story" : "stories"} rejected on ${config.defaultBranch}`,
@@ -247,22 +302,41 @@ export async function finish({
247
302
  if (commit === null) {
248
303
  throw err;
249
304
  }
250
- const e = new AcceptError(
251
- `the accepts were pushed as ${commit.slice(0, 10)}, but the reject comment failed: ${err.message}. ` +
252
- "Press Finish again to post the rejects.",
253
- );
254
- e.committed = commit;
255
- throw e;
305
+ // The accepts are pushed and cleared, so their notes are never posted again: name them.
306
+ // On master the notes are already in the seed pull request's description.
307
+ const lost =
308
+ isMaster || notes.length === 0
309
+ ? ""
310
+ : ` These accept notes were not posted and are not kept: ${noteList(notes)}.`;
311
+ if (rejects.length === 0) {
312
+ // Only accept notes: the accepts stand, and the page says the notes were not posted.
313
+ commentError = `${err.message}.${lost}`;
314
+ } else {
315
+ const withNotes =
316
+ isMaster || notes.length === 0
317
+ ? ""
318
+ : ` and ${notes.length === 1 ? "1 accept note" : `${notes.length} accept notes`}`;
319
+ const opened = isMaster && pullRequest ? ` and opened ${pullRequest}` : "";
320
+ const e = new AcceptError(
321
+ `the accepts were pushed as ${commit.slice(0, 10)}${opened}, but the ` +
322
+ `${isMaster ? "issue" : "comment"} with the rejects${withNotes} failed: ${err.message}. ` +
323
+ `Press Finish again to post the rejects.${lost}`,
324
+ );
325
+ e.committed = commit;
326
+ throw e;
327
+ }
256
328
  }
257
329
  }
258
330
  // One commit status per Finish, on the commit it pushed, or the captured one when it pushed
259
331
  // none. A failure here does not undo what was pushed and posted; the page shows it.
260
332
  const accepted = accepts.filter((a) => a.decision === "accept").length;
261
- const excluded = accepts.length - accepted;
262
- const state = rejects.length > 0 ? "failure" : undecided > 0 ? "pending" : "success";
263
- const status =
264
- `Reviewed: ${accepted} accepted, ${rejects.length} rejected, ${excluded} excluded, ` +
265
- `${undecided} left undecided`;
333
+ const { state, description: status } = commitStatus({
334
+ accepted,
335
+ rejected: rejects.length,
336
+ excluded: accepts.length - accepted,
337
+ undecided,
338
+ unloaded,
339
+ });
266
340
  let statusError = null;
267
341
  progress("posting the status");
268
342
  try {
@@ -270,7 +344,121 @@ export async function finish({
270
344
  } catch (err) {
271
345
  statusError = err.message;
272
346
  }
273
- return { commit, branch, pullRequest, issue, rejects: rejects.length, status, statusError };
347
+ return {
348
+ commit,
349
+ branch,
350
+ pullRequest,
351
+ issue,
352
+ rejects: rejects.length,
353
+ acceptNotes: (comment && commentError === null) || (isMaster && pullRequest) ? notes.length : 0,
354
+ state,
355
+ status,
356
+ statusError,
357
+ commentError,
358
+ };
359
+ }
360
+
361
+ /**
362
+ * The commit status a Finish sets: failure when anything is rejected, pending while items are
363
+ * left undecided or a project did not load, success otherwise. The review page shows it before
364
+ * Finish runs, so this is the one place the rule lives.
365
+ * @param {{ accepted: number, rejected: number, excluded: number, undecided: number,
366
+ * unloaded: string[] }} counts what the Finish applies, and what it leaves
367
+ * @returns {{ state: "failure" | "pending" | "success", description: string }} the status
368
+ */
369
+ export function commitStatus({ accepted, rejected, excluded, undecided, unloaded }) {
370
+ const state = rejected > 0 ? "failure" : undecided > 0 || unloaded.length > 0 ? "pending" : "success";
371
+ const description =
372
+ `Reviewed: ${accepted} accepted, ${rejected} rejected, ${excluded} excluded, ` +
373
+ `${undecided} left undecided${unloaded.length > 0 ? `, not loaded: ${unloaded.join(", ")}` : ""}`;
374
+ return { state, description };
375
+ }
376
+
377
+ /**
378
+ * The version 2 record a Finish of these decisions will commit, without its approval, built before
379
+ * any worktree exists so the owner's device can sign its hash.
380
+ * @param {object} input the work
381
+ * @param {string} input.repo the repository, with the captured head fetched
382
+ * @param {{ pr: number | null, branch: string | null }} input.target as in finish
383
+ * @param {Record<string, { dir: string, results: object }>} input.projects as in finish
384
+ * @param {object[]} input.decisions as in finish
385
+ * @param {Date} input.now the review time, which becomes the record's `reviewedAt`
386
+ * @param {{ baselines: string }} input.config the settings
387
+ * @returns {Promise<{ record: object, accepts: number, excludes: number, rejects: number }>} the
388
+ * record and what it holds, counted as Finish's commit status counts them
389
+ */
390
+ export async function prepareRecord({ repo, target, projects, decisions, now, config }) {
391
+ const { accepts, rejects } = check(projects, decisions);
392
+ const first = (accepts[0] ?? rejects[0])?.capture.results;
393
+ if (!first) {
394
+ throw new AcceptError("nothing decided");
395
+ }
396
+ const base = target.pr === null ? first.commit : first.headSha;
397
+ if (accepts.length > 0 && !(await gitOk(repo, ["cat-file", "-e", `${base}^{commit}`]))) {
398
+ throw new AcceptError("the captured commit is not fetched here yet: reload the page and press Finish again");
399
+ }
400
+ const { items } = await planWrites(repo, base, accepts, config.baselines);
401
+ return {
402
+ record: buildRecord({ version: 2, target, first, items, rejects, now, baselines: config.baselines }),
403
+ accepts: accepts.filter((a) => a.decision === "accept").length,
404
+ excludes: accepts.filter((a) => a.decision !== "accept").length,
405
+ rejects: rejects.length,
406
+ };
407
+ }
408
+
409
+ /**
410
+ * A review record.
411
+ * @param {object} input what it records
412
+ * @param {1 | 2} input.version 1 while no passkey is known, 2 for an approved record
413
+ * @param {{ pr: number | null }} input.target the pull request, or null for master
414
+ * @param {object} input.first the results.json of the first decided project
415
+ * @param {object[]} input.items the record items planWrites made
416
+ * @param {object[]} input.rejects the checked rejects
417
+ * @param {Date} input.now the review time
418
+ * @param {string} input.baselines the baselines directory
419
+ * @returns {object} the record, without `approval`
420
+ */
421
+ function buildRecord({ version, target, first, items, rejects, now, baselines }) {
422
+ const byPath = (a, b) => a.path.localeCompare(b.path);
423
+ return {
424
+ version,
425
+ ...(version === 1 && { unproven: true }),
426
+ pr: target.pr,
427
+ subject: {
428
+ builtMerge: first.commit,
429
+ head: first.headSha,
430
+ runId: first.runId,
431
+ runAttempt: first.runAttempt,
432
+ environment: first.environment,
433
+ scale: first.scale ?? 1,
434
+ },
435
+ items: dedupe(items).sort(byPath),
436
+ ...(version === 2 && {
437
+ rejects: rejects
438
+ .map((r) => ({
439
+ path: `${baselines}/${r.project}/${r.item.file}`,
440
+ capture: r.item.capture ?? r.item.baseline ?? null,
441
+ reason: r.reason,
442
+ }))
443
+ .sort(byPath),
444
+ }),
445
+ reviewedAt: now.toISOString(),
446
+ };
447
+ }
448
+
449
+ /**
450
+ * The keys passkeys.json holds at a ref; none when the file is absent there.
451
+ * @param {string} repo the repository
452
+ * @param {string} ref the ref
453
+ * @returns {Promise<object[]>} the keys
454
+ */
455
+ async function keysAt(repo, ref) {
456
+ const text = await git(repo, ["show", `${ref}:${PASSKEYS_FILE}`]).catch(() => null);
457
+ try {
458
+ return text === null ? [] : parsePasskeys(text);
459
+ } catch (err) {
460
+ throw new AcceptError(`${PASSKEYS_FILE} on ${ref} is invalid: ${err.message}`);
461
+ }
274
462
  }
275
463
 
276
464
  /**
@@ -279,15 +467,22 @@ export async function finish({
279
467
  * @param {string} input.repo the repository
280
468
  * @param {{ pr: number | null, branch: string | null }} input.target as in finish
281
469
  * @param {object[]} input.accepts the checked accepts and exclusions
470
+ * @param {object[]} input.rejects the checked rejects, for a version 2 record
282
471
  * @param {object} input.first the results.json of the first decided project
283
472
  * @param {Date} input.now the review time
284
473
  * @param {(step: string) => void} input.progress as in finish
285
474
  * @param {object} input.config as in finish
286
- * @returns {Promise<{ commit: string, branch: string }>} the pushed commit and branch
475
+ * @param {{ record: object, pendingKeys?: object[], origin: string } | null} input.approval as in finish
476
+ * @returns {Promise<{ commit: string, branch: string, record: string | null }>} the pushed commit
477
+ * and branch, and the record's path (null for a seed pushed earlier and reused)
287
478
  */
288
- async function commitAccepts({ repo, target, accepts, first, now, progress, config }) {
479
+ async function commitAccepts({ repo, target, accepts, rejects, first, now, progress, config, approval }) {
289
480
  const { baselines, defaultBranch } = config;
290
- const tracking = `refs/remotes/origin/${defaultBranch}`;
481
+ // Finish fetches into refs of its own, never the remote-tracking refs a page reload fetches
482
+ // into at the same time (two fetches of one ref fail on its lock).
483
+ const own = (b) => `refs/visual-review/origin/${b}`;
484
+ const fetch = (b) => git(repo, ["fetch", "-q", "--no-write-fetch-head", "origin", `+refs/heads/${b}:${own(b)}`]);
485
+ const tracking = own(defaultBranch);
291
486
  const isMaster = target.pr === null;
292
487
  const lfs = await lfsProblem(repo);
293
488
  if (lfs) {
@@ -319,25 +514,49 @@ async function commitAccepts({ repo, target, accepts, first, now, progress, conf
319
514
  }
320
515
  }
321
516
 
322
- await git(repo, ["fetch", "-q", "origin", `+refs/heads/${defaultBranch}:${tracking}`]);
517
+ await fetch(defaultBranch);
323
518
  if (isMaster) {
324
519
  if ((await git(repo, ["ls-remote", "--heads", "origin", branch])) !== "") {
520
+ // A seed this tool pushed whose pull request failed to open is used as it is.
521
+ // ponytail: assumes the decisions did not change since that push; compare the trees if
522
+ // they can.
523
+ await fetch(branch);
524
+ const [subject, parent] = (await git(repo, ["log", "-1", "--format=%s%n%P", own(branch)])).split("\n");
525
+ if (subject === `${config.commitPrefix}: seed visual baselines` && parent === base) {
526
+ return { commit: await git(repo, ["rev-parse", own(branch)]), branch, record: null };
527
+ }
325
528
  throw new AcceptError(`${branch} already exists on origin: merge or delete it first`);
326
529
  }
327
530
  } 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) {
531
+ await fetch(branch);
532
+ if ((await git(repo, ["rev-parse", own(branch)])) !== base) {
330
533
  throw new AcceptError("capture is stale, wait for CI: the branch has moved past the captured head");
331
534
  }
332
535
  }
333
536
  for (const project of new Set(accepts.map((a) => a.project))) {
334
- if (await behindMaster(repo, base, project, config)) {
537
+ if (await behindMaster(repo, base, project, config, tracking)) {
335
538
  throw new AcceptError(
336
539
  `merge ${defaultBranch} into the branch first: ${defaultBranch} has newer ${project} baselines`,
337
540
  );
338
541
  }
339
542
  }
340
543
 
544
+ // The items come from the captured commit, as prepareRecord's did: the record built here must
545
+ // hash to exactly what the owner approved.
546
+ const plan = await planWrites(repo, base, writes, baselines);
547
+ const body = buildRecord({
548
+ version: approval ? 2 : 1,
549
+ target,
550
+ first,
551
+ items: plan.items,
552
+ rejects,
553
+ now,
554
+ baselines,
555
+ });
556
+ if (approval && !recordHash(body).equals(recordHash(approval.record))) {
557
+ throw new AcceptError("the record changed after you approved it; press Finish again");
558
+ }
559
+
341
560
  const tree = join(repo, config.workDir, "worktrees", `accept-${isMaster ? "master" : target.pr}`);
342
561
  await removeWorktree(repo, tree);
343
562
  await git(repo, ["worktree", "add", "-q", "--detach", tree, base]);
@@ -350,29 +569,25 @@ async function commitAccepts({ repo, target, accepts, first, now, progress, conf
350
569
  await put(join(tree, ".gitattributes"), `${rules}\n`);
351
570
  await git(tree, ["add", "--", ".gitattributes"]);
352
571
  }
353
- const items = [];
354
- const counts = { accept: 0, exclude: 0, remove: 0 };
572
+ const { items, counts } = plan;
355
573
  progress(`writing ${writes.length} ${writes.length === 1 ? "file" : "files"}`);
356
- for (const w of writes) {
357
- items.push(...(await write(tree, w, counts, baselines)));
574
+ for (const f of plan.files) {
575
+ await (f.bytes === null ? rm(join(tree, f.path), { force: true }) : put(join(tree, f.path), f.bytes));
358
576
  }
359
577
  const stamp = now.toISOString().replace(/[-:]/g, "").replace(/\.\d+/, "");
360
578
  const record = `${baselines}/reviews/${stamp}-${isMaster ? "master" : `pr${target.pr}`}.json`;
361
- const body = {
362
- version: 1,
363
- unproven: true,
364
- pr: target.pr,
365
- subject: {
366
- builtMerge: first.commit,
367
- head: first.headSha,
368
- runId: first.runId,
369
- runAttempt: first.runAttempt,
370
- environment: first.environment,
371
- scale: first.scale ?? 1,
372
- },
373
- items: dedupe(items).sort((a, b) => a.path.localeCompare(b.path)),
374
- reviewedAt: now.toISOString(),
375
- };
579
+ if (approval) {
580
+ // The gate's own check, on the bytes about to be committed, with the default branch's
581
+ // keys as just fetched and the keys this server registered.
582
+ const keys = [...(await keysAt(repo, tracking)), ...(approval.pendingKeys ?? [])];
583
+ const why = verifyApproval({ ...body, approval: approval.record.approval }, keys, {
584
+ origin: approval.origin,
585
+ });
586
+ if (why) {
587
+ throw new AcceptError(`the approval does not verify (${why}); nothing was committed`);
588
+ }
589
+ body.approval = approval.record.approval;
590
+ }
376
591
  await put(join(tree, record), `${JSON.stringify(body, null, 2)}\n`);
377
592
  progress("committing");
378
593
  await git(tree, ["add", "-A", "--", baselines]);
@@ -406,13 +621,87 @@ async function commitAccepts({ repo, target, accepts, first, now, progress, conf
406
621
  progress("uploading images to LFS (checking the commit has them all)");
407
622
  await git(tree, ["lfs", "push", "origin", "HEAD"]);
408
623
  progress("pushing");
409
- await git(tree, ["push", "-q", "--no-verify", "origin", `HEAD:refs/heads/${branch}`]);
410
- return { commit: await git(tree, ["rev-parse", "HEAD"]), branch };
624
+ await git(tree, ["push", "-q", "--no-verify", "origin", `HEAD:refs/heads/${branch}`]).catch((err) => {
625
+ // git's own advice ("git pull") is wrong here: the capture no longer matches the branch.
626
+ throw /\[rejected\].*\((fetch first|non-fast-forward)\)/.test(err.message)
627
+ ? new AcceptError(
628
+ "capture is stale, wait for CI: the branch moved past the captured head while Finish ran; nothing was pushed",
629
+ )
630
+ : err;
631
+ });
632
+ return { commit: await git(tree, ["rev-parse", "HEAD"]), branch, record };
411
633
  } catch (err) {
412
634
  throw err instanceof AcceptError ? err : new AcceptError(err.message);
413
635
  } finally {
636
+ // A cleanup failure must not hide what was pushed; the next Finish removes the tree.
637
+ await removeWorktree(repo, tree).catch((err) =>
638
+ console.error(`visual-review: could not remove the accept worktree ${tree}: ${err.message}`),
639
+ );
640
+ }
641
+ }
642
+
643
+ /**
644
+ * Opens the pull request that registers a passkey: the default branch's passkeys.json (created
645
+ * when absent) with the entry appended, on a new branch. Merging it is the owner's trust step;
646
+ * from then on the gate requires approvals.
647
+ * @param {object} input the work
648
+ * @param {string} input.repo the repository
649
+ * @param {Function} input.gh the gh runner
650
+ * @param {{ id: string, publicKey: string, rpId: string, label: string, registeredAt: string }} input.entry
651
+ * the key, as verifyRegistration accepted it
652
+ * @param {Date} [input.now] when
653
+ * @param {{ defaultBranch: string, workDir: string, commitPrefix: string }} input.config the settings
654
+ * @returns {Promise<{ branch: string, pullRequest: string, gated: boolean }>} the pushed branch, its
655
+ * pull request, and whether the visual gate fails it (the default branch already holds a key)
656
+ */
657
+ export async function proposeKey({ repo, gh, entry, now = new Date(), config }) {
658
+ const { defaultBranch } = config;
659
+ const tracking = `refs/visual-review/origin/${defaultBranch}`;
660
+ const branch = `visual/passkey-${now.toISOString().replace(/[-:]/g, "").replace(/\.\d+/, "")}`;
661
+ const tree = join(repo, config.workDir, "worktrees", "passkey");
662
+ let keys;
663
+ try {
664
+ await git(repo, ["fetch", "-q", "--no-write-fetch-head", "origin", `+refs/heads/${defaultBranch}:${tracking}`]);
665
+ keys = await keysAt(repo, tracking);
666
+ if (keys.some((k) => k.id === entry.id)) {
667
+ throw new AcceptError("this passkey is already registered");
668
+ }
414
669
  await removeWorktree(repo, tree);
670
+ await git(repo, ["worktree", "add", "-q", "--detach", tree, tracking]);
671
+ try {
672
+ await put(
673
+ join(tree, PASSKEYS_FILE),
674
+ `${JSON.stringify({ version: 1, keys: [...keys, entry] }, null, 4)}\n`,
675
+ );
676
+ await git(tree, ["add", "--", PASSKEYS_FILE]);
677
+ const message =
678
+ `${config.commitPrefix}: register a visual review passkey\n\n` +
679
+ `Adds the passkey "${entry.label}" for ${entry.rpId} (credential ${entry.id}).\n`;
680
+ await git(tree, ["commit", "-q", "--no-verify", "-F", "-"], message);
681
+ await git(tree, ["push", "-q", "--no-verify", "origin", `HEAD:refs/heads/${branch}`]);
682
+ } finally {
683
+ await removeWorktree(repo, tree).catch((err) =>
684
+ console.error(`visual-review: could not remove the passkey worktree ${tree}: ${err.message}`),
685
+ );
686
+ }
687
+ } catch (err) {
688
+ throw err instanceof AcceptError ? err : new AcceptError(err.message);
415
689
  }
690
+ const gated = keys.length > 0;
691
+ const pullRequest = await createPullRequest(gh, {
692
+ title: `${config.commitPrefix}: register a visual review passkey`,
693
+ head: branch,
694
+ base: defaultBranch,
695
+ body: [
696
+ `Registers the passkey "${entry.label}" for the review page on \`${entry.rpId}\`, credential id \`${entry.id}\`.`,
697
+ "",
698
+ gated
699
+ ? `${PASSKEYS_FILE} on ${defaultBranch} already holds a key, so the visual gate fails this pull request: a new key is trusted only when an administrator merges it past the gate.`
700
+ : `Merging this turns approval enforcement on: from then on the visual gate accepts a review record a pull request adds only when this passkey (or another key in \`${PASSKEYS_FILE}\` on ${defaultBranch}) approved it with Face ID or Touch ID.`,
701
+ "Merge it only if you pressed Register passkey yourself just now and the review page showed this credential id.",
702
+ ].join("\n"),
703
+ });
704
+ return { branch, pullRequest, gated };
416
705
  }
417
706
 
418
707
  /**
@@ -421,62 +710,94 @@ async function commitAccepts({ repo, target, accepts, first, now, progress, conf
421
710
  * @param {string} head the captured head
422
711
  * @param {string} project the project id
423
712
  * @param {{ defaultBranch: string, baselines: string }} config the settings
713
+ * @param {string} [tracking] the fetched default branch
424
714
  * @returns {Promise<boolean>} true when the branch must merge the default branch before an accept
425
715
  */
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
- ]);
716
+ export async function behindMaster(
717
+ repo,
718
+ head,
719
+ project,
720
+ { defaultBranch, baselines },
721
+ tracking = `refs/remotes/origin/${defaultBranch}`,
722
+ ) {
723
+ const newest = await git(repo, ["log", "-1", "--format=%H", tracking, "--", `${baselines}/${project}/`]);
435
724
  return newest !== "" && !(await gitOk(repo, ["merge-base", "--is-ancestor", newest, head]));
436
725
  }
437
726
 
438
727
  /**
439
- * Writes one decision into the worktree.
440
- * @param {string} tree the worktree
441
- * @param {object} w the decision, its results item, and for an accept the verified bytes
442
- * @param {{ accept: number, exclude: number, remove: number }} counts tallied here
728
+ * What the decisions write, computed from the captured commit, never from a worktree: prepareRecord
729
+ * and the commit both use it, so the record approved and the record committed cannot drift.
730
+ * @param {string} repo the repository
731
+ * @param {string} base the captured commit
732
+ * @param {object[]} writes the accepts and exclusions, with their results items (and, for the
733
+ * commit, an accept's verified bytes)
443
734
  * @param {string} baselines the baselines directory
444
- * @returns {Promise<{ path: string, from: string | null, to: string | null, reason: string | null,
445
- * movedFrom?: string, movedTo?: string }[]>} its record items: two for a renamed story, whose
446
- * baseline moves to its new name
735
+ * @returns {Promise<{ items: { path: string, from: string | null, to: string | null,
736
+ * reason: string | null, movedFrom?: string, movedTo?: string }[],
737
+ * files: { path: string, bytes: Buffer | string | null }[],
738
+ * counts: { accept: number, exclude: number, remove: number } }>} the record items (two for a
739
+ * renamed story, whose baseline moves to its new name), the files to write in order (null
740
+ * bytes delete), and the tallies for the commit message
447
741
  */
448
- async function write(tree, w, counts, baselines) {
449
- const dir = `${baselines}/${w.project}`;
450
- if (w.decision === "exclude") {
451
- const path = `${dir}/${w.item.id}.json`;
452
- const old = await readFile(join(tree, path)).catch(() => null);
453
- const settings = { ...(old ? JSON.parse(old) : {}), disableSnapshot: true, reason: w.reason };
454
- const bytes = `${JSON.stringify(settings, null, 2)}\n`;
455
- await put(join(tree, path), bytes);
456
- counts.exclude++;
457
- return [{ path, from: old && sha256(old), to: sha256(bytes), reason: `exclude: ${w.reason}` }];
458
- }
459
- const path = `${dir}/${w.item.file}`;
460
- if (w.item.status === "removed") {
461
- await rm(join(tree, path), { force: true });
462
- counts.remove++;
463
- return [{ path, from: w.item.baseline, to: null, reason: w.reason }];
464
- }
465
- await put(join(tree, path), w.bytes);
466
- counts.accept++;
467
- if (!w.item.from) {
468
- return [{ path, from: w.item.baseline, to: w.item.capture, reason: w.reason }];
742
+ async function planWrites(repo, base, writes, baselines) {
743
+ const items = [];
744
+ const files = [];
745
+ const counts = { accept: 0, exclude: 0, remove: 0 };
746
+ // A settings file written earlier in this Finish (two modes of one story) is read as written.
747
+ const written = new Map();
748
+ const old = async (path) => (written.has(path) ? written.get(path) : await blobAt(repo, base, path));
749
+ for (const w of writes) {
750
+ const dir = `${baselines}/${w.project}`;
751
+ if (w.decision === "exclude") {
752
+ const path = `${dir}/${w.item.id}.json`;
753
+ const before = await old(path);
754
+ const settings = { ...(before ? JSON.parse(before) : {}), disableSnapshot: true, reason: w.reason };
755
+ const bytes = `${JSON.stringify(settings, null, 2)}\n`;
756
+ written.set(path, bytes);
757
+ files.push({ path, bytes });
758
+ counts.exclude++;
759
+ items.push({ path, from: before && sha256(before), to: sha256(bytes), reason: `exclude: ${w.reason}` });
760
+ continue;
761
+ }
762
+ const path = `${dir}/${w.item.file}`;
763
+ if (w.item.status === "removed") {
764
+ files.push({ path, bytes: null });
765
+ counts.remove++;
766
+ items.push({ path, from: w.item.baseline, to: null, reason: w.reason });
767
+ continue;
768
+ }
769
+ files.push({ path, bytes: w.bytes ?? null });
770
+ counts.accept++;
771
+ if (!w.item.from) {
772
+ items.push({ path, from: w.item.baseline, to: w.item.capture, reason: w.reason });
773
+ continue;
774
+ }
775
+ // A rename: the old id's baseline (of this mode) goes, the new one takes its place. For a
776
+ // moved item the bytes are the same, so git sees a rename and the LFS pointer is unchanged.
777
+ const oldPath = `${dir}/${w.item.mode === null ? w.item.from : `${w.item.from}.${w.item.mode}`}.png`;
778
+ files.push({ path: oldPath, bytes: null });
779
+ items.push(
780
+ { path: oldPath, from: w.item.baseline, to: null, reason: w.reason, movedTo: path },
781
+ { path, from: null, to: w.item.capture, reason: w.reason, movedFrom: oldPath },
782
+ );
469
783
  }
470
- // A rename: the old id's baseline (of this mode) goes, the new one takes its place. For a
471
- // moved item the bytes are the same, so git sees a rename and the LFS pointer is unchanged.
472
- const oldPath = `${dir}/${w.item.mode === null ? w.item.from : `${w.item.from}.${w.item.mode}`}.png`;
473
- await rm(join(tree, oldPath), { force: true });
474
- return [
475
- { path: oldPath, from: w.item.baseline, to: null, reason: w.reason, movedTo: path },
476
- { path, from: null, to: w.item.capture, reason: w.reason, movedFrom: oldPath },
477
- ];
784
+ return { items, files, counts };
478
785
  }
479
786
 
787
+ /**
788
+ * A file's exact bytes at a commit (git's output, untrimmed), or null when it has none.
789
+ * @param {string} repo the repository
790
+ * @param {string} ref the commit
791
+ * @param {string} path the path
792
+ * @returns {Promise<Buffer | null>} the bytes
793
+ */
794
+ const blobAt = (repo, ref, path) =>
795
+ new Promise((resolve) =>
796
+ execFile("git", ["show", `${ref}:${path}`], { cwd: repo, encoding: "buffer", maxBuffer: 1 << 26 }, (err, out) =>
797
+ resolve(err ? null : out),
798
+ ),
799
+ );
800
+
480
801
  // Two modes of one story excluded together write one settings file: keep one record item.
481
802
  const dedupe = (items) => [...new Map(items.map((i) => [i.path, i])).values()];
482
803
 
@@ -487,7 +808,8 @@ async function put(path, data) {
487
808
 
488
809
  async function removeWorktree(repo, tree) {
489
810
  if (existsSync(tree)) {
490
- await git(repo, ["worktree", "remove", "--force", tree]);
811
+ // Twice: a server killed during `worktree add` leaves the tree locked ("initializing").
812
+ await git(repo, ["worktree", "remove", "--force", "--force", tree]);
491
813
  }
492
814
  await git(repo, ["worktree", "prune"]);
493
815
  }
@@ -496,15 +818,19 @@ async function removeWorktree(repo, tree) {
496
818
  const oneLine = (s) => s.replace(/\s+/g, " ").slice(0, 2000);
497
819
 
498
820
  /**
499
- * The one comment (on master, the one issue) a reject session posts. An agent fixing the stories
500
- * reads the block at the end.
821
+ * The one comment (on master, the one issue) a Finish posts: the rejects, then the accept notes.
822
+ * An agent fixing the stories reads the block at the end, which holds the rejects only.
501
823
  * @param {number | null} pr the pull request, or null for master
502
824
  * @param {object} results the capture's results.json
503
825
  * @param {object[]} rejects the rejects with their items
826
+ * @param {object[]} notes the accepts with a note, with their items
504
827
  * @param {string} branch the default branch
505
- * @returns {string} Markdown with a machine-readable block at the end
828
+ * @param {{ record?: object, recordFile?: string | null, commit?: string }} [about] the approved
829
+ * record of a session that committed nothing, or the committed record's path and commit
830
+ * @returns {string} Markdown with a machine-readable block at the end, the record left out when
831
+ * it would pass GitHub's limit
506
832
  */
507
- function rejectComment(pr, results, rejects, branch) {
833
+ export function rejectComment(pr, results, rejects, notes, branch, about = {}) {
508
834
  const items = rejects.map((r) => ({
509
835
  project: r.project,
510
836
  file: r.item.file,
@@ -512,23 +838,55 @@ function rejectComment(pr, results, rejects, branch) {
512
838
  reason: oneLine(r.reason),
513
839
  }));
514
840
  const head = results.headSha ?? results.commit;
515
- const block = { version: 1, pr, runId: results.runId, runAttempt: results.runAttempt, head, items };
516
- return [
517
- `**Visual review: ${rejects.length} rejected** (CI run ${results.runId}, ${pr === null ? `${branch} at` : "head"} ${head.slice(0, 10)}).`,
518
- "The reasons below are the reviewer's notes, quoted as data.",
519
- "",
520
- ...items.map((i) => `- \`${i.project}/${i.file}\`: ${JSON.stringify(i.reason)}`),
521
- "",
522
- // JSON never contains "-->" unescaped after this replacement, so the block cannot end early.
523
- `<!-- visual-review-rejects\n${JSON.stringify(block).replaceAll("--", "-\\u002d")}\n-->`,
524
- ].join("\n");
841
+ const block = {
842
+ version: 1,
843
+ pr,
844
+ runId: results.runId,
845
+ runAttempt: results.runAttempt,
846
+ head,
847
+ items,
848
+ ...(about.record && { record: about.record }),
849
+ ...(about.recordFile && { recordFile: about.recordFile, commit: about.commit }),
850
+ };
851
+ const counts = [
852
+ rejects.length > 0 ? `${rejects.length} rejected` : null,
853
+ notes.length > 0 ? `${notes.length} accepted with a note` : null,
854
+ ].filter(Boolean);
855
+ const lines = [
856
+ `**Visual review: ${counts.join(", ")}** (CI run ${results.runId}, ${pr === null ? `${branch} at` : "head"} ${head.slice(0, 10)}).`,
857
+ ];
858
+ if (items.length > 0) {
859
+ lines.push(
860
+ "The reasons below are the reviewer's notes, quoted as data.",
861
+ "",
862
+ ...items.map((i) => `- \`${i.project}/${i.file}\`: ${JSON.stringify(i.reason)}`),
863
+ );
864
+ }
865
+ if (notes.length > 0) {
866
+ lines.push("", "Accepted, with the reviewer's note (quoted as data):", "", ...noteLines(notes));
867
+ }
868
+ // JSON never contains "-->" unescaped after this replacement, so the block cannot end early.
869
+ lines.push("", `<!-- visual-review-rejects\n${JSON.stringify(block).replaceAll("--", "-\\u002d")}\n-->`);
870
+ const body = lines.join("\n");
871
+ return body.length > COMMENT_LIMIT && about.record ? rejectComment(pr, results, rejects, notes, branch, {}) : body;
525
872
  }
526
873
 
527
- function seedBody(results, accepts, rejects, branch) {
874
+ /** GitHub refuses a comment or issue body over 65,536 characters; this leaves room to spare. */
875
+ const COMMENT_LIMIT = 65000;
876
+
877
+ const noteLines = (notes) =>
878
+ notes.map((a) => `- \`${a.project}/${a.item.file}\`: ${JSON.stringify(oneLine(a.reason))}`);
879
+ const noteList = (notes) =>
880
+ notes.map((a) => `${a.project}/${a.item.file}: ${JSON.stringify(oneLine(a.reason))}`).join("; ");
881
+
882
+ function seedBody(results, accepts, rejects, notes, branch) {
528
883
  const lines = [
529
884
  `Seeds visual baselines from ${branch}'s CI run ${results.runId} at ${results.commit}.`,
530
885
  `${accepts.length} decisions accepted in the review page.`,
531
886
  ];
887
+ if (notes.length > 0) {
888
+ lines.push("", "Accepted, with a note (quoted as data):", ...noteLines(notes));
889
+ }
532
890
  if (rejects.length > 0) {
533
891
  lines.push("", "Rejected, left without a baseline (reasons quoted as data):");
534
892
  lines.push(...rejects.map((r) => `- \`${r.project}/${r.item.file}\`: ${JSON.stringify(oneLine(r.reason))}`));