@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/capture/capture.mjs
CHANGED
|
@@ -154,6 +154,13 @@ export function storySettings(parameters, file) {
|
|
|
154
154
|
name,
|
|
155
155
|
globals: Object.fromEntries(Object.entries(m).filter(([k]) => k !== "disable")),
|
|
156
156
|
}));
|
|
157
|
+
// A mode names files and results.json items, which allow only these: refused before any story
|
|
158
|
+
// is captured, not when results.json is checked at the end.
|
|
159
|
+
for (const { name } of modes) {
|
|
160
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(name) || name.length > 50) {
|
|
161
|
+
throw new Error(`Chromatic mode "${name}": a mode name is lowercase letters, digits and "-", at most 50`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
157
164
|
const where = byFile ? "settings file" : "story's parameters";
|
|
158
165
|
return {
|
|
159
166
|
disableSnapshot,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@graphty/visual-review",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Self-hosted visual review of Storybook stories: capture in GitHub Actions, keep the baselines in git (Git LFS), accept or reject each change in a local page, and gate pull requests",
|
|
5
5
|
"author": "Adam Powers <apowers@ato.ms>",
|
|
6
6
|
"type": "module",
|
|
@@ -117,7 +117,9 @@ jobs:
|
|
|
117
117
|
|
|
118
118
|
# Fails while a project of the base branch's or the pull request's config, or with baselines on
|
|
119
119
|
# the base branch (seeded or not), holds anything but unchanged or excluded items in its newest
|
|
120
|
-
# capture, or a baseline file changed without a review record.
|
|
120
|
+
# capture, or a baseline file changed without a review record. Once visual-review/passkeys.json
|
|
121
|
+
# on the base branch holds a key, every review record the pull request adds must carry a passkey
|
|
122
|
+
# approval for this pull request (--pr) by one of the base branch's keys.
|
|
121
123
|
# It runs the published gate at a pinned version, so a pull request's own dependencies cannot
|
|
122
124
|
# change it. Depth 2: HEAD^1, the merge commit's first parent, is the base branch tip.
|
|
123
125
|
visual-gate:
|
|
@@ -139,4 +141,4 @@ jobs:
|
|
|
139
141
|
path: ${{ runner.temp }}/visual
|
|
140
142
|
|
|
141
143
|
- name: Check visual changes were accepted
|
|
142
|
-
run: npx --yes @graphty/visual-review@__VERSION__ gate --captures "$RUNNER_TEMP/visual" --base HEAD^1 --head HEAD
|
|
144
|
+
run: npx --yes @graphty/visual-review@__VERSION__ gate --captures "$RUNNER_TEMP/visual" --pr "${{ github.event.pull_request.number }}" --base HEAD^1 --head HEAD
|
package/trusted/cli.mjs
CHANGED
|
@@ -214,6 +214,7 @@ async function serve(args) {
|
|
|
214
214
|
origin,
|
|
215
215
|
masterRun,
|
|
216
216
|
results: values.results && resolve(values.results),
|
|
217
|
+
warm: true,
|
|
217
218
|
// What the owner types to run this server from their own shell, so Finish signs with
|
|
218
219
|
// their key rather than the environment of whoever started it (an agent, say).
|
|
219
220
|
startCommand:
|
package/trusted/gate.mjs
CHANGED
|
@@ -14,21 +14,35 @@
|
|
|
14
14
|
* leaves the visual jobs' old attempt as the newest) can neither hide nor resurrect a capture.
|
|
15
15
|
* Which projects exist and are seeded is read from <ref> (the base branch tip, fetched by the
|
|
16
16
|
* caller), and so is visual-review.config.json (the projects and where the baselines live), so
|
|
17
|
-
* neither deleting a project's baselines
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* neither deleting a project's baselines nor removing it from the config turns the gate off, and a
|
|
18
|
+
* pull request that moves the baselines directory fails. The pull request's config can only add
|
|
19
|
+
* projects. A gated project with no results.json, or an incomplete one, fails: a capture that
|
|
20
|
+
* crashed has shown the owner nothing. An invalid results.json counts as missing. So does a story
|
|
21
|
+
* compared at a diffThreshold above MAX_THRESHOLD, which would hide real changes.
|
|
20
22
|
*
|
|
21
|
-
* It also fails when a baseline PNG
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* guarantee").
|
|
23
|
+
* It also fails when a baseline PNG or a story's settings file differs from the base without the
|
|
24
|
+
* review records added in the pull request (<baselines>/reviews/*.json) taking that path from its
|
|
25
|
+
* contents on the base branch to its new ones: each record item moves a path `from` one hash `to`
|
|
26
|
+
* another, replayed in `reviewedAt` order, so a record approved for other contents (an old seed,
|
|
27
|
+
* a decision a later one replaced) moves nothing. Without that, committing the captured PNGs
|
|
28
|
+
* straight into the baselines directory would turn the capture check green with no review at all.
|
|
28
29
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
30
|
+
* Once visual-review/passkeys.json on the base branch holds a key, every record the pull request
|
|
31
|
+
* adds must also be version 2, for this pull request (`--pr`) or for none (a seed), with a passkey
|
|
32
|
+
* approval by one of the base branch's keys over exactly that record (approval.mjs), and not a
|
|
33
|
+
* copy of a record already on the base branch; a record that fails is reported and moves nothing.
|
|
34
|
+
* Keys are never read from the pull request, a pull request that would leave no key fails, and a
|
|
35
|
+
* change to passkeys.json itself needs an approved record like a baseline. Records already on the
|
|
36
|
+
* base branch are never checked again. Before that, records are read but not verified, so the gate
|
|
37
|
+
* proves a record names the change, not that the owner approved it (the README, "What the gate
|
|
38
|
+
* does and does not guarantee").
|
|
39
|
+
*
|
|
40
|
+
* CI runs this file as the base branch has it, never the pull request's copy, so a pull request
|
|
41
|
+
* cannot loosen the gate that judges it.
|
|
42
|
+
*
|
|
43
|
+
* Usage: visual-review gate --captures <dir> --base <ref> [--head <ref>] [--pr <number>], or node
|
|
44
|
+
* gate.mjs with the same options. Standard library only (results.mjs, config.mjs and approval.mjs
|
|
45
|
+
* have no dependencies), so it runs from a checkout of this package without an install.
|
|
32
46
|
*/
|
|
33
47
|
|
|
34
48
|
import { execFileSync } from "node:child_process";
|
|
@@ -38,11 +52,18 @@ import { join } from "node:path";
|
|
|
38
52
|
import { fileURLToPath } from "node:url";
|
|
39
53
|
import { parseArgs } from "node:util";
|
|
40
54
|
|
|
55
|
+
import { PASSKEYS_FILE, parsePasskeys, recordHash, verifyRecord } from "./lib/approval.mjs";
|
|
41
56
|
import { loadConfigAt, repoRoot } from "./lib/config.mjs";
|
|
42
57
|
import { validateResults } from "./lib/results.mjs";
|
|
43
58
|
|
|
44
59
|
const PASSING = new Set(["unchanged", "excluded"]);
|
|
45
60
|
|
|
61
|
+
/**
|
|
62
|
+
* The loosest diffThreshold the gate trusts, the highest a story here uses. At 1 pixelmatch counts
|
|
63
|
+
* no pixel as changed, so a settings file or a story's parameters could switch comparison off.
|
|
64
|
+
*/
|
|
65
|
+
export const MAX_THRESHOLD = 0.8;
|
|
66
|
+
|
|
46
67
|
/**
|
|
47
68
|
* The newest attempt's results.json of every project in a directory of downloaded artifacts.
|
|
48
69
|
* @param {string} dir the download directory, one subdirectory per artifact
|
|
@@ -63,7 +84,13 @@ export function newestResults(dir) {
|
|
|
63
84
|
continue;
|
|
64
85
|
}
|
|
65
86
|
const file = join(dir, name, "results.json");
|
|
66
|
-
|
|
87
|
+
let results = null;
|
|
88
|
+
try {
|
|
89
|
+
results = JSON.parse(readFileSync(file, "utf8"));
|
|
90
|
+
} catch {
|
|
91
|
+
// Missing or not JSON: counted as missing, like any invalid results.json.
|
|
92
|
+
}
|
|
93
|
+
out[project] = { attempt, results };
|
|
67
94
|
}
|
|
68
95
|
return out;
|
|
69
96
|
}
|
|
@@ -82,8 +109,9 @@ export function gatedProjects(config, seeded, headConfig) {
|
|
|
82
109
|
|
|
83
110
|
/**
|
|
84
111
|
* What blocks the pull request.
|
|
85
|
-
* @param {{ config: { defaultBranch: string,
|
|
86
|
-
*
|
|
112
|
+
* @param {{ config: { defaultBranch: string, baselines: string,
|
|
113
|
+
* projects: Record<string, { seedFromDefaultBranch: boolean }> },
|
|
114
|
+
* headConfig: { baselines: string, projects: Record<string, object> } | undefined, seeded: Set<string>,
|
|
87
115
|
* captures: Record<string, { attempt: number, results: object | null }> }} input the base
|
|
88
116
|
* branch's config, the pull request's config (if any), the projects with baselines on the base
|
|
89
117
|
* branch, and the newest capture of each project
|
|
@@ -91,6 +119,13 @@ export function gatedProjects(config, seeded, headConfig) {
|
|
|
91
119
|
*/
|
|
92
120
|
export function gateProblems({ config, headConfig, seeded, captures }) {
|
|
93
121
|
const problems = [];
|
|
122
|
+
if (headConfig && headConfig.baselines !== config.baselines) {
|
|
123
|
+
// Capture reads the pull request's config, so its baselines would be compared, not the base's.
|
|
124
|
+
problems.push(
|
|
125
|
+
`this pull request moves the baselines directory from ${config.baselines} to ${headConfig.baselines}; ` +
|
|
126
|
+
"move it in a pull request of its own that changes nothing else, merged with an administrator's review",
|
|
127
|
+
);
|
|
128
|
+
}
|
|
94
129
|
for (const p of gatedProjects(config, seeded, headConfig)) {
|
|
95
130
|
const r = captures[p]?.results;
|
|
96
131
|
if (!r) {
|
|
@@ -106,6 +141,13 @@ export function gateProblems({ config, headConfig, seeded, captures }) {
|
|
|
106
141
|
problems.push(`${p}: the capture did not finish (${r.items.length} of ${r.expected} items); re-run it`);
|
|
107
142
|
continue;
|
|
108
143
|
}
|
|
144
|
+
const loose = r.items.filter((i) => i.threshold > MAX_THRESHOLD).length;
|
|
145
|
+
if (loose > 0) {
|
|
146
|
+
problems.push(
|
|
147
|
+
`${p}: ${loose} ${loose === 1 ? "item is" : "items are"} compared at a diffThreshold above ${MAX_THRESHOLD}, ` +
|
|
148
|
+
"which hides real changes; lower it in the story's parameters or its settings file",
|
|
149
|
+
);
|
|
150
|
+
}
|
|
109
151
|
const open = r.items.map((i) => i.status).filter((s) => !PASSING.has(s));
|
|
110
152
|
if (open.length > 0) {
|
|
111
153
|
// No Object.groupBy: the gate runs on the runner's own Node, which may be 20.
|
|
@@ -146,18 +188,37 @@ function notSeeded(config, p) {
|
|
|
146
188
|
const gitOut = (cwd, args) => execFileSync("git", args, { cwd, maxBuffer: 1 << 28 });
|
|
147
189
|
|
|
148
190
|
/**
|
|
149
|
-
* Baseline changes between two refs that
|
|
191
|
+
* Baseline changes between two refs that the review records added between them do not account
|
|
192
|
+
* for. A changed PNG, story settings file (and, once approvals are enforced, passkeys.json) counts
|
|
193
|
+
* as reviewed only when the added records' items, replayed oldest `reviewedAt` first, take it from
|
|
194
|
+
* its hash on the base branch (null when absent) to its hash at the head (null when deleted); an
|
|
195
|
+
* item whose `from` is not the path's current hash moves nothing. A deleted settings file and
|
|
196
|
+
* renames.json need no record: the captures they cause are reviewed.
|
|
150
197
|
* @param {string} base the base branch tip
|
|
151
198
|
* @param {string} head the pull request's checkout
|
|
152
199
|
* @param {string} [cwd] the repository
|
|
153
200
|
* @param {string} [baselines] the baselines directory
|
|
201
|
+
* @param {{ keys?: object[] | null, pr?: number }} [approvals] with `keys`, every added record must
|
|
202
|
+
* carry a passkey approval by one of them for pull request `pr` (verifyRecord) and not copy a
|
|
203
|
+
* record already on the base branch, or it counts for nothing
|
|
154
204
|
* @returns {string[]} one line per unaccounted change; empty when every change has a record
|
|
155
205
|
*/
|
|
156
|
-
export function unrecordedChanges(base, head, cwd = process.cwd(), baselines = "visual-baselines") {
|
|
157
|
-
const
|
|
206
|
+
export function unrecordedChanges(base, head, cwd = process.cwd(), baselines = "visual-baselines", approvals = {}) {
|
|
207
|
+
const enforced = Boolean(approvals.keys);
|
|
208
|
+
const fields = gitOut(cwd, [
|
|
209
|
+
"diff",
|
|
210
|
+
"-z",
|
|
211
|
+
"--no-renames",
|
|
212
|
+
"--name-status",
|
|
213
|
+
base,
|
|
214
|
+
head,
|
|
215
|
+
"--",
|
|
216
|
+
`${baselines}/`,
|
|
217
|
+
...(enforced ? [PASSKEYS_FILE] : []),
|
|
218
|
+
])
|
|
158
219
|
.toString("utf8")
|
|
159
220
|
.split("\0");
|
|
160
|
-
const show = (path) => gitOut(cwd, ["show", `${
|
|
221
|
+
const show = (ref, path) => gitOut(cwd, ["show", `${ref}:${path}`]);
|
|
161
222
|
const problems = [];
|
|
162
223
|
const records = [];
|
|
163
224
|
const changed = [];
|
|
@@ -169,27 +230,57 @@ export function unrecordedChanges(base, head, cwd = process.cwd(), baselines = "
|
|
|
169
230
|
} else {
|
|
170
231
|
problems.push(`${path}: review records are append-only, but this one was changed or deleted`);
|
|
171
232
|
}
|
|
172
|
-
} else if (
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
233
|
+
} else if (
|
|
234
|
+
path.endsWith(".png") ||
|
|
235
|
+
path === PASSKEYS_FILE ||
|
|
236
|
+
(path.endsWith(".json") && status !== "D" && !path.endsWith("/renames.json"))
|
|
237
|
+
) {
|
|
238
|
+
const hash = path.endsWith(".png") ? contentHash : sha256;
|
|
239
|
+
changed.push({
|
|
240
|
+
path,
|
|
241
|
+
from: status === "A" ? null : hash(show(base, path)),
|
|
242
|
+
to: status === "D" ? null : hash(show(head, path)),
|
|
243
|
+
});
|
|
181
244
|
}
|
|
182
245
|
}
|
|
183
|
-
|
|
246
|
+
let onBase = null;
|
|
247
|
+
const entries = [];
|
|
184
248
|
for (const path of records) {
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
249
|
+
const record = parseOr(show(head, path));
|
|
250
|
+
if (enforced) {
|
|
251
|
+
let why = record === null ? "not valid JSON" : verifyRecord(record, approvals.keys, { pr: approvals.pr });
|
|
252
|
+
if (!why) {
|
|
253
|
+
onBase ??= baseRecordHashes(base, cwd, baselines);
|
|
254
|
+
if (onBase.has(recordHash(record).toString("hex"))) {
|
|
255
|
+
why = "a copy of a record already on the base branch: an approval counts once";
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (why) {
|
|
259
|
+
problems.push(`${path}: ${why}`);
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
188
262
|
}
|
|
263
|
+
const items = record?.items;
|
|
264
|
+
entries.push({ at: String(record?.reviewedAt ?? ""), items: Array.isArray(items) ? items : [] });
|
|
189
265
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
266
|
+
entries.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0));
|
|
267
|
+
const missing = changed.filter((c) => {
|
|
268
|
+
let now = c.from;
|
|
269
|
+
for (const { items } of entries) {
|
|
270
|
+
for (const item of items) {
|
|
271
|
+
if (item?.path === c.path && (item.from ?? null) === now) {
|
|
272
|
+
now = item.to ?? null;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return now !== c.to;
|
|
277
|
+
});
|
|
278
|
+
for (const { path } of missing.slice(0, 20)) {
|
|
279
|
+
problems.push(
|
|
280
|
+
path === PASSKEYS_FILE
|
|
281
|
+
? `${path}: changed with no record approved by a key on the base branch; once a key is registered, adding or replacing one is merged by an administrator (the README, "Replacing the passkey")`
|
|
282
|
+
: `${path}: changed with no review record taking it from its base branch contents to these`,
|
|
283
|
+
);
|
|
193
284
|
}
|
|
194
285
|
if (missing.length > 20) {
|
|
195
286
|
problems.push(`... and ${missing.length - 20} more baseline files with no review record`);
|
|
@@ -197,6 +288,32 @@ export function unrecordedChanges(base, head, cwd = process.cwd(), baselines = "
|
|
|
197
288
|
return problems;
|
|
198
289
|
}
|
|
199
290
|
|
|
291
|
+
/**
|
|
292
|
+
* The record hashes of the version 2 records on the base branch.
|
|
293
|
+
* @param {string} base the base branch tip
|
|
294
|
+
* @param {string} cwd the repository
|
|
295
|
+
* @param {string} baselines the baselines directory
|
|
296
|
+
* @returns {Set<string>} hex hashes
|
|
297
|
+
*/
|
|
298
|
+
function baseRecordHashes(base, cwd, baselines) {
|
|
299
|
+
const out = new Set();
|
|
300
|
+
const files = gitOut(cwd, ["ls-tree", "-r", "--name-only", base, "--", `${baselines}/reviews/`])
|
|
301
|
+
.toString("utf8")
|
|
302
|
+
.split("\n")
|
|
303
|
+
.filter(Boolean);
|
|
304
|
+
for (const path of files) {
|
|
305
|
+
const r = parseOr(gitOut(cwd, ["show", `${base}:${path}`]));
|
|
306
|
+
if (r?.version === 2) {
|
|
307
|
+
try {
|
|
308
|
+
out.add(recordHash(r).toString("hex"));
|
|
309
|
+
} catch {
|
|
310
|
+
// A number canonical() refuses: no approval can cover it, so nothing can copy it.
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return out;
|
|
315
|
+
}
|
|
316
|
+
|
|
200
317
|
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
201
318
|
|
|
202
319
|
/**
|
|
@@ -244,14 +361,90 @@ export function seededAt(ref, cwd = process.cwd(), baselines = "visual-baselines
|
|
|
244
361
|
);
|
|
245
362
|
}
|
|
246
363
|
|
|
247
|
-
|
|
364
|
+
/**
|
|
365
|
+
* A file at a git ref, or null when the ref does not hold it.
|
|
366
|
+
* @param {string} ref the commit
|
|
367
|
+
* @param {string} path the path
|
|
368
|
+
* @param {string} cwd the repository
|
|
369
|
+
* @returns {string | null} its text
|
|
370
|
+
*/
|
|
371
|
+
function fileAt(ref, path, cwd) {
|
|
372
|
+
const listed = gitOut(cwd, ["ls-tree", "--name-only", ref, "--", path]).toString("utf8").trim();
|
|
373
|
+
return listed === "" ? null : gitOut(cwd, ["show", `${ref}:${path}`]).toString("utf8");
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Whether approvals are enforced, and the keys they must come from: those of passkeys.json on the
|
|
378
|
+
* base branch, never the pull request's. Absent, or with no key, enforcement is off.
|
|
379
|
+
* @param {string} base the base branch tip
|
|
380
|
+
* @param {string} head the pull request's checkout
|
|
381
|
+
* @param {number | undefined} pr the pull request (`--pr`)
|
|
382
|
+
* @param {string} [cwd] the repository
|
|
383
|
+
* @returns {{ keys: object[] | null, problems: string[] }} the keys (null when off), and what fails
|
|
384
|
+
* the gate: an invalid base file, no `--pr`, or a pull request that would switch enforcement off
|
|
385
|
+
*/
|
|
386
|
+
export function approvalKeys(base, head, pr, cwd = process.cwd()) {
|
|
387
|
+
const text = fileAt(base, PASSKEYS_FILE, cwd);
|
|
388
|
+
let keys = [];
|
|
389
|
+
try {
|
|
390
|
+
keys = text === null ? [] : parsePasskeys(text);
|
|
391
|
+
} catch (err) {
|
|
392
|
+
return { keys: null, problems: [`${PASSKEYS_FILE} on the base branch is invalid: ${err.message}`] };
|
|
393
|
+
}
|
|
394
|
+
if (keys.length === 0) {
|
|
395
|
+
return { keys: null, problems: [] };
|
|
396
|
+
}
|
|
397
|
+
const problems = [];
|
|
398
|
+
if (pr === undefined) {
|
|
399
|
+
problems.push(
|
|
400
|
+
`approvals are enforced (${PASSKEYS_FILE} on the base branch holds a key), so the gate needs --pr <number>`,
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
let left = 0;
|
|
404
|
+
try {
|
|
405
|
+
const own = fileAt(head, PASSKEYS_FILE, cwd);
|
|
406
|
+
left = own === null ? 0 : parsePasskeys(own).length;
|
|
407
|
+
} catch {
|
|
408
|
+
// Invalid: counts as no key.
|
|
409
|
+
}
|
|
410
|
+
if (left === 0) {
|
|
411
|
+
problems.push(
|
|
412
|
+
`this pull request would switch approval enforcement off: its ${PASSKEYS_FILE} is missing, invalid or holds no key`,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
return { keys, problems };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* The files a pull request could loosen the gate with: passkeys.json, the tool's trusted code and
|
|
420
|
+
* its capture, and the workflow that runs them. A change to one is a warning, never a failure (CI
|
|
421
|
+
* runs the base branch's copy of the code, and work on the tool changes it), so code review looks
|
|
422
|
+
* at it.
|
|
423
|
+
* @param {string} base the base branch tip
|
|
424
|
+
* @param {string} head the pull request's checkout
|
|
425
|
+
* @param {string} workflow the workflow file that runs the gate
|
|
426
|
+
* @param {string} [cwd] the repository
|
|
427
|
+
* @returns {string[]} the changed ones
|
|
428
|
+
*/
|
|
429
|
+
export function trustFilesChanged(base, head, workflow, cwd = process.cwd()) {
|
|
430
|
+
const files = [PASSKEYS_FILE, "visual-review/trusted/", "visual-review/capture/", `.github/workflows/${workflow}`];
|
|
431
|
+
return gitOut(cwd, ["diff", "--name-only", base, head, "--", ...files])
|
|
432
|
+
.toString("utf8")
|
|
433
|
+
.split("\n")
|
|
434
|
+
.filter(Boolean);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export const GATE_USAGE = `usage: visual-review gate --captures <dir> --base <ref> [--head <ref>] [--pr <number>]
|
|
248
438
|
|
|
249
439
|
Fails (exit 1) while a pull request holds visual changes nobody accepted, or a baseline change
|
|
250
|
-
with no review record.
|
|
440
|
+
with no review record. Once ${PASSKEYS_FILE} on the base branch holds a key, every review
|
|
441
|
+
record the pull request adds must also carry a passkey approval for this pull request. Run it in
|
|
442
|
+
CI after the capture jobs, on the pull request's merge commit.
|
|
251
443
|
|
|
252
444
|
--captures <dir> the downloaded visual-<project>-<attempt> artifacts of this run
|
|
253
445
|
--base <ref> the base branch tip (HEAD^1 on a pull request's merge commit)
|
|
254
|
-
--head <ref> the pull request's checkout (default HEAD)
|
|
446
|
+
--head <ref> the pull request's checkout (default HEAD)
|
|
447
|
+
--pr <number> the pull request's number (required once approvals are enforced)`;
|
|
255
448
|
|
|
256
449
|
/**
|
|
257
450
|
* The gate as a command.
|
|
@@ -265,6 +458,7 @@ export function runGate(args) {
|
|
|
265
458
|
captures: { type: "string" },
|
|
266
459
|
base: { type: "string" },
|
|
267
460
|
head: { type: "string", default: "HEAD" },
|
|
461
|
+
pr: { type: "string" },
|
|
268
462
|
help: { type: "boolean", default: false },
|
|
269
463
|
},
|
|
270
464
|
});
|
|
@@ -272,7 +466,8 @@ export function runGate(args) {
|
|
|
272
466
|
console.log(GATE_USAGE);
|
|
273
467
|
return 0;
|
|
274
468
|
}
|
|
275
|
-
|
|
469
|
+
const pr = values.pr === undefined ? undefined : Number(values.pr);
|
|
470
|
+
if (!values.captures || !values.base || (pr !== undefined && !(Number.isInteger(pr) && pr > 0))) {
|
|
276
471
|
console.error(GATE_USAGE);
|
|
277
472
|
return 2;
|
|
278
473
|
}
|
|
@@ -284,11 +479,20 @@ export function runGate(args) {
|
|
|
284
479
|
for (const line of problems) {
|
|
285
480
|
console.log(`::error::visual changes not accepted -- ${line}`);
|
|
286
481
|
}
|
|
287
|
-
const
|
|
482
|
+
const { keys, problems: approval } = approvalKeys(values.base, values.head, pr, root);
|
|
483
|
+
for (const line of approval) {
|
|
484
|
+
console.log(`::error::passkey approval -- ${line}`);
|
|
485
|
+
}
|
|
486
|
+
const unrecorded = unrecordedChanges(values.base, values.head, root, config.baselines, { keys, pr });
|
|
288
487
|
for (const line of unrecorded) {
|
|
289
488
|
console.log(`::error::baseline without a review -- ${line}`);
|
|
290
489
|
}
|
|
291
|
-
|
|
490
|
+
for (const file of trustFilesChanged(values.base, values.head, config.workflow, root)) {
|
|
491
|
+
console.log(
|
|
492
|
+
`::warning::this pull request changes ${file}, which decides what the visual gate accepts: review that change with care`,
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
if (problems.length + approval.length + unrecorded.length > 0) {
|
|
292
496
|
console.log("Review them with `visual-review serve` (the @graphty/visual-review README).");
|
|
293
497
|
return 1;
|
|
294
498
|
}
|