@graphty/visual-review 0.0.1 → 0.1.0

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.
@@ -0,0 +1,526 @@
1
+ /**
2
+ * Finish: turns the owner's decisions on one pull request (or on the default branch, for the
3
+ * seed, which the page and this module call "master") into one commit, one push and one comment
4
+ * (on master, one issue).
5
+ *
6
+ * Accepts and exclusions are written as files under the baselines directory (the config's
7
+ * `baselines`) in a throwaway worktree
8
+ * at the captured head, never in the main checkout, together with one review record, and pushed
9
+ * to the pull request's branch. Rejects become one pull request comment. Every accepted PNG is
10
+ * the artifact's file only when its bytes hash to the capture results.json names.
11
+ *
12
+ * Baseline PNGs are stored in Git LFS (the repository's .gitattributes). The commit must hold LFS pointers,
13
+ * never raw PNGs, and the LFS objects must reach GitHub before the commit does; with hooks
14
+ * switched off nothing else uploads them, so this module checks git-lfs is set up, checks every
15
+ * committed PNG is a pointer, and runs `git lfs push` before `git push`.
16
+ */
17
+
18
+ import { createHash } from "node:crypto";
19
+ import { existsSync } from "node:fs";
20
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
21
+ import { dirname, join } from "node:path";
22
+
23
+ import { commentOnPullRequest, createIssue, createPullRequest, exec, postStatus } from "./github.mjs";
24
+ import { isLfsPointer } from "./compare.mjs";
25
+
26
+ const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
27
+
28
+ /** The statuses an item can be accepted or rejected in; unstable and failed are only excluded. */
29
+ const DECIDABLE = new Set(["changed", "new", "removed"]);
30
+ const EXCLUDABLE = new Set([...DECIDABLE, "unstable", "failed"]);
31
+
32
+ /**
33
+ * Refused decisions and failed git commands; the message is shown to the owner as is.
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.
36
+ */
37
+ export class AcceptError extends Error {
38
+ committed = null;
39
+ }
40
+
41
+ /**
42
+ * Runs git with every hook switched off.
43
+ *
44
+ * A repository's hooks (husky's, say: secretlint, commitlint, Commitizen, a pre-push gate) would
45
+ * run in an accept worktree that has no node_modules for them, and some want a terminal. This
46
+ * commit holds only PNG and JSON files the tool wrote under the baselines directory, and its
47
+ * message is a conventional commit (`commitPrefix` in the config; a test checks it). `--no-verify` alone is not enough, because git runs prepare-commit-msg even
48
+ * with it, so the hooks path points nowhere. Signing is left as the repository configures it, so
49
+ * the commit carries the owner's identity and signature. GIT_LFS_SKIP_SMUDGE keeps the worktree's
50
+ * checkout from downloading every baseline image: untouched baselines stay pointer files there.
51
+ * @param {string} cwd the repository or worktree
52
+ * @param {string[]} args git's arguments
53
+ * @param {string} [input] stdin
54
+ * @returns {Promise<string>} stdout
55
+ */
56
+ const git = (cwd, args, input) =>
57
+ exec("git", ["-c", "core.hooksPath=/dev/null", ...args], {
58
+ cwd,
59
+ input,
60
+ env: { ...process.env, HUSKY: "0", GIT_LFS_SKIP_SMUDGE: "1" },
61
+ });
62
+
63
+ /** Images per `git lfs push --object-id`, so a large seed reports its upload as it goes. */
64
+ const LFS_BATCH = 50;
65
+
66
+ const gitOk = (cwd, args) =>
67
+ git(cwd, args).then(
68
+ () => true,
69
+ () => false,
70
+ );
71
+
72
+ /** How to set up git-lfs; the README of @graphty/visual-review, "Requirements", has more. */
73
+ const LFS_SETUP =
74
+ "on Ubuntu 22.04 run `sudo apt-get install git-lfs`, or put the git-lfs binary from " +
75
+ "https://github.com/git-lfs/git-lfs/releases in ~/bin; then run `git lfs install` " +
76
+ '(the @graphty/visual-review README, "Requirements")';
77
+
78
+ /**
79
+ * Why an accept would commit raw PNGs instead of Git LFS pointers, if it would: git-lfs is
80
+ * missing, or its filter is not configured (`git lfs install` never ran).
81
+ * @param {string} repo the repository
82
+ * @returns {Promise<string | null>} the problem and how to fix it, or null when git-lfs is ready
83
+ */
84
+ export async function lfsProblem(repo) {
85
+ const env = await exec("git", ["lfs", "env"], { cwd: repo }).catch(() => null);
86
+ if (env === null) {
87
+ return `git-lfs is not installed (git lfs env failed): ${LFS_SETUP}`;
88
+ }
89
+ if (!/^git config filter\.lfs\.clean = "[^"]/m.test(env)) {
90
+ return `git-lfs is installed but its filter is not configured: ${LFS_SETUP}`;
91
+ }
92
+ return null;
93
+ }
94
+
95
+ /**
96
+ * The generated commit message.
97
+ * @param {{ pr: number | null, counts: { accept: number, exclude: number, remove: number },
98
+ * runId: number, runAttempt: number, record: string, prefix: string }} input what the commit
99
+ * holds, and the conventional-commit type and scope it starts with (`commitPrefix`)
100
+ * @returns {string} a conventional commit message
101
+ */
102
+ export function commitMessage({ pr, counts, runId, runAttempt, record, prefix }) {
103
+ const subject = pr === null ? `${prefix}: seed visual baselines` : `${prefix}: accept visual baselines for #${pr}`;
104
+ const n = (count, one, many) => `${count} ${count === 1 ? one : many}`;
105
+ return [
106
+ subject,
107
+ "",
108
+ `Accepted in the review page: ${n(counts.accept, "image", "images")}, ` +
109
+ `${n(counts.exclude, "exclusion", "exclusions")}, ${n(counts.remove, "removal", "removals")}.`,
110
+ `Captured by CI run ${runId}, attempt ${runAttempt}.`,
111
+ `Record: ${record}`,
112
+ "",
113
+ ].join("\n");
114
+ }
115
+
116
+ /**
117
+ * Why a decision cannot be taken on an item, if it cannot.
118
+ * @param {{ status: string, file: string }} item the results.json item
119
+ * @param {string} decision accept, reject or exclude
120
+ * @param {string | null} reason the owner's reason
121
+ * @returns {{ status: number, message: string } | null} an HTTP status and a message, or null
122
+ */
123
+ export function decisionProblem(item, decision, reason) {
124
+ if (!["accept", "reject", "exclude"].includes(decision)) {
125
+ return { status: 400, message: `unknown decision "${decision}"` };
126
+ }
127
+ const allowed = decision === "exclude" ? EXCLUDABLE : DECIDABLE;
128
+ if (!allowed.has(item.status)) {
129
+ const only = ["unstable", "failed"].includes(item.status);
130
+ return {
131
+ status: 409,
132
+ message: `${item.file} is ${item.status}: ${only ? "it can only be excluded" : "nothing to decide"}`,
133
+ };
134
+ }
135
+ if (reason === null && decision !== "accept") {
136
+ return { status: 400, message: `${item.file}: a ${decision} needs a reason` };
137
+ }
138
+ return null;
139
+ }
140
+
141
+ /**
142
+ * A reason as stored: trimmed and at most 2,000 characters.
143
+ * @param {unknown} r the reason as sent
144
+ * @returns {string | null} the reason, or null when empty
145
+ */
146
+ export const cleanReason = (r) => (typeof r === "string" && r.trim() !== "" ? r.trim().slice(0, 2000) : null);
147
+
148
+ /**
149
+ * Checks every decision against its results.json and sorts it into accepts and rejects.
150
+ * @param {Record<string, { dir: string, results: object }>} projects the captures, by project
151
+ * @param {{ project: string, file: string, decision: string, reason: string | null }[]} decisions
152
+ * the owner's decisions
153
+ * @returns {{ accepts: object[], rejects: object[] }} each with its results item attached
154
+ */
155
+ function check(projects, decisions) {
156
+ const accepts = [];
157
+ const rejects = [];
158
+ for (const d of decisions) {
159
+ const capture = Object.hasOwn(projects, d.project) ? projects[d.project] : undefined;
160
+ const item = capture?.results.items.find((i) => i.file === d.file);
161
+ if (!item) {
162
+ throw new AcceptError(`${d.project}/${d.file} is not in this capture`);
163
+ }
164
+ if (capture.results.local !== null || capture.results.runId === null) {
165
+ throw new AcceptError(`${d.project} is a local preview, not acceptable: only CI captures are`);
166
+ }
167
+ const reason = cleanReason(d.reason);
168
+ const problem = decisionProblem(item, d.decision, reason);
169
+ if (problem) {
170
+ throw new AcceptError(`${d.project}/${problem.message}`);
171
+ }
172
+ (d.decision === "reject" ? rejects : accepts).push({ ...d, reason, item, capture });
173
+ }
174
+ return { accepts, rejects };
175
+ }
176
+
177
+ /**
178
+ * Applies the owner's decisions for one target.
179
+ * @param {object} input the work
180
+ * @param {string} input.repo the repository (its `.worktrees/` holds the accept worktree)
181
+ * @param {Function} input.gh the gh runner
182
+ * @param {{ pr: number | null, branch: string | null }} input.target a pull request and its
183
+ * branch, or `{ pr: null }` for master
184
+ * @param {Record<string, { dir: string, results: object }>} input.projects the downloaded captures
185
+ * @param {{ project: string, file: string, decision: string, reason: string | null }[]} input.decisions
186
+ * what the owner decided: accept, reject or exclude (checked here)
187
+ * @param {number} [input.undecided] how many reviewable items are left undecided, for the status
188
+ * @param {Date} [input.now] the review time
189
+ * @param {(step: string) => void} [input.progress] told each step as it starts, for the page
190
+ * @param {ReturnType<typeof import("./config.mjs").normalizeConfig>} input.config the settings
191
+ * @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)
195
+ */
196
+ export async function finish({
197
+ repo,
198
+ gh,
199
+ target,
200
+ projects,
201
+ decisions,
202
+ undecided = 0,
203
+ now = new Date(),
204
+ progress = () => {},
205
+ config,
206
+ }) {
207
+ progress("checking");
208
+ const { accepts, rejects } = check(projects, decisions);
209
+ const first = (accepts[0] ?? rejects[0])?.capture.results;
210
+ if (!first) {
211
+ throw new AcceptError("nothing decided");
212
+ }
213
+ const isMaster = target.pr === null;
214
+
215
+ let commit = null;
216
+ let branch = target.branch;
217
+ let pullRequest = null;
218
+ let issue = null;
219
+ if (accepts.length > 0) {
220
+ ({ commit, branch } = await commitAccepts({ repo, target, accepts, first, now, progress, config }));
221
+ if (isMaster) {
222
+ 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
+ }
230
+ }
231
+ if (rejects.length > 0) {
232
+ try {
233
+ // Master has no pull request to comment on: its rejects are stories that do not look
234
+ // 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);
237
+ if (isMaster) {
238
+ issue = await createIssue(gh, {
239
+ title: `Visual review: ${rejects.length} ${rejects.length === 1 ? "story" : "stories"} rejected on ${config.defaultBranch}`,
240
+ body,
241
+ labels: config.issueLabels,
242
+ });
243
+ } else {
244
+ await commentOnPullRequest(gh, target.pr, body);
245
+ }
246
+ } catch (err) {
247
+ if (commit === null) {
248
+ throw err;
249
+ }
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;
256
+ }
257
+ }
258
+ // One commit status per Finish, on the commit it pushed, or the captured one when it pushed
259
+ // none. A failure here does not undo what was pushed and posted; the page shows it.
260
+ 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`;
266
+ let statusError = null;
267
+ progress("posting the status");
268
+ try {
269
+ await postStatus(gh, commit ?? (isMaster ? first.commit : first.headSha), { state, description: status });
270
+ } catch (err) {
271
+ statusError = err.message;
272
+ }
273
+ return { commit, branch, pullRequest, issue, rejects: rejects.length, status, statusError };
274
+ }
275
+
276
+ /**
277
+ * Writes, commits and pushes the accepts. Every check runs before the worktree is created.
278
+ * @param {object} input the work
279
+ * @param {string} input.repo the repository
280
+ * @param {{ pr: number | null, branch: string | null }} input.target as in finish
281
+ * @param {object[]} input.accepts the checked accepts and exclusions
282
+ * @param {object} input.first the results.json of the first decided project
283
+ * @param {Date} input.now the review time
284
+ * @param {(step: string) => void} input.progress as in finish
285
+ * @param {object} input.config as in finish
286
+ * @returns {Promise<{ commit: string, branch: string }>} the pushed commit and branch
287
+ */
288
+ async function commitAccepts({ repo, target, accepts, first, now, progress, config }) {
289
+ const { baselines, defaultBranch } = config;
290
+ const tracking = `refs/remotes/origin/${defaultBranch}`;
291
+ const isMaster = target.pr === null;
292
+ const lfs = await lfsProblem(repo);
293
+ if (lfs) {
294
+ throw new AcceptError(lfs);
295
+ }
296
+ const base = isMaster ? first.commit : first.headSha;
297
+ for (const { capture } of accepts) {
298
+ const r = capture.results;
299
+ if ((isMaster ? r.commit : r.headSha) !== base || r.runId !== first.runId) {
300
+ throw new AcceptError("the projects of one Finish must come from one CI run");
301
+ }
302
+ }
303
+ const branch = isMaster ? `visual/seed-${now.toISOString().slice(0, 10)}` : target.branch;
304
+ if (!branch || !(await gitOk(repo, ["check-ref-format", `refs/heads/${branch}`]))) {
305
+ throw new AcceptError(`no usable branch for this target: ${branch}`);
306
+ }
307
+
308
+ // Read and verify every new file before git is touched.
309
+ const writes = [];
310
+ for (const a of accepts) {
311
+ if (a.decision === "accept" && a.item.status !== "removed") {
312
+ const bytes = await readFile(join(a.capture.dir, a.item.file));
313
+ if (sha256(bytes) !== a.item.capture) {
314
+ throw new AcceptError(`${a.project}/${a.item.file} does not match the capture results.json names`);
315
+ }
316
+ writes.push({ ...a, bytes });
317
+ } else {
318
+ writes.push(a);
319
+ }
320
+ }
321
+
322
+ await git(repo, ["fetch", "-q", "origin", `+refs/heads/${defaultBranch}:${tracking}`]);
323
+ if (isMaster) {
324
+ if ((await git(repo, ["ls-remote", "--heads", "origin", branch])) !== "") {
325
+ throw new AcceptError(`${branch} already exists on origin: merge or delete it first`);
326
+ }
327
+ } 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) {
330
+ throw new AcceptError("capture is stale, wait for CI: the branch has moved past the captured head");
331
+ }
332
+ }
333
+ for (const project of new Set(accepts.map((a) => a.project))) {
334
+ if (await behindMaster(repo, base, project, config)) {
335
+ throw new AcceptError(
336
+ `merge ${defaultBranch} into the branch first: ${defaultBranch} has newer ${project} baselines`,
337
+ );
338
+ }
339
+ }
340
+
341
+ const tree = join(repo, config.workDir, "worktrees", `accept-${isMaster ? "master" : target.pr}`);
342
+ await removeWorktree(repo, tree);
343
+ await git(repo, ["worktree", "add", "-q", "--detach", tree, base]);
344
+ try {
345
+ // A seed may be built on a commit older than the rule that stores baselines in Git LFS.
346
+ // Carry the default branch's .gitattributes into it, so the PNGs become pointers and the
347
+ // seed merges back with the same rule.
348
+ const rules = isMaster ? await git(repo, ["show", `${tracking}:.gitattributes`]).catch(() => "") : "";
349
+ if (rules !== "") {
350
+ await put(join(tree, ".gitattributes"), `${rules}\n`);
351
+ await git(tree, ["add", "--", ".gitattributes"]);
352
+ }
353
+ const items = [];
354
+ const counts = { accept: 0, exclude: 0, remove: 0 };
355
+ progress(`writing ${writes.length} ${writes.length === 1 ? "file" : "files"}`);
356
+ for (const w of writes) {
357
+ items.push(await write(tree, w, counts, baselines));
358
+ }
359
+ const stamp = now.toISOString().replace(/[-:]/g, "").replace(/\.\d+/, "");
360
+ 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
+ };
376
+ await put(join(tree, record), `${JSON.stringify(body, null, 2)}\n`);
377
+ progress("committing");
378
+ await git(tree, ["add", "-A", "--", baselines]);
379
+ const message = commitMessage({
380
+ pr: target.pr,
381
+ counts,
382
+ runId: first.runId,
383
+ runAttempt: first.runAttempt,
384
+ record,
385
+ prefix: config.commitPrefix,
386
+ });
387
+ await git(tree, ["commit", "-q", "--no-verify", "-F", "-"], message);
388
+ for (const { path, to } of items) {
389
+ if (
390
+ path.endsWith(".png") &&
391
+ to !== null &&
392
+ !isLfsPointer(Buffer.from(await git(tree, ["cat-file", "blob", `HEAD:${path}`])))
393
+ ) {
394
+ throw new AcceptError(
395
+ `${path} was committed as a raw PNG, not a Git LFS pointer: check .gitattributes`,
396
+ );
397
+ }
398
+ }
399
+ // git lfs push reports progress only to a terminal, so the new images go up in batches the
400
+ // page can count; the push of HEAD after them uploads whatever else the commit needs.
401
+ const oids = [...new Set(writes.filter((w) => w.bytes).map((w) => w.item.capture))];
402
+ for (let i = 0; i < oids.length; i += LFS_BATCH) {
403
+ progress(`uploading images to LFS (${i} of ${oids.length} done)`);
404
+ await git(tree, ["lfs", "push", "--object-id", "origin", ...oids.slice(i, i + LFS_BATCH)]);
405
+ }
406
+ progress("uploading images to LFS (checking the commit has them all)");
407
+ await git(tree, ["lfs", "push", "origin", "HEAD"]);
408
+ 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 };
411
+ } catch (err) {
412
+ throw err instanceof AcceptError ? err : new AcceptError(err.message);
413
+ } finally {
414
+ await removeWorktree(repo, tree);
415
+ }
416
+ }
417
+
418
+ /**
419
+ * Whether the default branch holds a commit touching the project's baselines that `head` lacks.
420
+ * @param {string} repo the repository, with the default branch fetched
421
+ * @param {string} head the captured head
422
+ * @param {string} project the project id
423
+ * @param {{ defaultBranch: string, baselines: string }} config the settings
424
+ * @returns {Promise<boolean>} true when the branch must merge the default branch before an accept
425
+ */
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
+ ]);
435
+ return newest !== "" && !(await gitOk(repo, ["merge-base", "--is-ancestor", newest, head]));
436
+ }
437
+
438
+ /**
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
443
+ * @param {string} baselines the baselines directory
444
+ * @returns {Promise<{ path: string, from: string | null, to: string | null, reason: string | null }>}
445
+ * the record item
446
+ */
447
+ async function write(tree, w, counts, baselines) {
448
+ const dir = `${baselines}/${w.project}`;
449
+ if (w.decision === "exclude") {
450
+ const path = `${dir}/${w.item.id}.json`;
451
+ const old = await readFile(join(tree, path)).catch(() => null);
452
+ const settings = { ...(old ? JSON.parse(old) : {}), disableSnapshot: true, reason: w.reason };
453
+ const bytes = `${JSON.stringify(settings, null, 2)}\n`;
454
+ await put(join(tree, path), bytes);
455
+ counts.exclude++;
456
+ return { path, from: old && sha256(old), to: sha256(bytes), reason: `exclude: ${w.reason}` };
457
+ }
458
+ const path = `${dir}/${w.item.file}`;
459
+ if (w.item.status === "removed") {
460
+ await rm(join(tree, path), { force: true });
461
+ counts.remove++;
462
+ return { path, from: w.item.baseline, to: null, reason: w.reason };
463
+ }
464
+ await put(join(tree, path), w.bytes);
465
+ counts.accept++;
466
+ return { path, from: w.item.baseline, to: w.item.capture, reason: w.reason };
467
+ }
468
+
469
+ // Two modes of one story excluded together write one settings file: keep one record item.
470
+ const dedupe = (items) => [...new Map(items.map((i) => [i.path, i])).values()];
471
+
472
+ async function put(path, data) {
473
+ await mkdir(dirname(path), { recursive: true });
474
+ await writeFile(path, data);
475
+ }
476
+
477
+ async function removeWorktree(repo, tree) {
478
+ if (existsSync(tree)) {
479
+ await git(repo, ["worktree", "remove", "--force", tree]);
480
+ }
481
+ await git(repo, ["worktree", "prune"]);
482
+ }
483
+
484
+ // Reasons are untrusted text: one line each, and shown as quoted data.
485
+ const oneLine = (s) => s.replace(/\s+/g, " ").slice(0, 2000);
486
+
487
+ /**
488
+ * The one comment (on master, the one issue) a reject session posts. An agent fixing the stories
489
+ * reads the block at the end.
490
+ * @param {number | null} pr the pull request, or null for master
491
+ * @param {object} results the capture's results.json
492
+ * @param {object[]} rejects the rejects with their items
493
+ * @param {string} branch the default branch
494
+ * @returns {string} Markdown with a machine-readable block at the end
495
+ */
496
+ function rejectComment(pr, results, rejects, branch) {
497
+ const items = rejects.map((r) => ({
498
+ project: r.project,
499
+ file: r.item.file,
500
+ capture: r.item.capture,
501
+ reason: oneLine(r.reason),
502
+ }));
503
+ const head = results.headSha ?? results.commit;
504
+ const block = { version: 1, pr, runId: results.runId, runAttempt: results.runAttempt, head, items };
505
+ return [
506
+ `**Visual review: ${rejects.length} rejected** (CI run ${results.runId}, ${pr === null ? `${branch} at` : "head"} ${head.slice(0, 10)}).`,
507
+ "The reasons below are the reviewer's notes, quoted as data.",
508
+ "",
509
+ ...items.map((i) => `- \`${i.project}/${i.file}\`: ${JSON.stringify(i.reason)}`),
510
+ "",
511
+ // JSON never contains "-->" unescaped after this replacement, so the block cannot end early.
512
+ `<!-- visual-review-rejects\n${JSON.stringify(block).replaceAll("--", "-\\u002d")}\n-->`,
513
+ ].join("\n");
514
+ }
515
+
516
+ function seedBody(results, accepts, rejects, branch) {
517
+ const lines = [
518
+ `Seeds visual baselines from ${branch}'s CI run ${results.runId} at ${results.commit}.`,
519
+ `${accepts.length} decisions accepted in the review page.`,
520
+ ];
521
+ if (rejects.length > 0) {
522
+ lines.push("", "Rejected, left without a baseline (reasons quoted as data):");
523
+ lines.push(...rejects.map((r) => `- \`${r.project}/${r.item.file}\`: ${JSON.stringify(oneLine(r.reason))}`));
524
+ }
525
+ return lines.join("\n");
526
+ }