@graphty/visual-review 0.2.1 → 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
 
@@ -193,10 +200,16 @@ function check(projects, decisions) {
193
200
  * @param {Date} [input.now] the review time
194
201
  * @param {(step: string) => void} [input.progress] told each step as it starts, for the page
195
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)
196
206
  * @returns {Promise<{ commit: string | null, branch: string | null, pullRequest: string | null,
197
- * issue: string | null, rejects: number, status: string | null, statusError: string | null }>}
198
- * what was pushed and posted (`issue`: master's rejects; `status`: the commit status's
199
- * 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)
200
213
  */
201
214
  export async function finish({
202
215
  repo,
@@ -209,6 +222,7 @@ export async function finish({
209
222
  now = new Date(),
210
223
  progress = () => {},
211
224
  config,
225
+ approval = null,
212
226
  }) {
213
227
  progress("checking");
214
228
  const { accepts, rejects } = check(projects, decisions);
@@ -217,13 +231,26 @@ export async function finish({
217
231
  throw new AcceptError("nothing decided");
218
232
  }
219
233
  const isMaster = target.pr === null;
234
+ const notes = accepts.filter((a) => a.decision === "accept" && a.reason !== null);
220
235
 
221
236
  let commit = null;
237
+ let commentError = null;
238
+ let record = null;
222
239
  let branch = target.branch;
223
240
  let pullRequest = null;
224
241
  let issue = null;
225
242
  if (accepts.length > 0) {
226
- ({ 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
+ }));
227
254
  if (isMaster) {
228
255
  progress("opening the pull request");
229
256
  try {
@@ -231,7 +258,7 @@ export async function finish({
231
258
  title: `${config.commitPrefix}: seed visual baselines`,
232
259
  head: branch,
233
260
  base: config.defaultBranch,
234
- body: seedBody(first, accepts, rejects, config.defaultBranch),
261
+ body: seedBody(first, accepts, rejects, notes, config.defaultBranch),
235
262
  });
236
263
  } catch (err) {
237
264
  const e = new AcceptError(
@@ -244,12 +271,24 @@ export async function finish({
244
271
  }
245
272
  }
246
273
  }
247
- 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) {
248
278
  try {
249
279
  // Master has no pull request to comment on: its rejects are stories that do not look
250
280
  // right yet, so they become one issue an agent can pick up.
251
- progress(isMaster ? "opening the issue for the rejects" : "posting the rejects");
252
- 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
+ );
253
292
  if (isMaster) {
254
293
  issue = await createIssue(gh, {
255
294
  title: `Visual review: ${rejects.length} ${rejects.length === 1 ? "story" : "stories"} rejected on ${config.defaultBranch}`,
@@ -263,22 +302,41 @@ export async function finish({
263
302
  if (commit === null) {
264
303
  throw err;
265
304
  }
266
- const e = new AcceptError(
267
- `the accepts were pushed as ${commit.slice(0, 10)}, but the reject comment failed: ${err.message}. ` +
268
- "Press Finish again to post the rejects.",
269
- );
270
- e.committed = commit;
271
- 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
+ }
272
328
  }
273
329
  }
274
330
  // One commit status per Finish, on the commit it pushed, or the captured one when it pushed
275
331
  // none. A failure here does not undo what was pushed and posted; the page shows it.
276
332
  const accepted = accepts.filter((a) => a.decision === "accept").length;
277
- const excluded = accepts.length - accepted;
278
- const state = rejects.length > 0 ? "failure" : undecided > 0 || unloaded.length > 0 ? "pending" : "success";
279
- const status =
280
- `Reviewed: ${accepted} accepted, ${rejects.length} rejected, ${excluded} excluded, ` +
281
- `${undecided} left undecided${unloaded.length > 0 ? `, not loaded: ${unloaded.join(", ")}` : ""}`;
333
+ const { state, description: status } = commitStatus({
334
+ accepted,
335
+ rejected: rejects.length,
336
+ excluded: accepts.length - accepted,
337
+ undecided,
338
+ unloaded,
339
+ });
282
340
  let statusError = null;
283
341
  progress("posting the status");
284
342
  try {
@@ -286,7 +344,121 @@ export async function finish({
286
344
  } catch (err) {
287
345
  statusError = err.message;
288
346
  }
289
- 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
+ }
290
462
  }
291
463
 
292
464
  /**
@@ -295,13 +467,16 @@ export async function finish({
295
467
  * @param {string} input.repo the repository
296
468
  * @param {{ pr: number | null, branch: string | null }} input.target as in finish
297
469
  * @param {object[]} input.accepts the checked accepts and exclusions
470
+ * @param {object[]} input.rejects the checked rejects, for a version 2 record
298
471
  * @param {object} input.first the results.json of the first decided project
299
472
  * @param {Date} input.now the review time
300
473
  * @param {(step: string) => void} input.progress as in finish
301
474
  * @param {object} input.config as in finish
302
- * @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)
303
478
  */
304
- async function commitAccepts({ repo, target, accepts, first, now, progress, config }) {
479
+ async function commitAccepts({ repo, target, accepts, rejects, first, now, progress, config, approval }) {
305
480
  const { baselines, defaultBranch } = config;
306
481
  // Finish fetches into refs of its own, never the remote-tracking refs a page reload fetches
307
482
  // into at the same time (two fetches of one ref fail on its lock).
@@ -348,7 +523,7 @@ async function commitAccepts({ repo, target, accepts, first, now, progress, conf
348
523
  await fetch(branch);
349
524
  const [subject, parent] = (await git(repo, ["log", "-1", "--format=%s%n%P", own(branch)])).split("\n");
350
525
  if (subject === `${config.commitPrefix}: seed visual baselines` && parent === base) {
351
- return { commit: await git(repo, ["rev-parse", own(branch)]), branch };
526
+ return { commit: await git(repo, ["rev-parse", own(branch)]), branch, record: null };
352
527
  }
353
528
  throw new AcceptError(`${branch} already exists on origin: merge or delete it first`);
354
529
  }
@@ -366,6 +541,22 @@ async function commitAccepts({ repo, target, accepts, first, now, progress, conf
366
541
  }
367
542
  }
368
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
+
369
560
  const tree = join(repo, config.workDir, "worktrees", `accept-${isMaster ? "master" : target.pr}`);
370
561
  await removeWorktree(repo, tree);
371
562
  await git(repo, ["worktree", "add", "-q", "--detach", tree, base]);
@@ -378,29 +569,25 @@ async function commitAccepts({ repo, target, accepts, first, now, progress, conf
378
569
  await put(join(tree, ".gitattributes"), `${rules}\n`);
379
570
  await git(tree, ["add", "--", ".gitattributes"]);
380
571
  }
381
- const items = [];
382
- const counts = { accept: 0, exclude: 0, remove: 0 };
572
+ const { items, counts } = plan;
383
573
  progress(`writing ${writes.length} ${writes.length === 1 ? "file" : "files"}`);
384
- for (const w of writes) {
385
- 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));
386
576
  }
387
577
  const stamp = now.toISOString().replace(/[-:]/g, "").replace(/\.\d+/, "");
388
578
  const record = `${baselines}/reviews/${stamp}-${isMaster ? "master" : `pr${target.pr}`}.json`;
389
- const body = {
390
- version: 1,
391
- unproven: true,
392
- pr: target.pr,
393
- subject: {
394
- builtMerge: first.commit,
395
- head: first.headSha,
396
- runId: first.runId,
397
- runAttempt: first.runAttempt,
398
- environment: first.environment,
399
- scale: first.scale ?? 1,
400
- },
401
- items: dedupe(items).sort((a, b) => a.path.localeCompare(b.path)),
402
- reviewedAt: now.toISOString(),
403
- };
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
+ }
404
591
  await put(join(tree, record), `${JSON.stringify(body, null, 2)}\n`);
405
592
  progress("committing");
406
593
  await git(tree, ["add", "-A", "--", baselines]);
@@ -442,7 +629,7 @@ async function commitAccepts({ repo, target, accepts, first, now, progress, conf
442
629
  )
443
630
  : err;
444
631
  });
445
- return { commit: await git(tree, ["rev-parse", "HEAD"]), branch };
632
+ return { commit: await git(tree, ["rev-parse", "HEAD"]), branch, record };
446
633
  } catch (err) {
447
634
  throw err instanceof AcceptError ? err : new AcceptError(err.message);
448
635
  } finally {
@@ -453,6 +640,70 @@ async function commitAccepts({ repo, target, accepts, first, now, progress, conf
453
640
  }
454
641
  }
455
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
+ }
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);
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 };
705
+ }
706
+
456
707
  /**
457
708
  * Whether the default branch holds a commit touching the project's baselines that `head` lacks.
458
709
  * @param {string} repo the repository, with the default branch fetched
@@ -474,47 +725,79 @@ export async function behindMaster(
474
725
  }
475
726
 
476
727
  /**
477
- * Writes one decision into the worktree.
478
- * @param {string} tree the worktree
479
- * @param {object} w the decision, its results item, and for an accept the verified bytes
480
- * @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)
481
734
  * @param {string} baselines the baselines directory
482
- * @returns {Promise<{ path: string, from: string | null, to: string | null, reason: string | null,
483
- * movedFrom?: string, movedTo?: string }[]>} its record items: two for a renamed story, whose
484
- * 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
485
741
  */
486
- async function write(tree, w, counts, baselines) {
487
- const dir = `${baselines}/${w.project}`;
488
- if (w.decision === "exclude") {
489
- const path = `${dir}/${w.item.id}.json`;
490
- const old = await readFile(join(tree, path)).catch(() => null);
491
- const settings = { ...(old ? JSON.parse(old) : {}), disableSnapshot: true, reason: w.reason };
492
- const bytes = `${JSON.stringify(settings, null, 2)}\n`;
493
- await put(join(tree, path), bytes);
494
- counts.exclude++;
495
- return [{ path, from: old && sha256(old), to: sha256(bytes), reason: `exclude: ${w.reason}` }];
496
- }
497
- const path = `${dir}/${w.item.file}`;
498
- if (w.item.status === "removed") {
499
- await rm(join(tree, path), { force: true });
500
- counts.remove++;
501
- return [{ path, from: w.item.baseline, to: null, reason: w.reason }];
502
- }
503
- await put(join(tree, path), w.bytes);
504
- counts.accept++;
505
- if (!w.item.from) {
506
- 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
+ );
507
783
  }
508
- // A rename: the old id's baseline (of this mode) goes, the new one takes its place. For a
509
- // moved item the bytes are the same, so git sees a rename and the LFS pointer is unchanged.
510
- const oldPath = `${dir}/${w.item.mode === null ? w.item.from : `${w.item.from}.${w.item.mode}`}.png`;
511
- await rm(join(tree, oldPath), { force: true });
512
- return [
513
- { path: oldPath, from: w.item.baseline, to: null, reason: w.reason, movedTo: path },
514
- { path, from: null, to: w.item.capture, reason: w.reason, movedFrom: oldPath },
515
- ];
784
+ return { items, files, counts };
516
785
  }
517
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
+
518
801
  // Two modes of one story excluded together write one settings file: keep one record item.
519
802
  const dedupe = (items) => [...new Map(items.map((i) => [i.path, i])).values()];
520
803
 
@@ -535,15 +818,19 @@ async function removeWorktree(repo, tree) {
535
818
  const oneLine = (s) => s.replace(/\s+/g, " ").slice(0, 2000);
536
819
 
537
820
  /**
538
- * The one comment (on master, the one issue) a reject session posts. An agent fixing the stories
539
- * 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.
540
823
  * @param {number | null} pr the pull request, or null for master
541
824
  * @param {object} results the capture's results.json
542
825
  * @param {object[]} rejects the rejects with their items
826
+ * @param {object[]} notes the accepts with a note, with their items
543
827
  * @param {string} branch the default branch
544
- * @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
545
832
  */
546
- function rejectComment(pr, results, rejects, branch) {
833
+ export function rejectComment(pr, results, rejects, notes, branch, about = {}) {
547
834
  const items = rejects.map((r) => ({
548
835
  project: r.project,
549
836
  file: r.item.file,
@@ -551,23 +838,55 @@ function rejectComment(pr, results, rejects, branch) {
551
838
  reason: oneLine(r.reason),
552
839
  }));
553
840
  const head = results.headSha ?? results.commit;
554
- const block = { version: 1, pr, runId: results.runId, runAttempt: results.runAttempt, head, items };
555
- return [
556
- `**Visual review: ${rejects.length} rejected** (CI run ${results.runId}, ${pr === null ? `${branch} at` : "head"} ${head.slice(0, 10)}).`,
557
- "The reasons below are the reviewer's notes, quoted as data.",
558
- "",
559
- ...items.map((i) => `- \`${i.project}/${i.file}\`: ${JSON.stringify(i.reason)}`),
560
- "",
561
- // JSON never contains "-->" unescaped after this replacement, so the block cannot end early.
562
- `<!-- visual-review-rejects\n${JSON.stringify(block).replaceAll("--", "-\\u002d")}\n-->`,
563
- ].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;
564
872
  }
565
873
 
566
- 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) {
567
883
  const lines = [
568
884
  `Seeds visual baselines from ${branch}'s CI run ${results.runId} at ${results.commit}.`,
569
885
  `${accepts.length} decisions accepted in the review page.`,
570
886
  ];
887
+ if (notes.length > 0) {
888
+ lines.push("", "Accepted, with a note (quoted as data):", ...noteLines(notes));
889
+ }
571
890
  if (rejects.length > 0) {
572
891
  lines.push("", "Rejected, left without a baseline (reasons quoted as data):");
573
892
  lines.push(...rejects.map((r) => `- \`${r.project}/${r.item.file}\`: ${JSON.stringify(oneLine(r.reason))}`));