@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.
- package/LICENSE +21 -0
- package/README.md +590 -4
- package/capture/capture.mjs +764 -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 +537 -0
- package/trusted/lib/compare.mjs +203 -0
- package/trusted/lib/config.mjs +168 -0
- package/trusted/lib/github.mjs +231 -0
- package/trusted/lib/init.mjs +196 -0
- package/trusted/lib/results.mjs +158 -0
- package/trusted/lib/serve.mjs +663 -0
- package/trusted/page/index.html +19 -0
- package/trusted/page/review.css +503 -0
- package/trusted/page/review.js +1768 -0
- package/trusted/vendor/pixelmatch.mjs +336 -0
|
@@ -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
|
+
}
|