@graphty/visual-review 0.0.1 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Comparison: is a capture the same as its baseline, and what does a story's pair of captures say?
3
+ *
4
+ * The SHA-256 of the PNG bytes decides first; only when the bytes differ are both images decoded
5
+ * and compared with pixelmatch at the story's threshold. A bounding box is [x, y, width, height].
6
+ */
7
+
8
+ import { createHash } from "node:crypto";
9
+ import { readFile } from "node:fs/promises";
10
+
11
+ // ponytail: pngjs until milestone 3 needs a dependency-free decoder
12
+ import { PNG } from "pngjs";
13
+
14
+ import pixelmatch from "../vendor/pixelmatch.mjs";
15
+
16
+ /** Chromatic's default `diffThreshold`, used when a story sets none. */
17
+ export const DEFAULT_THRESHOLD = 0.063;
18
+
19
+ /**
20
+ * Hashes PNG bytes; equal hashes mean the images are identical without decoding either.
21
+ * @param {Buffer} bytes the file's bytes
22
+ * @returns {string} the hex SHA-256 of the bytes
23
+ */
24
+ export const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
25
+
26
+ /** The first line of every Git LFS pointer file. */
27
+ const LFS_POINTER = "version https://git-lfs.github.com/spec/";
28
+
29
+ /**
30
+ * Whether bytes are a Git LFS pointer file rather than the image it stands for.
31
+ * @param {Buffer} bytes a file's bytes
32
+ * @returns {boolean} true for a pointer
33
+ */
34
+ export const isLfsPointer = (bytes) => bytes.subarray(0, LFS_POINTER.length).toString("latin1") === LFS_POINTER;
35
+
36
+ /**
37
+ * Reads a baseline PNG. Baselines are stored in Git LFS; a checkout without `git lfs pull` holds
38
+ * pointer files, and comparing a pointer would report every image as changed, so it throws.
39
+ * @param {string} path the baseline's path
40
+ * @returns {Promise<Buffer | null>} its bytes, or null when there is no baseline
41
+ */
42
+ export async function readBaseline(path) {
43
+ const bytes = await readFile(path).catch((e) => {
44
+ if (e.code === "ENOENT") {
45
+ return null;
46
+ }
47
+ throw e;
48
+ });
49
+ if (bytes && isLfsPointer(bytes)) {
50
+ throw new Error(`${path}: baseline is an LFS pointer; run git lfs pull`);
51
+ }
52
+ return bytes;
53
+ }
54
+
55
+ /**
56
+ * Compares a capture with its baseline.
57
+ * @param {Buffer} baseline PNG bytes
58
+ * @param {Buffer} capture PNG bytes
59
+ * @param {{ threshold: number, includeAA: boolean }} options pixelmatch's threshold (0..1) and
60
+ * whether anti-aliased pixels count as changed
61
+ * @returns {{ status: "unchanged" | "changed", baseline: string, capture: string,
62
+ * size: number[] | null, baselineSize: number[] | null, changedPixels: number,
63
+ * bbox: number[] | null }} sizes are [width, height]
64
+ */
65
+ export function compareImages(baseline, capture, { threshold, includeAA }) {
66
+ const hashes = { baseline: sha256(baseline), capture: sha256(capture) };
67
+ if (hashes.baseline === hashes.capture) {
68
+ const size = pngSize(capture);
69
+ return { status: "unchanged", ...hashes, size, baselineSize: size, changedPixels: 0, bbox: null };
70
+ }
71
+ const a = PNG.sync.read(baseline);
72
+ const b = PNG.sync.read(capture);
73
+ const { changedPixels, bbox } = diffPixels(a, b, { threshold, includeAA });
74
+ return {
75
+ status: changedPixels === 0 ? "unchanged" : "changed",
76
+ ...hashes,
77
+ size: [b.width, b.height],
78
+ baselineSize: [a.width, a.height],
79
+ changedPixels,
80
+ bbox,
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Counts the pixels that differ. Images of different sizes are compared over their top-left
86
+ * overlap, and every pixel of the larger canvas outside that overlap counts as changed.
87
+ * @param {PNG} a the decoded baseline
88
+ * @param {PNG} b the decoded capture
89
+ * @param {{ threshold: number, includeAA: boolean }} options pixelmatch's settings
90
+ * @returns {{ changedPixels: number, bbox: number[] | null }} the count and the box around them
91
+ */
92
+ function diffPixels(a, b, options) {
93
+ const w = Math.min(a.width, b.width);
94
+ const h = Math.min(a.height, b.height);
95
+ const W = Math.max(a.width, b.width);
96
+ const H = Math.max(a.height, b.height);
97
+ const mask = Buffer.alloc(w * h * 4);
98
+ let changedPixels = pixelmatch(crop(a, w, h), crop(b, w, h), mask, w, h, { ...options, diffMask: true });
99
+
100
+ let [x0, y0, x1, y1] = [W, H, -1, -1];
101
+ const grow = (x, y) => {
102
+ x0 = Math.min(x0, x);
103
+ y0 = Math.min(y0, y);
104
+ x1 = Math.max(x1, x);
105
+ y1 = Math.max(y1, y);
106
+ };
107
+ if (changedPixels > 0) {
108
+ // With diffMask, pixelmatch paints only the counted pixels, so opaque means changed.
109
+ for (let i = 3; i < mask.length; i += 4) {
110
+ if (mask[i] !== 0) {
111
+ grow(((i - 3) / 4) % w, Math.floor((i - 3) / 4 / w));
112
+ }
113
+ }
114
+ }
115
+ const padding = W * H - w * h;
116
+ if (padding > 0) {
117
+ changedPixels += padding;
118
+ // The padding is the canvas outside the overlap: right of it, below it, or both.
119
+ if (W > w) {
120
+ grow(w, 0);
121
+ grow(W - 1, H - 1);
122
+ }
123
+ if (H > h) {
124
+ grow(0, h);
125
+ grow(W - 1, H - 1);
126
+ }
127
+ }
128
+ return { changedPixels, bbox: x1 < 0 ? null : [x0, y0, x1 - x0 + 1, y1 - y0 + 1] };
129
+ }
130
+
131
+ /**
132
+ * Copies out the top-left corner of an image, or returns its data when it is already that size.
133
+ * @param {PNG} img the decoded image
134
+ * @param {number} w the width to keep
135
+ * @param {number} h the height to keep
136
+ * @returns {Buffer} the RGBA of the top-left w x h of the image
137
+ */
138
+ function crop(img, w, h) {
139
+ if (img.width === w && img.height === h) {
140
+ return img.data;
141
+ }
142
+ const out = Buffer.alloc(w * h * 4);
143
+ for (let y = 0; y < h; y++) {
144
+ img.data.copy(out, y * w * 4, y * img.width * 4, (y * img.width + w) * 4);
145
+ }
146
+ return out;
147
+ }
148
+
149
+ /**
150
+ * Classifies one story and mode from its baseline and up to two captures. A second capture is
151
+ * taken, in a fresh browser context, only when the first differs from the baseline or there is
152
+ * no baseline; without one (a local run) the first capture stands alone.
153
+ *
154
+ * A story with no baseline is `unseeded` ("no baseline yet") when its capture matches
155
+ * `reference`, master's newest capture of it: the pull request did not change it, so it needs no
156
+ * review here. It is `new` when it differs from master's, or master has none (a new story).
157
+ *
158
+ * `moved` says the baseline is another story id's, named in the project's renames.json: a story
159
+ * that only moved (same image, new id) is `moved` rather than `unchanged`, so it still needs an
160
+ * accept, which writes its baseline under the new name.
161
+ * @param {{ baseline: Buffer | null, first: Buffer | null, second?: Buffer | null,
162
+ * reference?: Buffer | null, threshold: number, includeAA: boolean, moved?: boolean }} input
163
+ * `first` is null when the story is gone
164
+ * @returns {{ status: "unchanged" | "moved" | "changed" | "new" | "unseeded" | "removed" | "unstable", flaky: boolean,
165
+ * baseline: string | null, capture: string | null, size: number[] | null,
166
+ * baselineSize: number[] | null, changedPixels: number | null, bbox: number[] | null }}
167
+ * `capture` is the hash of the capture the status describes: the second one when flaky
168
+ */
169
+ export function classify({ baseline, first, second = null, reference = null, threshold, includeAA, moved = false }) {
170
+ const options = { threshold, includeAA };
171
+ const matched = (r, flaky) => ({ ...r, status: moved ? "moved" : "unchanged", flaky });
172
+ const none = { flaky: false, size: null, baselineSize: null, changedPixels: null, bbox: null };
173
+ if (first === null) {
174
+ return { status: "removed", ...none, baseline: sha256(baseline), capture: null };
175
+ }
176
+ const agree = () => second === null || compareImages(first, second, options).status === "unchanged";
177
+ if (baseline === null) {
178
+ const same = reference !== null && compareImages(reference, first, options).status === "unchanged";
179
+ const status = same ? "unseeded" : agree() ? "new" : "unstable";
180
+ return { status, ...none, baseline: null, capture: sha256(first), size: pngSize(first) };
181
+ }
182
+ const vsFirst = compareImages(baseline, first, options);
183
+ if (vsFirst.status === "unchanged") {
184
+ return matched(vsFirst, false);
185
+ }
186
+ if (second === null) {
187
+ return { ...vsFirst, flaky: false };
188
+ }
189
+ const vsSecond = compareImages(baseline, second, options);
190
+ if (vsSecond.status === "unchanged") {
191
+ return matched(vsSecond, true);
192
+ }
193
+ return { ...vsFirst, status: agree() ? "changed" : "unstable", flaky: false };
194
+ }
195
+
196
+ /**
197
+ * Reads a PNG's dimensions.
198
+ * @param {Buffer} bytes PNG bytes
199
+ * @returns {number[]} [width, height], read from the IHDR chunk without decoding the image
200
+ */
201
+ function pngSize(bytes) {
202
+ return [bytes.readUInt32BE(16), bytes.readUInt32BE(20)];
203
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * The consuming repository's settings: `visual-review.config.json` at the root of its git
3
+ * checkout. Everything that differs from one repository to the next (its Storybooks, where the
4
+ * baselines live, its default branch, the workflow that captures, its commit and label
5
+ * conventions) is read from here, so the tool itself assumes none of it. The README's
6
+ * "Configuration" section documents every key. Standard library only: the gate reads it too.
7
+ */
8
+
9
+ import { execFileSync } from "node:child_process";
10
+ import { existsSync, readFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+
13
+ export const CONFIG_FILE = "visual-review.config.json";
14
+
15
+ const DEFAULTS = {
16
+ defaultBranch: "main",
17
+ workflow: "visual-review.yml",
18
+ baselines: "visual-baselines",
19
+ workDir: ".visual-review",
20
+ commitPrefix: "test",
21
+ issueLabels: ["bug"],
22
+ };
23
+
24
+ // Project ids name artifacts, jobs, directories and regular expressions, so they stay plain.
25
+ const PROJECT_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
26
+ // A path inside the repository: relative, no "..", no backslashes.
27
+ const REPO_PATH = /^(?!\/)(?!.*(^|\/)\.\.(\/|$))[^\\]+$/;
28
+
29
+ /**
30
+ * Checks a parsed config and fills in the defaults.
31
+ * @param {unknown} input the parsed JSON
32
+ * @returns {{ defaultBranch: string, workflow: string, baselines: string, workDir: string,
33
+ * commitPrefix: string, issueLabels: string[], projects: Record<string, { storybook: string,
34
+ * build: string | null, workers: number, seedFromDefaultBranch: boolean, waitFor: { selector:
35
+ * string, method: string, failOnConsole: string | null } | null }> }} the settings
36
+ */
37
+ export function normalizeConfig(input) {
38
+ const raw = /** @type {any} */ (input);
39
+ const fail = (msg) => {
40
+ throw new Error(`${CONFIG_FILE}: ${msg}`);
41
+ };
42
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
43
+ fail("must be a JSON object");
44
+ }
45
+ /** @type {any} */
46
+ const out = { ...DEFAULTS };
47
+ for (const key of ["defaultBranch", "workflow", "baselines", "workDir", "commitPrefix"]) {
48
+ if (raw[key] !== undefined) {
49
+ if (typeof raw[key] !== "string" || raw[key] === "") {
50
+ fail(`${key} must be a non-empty string`);
51
+ }
52
+ out[key] = raw[key];
53
+ }
54
+ }
55
+ for (const key of ["baselines", "workDir"]) {
56
+ if (!REPO_PATH.test(out[key])) {
57
+ fail(`${key} must be a path inside the repository, relative and without ".."`);
58
+ }
59
+ out[key] = out[key].replace(/\/+$/, "");
60
+ }
61
+ if (raw.issueLabels !== undefined) {
62
+ if (!Array.isArray(raw.issueLabels) || raw.issueLabels.some((l) => typeof l !== "string")) {
63
+ fail("issueLabels must be an array of strings");
64
+ }
65
+ out.issueLabels = raw.issueLabels;
66
+ }
67
+ const projects = raw.projects;
68
+ if (typeof projects !== "object" || projects === null || Object.keys(projects).length === 0) {
69
+ fail('projects must name at least one Storybook, e.g. { "web": { "storybook": "storybook-static" } }');
70
+ }
71
+ out.projects = {};
72
+ for (const [id, p] of Object.entries(projects)) {
73
+ const where = `projects.${id}`;
74
+ if (!PROJECT_ID.test(id)) {
75
+ fail(`${where}: a project id is letters, digits, ".", "_" and "-"`);
76
+ }
77
+ if (typeof p !== "object" || p === null) {
78
+ fail(`${where} must be an object`);
79
+ }
80
+ if (typeof p.storybook !== "string" || !REPO_PATH.test(p.storybook)) {
81
+ fail(`${where}.storybook must be the built Storybook's directory, relative to the repository`);
82
+ }
83
+ if (p.build !== undefined && typeof p.build !== "string") {
84
+ fail(`${where}.build must be a shell command`);
85
+ }
86
+ const workers = p.workers ?? 4;
87
+ if (!Number.isInteger(workers) || workers < 1) {
88
+ fail(`${where}.workers must be a positive integer`);
89
+ }
90
+ if (p.seedFromDefaultBranch !== undefined && typeof p.seedFromDefaultBranch !== "boolean") {
91
+ fail(`${where}.seedFromDefaultBranch must be true or false`);
92
+ }
93
+ let waitFor = null;
94
+ if (p.waitFor !== undefined && p.waitFor !== null) {
95
+ const w = p.waitFor;
96
+ if (typeof w?.selector !== "string" || typeof w?.method !== "string") {
97
+ fail(`${where}.waitFor needs a "selector" and a "method" to call on each matching element`);
98
+ }
99
+ waitFor = { selector: w.selector, method: w.method, failOnConsole: w.failOnConsole ?? null };
100
+ }
101
+ out.projects[id] = {
102
+ storybook: p.storybook.replace(/\/+$/, ""),
103
+ build: p.build ?? null,
104
+ workers,
105
+ seedFromDefaultBranch: p.seedFromDefaultBranch ?? true,
106
+ waitFor,
107
+ };
108
+ }
109
+ return out;
110
+ }
111
+
112
+ /**
113
+ * The root of the git checkout holding a directory.
114
+ * @param {string} [cwd] where to start
115
+ * @returns {string} the repository's top level
116
+ */
117
+ export function repoRoot(cwd = process.cwd()) {
118
+ try {
119
+ return execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8", stdio: "pipe" }).trim();
120
+ } catch {
121
+ throw new Error(`${cwd} is not inside a git repository: run visual-review in your repository`);
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Reads and checks the config of a checkout.
127
+ * @param {string} root the repository's top level
128
+ * @returns {ReturnType<typeof normalizeConfig>} the settings
129
+ */
130
+ export function loadConfig(root) {
131
+ const file = join(root, CONFIG_FILE);
132
+ if (!existsSync(file)) {
133
+ throw new Error(`no ${CONFIG_FILE} in ${root}: run \`visual-review init\` there first`);
134
+ }
135
+ return parse(readFileSync(file, "utf8"));
136
+ }
137
+
138
+ /**
139
+ * Reads the config as it is at a git ref, falling back to the working tree when the ref has none
140
+ * (the pull request that adds visual review). The gate uses this with the base branch tip, so a
141
+ * pull request cannot move the baselines directory out from under the gate.
142
+ * @param {string} ref the ref
143
+ * @param {string} root the repository's top level
144
+ * @returns {ReturnType<typeof normalizeConfig>} the settings
145
+ */
146
+ export function loadConfigAt(ref, root) {
147
+ let text;
148
+ try {
149
+ text = execFileSync("git", ["show", `${ref}:${CONFIG_FILE}`], {
150
+ cwd: root,
151
+ encoding: "utf8",
152
+ stdio: ["ignore", "pipe", "ignore"],
153
+ });
154
+ } catch {
155
+ return loadConfig(root);
156
+ }
157
+ return parse(text);
158
+ }
159
+
160
+ function parse(text) {
161
+ let raw;
162
+ try {
163
+ raw = JSON.parse(text);
164
+ } catch (e) {
165
+ throw new Error(`${CONFIG_FILE} is not valid JSON: ${e.message}`);
166
+ }
167
+ return normalizeConfig(raw);
168
+ }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Everything the review page needs from GitHub, through the `gh` CLI with the reviewer's login:
3
+ * open pull requests, the CI run for a head, the `visual` job's outcome, the capture artifacts,
4
+ * and the comment, issue and pull request that a Finish writes. CI uses one of these too: a pull
5
+ * request's capture downloads master's newest capture with `newestMasterCapture`.
6
+ *
7
+ * Only `gh api` and `gh run download` are used, because they exist in every gh release still in
8
+ * use (Ubuntu 22.04 ships gh 2.4, which lacks `gh run list --commit` and most `--json` fields).
9
+ * `{owner}/{repo}` is filled in by gh from the repository's remote.
10
+ */
11
+
12
+ import { execFile } from "node:child_process";
13
+ import { existsSync, readFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+
16
+ import { validateResults } from "./results.mjs";
17
+
18
+ /**
19
+ * Runs a program and resolves with its trimmed stdout.
20
+ * @param {string} cmd the program
21
+ * @param {string[]} args its arguments
22
+ * @param {{ cwd?: string, input?: string, env?: object }} [options] stdin and environment
23
+ * @returns {Promise<string>} stdout; rejects with an Error whose message is the program's stderr
24
+ */
25
+ export function exec(cmd, args, { cwd, input, env } = {}) {
26
+ return new Promise((resolve, reject) => {
27
+ const child = execFile(cmd, args, { cwd, env, encoding: "utf8", maxBuffer: 64 << 20 }, (err, out, stderr) => {
28
+ if (err) {
29
+ reject(new Error(stderr.trim() || err.message));
30
+ } else {
31
+ resolve(out.trim());
32
+ }
33
+ });
34
+ // A program that exits without reading stdin closes the pipe; its exit status reports that.
35
+ child.stdin.on("error", () => {});
36
+ child.stdin.end(input ?? "");
37
+ });
38
+ }
39
+
40
+ /**
41
+ * The real gh, run in the repository so `{owner}/{repo}` resolves.
42
+ * @param {string} cwd the repository
43
+ * @returns {(args: string[], input?: string) => Promise<string>} runs gh and returns its stdout
44
+ */
45
+ export const ghRunner = (cwd) => (args, input) => exec("gh", args, { cwd, input });
46
+
47
+ const api = async (gh, path) => JSON.parse(await gh(["api", path]));
48
+
49
+ /**
50
+ * Open pull requests, newest first.
51
+ * @param {Function} gh the gh runner
52
+ * @returns {Promise<{ number: number, title: string, url: string, headSha: string, branch: string }[]>}
53
+ * the pull requests
54
+ */
55
+ export async function openPullRequests(gh) {
56
+ const pulls = await api(gh, "repos/{owner}/{repo}/pulls?state=open&per_page=100");
57
+ return pulls.map((p) => ({
58
+ number: p.number,
59
+ title: p.title,
60
+ url: p.html_url,
61
+ headSha: p.head.sha,
62
+ branch: p.head.ref,
63
+ }));
64
+ }
65
+
66
+ const toRun = (r) => ({
67
+ id: r.id,
68
+ attempt: r.run_attempt,
69
+ status: r.status,
70
+ conclusion: r.conclusion,
71
+ url: r.html_url,
72
+ headSha: r.head_sha,
73
+ });
74
+
75
+ /**
76
+ * The newest run of the capturing workflow for a head commit.
77
+ * @param {Function} gh the gh runner
78
+ * @param {string} sha the pull request's head
79
+ * @param {{ workflow: string }} config the workflow file that captures (the config's `workflow`)
80
+ * @returns {Promise<object | null>} the run, or null when CI never ran on it
81
+ */
82
+ export async function newestCiRun(gh, sha, { workflow }) {
83
+ const { workflow_runs: runs } = await api(
84
+ gh,
85
+ `repos/{owner}/{repo}/actions/workflows/${encodeURIComponent(workflow)}/runs?head_sha=${sha}&per_page=1`,
86
+ );
87
+ return runs.length > 0 ? toRun(runs[0]) : null;
88
+ }
89
+
90
+ /**
91
+ * One run by id.
92
+ * @param {Function} gh the gh runner
93
+ * @param {number} id the run id
94
+ * @returns {Promise<object>} the run
95
+ */
96
+ export const getRun = async (gh, id) => toRun(await api(gh, `repos/{owner}/{repo}/actions/runs/${id}`));
97
+
98
+ /**
99
+ * The `visual` matrix job of each project in one attempt of a run.
100
+ * @param {Function} gh the gh runner
101
+ * @param {{ id: number }} run the run
102
+ * @param {number} attempt the attempt
103
+ * @param {string[]} projects project ids
104
+ * @returns {Promise<Record<string, { conclusion: string | null, url: string } | undefined>>} by project
105
+ */
106
+ export async function visualJobs(gh, run, attempt, projects) {
107
+ const { jobs } = await api(gh, `repos/{owner}/{repo}/actions/runs/${run.id}/attempts/${attempt}/jobs?per_page=100`);
108
+ const visual = jobs.filter((j) => /^visual\b/.test(j.name));
109
+ return Object.fromEntries(
110
+ projects.map((p) => {
111
+ const job = visual.find((j) => j.name.includes(p));
112
+ return [p, job && { conclusion: job.conclusion, url: job.html_url }];
113
+ }),
114
+ );
115
+ }
116
+
117
+ /**
118
+ * Downloads each project's capture artifact from the highest attempt that uploaded one, into
119
+ * `<tmp>/<run>-<attempt>/<project>/`. An artifact already downloaded is not fetched again.
120
+ * @param {Function} gh the gh runner
121
+ * @param {{ id: number }} run the run
122
+ * @param {string[]} projects project ids
123
+ * @param {string} tmp the download root
124
+ * @returns {Promise<Record<string, { dir: string, attempt: number } | null>>} null for a project
125
+ * with no unexpired artifact
126
+ */
127
+ export async function downloadCaptures(gh, run, projects, tmp) {
128
+ const { artifacts } = await api(gh, `repos/{owner}/{repo}/actions/runs/${run.id}/artifacts?per_page=100`);
129
+ /** @type {Record<string, { dir: string, attempt: number } | null>} */
130
+ const out = {};
131
+ for (const project of projects) {
132
+ const pattern = new RegExp(`^visual-${project}-(\\d+)$`);
133
+ const newest = artifacts
134
+ .filter((a) => !a.expired && pattern.test(a.name))
135
+ .map((a) => ({ name: a.name, attempt: Number(pattern.exec(a.name)[1]) }))
136
+ .sort((a, b) => b.attempt - a.attempt)[0];
137
+ if (!newest) {
138
+ out[project] = null;
139
+ continue;
140
+ }
141
+ const dir = join(tmp, `${run.id}-${newest.attempt}`, project);
142
+ if (!existsSync(join(dir, "results.json"))) {
143
+ await gh(["run", "download", String(run.id), "-n", newest.name, "-D", dir]);
144
+ }
145
+ out[project] = { dir, attempt: newest.attempt };
146
+ }
147
+ return out;
148
+ }
149
+
150
+ /**
151
+ * Downloads the default branch's newest complete capture of one project: the reference a pull
152
+ * request's capture compares stories without a baseline against.
153
+ * ponytail: the newest default-branch run with a complete capture, not the run of the pull
154
+ * request's exact base; a story changed there since then shows as `new` (it blocks, never passes).
155
+ * @param {Function} gh the gh runner
156
+ * @param {string} project the project id
157
+ * @param {string} tmp the download root
158
+ * @param {{ workflow: string, defaultBranch: string }} config the capturing workflow and the branch
159
+ * @returns {Promise<string | null>} the capture's directory, or null when no run has one
160
+ */
161
+ export async function newestMasterCapture(gh, project, tmp, { workflow, defaultBranch }) {
162
+ // The branch's newest commits, then each one's run: GitHub's list of a workflow's runs filtered
163
+ // by branch now and then answers with a stale page (runs from weeks ago), which made a pull
164
+ // request compare with an old capture or none. Commits and a run by head sha answer consistently.
165
+ const commits = await api(gh, `repos/{owner}/{repo}/commits?sha=${encodeURIComponent(defaultBranch)}&per_page=10`);
166
+ for (const { sha } of commits) {
167
+ const run = await newestCiRun(gh, sha, { workflow });
168
+ if (!run) {
169
+ continue;
170
+ }
171
+ const dir = (await downloadCaptures(gh, run, [project], tmp))[project]?.dir;
172
+ let results = null;
173
+ try {
174
+ results = dir ? JSON.parse(readFileSync(join(dir, "results.json"), "utf8")) : null;
175
+ } catch {
176
+ // An unreadable capture is skipped like a missing one.
177
+ }
178
+ if (results && validateResults(results).length === 0 && results.complete && results.project === project) {
179
+ return dir;
180
+ }
181
+ }
182
+ return null;
183
+ }
184
+
185
+ /**
186
+ * Opens an issue.
187
+ * @param {Function} gh the gh runner
188
+ * @param {{ title: string, body: string, labels: string[] }} issue what to open
189
+ * @returns {Promise<string>} its URL
190
+ */
191
+ export async function createIssue(gh, { title, body, labels }) {
192
+ const input = JSON.stringify({ title, body, labels });
193
+ return JSON.parse(await gh(["api", "repos/{owner}/{repo}/issues", "--input", "-"], input)).html_url;
194
+ }
195
+
196
+ /**
197
+ * Posts a comment on a pull request.
198
+ * @param {Function} gh the gh runner
199
+ * @param {number} pr the pull request
200
+ * @param {string} body Markdown
201
+ * @returns {Promise<void>}
202
+ */
203
+ export async function commentOnPullRequest(gh, pr, body) {
204
+ await gh(["api", `repos/{owner}/{repo}/issues/${pr}/comments`, "--input", "-"], JSON.stringify({ body }));
205
+ }
206
+
207
+ /**
208
+ * Sets the "Visual review" commit status on one commit. Finish posts it once, when it completes,
209
+ * never per decision.
210
+ * @param {Function} gh the gh runner
211
+ * @param {string} sha the commit
212
+ * @param {{ state: "success" | "failure" | "pending", description: string }} status what to post;
213
+ * GitHub cuts the description at 140 characters
214
+ * @returns {Promise<void>}
215
+ */
216
+ export async function postStatus(gh, sha, { state, description }) {
217
+ const input = JSON.stringify({ state, context: "Visual review", description: description.slice(0, 140) });
218
+ await gh(["api", `repos/{owner}/{repo}/statuses/${sha}`, "--input", "-"], input);
219
+ }
220
+
221
+ /**
222
+ * Opens a pull request.
223
+ * @param {Function} gh the gh runner
224
+ * @param {{ title: string, head: string, base: string, body: string }} pr what to open, and the
225
+ * branch it merges into
226
+ * @returns {Promise<string>} its URL
227
+ */
228
+ export async function createPullRequest(gh, { title, head, base, body }) {
229
+ const input = JSON.stringify({ title, head, base, body });
230
+ return JSON.parse(await gh(["api", "repos/{owner}/{repo}/pulls", "--input", "-"], input)).html_url;
231
+ }