@impeccable/reviews 0.1.0 → 0.1.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.
@@ -0,0 +1,291 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { commandRunner } from './netlify.js';
4
+ import { ownFolder } from './sandbox.js';
5
+ /**
6
+ * Cloudflare Pages: the preview deployments this repository's pull requests
7
+ * get, from GitHub's record and from cf, Cloudflare's CLI, on this machine
8
+ * when it is signed in (read only), and whether cf is signed in. Cloudflare's
9
+ * GitHub app records no deployment and no commit status: its record is a
10
+ * check run on each commit it built and a comment on the pull request, both
11
+ * naming the deployment's address (https://<id>.<project>.pages.dev) and the
12
+ * branch's alias (https://<branch>.<project>.pages.dev), which follows the
13
+ * branch's latest build. Nothing here reads a token, and nothing comes back
14
+ * beyond the fields below.
15
+ */
16
+ /** How many pull requests, the most recently updated, the evidence reads. */
17
+ const NEWEST = 10;
18
+ const LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?';
19
+ /** A Pages deployment's address, or a branch's alias: one label, then the project, under pages.dev. */
20
+ export const PAGES_ADDRESS = new RegExp(`^https://(${LABEL})\\.(${LABEL})\\.pages\\.dev$`);
21
+ const LINKS = new RegExp(`https://${LABEL}\\.${LABEL}\\.pages\\.dev`, 'g');
22
+ /** Cloudflare's GitHub app, by the slug its check runs carry and the login its comments carry; the earlier Pages-only app posted the same. */
23
+ const APPS = new Set(['cloudflare-workers-and-pages', 'cloudflare-pages']);
24
+ const isApp = (login) => typeof login === 'string' && APPS.has(login.replace(/\[bot\]$/, ''));
25
+ const record = (value) => (value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined);
26
+ const positive = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
27
+ const list = (value) => (Array.isArray(value) ? value.map(record).filter((item) => item !== undefined) : []);
28
+ const sha = (value) => typeof value === 'string' && /^[0-9a-f]{40}$/.test(value);
29
+ /** A branch name GitHub can be asked about: the characters git allows, no `..`, and neither a slash nor a dot at the end. */
30
+ const branchName = (value) => /^[A-Za-z0-9][A-Za-z0-9._/-]{0,254}$/.test(value) && !value.includes('..') && !value.endsWith('/') && !value.endsWith('.');
31
+ /** A branch, as GitHub names it in a check run's summary or cf in a deployment: git's characters, and not too long to be one. */
32
+ const branchOf = (value) => (typeof value === 'string' && branchName(value) ? value : undefined);
33
+ /** The project a Pages address belongs to; null for any other address. */
34
+ export function pagesProject(url) {
35
+ return PAGES_ADDRESS.exec(url)?.[2] ?? null;
36
+ }
37
+ /** The deployment's address and the branch's alias, as the app's table names them, else the first two Pages links in order; a page mentions each once. */
38
+ function addresses(html) {
39
+ const row = (label) => new RegExp(`${label}:</strong>[\\s\\S]{0,200}?href='(https://[^']+)'`).exec(html)?.[1];
40
+ const fits = (value) => (value && PAGES_ADDRESS.test(value) ? value : undefined);
41
+ const links = [...new Set(html.match(LINKS) ?? [])];
42
+ return { url: fits(row('Preview URL')) ?? links[0], alias: fits(row('Branch Preview URL')) ?? (links[1] !== links[0] ? links[1] : undefined) };
43
+ }
44
+ /** What a check run's conclusion, or the words the app's table says, tell of the build. */
45
+ const stateOf = (value) => (typeof value !== 'string' ? 'building' : /succe/i.test(value) ? 'success' : /fail|timed_out|error/i.test(value) ? 'failure' : /cancel/i.test(value) ? 'canceled' : 'building');
46
+ /**
47
+ * GitHub's record: the Cloudflare check run on each head (or, with `only`, that pull request's; with `branch`, the
48
+ * pushed branch's head alone, before its pull request exists), and the app's comment on each pull request when it
49
+ * describes the head. `failed` when a read did not answer.
50
+ */
51
+ async function fromGitHub(owner, repo, gh, only, branch) {
52
+ const base = `/repos/${owner}/${repo}`;
53
+ const get = async (route) => JSON.parse(await gh(['api', '--hostname', 'github.com', '--method', 'GET', route]));
54
+ let failed = false;
55
+ const read = async (what, none) => what.catch(() => ((failed = true), none));
56
+ const when = (value) => (typeof value === 'string' && Number.isFinite(Date.parse(value)) ? Date.parse(value) : 0);
57
+ const heads = [];
58
+ if (branch !== undefined) {
59
+ const ref = record(await read(get(`${base}/branches/${encodeURIComponent(branch)}`), null));
60
+ const head = record(ref?.commit)?.sha;
61
+ if (ref?.name === branch && sha(head))
62
+ heads.push({ branch, sha: head, at: 0 });
63
+ }
64
+ else {
65
+ const pulls = await read(only ? get(`${base}/pulls/${only}`).then((one) => [one]) : get(`${base}/pulls?state=all&sort=updated&direction=desc&per_page=${NEWEST}`), []);
66
+ for (const pull of list(pulls)) {
67
+ const head = record(pull.head);
68
+ if (positive(pull.number) && (!only || pull.number === only) && sha(head?.sha))
69
+ heads.push({ number: pull.number, branch: branchOf(head?.ref), sha: head.sha, at: when(pull.updated_at) });
70
+ }
71
+ }
72
+ const found = [];
73
+ // Cloudflare posts its check run once the build ended, so a head with none is still building, or was never built.
74
+ const runs = await Promise.all(heads.map((head) => read(get(`${base}/commits/${head.sha}/check-runs?per_page=100`), null)));
75
+ runs.forEach((result, i) => {
76
+ const head = heads[i];
77
+ for (const run of list(record(result)?.check_runs)) {
78
+ if (!isApp(record(run.app)?.slug))
79
+ continue;
80
+ const { url, alias } = addresses(String(record(run.output)?.summary ?? ''));
81
+ if (!url)
82
+ continue;
83
+ found.push({ ...(head.number ? { pullRequest: head.number } : {}), ...(head.branch ? { branch: head.branch } : {}), sha: head.sha, url, ...(alias ? { alias } : {}), state: run.status === 'completed' ? stateOf(run.conclusion) : 'building', from: 'check run', at: when(run.completed_at) || head.at });
84
+ break;
85
+ }
86
+ });
87
+ if (branch === undefined) {
88
+ const comments = await read(get(`${base}/issues/comments?sort=updated&direction=desc&per_page=100`), []);
89
+ for (const comment of list(comments)) {
90
+ const issue = typeof comment.issue_url === 'string' ? Number(/\/issues\/([1-9]\d*)$/.exec(comment.issue_url)?.[1]) : NaN;
91
+ const head = heads.find((h) => h.number === issue);
92
+ if (!head || !isApp(record(comment.user)?.login) || typeof comment.body !== 'string')
93
+ continue;
94
+ // The app edits its one comment as the pull request moves on: it describes the head when the commit it names is the head's.
95
+ const commit = /Latest commit:<\/strong>[\s\S]{0,80}?<code>([0-9a-f]{7,40})<\/code>/.exec(comment.body)?.[1];
96
+ if (!commit || !head.sha.startsWith(commit))
97
+ continue;
98
+ const { url, alias } = addresses(comment.body);
99
+ if (!url || found.some((p) => p.pullRequest === issue && p.url === url))
100
+ continue;
101
+ found.push({ pullRequest: issue, ...(head.branch ? { branch: head.branch } : {}), sha: head.sha, url, ...(alias ? { alias } : {}), state: stateOf(/Status:<\/strong>[\s\S]{0,120}?<\/td>/.exec(comment.body)?.[0]), from: 'bot comment', at: when(comment.updated_at) || head.at });
102
+ }
103
+ }
104
+ return { previews: found, heads, failed };
105
+ }
106
+ /** The Pages project the checkout's wrangler config names: only a config with a Pages output directory is a Pages project's, since a Worker's config names a Worker. */
107
+ export async function configuredProject(cwd) {
108
+ const name = (value) => (typeof value === 'string' && new RegExp(`^${LABEL}$`).test(value) ? value : undefined);
109
+ try {
110
+ const toml = await readFile(join(cwd, 'wrangler.toml'), 'utf8');
111
+ if (/^\s*pages_build_output_dir\s*=/m.test(toml))
112
+ return name(/^\s*name\s*=\s*"([^"\n]+)"/m.exec(toml)?.[1]);
113
+ }
114
+ catch {
115
+ // No TOML config: the JSON ones next.
116
+ }
117
+ for (const file of ['wrangler.json', 'wrangler.jsonc']) {
118
+ try {
119
+ // A jsonc config may carry comments, on their own lines or after a value, and trailing commas.
120
+ const config = record(JSON.parse(plainJson(await readFile(join(cwd, file), 'utf8'))));
121
+ if (typeof config?.pages_build_output_dir === 'string')
122
+ return name(config.name);
123
+ }
124
+ catch {
125
+ // Not there, or not readable as JSON.
126
+ }
127
+ }
128
+ return undefined;
129
+ }
130
+ /** JSON with comments as JSON: line and block comments outside strings go, and so does a comma before a closing bracket. */
131
+ export function plainJson(text) {
132
+ let out = '';
133
+ for (let i = 0; i < text.length; i++) {
134
+ const c = text[i];
135
+ if (c === '"') {
136
+ // A string, its escapes kept, so a // inside it stays.
137
+ let j = i + 1;
138
+ for (; j < text.length && text[j] !== '"'; j++)
139
+ if (text[j] === '\\')
140
+ j++;
141
+ out += text.slice(i, j + 1);
142
+ i = j;
143
+ }
144
+ else if (c === '/' && text[i + 1] === '/') {
145
+ const end = text.indexOf('\n', i);
146
+ i = end < 0 ? text.length : end - 1;
147
+ }
148
+ else if (c === '/' && text[i + 1] === '*') {
149
+ const end = text.indexOf('*/', i + 2);
150
+ i = end < 0 ? text.length : end + 1;
151
+ }
152
+ else
153
+ out += c;
154
+ }
155
+ return out.replace(/,(\s*[}\]])/g, '$1');
156
+ }
157
+ /** cf, Cloudflare's CLI, at the version this command is built against, run through npx, which installs it on first use. */
158
+ export const CF_PACKAGE = 'cf@1.0.0-beta.8';
159
+ /**
160
+ * cf here: its JSON uncoloured, and an account's command given its account, which cf otherwise asks the person to pick
161
+ * when they have several. `browser` lets its sign-in open one. npx runs it from a folder of the command's own, never the
162
+ * checkout, whose node_modules and .npmrc would choose which cf runs as the person, outside the sandbox: every cf
163
+ * command names its project or its account, so none needs the checkout.
164
+ */
165
+ export function cfRunner(opts = {}, runner = commandRunner) {
166
+ return (args, { account, signal } = {}) => runner('npx', ownFolder(), { timeoutMs: 120_000, ...opts, signal, env: { NO_COLOR: '1', ...(account ? { CLOUDFLARE_ACCOUNT_ID: account } : {}) } })(['--yes', CF_PACKAGE, ...args]);
167
+ }
168
+ /** The status of the API call a cf command failed on, which its error box names on a line of its own ("│ 403 Forbidden", "│ 404 Not Found · HTTP /accounts/…"); null when it names none. */
169
+ export function cfFailure(err) {
170
+ const said = String(err.stderr ?? '').replace(/\x1b\[[0-9;]*m/g, '');
171
+ const status = /^│?\s*([1-5]\d\d)(?: [A-Z][A-Za-z ]*)?(?: · HTTP \S*)?\s*$/m.exec(said)?.[1];
172
+ return status ? Number(status) : null;
173
+ }
174
+ /** `cf auth whoami`, which prints JSON signed in or out: whether it is signed in with a token that works, its scopes and its accounts. */
175
+ export async function cfStatus(cf, signal) {
176
+ let status;
177
+ try {
178
+ const printed = await cf(['auth', 'whoami'], { signal });
179
+ status = record(JSON.parse(printed.slice(printed.indexOf('{'), printed.lastIndexOf('}') + 1)));
180
+ }
181
+ catch (err) {
182
+ return { cli: err.missing ? 'missing' : 'unknown', accounts: [] };
183
+ }
184
+ // A stored sign-in that neither the user's record nor the accounts answer is one to sign in again.
185
+ if (status?.authenticated === false || (status?.authenticated === true && status.tokenValid === false))
186
+ return { cli: 'signed-out', accounts: [] };
187
+ if (status?.authenticated !== true)
188
+ return { cli: 'unknown', accounts: [] };
189
+ const scopes = Array.isArray(status.scopes) ? status.scopes.filter((s) => typeof s === 'string') : undefined;
190
+ const accounts = list(status.accounts).map((a) => a.id).filter((id) => typeof id === 'string' && /^[0-9a-f]{32}$/.test(id));
191
+ return { cli: 'signed-in', ...(scopes ? { scopes } : {}), accounts };
192
+ }
193
+ /** What a Pages deployment's latest stage says of its build: success once it deployed, and building until a stage failed or it was cancelled or skipped. */
194
+ const stageState = (stage) => stage?.status === 'failure' ? 'failure' : stage?.status === 'canceled' || stage?.status === 'skipped' ? 'canceled' : stage?.status === 'success' && stage.name === 'deploy' ? 'success' : 'building';
195
+ /** A page of cf's list of deployments, and how many it reads of each account at most: the newest pull requests' builds come first. */
196
+ const PER_PAGE = 25;
197
+ const PAGES = 4;
198
+ /**
199
+ * What cf lists of the project's preview deployments when it is signed in with Pages access: `pages deployments list`
200
+ * prints them as Cloudflare's API has them, each with its address, the branch's alias, the branch and commit it built,
201
+ * its latest stage and the repository it was built from. Each account the sign-in reaches is read, since another may hold
202
+ * a project of the same name, a page at a time, until the list ends or every head has its build; a deployment Cloudflare
203
+ * built from another repository is not this one's.
204
+ */
205
+ async function fromCf(project, cf, repository, signal) {
206
+ const { cli, scopes, accounts } = await cfStatus(cf, signal);
207
+ const pages = !scopes || scopes.includes('pages:read') || scopes.includes('pages:write');
208
+ if (cli !== 'signed-in' || !project || !pages)
209
+ return { cli, rows: [], failed: cli === 'unknown' };
210
+ const same = (value, name) => typeof value === 'string' && value.toLowerCase() === name.toLowerCase();
211
+ const ours = (d) => {
212
+ const source = record(record(d.source)?.config);
213
+ return !source || (same(source.owner, repository.owner) && same(source.repo_name, repository.repo));
214
+ };
215
+ const rows = [];
216
+ const built = () => repository.heads.every((sha) => rows.some((row) => row.sha && sha.startsWith(row.sha)));
217
+ let failed = false;
218
+ for (const account of accounts.length ? accounts : [undefined]) {
219
+ if (rows.length && built())
220
+ break;
221
+ for (let page = 1; page <= PAGES; page++) {
222
+ let listed;
223
+ try {
224
+ const printed = await cf(['pages', 'deployments', 'list', '--project-name', project, '--env', 'preview', '--page', String(page), '--per-page', String(PER_PAGE)], { account, signal });
225
+ listed = list(JSON.parse(printed.slice(printed.indexOf('['), printed.lastIndexOf(']') + 1)));
226
+ }
227
+ catch (err) {
228
+ // An account with no such project answers 404; one that could not be read leaves the others' builds standing.
229
+ failed ||= cfFailure(err) !== 404;
230
+ break;
231
+ }
232
+ for (const d of listed.filter(ours)) {
233
+ const url = typeof d.url === 'string' && PAGES_ADDRESS.test(d.url) ? d.url : undefined;
234
+ if (!url || d.environment !== 'preview')
235
+ continue;
236
+ const trigger = record(record(d.deployment_trigger)?.metadata);
237
+ const alias = (Array.isArray(d.aliases) ? d.aliases : []).find((a) => typeof a === 'string' && PAGES_ADDRESS.test(a));
238
+ const commit = typeof trigger?.commit_hash === 'string' && /^[0-9a-f]{7,40}$/.test(trigger.commit_hash) ? trigger.commit_hash : undefined;
239
+ rows.push({ ...(branchOf(trigger?.branch) ? { branch: trigger.branch } : {}), ...(commit ? { sha: commit } : {}), url, ...(alias ? { alias } : {}), state: stageState(record(d.latest_stage)), from: 'cf', at: -rows.length });
240
+ }
241
+ if (listed.length < PER_PAGE || built())
242
+ break;
243
+ }
244
+ }
245
+ return { cli, rows, failed };
246
+ }
247
+ /**
248
+ * Whether this repository's pull requests get Cloudflare Pages previews: the newest, as GitHub and cf have them;
249
+ * with `pull`, that pull request's only; with `branch`, the pushed branch's head's only, before its pull request exists.
250
+ */
251
+ export async function readCloudflarePreviews(owner, repo, cwd, deps = {}) {
252
+ const unread = () => ({ previews: [], cli: 'missing', error: "Cloudflare's previews could not be read.", unread: ['GitHub'] });
253
+ if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/.test(owner) || !/^[A-Za-z0-9_.-]{1,100}$/.test(repo) || repo === '.' || repo === '..')
254
+ return unread();
255
+ if (deps.branch !== undefined && !branchName(deps.branch))
256
+ return unread();
257
+ const github = await fromGitHub(owner, repo, deps.gh ?? commandRunner('gh', cwd, { signal: deps.signal }), deps.pull, deps.branch);
258
+ const project = github.previews.map((p) => pagesProject(p.url)).find(Boolean) ?? (await configuredProject(cwd));
259
+ // cf installs itself through npx on a machine without it, which takes a while the first time.
260
+ const cf = await fromCf(project, deps.cf ?? cfRunner(), { owner, repo, heads: github.heads.map((h) => h.sha) }, deps.signal);
261
+ // A row is the head's when its commit is the head's; the pull request's heads say which. A wait for a pushed branch takes its head's rows alone.
262
+ const head = (row) => github.heads.find((h) => row.sha && h.sha.startsWith(row.sha) && (deps.branch === undefined || h.branch === deps.branch));
263
+ const rows = cf.rows.flatMap((row) => {
264
+ const of = head(row);
265
+ if (deps.branch !== undefined && !of)
266
+ return [];
267
+ if (deps.pull !== undefined && of?.number !== deps.pull)
268
+ return [];
269
+ return [of ? { ...row, ...(of.number ? { pullRequest: of.number } : {}), branch: of.branch ?? row.branch, sha: of.sha, at: of.at } : row];
270
+ });
271
+ // Of the same build, the check run outranks cf's row, and both the app's comment; each address once.
272
+ const rank = (p) => (p.from === 'check run' ? 0 : p.from === 'cf' ? 1 : 2);
273
+ const previews = [];
274
+ for (const p of [...github.previews, ...rows].sort((a, b) => rank(a) - rank(b))) {
275
+ const twin = previews.find((q) => q.url === p.url && (q.pullRequest ?? q.branch) === (p.pullRequest ?? p.branch));
276
+ if (!twin)
277
+ previews.push(p);
278
+ else if (!twin.alias && p.alias)
279
+ twin.alias = p.alias;
280
+ }
281
+ // The most recently updated pull request's first; a branch with no pull request among them last, as cf lists it.
282
+ previews.sort((a, b) => (b.pullRequest ? 1 : 0) - (a.pullRequest ? 1 : 0) || b.at - a.at);
283
+ return {
284
+ ...(project ? { project } : {}),
285
+ previews: previews.slice(0, deps.branch !== undefined ? previews.length : NEWEST).map(({ at, ...preview }) => preview),
286
+ cli: cf.cli,
287
+ ...(github.failed || cf.failed
288
+ ? { error: "Some of Cloudflare's previews could not be read: what is missing is unknown, not absent.", unread: [...(github.failed ? ['GitHub'] : []), ...(cf.failed ? ['cf'] : [])] }
289
+ : {}),
290
+ };
291
+ }