@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.
- package/README.md +424 -148
- package/package.json +1 -1
- package/templates/visual-review.yml +4 -2
- package/trusted/gate.mjs +239 -41
- package/trusted/lib/accept.mjs +413 -94
- package/trusted/lib/approval.mjs +300 -0
- package/trusted/lib/github.mjs +96 -29
- package/trusted/lib/serve.mjs +850 -101
- 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 +915 -102
- package/trusted/page/review.js +3062 -1044
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
|
|
|
@@ -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,
|
|
198
|
-
*
|
|
199
|
-
*
|
|
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({
|
|
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
|
-
|
|
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
|
|
252
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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 {
|
|
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
|
-
* @
|
|
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
|
|
385
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
*
|
|
478
|
-
*
|
|
479
|
-
* @param {
|
|
480
|
-
* @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)
|
|
481
734
|
* @param {string} baselines the baselines directory
|
|
482
|
-
* @returns {Promise<{ path: string, from: string | null, to: string | null,
|
|
483
|
-
* movedFrom?: string, movedTo?: string }[]
|
|
484
|
-
*
|
|
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
|
|
487
|
-
const
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
* @
|
|
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 = {
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
|
|
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))}`));
|