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