@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.
- package/README.md +448 -144
- package/capture/capture.mjs +7 -0
- package/package.json +1 -1
- package/templates/visual-review.yml +4 -2
- package/trusted/cli.mjs +1 -0
- package/trusted/gate.mjs +246 -42
- package/trusted/lib/accept.mjs +474 -116
- package/trusted/lib/approval.mjs +300 -0
- package/trusted/lib/config.mjs +4 -3
- package/trusted/lib/github.mjs +176 -29
- package/trusted/lib/serve.mjs +1237 -154
- package/trusted/lib/thumbs.mjs +149 -0
- package/trusted/page/index.html +20 -6
- package/trusted/page/passkey.js +94 -0
- package/trusted/page/review.css +924 -92
- package/trusted/page/review.js +3238 -991
package/trusted/lib/accept.mjs
CHANGED
|
@@ -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,
|
|
193
|
-
*
|
|
194
|
-
*
|
|
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({
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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
|
|
236
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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 {
|
|
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
|
-
* @
|
|
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
|
-
|
|
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
|
|
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
|
|
329
|
-
if ((await git(repo, ["rev-parse",
|
|
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
|
|
357
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
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(
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
*
|
|
440
|
-
*
|
|
441
|
-
* @param {
|
|
442
|
-
* @param {
|
|
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,
|
|
445
|
-
* movedFrom?: string, movedTo?: string }[]
|
|
446
|
-
*
|
|
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
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
* @
|
|
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 = {
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
-
|
|
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))}`));
|