atris 3.75.0 → 3.76.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.
@@ -0,0 +1,589 @@
1
+ 'use strict';
2
+
3
+ // Held jobs. A held job is work that is done and whose pull request waits for a person to land
4
+ // it: the job sits in Review under the builder's name with the pull request open on the row.
5
+ // When a person merges that pull request on GitHub, by any method, nothing in the task store
6
+ // hears about it, so the rating shows the job held forever and the builder is never credited.
7
+ // `atris task sweep --held` asks GitHub about each held job and writes down what happened.
8
+ //
9
+ // merged: the merge is recorded the way any landed merge is recorded, and the job closes as
10
+ // done with the action "merged", which the rating counts as landed for the builder
11
+ // who held the claim. A person's merge is the acceptance, so it is not a review
12
+ // pass and it certifies nothing. The landing is dated at the merge, not at the
13
+ // sweep, so a merge swept in late does not count as new.
14
+ // closed: the pull request is recorded as closed and the claim is released with the reason
15
+ // "pull request closed without landing: <url>". The rating counts that as a miss,
16
+ // and a person can still mark the job not needed.
17
+ // open: nothing changes.
18
+ //
19
+ // The link on a row is whatever a builder wrote there, so before it credits a merge or lets a
20
+ // claim go the sweep proves the pull request is this job's own build, from what GitHub itself
21
+ // says. It is in the repository the job's workspace came from (the owner and name in GitHub's
22
+ // own link equal those of the workspace's origin remote), and so is its head branch (a fork's
23
+ // branch is not this workspace's build). The head branch carries the job's row id as a whole
24
+ // word, and no other row id: that is how the flights name the branch they build on
25
+ // (flightTaskName in lib/fleet.js). No other job has the pull request: none already landed with
26
+ // it, and none still on the board also points at it, because two jobs cannot both be credited for
27
+ // one merge. And, for a merge, it was merged after the claim now on the job began, and into the
28
+ // repository's default branch (a merge into a feature branch has not landed). If any of that
29
+ // fails the job is refused: nothing is written, one plain line says why, and the sweep goes on to
30
+ // the next job. A merge with no usable merge time is not credited either. The sweep never looks
31
+ // at what is inside the pull request: a person merged it, and that person is the judge of that.
32
+ //
33
+ // GitHub is the only source, so anyone may run this, agents included: the run grants nothing
34
+ // of its own, it writes down what GitHub says. It works by row id, only on jobs in the
35
+ // workspace being swept (or every workspace when asked), and only while the row is still
36
+ // what it read: GitHub answers slowly, and a job someone settled in the meantime is left alone.
37
+ //
38
+ // One outage rule. When GitHub does not answer in time, or gh is not installed (it is not found,
39
+ // or a shim says "command not found" and exits 127), the run stops asking and changes nothing
40
+ // more. Any other failure leaves that one job unknown, and the rest are still checked.
41
+
42
+ const { spawnSync } = require('child_process');
43
+ const { pullRequestOfLink } = require('./pull-request-link');
44
+
45
+ // Per call. A hung gh must not hang the whole sweep.
46
+ const ASK_TIMEOUT_MS = 30000;
47
+ const FIELDS = 'state,mergeCommit,mergedAt,mergedBy,number,url,headRefName,headRepository,headRepositoryOwner,baseRefName';
48
+ const COMMIT = /^[0-9a-f]{7,64}$/i;
49
+ // How GitHub names a person or a bot: one word of letters, digits and a few marks.
50
+ const LOGIN = /^[A-Za-z0-9][A-Za-z0-9_.\-[\]/]{0,63}$/;
51
+ const STATES = { OPEN: 'open', MERGED: 'merged', CLOSED: 'closed' };
52
+ // One word of an owner or a repository name as GitHub writes them.
53
+ const REPO_WORD = /^[A-Za-z0-9_.-]+$/;
54
+ // The shape of a job's row id: 26 characters of the alphabet row ids are written in (digits and
55
+ // lower case letters, never i, l, o or u).
56
+ const ROW_ID_WORD = /^[0-9a-hjkmnp-tv-z]{26}$/;
57
+
58
+ const TITLE_MAX = 80;
59
+ const DETAIL_MAX = 200;
60
+
61
+ function oneLine(value, max) {
62
+ const text = String(value || '').replace(/\s+/g, ' ').trim();
63
+ return text.length > max ? `${text.slice(0, Math.max(0, max - 3)).trim()}...` : text;
64
+ }
65
+
66
+ function firstLine(value) {
67
+ return String(value || '').split('\n').map((line) => line.trim()).find(Boolean) || '';
68
+ }
69
+
70
+ function secondsLabel(ms) {
71
+ const seconds = ms / 1000;
72
+ return `${seconds} ${seconds === 1 ? 'second' : 'seconds'}`;
73
+ }
74
+
75
+ // The owner and name, in lower case, of the repository a pull request link is in
76
+ // (https://<host>/<owner>/<name>/pull/<number>, however it is decorated), or null when it is not
77
+ // such a link.
78
+ function repoOfPullRequestLink(link) {
79
+ const named = pullRequestOfLink(link);
80
+ return named ? named.repo : null;
81
+ }
82
+
83
+ // The owner and name, in lower case, of a repository given as an owner and a name (the head
84
+ // repository GitHub reports for a branch), or null when either is missing or not a name.
85
+ function repoOfParts(owner, name) {
86
+ const ownerText = typeof owner === 'string' ? owner.trim() : '';
87
+ const nameText = typeof name === 'string' ? name.trim() : '';
88
+ return REPO_WORD.test(ownerText) && REPO_WORD.test(nameText) ? `${ownerText}/${nameText}`.toLowerCase() : null;
89
+ }
90
+
91
+ // The owner and name, in lower case, of the repository a git remote points at, or null when it
92
+ // does not read as one: a web or ssh address (https://host/owner/name.git, ssh://git@host/owner/name,
93
+ // git@host:owner/name.git) with exactly an owner and a name after the host. The host is not
94
+ // compared, because an ssh alias in front of github.com is an ordinary way to name it.
95
+ function repoOfRemote(remote) {
96
+ const text = String(remote || '').trim();
97
+ const match = /^(?:https?|ssh|git):\/\/(?:[^@/\s]+@)?[^/:\s]+(?::\d+)?\/([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i.exec(text)
98
+ || /^(?:[^@/\s]+@)?[^/:\s]+:([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/.exec(text);
99
+ return match ? `${match[1]}/${match[2]}`.toLowerCase() : null;
100
+ }
101
+
102
+ // The row ids in a branch name: the words of it (split on anything that is not a letter or a
103
+ // digit, any case) that have the shape of a row id, in order.
104
+ function jobIdsIn(headRef) {
105
+ return String(headRef || '').toLowerCase().split(/[^a-z0-9]+/).filter((word) => ROW_ID_WORD.test(word));
106
+ }
107
+
108
+ // Whether a pull request's head branch is this job's build: it carries exactly one row id as a whole
109
+ // word, and it is this job's. The case is ignored, so a hand-named branch with the id as the store
110
+ // spells it counts. Another job's id, or two ids (even this job's twice), say the branch is not one
111
+ // job's build, so it binds nothing. Only words the shape of a row id are ever counted, so a job whose
112
+ // own id is not that shape binds nothing either.
113
+ function branchNamesJob(headRef, rowId) {
114
+ const id = String(rowId || '').trim().toLowerCase();
115
+ const named = jobIdsIn(headRef);
116
+ return named.length === 1 && named[0] === id;
117
+ }
118
+
119
+ // One call to gh, with its failures told apart the one way every question the sweep asks tells
120
+ // them apart. Exactly one of:
121
+ // { stdout } gh ran and exited 0
122
+ // { outage } GitHub did not answer in time, or gh is missing (not found, or a shim or wrapper
123
+ // that cannot find it exits 127): the run stops asking
124
+ // { unknown } one plain sentence on why this one question could not be answered
125
+ function runGh(args, { run = spawnSync, timeoutMs = ASK_TIMEOUT_MS } = {}) {
126
+ // gh is found on the PATH: a fixed path adds no boundary, since a process that can change the PATH can write the task store directly.
127
+ const result = run('gh', args, {
128
+ encoding: 'utf8',
129
+ timeout: timeoutMs,
130
+ killSignal: 'SIGKILL',
131
+ maxBuffer: 1024 * 1024,
132
+ stdio: ['ignore', 'pipe', 'pipe'],
133
+ }) || {};
134
+ if (result.error) {
135
+ if (result.error.code === 'ETIMEDOUT') {
136
+ return { outage: { kind: 'timeout', detail: `GitHub did not answer within ${secondsLabel(timeoutMs)}` } };
137
+ }
138
+ if (result.error.code === 'ENOENT') {
139
+ return { outage: { kind: 'gh_missing', detail: 'gh is not installed or not on the PATH, so nothing could be checked' } };
140
+ }
141
+ return { unknown: oneLine(`gh did not run: ${firstLine(result.error.message)}`, DETAIL_MAX) };
142
+ }
143
+ if (result.status !== 0) {
144
+ if (result.status === null) return { unknown: `gh did not finish${result.signal ? ` (${result.signal})` : ''}` };
145
+ // 127 is "command not found": a shim or wrapper that cannot find gh is the same outage as no gh.
146
+ if (result.status === 127) {
147
+ return { outage: { kind: 'gh_missing', detail: 'gh is not installed or not on the PATH (it exited 127), so nothing could be checked' } };
148
+ }
149
+ const said = firstLine(result.stderr);
150
+ return { unknown: said ? oneLine(`gh said: ${said}`, DETAIL_MAX) : `gh exited ${result.status}` };
151
+ }
152
+ return { stdout: String(result.stdout || '') };
153
+ }
154
+
155
+ // What gh printed, as a JSON object, or null when it is anything else.
156
+ function jsonObject(text) {
157
+ let data = null;
158
+ try { data = JSON.parse(text); } catch { data = null; }
159
+ return data && typeof data === 'object' && !Array.isArray(data) ? data : null;
160
+ }
161
+
162
+ // What GitHub says about one pull request, in the shape the sweep reads. Exactly one of:
163
+ // { answer } state is open, merged or closed, with the number, the link, the repository GitHub's
164
+ // own link puts it in, its head branch and the repository that branch lives in, the
165
+ // branch it went (or goes) to, and for a merge its commit, time and merger (null
166
+ // where GitHub gave none or gave nonsense)
167
+ // { outage } GitHub did not answer in time, or gh is missing: stop asking
168
+ // { unknown } one plain sentence on why this one pull request could not be read
169
+ // `run` and `timeoutMs` are for tests.
170
+ function askGitHub(url, { run = spawnSync, timeoutMs = ASK_TIMEOUT_MS } = {}) {
171
+ const ran = runGh(['pr', 'view', url, '--json', FIELDS], { run, timeoutMs });
172
+ if (ran.outage || ran.unknown) return ran;
173
+ const data = jsonObject(ran.stdout);
174
+ if (!data) return { unknown: "GitHub's answer could not be read" };
175
+ const number = Number(data.number);
176
+ if (!Number.isInteger(number) || number <= 0) {
177
+ return { unknown: "GitHub's answer could not be read: it names no pull request number" };
178
+ }
179
+ const rawState = String(data.state || '').trim();
180
+ const state = STATES[rawState.toUpperCase()];
181
+ if (!state) {
182
+ return { unknown: oneLine(`GitHub's answer has a state it does not know (${rawState || 'none'})`, DETAIL_MAX) };
183
+ }
184
+ const commit = data.mergeCommit && typeof data.mergeCommit === 'object' ? String(data.mergeCommit.oid || '').trim() : '';
185
+ const login = data.mergedBy && typeof data.mergedBy === 'object' ? String(data.mergedBy.login || '').trim() : '';
186
+ const mergedAt = typeof data.mergedAt === 'string' ? data.mergedAt.trim() : '';
187
+ const link = typeof data.url === 'string' ? data.url.trim() : '';
188
+ const headRef = typeof data.headRefName === 'string' ? data.headRefName.trim() : '';
189
+ const baseRef = typeof data.baseRefName === 'string' ? data.baseRefName.trim() : '';
190
+ const headOwner = data.headRepositoryOwner && typeof data.headRepositoryOwner === 'object' ? data.headRepositoryOwner.login : null;
191
+ const headName = data.headRepository && typeof data.headRepository === 'object' ? data.headRepository.name : null;
192
+ return {
193
+ answer: {
194
+ state,
195
+ number,
196
+ url: /^https?:\/\//i.test(link) ? link : url,
197
+ // The repository is read only from the link GitHub gave, never from the one that was asked.
198
+ repo: repoOfPullRequestLink(link),
199
+ // The repository the head branch lives in, which is not the pull request's own repository
200
+ // when the branch is in a fork.
201
+ head_repo: repoOfParts(headOwner, headName),
202
+ base_ref: baseRef || null,
203
+ head_ref: headRef || null,
204
+ merge_commit: COMMIT.test(commit) ? commit : null,
205
+ merged_at: mergedAt && Number.isFinite(Date.parse(mergedAt)) ? mergedAt : null,
206
+ merged_by: LOGIN.test(login) ? login : null,
207
+ },
208
+ };
209
+ }
210
+
211
+ // The branch a repository lands on by default, for the link of the repository itself. Exactly one of:
212
+ // { branch } the default branch's name
213
+ // { outage } as for askGitHub: stop asking
214
+ // { unknown } one plain sentence on why it could not be read
215
+ // `run` and `timeoutMs` are for tests.
216
+ function askDefaultBranch(repoLink, { run = spawnSync, timeoutMs = ASK_TIMEOUT_MS } = {}) {
217
+ const ran = runGh(['repo', 'view', repoLink, '--json', 'defaultBranchRef'], { run, timeoutMs });
218
+ if (ran.outage || ran.unknown) return ran;
219
+ const data = jsonObject(ran.stdout);
220
+ if (!data) return { unknown: "GitHub's answer could not be read" };
221
+ const ref = data.defaultBranchRef;
222
+ const name = ref && typeof ref === 'object' && typeof ref.name === 'string' ? ref.name.trim() : '';
223
+ if (!name) return { unknown: "GitHub's answer does not say which branch is the repository's default" };
224
+ return { branch: name };
225
+ }
226
+
227
+ const NOT_ASKED = 'not asked, because the run stopped when GitHub could not be reached';
228
+
229
+ // The variables a hook or a parent git leaves behind that point git at some other repository.
230
+ const GIT_REPOSITORY_VARIABLES = [
231
+ 'GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR', 'GIT_OBJECT_DIRECTORY',
232
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_NAMESPACE', 'GIT_PREFIX',
233
+ ];
234
+
235
+ // Which repository a workspace came from: the owner and name its origin remote points at, asked of
236
+ // git in the workspace's own folder. Null when git cannot say (no folder, no origin) or the remote
237
+ // does not read as a GitHub repository.
238
+ function originRepoOf(root, { run = spawnSync } = {}) {
239
+ if (!root) return null;
240
+ const env = { ...process.env };
241
+ for (const key of GIT_REPOSITORY_VARIABLES) delete env[key];
242
+ const done = run('git', ['-C', String(root), 'remote', 'get-url', 'origin'], {
243
+ encoding: 'utf8',
244
+ env,
245
+ timeout: 10000,
246
+ killSignal: 'SIGKILL',
247
+ stdio: ['ignore', 'pipe', 'ignore'],
248
+ }) || {};
249
+ if (done.error || done.status !== 0) return null;
250
+ return repoOfRemote(done.stdout);
251
+ }
252
+
253
+ // Why a pull request GitHub says is merged or closed is not this job's own build, in a plain
254
+ // phrase, or null when it is. The repository comes from GitHub's own link for the pull request
255
+ // and must be the one the workspace's origin remote points at, and so must the repository the
256
+ // head branch lives in (a fork's branch is not this workspace's build); the head branch must
257
+ // carry the job's row id, and no other.
258
+ function notBuildReason(row, answer, originRepo) {
259
+ if (!originRepo) return "this workspace's origin remote could not be read as a GitHub repository";
260
+ if (!answer.repo) return "GitHub's answer does not say which repository it is in";
261
+ if (answer.repo !== originRepo) return `it is in ${answer.repo}, and this workspace's origin is ${originRepo}`;
262
+ if (!answer.head_repo) return "GitHub's answer does not say which repository its branch is in";
263
+ if (answer.head_repo !== originRepo) return `its branch is in ${answer.head_repo}, and this workspace's origin is ${originRepo}`;
264
+ if (!branchNamesJob(answer.head_ref, row.id)) {
265
+ const branch = answer.head_ref ? oneLine(answer.head_ref, DETAIL_MAX) : 'none given';
266
+ return jobIdsIn(answer.head_ref).length > 1
267
+ ? `its branch (${branch}) carries more than one job id`
268
+ : `its branch (${branch}) does not carry this job's id`;
269
+ }
270
+ return null;
271
+ }
272
+
273
+ // Why a merge cannot be this job's current attempt, or null when it can: the claim now on the job
274
+ // began before the merge. A job reopened and claimed again must not be credited for a merge that
275
+ // happened before its new claim, and a missing claim time proves nothing.
276
+ function mergeBeforeClaimReason(row, answer) {
277
+ const claimedAt = row.claimed_at == null ? NaN : Number(row.claimed_at);
278
+ if (!Number.isFinite(claimedAt)) return 'this job has no claim time to compare the merge with';
279
+ if (!(Date.parse(answer.merged_at) > claimedAt)) {
280
+ return `it was merged at ${answer.merged_at}, not after this job's claim began at ${new Date(claimedAt).toISOString()}`;
281
+ }
282
+ return null;
283
+ }
284
+
285
+ // Why a store refused to settle a job the sweep had read, in a plain sentence.
286
+ function leftAloneDetail(reason) {
287
+ if (reason === 'not_held') return 'it no longer waits on a pull request, so it was left alone';
288
+ if (/^already_/.test(String(reason))) return `it is now ${String(reason).slice('already_'.length)}, so it was left alone`;
289
+ return 'it changed while GitHub was being asked, so it was left alone';
290
+ }
291
+
292
+ // Check every held job against GitHub and, unless dryRun, write down what happened.
293
+ //
294
+ // taskStore lib/task-db
295
+ // db an open connection (read only is enough for a dry run)
296
+ // workspaceRoot the workspace being swept; null sweeps every workspace
297
+ // actor who ran the check; the events carry this name, never the builder's
298
+ // ask how to ask GitHub about a link (askGitHub); asked once per link per run
299
+ // askBranch how to ask GitHub for a repository's default branch (askDefaultBranch); asked
300
+ // once per repository per run, and only for a pull request that was merged
301
+ //
302
+ // Returns { ok, action, dry_run, scope, actor, outage, summary, results }. ok is false only on
303
+ // an outage. Each result is { task_id, ref, title, builder, workspace_root, pull_request,
304
+ // outcome, action, detail } where outcome is what GitHub said (merged, closed, open) or
305
+ // unknown or not_asked, and action is what was done: landed, released, would_land,
306
+ // would_release, none, left_alone, or refused (the pull request is not this job's build: nothing
307
+ // was written, and detail is the plain line saying why).
308
+ function sweepHeldJobs(taskStore, db, { workspaceRoot = null, actor, dryRun = false, ask = askGitHub, askBranch = askDefaultBranch } = {}) {
309
+ if (!actor) throw new Error('actor required');
310
+ const scope = workspaceRoot ? 'workspace' : 'everywhere';
311
+ // No task list yet means nothing is held.
312
+ const inScope = db ? taskStore.listTasks(db, { workspaceRoot: workspaceRoot || null }) : [];
313
+ const held = inScope
314
+ .filter((row) => row.status === 'review' && taskStore.heldPullRequestOf(row))
315
+ .sort((a, b) => (Number(a.created_at || 0) - Number(b.created_at || 0)) || String(a.id).localeCompare(String(b.id)));
316
+ const named = taskStore.withTaskDisplayRefs(held, inScope);
317
+
318
+ // Each question goes to GitHub once per run, whatever the answer: a failure is not asked again.
319
+ const answers = new Map();
320
+ const askOnce = (url) => {
321
+ if (!answers.has(url)) {
322
+ let asked;
323
+ try {
324
+ asked = ask(url);
325
+ } catch (err) {
326
+ asked = { unknown: oneLine(`could not ask GitHub: ${firstLine(err && err.message) || 'unknown error'}`, DETAIL_MAX) };
327
+ }
328
+ answers.set(url, asked && typeof asked === 'object' ? asked : { unknown: "GitHub's answer could not be read" });
329
+ }
330
+ return answers.get(url);
331
+ };
332
+ const branches = new Map();
333
+ const defaultBranchOf = (repoLink) => {
334
+ const key = repoLink.toLowerCase();
335
+ if (!branches.has(key)) {
336
+ let asked;
337
+ try {
338
+ asked = askBranch(repoLink);
339
+ } catch (err) {
340
+ asked = { unknown: oneLine(`could not ask GitHub: ${firstLine(err && err.message) || 'unknown error'}`, DETAIL_MAX) };
341
+ }
342
+ branches.set(key, asked && typeof asked === 'object' ? asked : { unknown: "GitHub's answer could not be read" });
343
+ }
344
+ return branches.get(key);
345
+ };
346
+
347
+ // A workspace's origin is read once per run, and only if a pull request needs it.
348
+ const origins = new Map();
349
+ const originOf = (root) => {
350
+ if (!origins.has(root)) origins.set(root, originRepoOf(root));
351
+ return origins.get(root);
352
+ };
353
+
354
+ // The name a person reads for a job, in its own workspace's numbering.
355
+ const refOfJob = (id) => {
356
+ const task = taskStore.getTask(db, id);
357
+ if (!task) return String(id);
358
+ const [shown] = taskStore.withTaskDisplayRefs([task], taskStore.listTasks(db, { workspaceRoot: task.workspace_root || null }));
359
+ return (shown && (shown.display_id || shown.legacy_ref || shown.id)) || String(id);
360
+ };
361
+ // Why this pull request is not this job's to settle, when another job has it.
362
+ const takenReason = (taken, answer) => (taken.how === 'landed'
363
+ ? `pull request ${answer.number} in ${answer.repo} already landed with ${refOfJob(taken.id)}`
364
+ : `another job (${refOfJob(taken.id)}) still points at pull request ${answer.number} in ${answer.repo}`);
365
+
366
+ let outage = null;
367
+ const results = [];
368
+ for (const row of named) {
369
+ const pullRequest = taskStore.heldPullRequestOf(row);
370
+ const result = {
371
+ task_id: row.id,
372
+ ref: row.display_id || row.legacy_ref || row.id,
373
+ title: oneLine(row.title, TITLE_MAX),
374
+ builder: row.claimed_by,
375
+ workspace_root: row.workspace_root,
376
+ pull_request: { number: pullRequest.number, url: pullRequest.url },
377
+ outcome: 'unknown',
378
+ action: 'none',
379
+ detail: null,
380
+ };
381
+ results.push(result);
382
+ if (outage) {
383
+ result.outcome = 'not_asked';
384
+ result.detail = NOT_ASKED;
385
+ continue;
386
+ }
387
+ if (!pullRequest.url) {
388
+ result.detail = `the job names pull request ${pullRequest.number} but no link, so there is nothing to ask`;
389
+ continue;
390
+ }
391
+ const asked = askOnce(pullRequest.url);
392
+ if (asked.outage) {
393
+ outage = asked.outage;
394
+ result.detail = outage.detail;
395
+ continue;
396
+ }
397
+ if (!asked.answer) {
398
+ result.detail = asked.unknown || "GitHub's answer could not be read";
399
+ continue;
400
+ }
401
+ const answer = asked.answer;
402
+ if (pullRequest.number && answer.number !== pullRequest.number) {
403
+ result.detail = `GitHub answered for pull request ${answer.number}, not ${pullRequest.number}`;
404
+ continue;
405
+ }
406
+ const expected = { updatedAt: row.updated_at, claimedBy: row.claimed_by, pullRequestUrl: pullRequest.url };
407
+ if (answer.state === 'open') {
408
+ result.outcome = 'open';
409
+ continue;
410
+ }
411
+ // Merged or closed: before a merge is credited or a claim let go, the pull request must be
412
+ // this job's own build. If it is not, nothing is written and the sweep goes on.
413
+ result.outcome = answer.state;
414
+ const refuse = (why) => {
415
+ result.action = 'refused';
416
+ result.detail = `${result.ref}'s recorded pull request is not this job's build: ${why}`;
417
+ };
418
+ const notBuild = notBuildReason(row, answer, originOf(row.workspace_root));
419
+ if (notBuild) {
420
+ refuse(notBuild);
421
+ continue;
422
+ }
423
+ // The pull request this job would settle, in the words the store compares them by.
424
+ const identity = { repo: answer.repo, number: answer.number };
425
+ // The same question is asked of the store again inside the write, where nothing can slip in
426
+ // between; this look lets a refusal made from the board alone skip the questions below.
427
+ const takenBefore = () => taskStore.pullRequestTakenBy(db, { ...identity, exceptId: row.id });
428
+ if (answer.state === 'merged') {
429
+ if (!answer.merge_commit) {
430
+ result.outcome = 'unknown';
431
+ result.detail = `GitHub says pull request ${answer.number} is merged but gave no merge commit`;
432
+ continue;
433
+ }
434
+ if (!answer.merged_at) {
435
+ result.outcome = 'unknown';
436
+ result.detail = `GitHub says pull request ${answer.number} is merged but gave no usable merge time`;
437
+ continue;
438
+ }
439
+ const early = mergeBeforeClaimReason(row, answer);
440
+ if (early) {
441
+ refuse(early);
442
+ continue;
443
+ }
444
+ const takenNow = takenBefore();
445
+ if (takenNow) {
446
+ refuse(takenReason(takenNow, answer));
447
+ continue;
448
+ }
449
+ // A merge counts only on the repository's default branch. The default branch is asked of
450
+ // GitHub once for each repository in the run, from the repository's own address.
451
+ if (!answer.base_ref) {
452
+ refuse("GitHub's answer does not say which branch it was merged into");
453
+ continue;
454
+ }
455
+ const repository = pullRequestOfLink(answer.url);
456
+ const defaults = repository ? defaultBranchOf(repository.repoLink) : { unknown: "GitHub's answer does not say which repository it is in" };
457
+ if (defaults.outage) {
458
+ outage = defaults.outage;
459
+ result.outcome = 'unknown';
460
+ result.detail = outage.detail;
461
+ continue;
462
+ }
463
+ if (!defaults.branch) {
464
+ result.outcome = 'unknown';
465
+ result.detail = defaults.unknown || "GitHub's answer could not be read";
466
+ continue;
467
+ }
468
+ if (answer.base_ref !== defaults.branch) {
469
+ refuse(`it was merged into ${answer.base_ref}, not the repository's default branch ${defaults.branch}`);
470
+ continue;
471
+ }
472
+ const sentence = `pull request ${answer.number} was merged${answer.merged_by ? ` by ${answer.merged_by}` : ''}`;
473
+ if (dryRun) {
474
+ result.action = 'would_land';
475
+ result.detail = `${sentence}; ${row.claimed_by} would be credited`;
476
+ continue;
477
+ }
478
+ const landed = taskStore.landHeldMerge(db, {
479
+ id: row.id,
480
+ actor,
481
+ workspaceRoot: workspaceRoot || row.workspace_root,
482
+ expected,
483
+ pullRequest: identity,
484
+ merge: {
485
+ merge_commit: answer.merge_commit,
486
+ number: answer.number,
487
+ url: answer.url,
488
+ merged_at: answer.merged_at,
489
+ merged_by: answer.merged_by,
490
+ },
491
+ proof: sentence,
492
+ });
493
+ if (landed.landed) {
494
+ result.action = 'landed';
495
+ result.detail = `${sentence}; ${row.claimed_by} is credited`;
496
+ } else if (landed.reason === 'pull_request_taken') {
497
+ refuse(takenReason({ id: landed.taken_by, how: landed.taken_how }, answer));
498
+ } else if (landed.reason === 'invalid_merge_record') {
499
+ result.outcome = 'unknown';
500
+ result.detail = `GitHub's merge commit for pull request ${answer.number} could not be used`;
501
+ } else {
502
+ result.action = 'left_alone';
503
+ result.detail = leftAloneDetail(landed.reason);
504
+ }
505
+ continue;
506
+ }
507
+ // closed without merging, whatever branch it was aimed at
508
+ const takenNow = takenBefore();
509
+ if (takenNow) {
510
+ refuse(takenReason(takenNow, answer));
511
+ continue;
512
+ }
513
+ const reason = `pull request closed without landing: ${pullRequest.url}`;
514
+ if (dryRun) {
515
+ result.action = 'would_release';
516
+ result.detail = reason;
517
+ continue;
518
+ }
519
+ const released = taskStore.releaseClosedPullRequest(db, {
520
+ id: row.id,
521
+ actor,
522
+ workspaceRoot: workspaceRoot || row.workspace_root,
523
+ expected,
524
+ pullRequest: identity,
525
+ reason,
526
+ });
527
+ if (released.released) {
528
+ result.action = 'released';
529
+ result.detail = reason;
530
+ } else if (released.reason === 'pull_request_taken') {
531
+ refuse(takenReason({ id: released.taken_by, how: released.taken_how }, answer));
532
+ } else {
533
+ result.action = 'left_alone';
534
+ result.detail = leftAloneDetail(released.reason);
535
+ }
536
+ }
537
+
538
+ const count = (test) => results.filter(test).length;
539
+ const summary = {
540
+ checked: count((r) => r.outcome !== 'not_asked'),
541
+ landed: count((r) => r.action === 'landed'),
542
+ released: count((r) => r.action === 'released'),
543
+ would_land: count((r) => r.action === 'would_land'),
544
+ would_release: count((r) => r.action === 'would_release'),
545
+ refused: count((r) => r.action === 'refused'),
546
+ open: count((r) => r.outcome === 'open'),
547
+ unknown: count((r) => r.outcome === 'unknown'),
548
+ left_alone: count((r) => r.action === 'left_alone'),
549
+ not_asked: count((r) => r.outcome === 'not_asked'),
550
+ };
551
+ return {
552
+ ok: outage === null,
553
+ action: 'sweep_held',
554
+ dry_run: Boolean(dryRun),
555
+ scope,
556
+ actor,
557
+ outage,
558
+ summary,
559
+ results,
560
+ };
561
+ }
562
+
563
+ // The plain lines a person reads, one per job that moved or could not be read, then a count of
564
+ // the ones still open.
565
+ function describeHeldSweep(report) {
566
+ if (!report.results.length) return ['no held jobs: nothing is waiting on an open pull request'];
567
+ const lines = [`held jobs: ${report.summary.checked} checked against GitHub${report.dry_run ? ' (dry run, nothing written)' : ''}`];
568
+ const verbs = {
569
+ landed: 'landed',
570
+ released: 'released',
571
+ would_land: 'would land',
572
+ would_release: 'would release',
573
+ left_alone: 'left alone',
574
+ };
575
+ for (const result of report.results) {
576
+ if (result.outcome === 'open') continue;
577
+ // A refusal names the job and says why in one line of its own.
578
+ if (result.action === 'refused') {
579
+ lines.push(result.detail);
580
+ continue;
581
+ }
582
+ const verb = verbs[result.action] || (result.outcome === 'not_asked' ? 'not asked' : 'could not check');
583
+ lines.push(`${verb} ${result.ref} ${result.title}: ${result.detail}`);
584
+ }
585
+ if (report.summary.open) lines.push(`still open: ${report.summary.open}`);
586
+ return lines;
587
+ }
588
+
589
+ module.exports = { ASK_TIMEOUT_MS, askGitHub, askDefaultBranch, branchNamesJob, originRepoOf, sweepHeldJobs, describeHeldSweep };
@@ -0,0 +1,25 @@
1
+ 'use strict';
2
+
3
+ // A pull request link, read the one way everything that compares links reads it. The task store
4
+ // (which pull request a job points at) and the held-job sweep (which pull request GitHub answered
5
+ // for) both use it, so the two cannot disagree about what a link names.
6
+ //
7
+ // The link is https://<host>/<owner>/<name>/pull/<number>, however a person decorated it: a
8
+ // trailing slash, /files, a query string, a fragment, any case. A link with no number, or a number
9
+ // that is not a positive whole number, names no pull request.
10
+ //
11
+ // repo the owner and name, in lower case
12
+ // number the pull request number
13
+ // repoLink the repository's own address, as the link spells it
14
+
15
+ const PULL_REQUEST_LINK = /^(https?:\/\/[^/\s]+\/([^/\s]+)\/([^/\s]+))\/pull\/(\d+)(?:[/?#]\S*)?$/i;
16
+
17
+ function pullRequestOfLink(link) {
18
+ const match = PULL_REQUEST_LINK.exec(String(link || '').trim());
19
+ if (!match) return null;
20
+ const number = Number(match[4]);
21
+ if (!Number.isSafeInteger(number) || number <= 0) return null;
22
+ return { repo: `${match[2]}/${match[3]}`.toLowerCase(), number, repoLink: match[1] };
23
+ }
24
+
25
+ module.exports = { pullRequestOfLink };