atris 3.76.0 → 3.78.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,296 @@
1
+ 'use strict';
2
+
3
+ // Whether a landing really landed. The rating counts a job as landed when its record says it merged. The
4
+ // builder picker steers by that count, so it asks more of each landing, and it asks git, on this computer:
5
+ //
6
+ // One. The commit the record names is on origin's default branch. The branch is listed once (git rev-list, back to
7
+ // a day before the earliest claim the caller will ask about), and every commit is looked up in that list.
8
+ // However many landings there are, git is asked the same few questions, and no process is started for any one
9
+ // landing.
10
+ // Two. The commit is no older than the claim it lands, allowing five minutes for clocks that disagree. Work
11
+ // that was already on the branch before the job began is not what the job landed. Every commit is read
12
+ // in one batched call (git rev-list --stdin), however many landings there are.
13
+ // Three. No other job recorded the same commit first. The check does not know jobs: the caller passes
14
+ // `owns(commit)`, and a landing counts only when one of its commits is the caller's to claim.
15
+ //
16
+ // The default branch is the one origin/HEAD names, and nothing else: origin/main and origin/master are never
17
+ // guessed, because a repository whose default branch is something else would be checked against the wrong
18
+ // branch without a word. When origin/HEAD is not set, or names a branch this folder does not have, no landing can be
19
+ // checked here and the reason names the fix (git remote set-head origin --auto). The same goes for a folder
20
+ // that is not a git repository, a computer without git, and a folder with no origin.
21
+ //
22
+ // Before anything is counted, refresh() fetches origin's default branch once (and only that branch), so a merge
23
+ // that happened a minute ago is known. The fetch names where the branch goes (+refs/heads/<branch>:refs/remotes/
24
+ // origin/<branch>), so the ref that is checked is the ref that was fetched, whatever the repository's own fetch
25
+ // settings say. If the fetch fails or takes too long, no landing can be checked, and the reason says so. Everything
26
+ // else asks only about this computer's repository; the fetch is the one question that goes out, and it is marked so
27
+ // a stand-in for git can tell it apart.
28
+ //
29
+ // Everything git does before a pick shares one budget of five seconds, counted from the first question: each
30
+ // question is given what is left of it, the question about the person's ssh settings and the fetch included. When it
31
+ // is gone nothing more is asked, and no landing can be checked. Git never prompts a person: its standard input is
32
+ // closed, GIT_TERMINAL_PROMPT is 0, and the fetch runs ssh in batch mode (-o BatchMode=yes). A person's own ssh
33
+ // command (GIT_SSH_COMMAND, GIT_SSH or core.sshCommand) is not replaced: the fetch runs it as it is, the five
34
+ // seconds still end it, and notes() says so in one line.
35
+ //
36
+ // A landing with no commit, a commit git cannot find or that sits on a side branch only, a commit made before the
37
+ // claim, or one another job recorded first: none of these count as landed. The check changes nothing the rating
38
+ // counts. One check lives for one run: it reads the default branch once, reads each commit once, and looks up
39
+ // each commit in what it read. If git stops answering in time (a hung disk, a lock) or the budget runs out, the
40
+ // check stops asking and cannotCheck() says so: nothing more counts as landed.
41
+ //
42
+ // The limit of listing only the window: `git rev-list --max-age` skips a commit older than the limit and does not
43
+ // walk on to its parents, so a branch whose commit dates run backwards by more than a day (a clock set wrong, a
44
+ // rebase on a machine far behind) can hide commits behind such a commit. A hidden commit is not landed, which is
45
+ // the safe answer. The day on top of the five minutes of skew is what that allowance is.
46
+
47
+ const { spawnSync } = require('child_process');
48
+
49
+ const HEX = /^[0-9a-f]{7,64}$/i;
50
+ const COMMIT_KEYS = ['merge_commit', 'merge_commit_sha', 'merge_sha'];
51
+
52
+ // A folder's own git, not the repository of whatever hook or script started this run.
53
+ const GIT_POINTERS = ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE', 'GIT_OBJECT_DIRECTORY',
54
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_NAMESPACE', 'GIT_SHALLOW_FILE', 'GIT_PREFIX'];
55
+
56
+ // All of git's work before a pick shares this long, as short as the fetch a worktree start makes
57
+ // (commands/worktree.js refreshRemoteRef): it runs before a flight's first job is dealt, and a slow network is
58
+ // answered by dealing in turn, not by waiting.
59
+ const BUDGET_MS = 5000;
60
+ // A commit made up to this long before its claim still counts: two computers' clocks are never quite together.
61
+ const SKEW_MS = 5 * 60 * 1000;
62
+ // The default branch is listed back to this much before the skew allowance, for commits whose dates are out of order.
63
+ const WINDOW_MARGIN_MS = 24 * 60 * 60 * 1000;
64
+ const FIX = 'git remote set-head origin --auto';
65
+ const ORIGIN_BRANCH = /^refs\/remotes\/origin\/([^\s:-][^\s:]*)$/;
66
+ // Run ssh so that it cannot wait on a person (no password, no host-key question).
67
+ const BATCH_SSH = 'ssh -o BatchMode=yes';
68
+
69
+ // The commits one landing record names, from the same places the rating looks for a merge (the record itself,
70
+ // its landing, its pull request). A merged pull request's merge commit is checked like any other commit.
71
+ function commitsOf(payload) {
72
+ const record = payload && typeof payload === 'object' ? payload : {};
73
+ const places = [record, record.landing, record.pull_request, record.pr]
74
+ .filter((place) => place && typeof place === 'object' && !Array.isArray(place));
75
+ const found = [];
76
+ for (const place of places) {
77
+ for (const key of COMMIT_KEYS) {
78
+ if (typeof place[key] === 'string' && HEX.test(place[key])) found.push(place[key].toLowerCase());
79
+ }
80
+ }
81
+ return [...new Set(found)];
82
+ }
83
+
84
+ function cleanEnv(base) {
85
+ const env = { ...base, GIT_TERMINAL_PROMPT: '0' };
86
+ for (const key of GIT_POINTERS) delete env[key];
87
+ return env;
88
+ }
89
+
90
+ // Start git in `root`. Standard input is closed unless there is text for it, `timeout` is what is left of the budget,
91
+ // and git never prompts. `input` is text for git's standard input. `network` marks the one question that goes out; it
92
+ // is given `ssh` (the ssh command to run, when the check chose one) and keeps the repository's own network settings.
93
+ function runGit(spawn, base, root, args, { input, network = false, ssh = '', timeout } = {}) {
94
+ const env = cleanEnv(base);
95
+ if (!network) env.GIT_NO_LAZY_FETCH = '1';
96
+ if (ssh) env.GIT_SSH_COMMAND = ssh;
97
+ const done = spawn('git', ['-C', root, ...args], {
98
+ env,
99
+ encoding: 'utf8',
100
+ timeout,
101
+ killSignal: 'SIGKILL',
102
+ maxBuffer: 64 * 1024 * 1024,
103
+ ...(input === undefined ? {} : { input }),
104
+ stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'ignore'],
105
+ });
106
+ if (done.error) return { status: -1, stdout: '', missing: done.error.code === 'ENOENT', timedOut: done.error.code === 'ETIMEDOUT' };
107
+ return { status: done.status, stdout: String(done.stdout || ''), missing: false, timedOut: false };
108
+ }
109
+
110
+ // `run(root, args, { input, network, ssh, timeout })` stands in for git in tests, and `spawn` for the operating
111
+ // system's way of starting it. They answer { status, stdout, missing, timedOut }. `env` is the environment git
112
+ // runs in (this process's by default), `now` the clock the budget is counted on, and `since` (milliseconds) the
113
+ // earliest claim any landing asked about could have: the default branch is listed back to a day before it.
114
+ function createLandingCheck(root, { run, spawn = spawnSync, env = process.env, now = Date.now, budgetMs = BUDGET_MS, since } = {}) {
115
+ const gitRun = run || ((dir, args, options) => runGit(spawn, env, dir, args, options));
116
+ let startedAt = null; // the first question starts the budget
117
+ let spent = false; // the budget is used up, or git did not answer in time: no more questions are asked
118
+ let target = null; // { ref, branch }, or { problem }: looked up once
119
+ let fetched = null; // null until refresh() has run, then '' or why it could not fetch
120
+ let unreadable = ''; // why the commits, or the default branch, could not be read
121
+ const told = []; // what the check did that a person should hear about
122
+ const sightings = new Map(); // a commit as spelled (lower case) -> { oid, time } or null
123
+ let onBranch = null; // the full ids of the commits on the default branch inside the window
124
+
125
+ // Every question goes through here, with what is left of the budget. Once git has not answered in time, or the
126
+ // budget is gone, no more are asked.
127
+ function ask(args, options = {}) {
128
+ const silent = { status: -1, stdout: '', missing: false, timedOut: true };
129
+ if (spent) return silent;
130
+ if (startedAt === null) startedAt = now();
131
+ const left = budgetMs - (now() - startedAt);
132
+ if (left <= 0) {
133
+ spent = true;
134
+ return silent;
135
+ }
136
+ const answer = gitRun(root, args, { ...options, timeout: left });
137
+ if (answer.timedOut) spent = true;
138
+ return answer;
139
+ }
140
+
141
+ // The branch to check against, or why there is none. Looked up once.
142
+ function look() {
143
+ if (target) return target;
144
+ const fail = (problem) => (target = { problem });
145
+ const inside = ask(['rev-parse', '--git-dir']);
146
+ if (inside.missing) return fail('git is not installed here');
147
+ if (inside.timedOut) return fail('git did not answer in time');
148
+ if (inside.status !== 0) return fail('this folder is not a git repository');
149
+ const head = ask(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']);
150
+ if (head.timedOut) return fail('git did not answer in time');
151
+ const named = head.status === 0 ? ORIGIN_BRANCH.exec(head.stdout.trim()) : null;
152
+ if (!named) {
153
+ // Not set. Say which of two things it is: no origin at all, or an origin whose default branch was never read.
154
+ const remote = ask(['remote', 'get-url', 'origin']);
155
+ if (remote.timedOut) return fail('git did not answer in time');
156
+ return fail(remote.status === 0 ? `origin's default branch is not set here, so there is no branch to check against; fix it with ${FIX}` : 'no origin branch');
157
+ }
158
+ const ref = head.stdout.trim();
159
+ const there = ask(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
160
+ if (there.timedOut) return fail('git did not answer in time');
161
+ if (there.status !== 0) return fail(`origin's default branch, origin/${named[1]}, is not in this folder; fix it with ${FIX}`);
162
+ return (target = { ref, branch: named[1] });
163
+ }
164
+
165
+ // Why no landing can be checked in this folder at all, or ''. A fetch that failed counts: a folder that cannot
166
+ // bring origin up to date does not vouch for anything. So does a git that stopped answering.
167
+ function cannotCheck() {
168
+ return look().problem || fetched || unreadable || '';
169
+ }
170
+
171
+ // The ssh command the fetch runs with, or '' to leave ssh as the repository has it. A person's own ssh command
172
+ // (GIT_SSH_COMMAND, GIT_SSH, or core.sshCommand in the repository's settings) is never replaced: it runs as it is, the
173
+ // five seconds still end the fetch, and the check says so. With none, ssh is told never to wait on a person.
174
+ function sshForFetch() {
175
+ let own = '';
176
+ if (String(env.GIT_SSH_COMMAND || '').trim()) own = 'GIT_SSH_COMMAND';
177
+ else if (String(env.GIT_SSH || '').trim()) own = 'GIT_SSH';
178
+ else {
179
+ const configured = ask(['config', '--get', 'core.sshCommand']);
180
+ if (configured.status === 0 && configured.stdout.trim()) own = 'core.sshCommand';
181
+ }
182
+ if (!own) return BATCH_SSH;
183
+ told.push(`your own ssh command (${own}) was left as it is, so ssh may stop to ask for a password; the fetch gives up after five seconds either way`);
184
+ return '';
185
+ }
186
+
187
+ // Fetch origin's default branch, once. Returns '' when it worked, else why it did not. Asked again, it answers the
188
+ // same and does not try again. A folder that cannot be checked is not fetched.
189
+ function refresh() {
190
+ const found = look();
191
+ if (found.problem) return found.problem;
192
+ if (fetched !== null) return fetched;
193
+ const ssh = sshForFetch();
194
+ const spec = `+refs/heads/${found.branch}:refs/remotes/origin/${found.branch}`;
195
+ const done = ask(['fetch', 'origin', spec], { network: true, ...(ssh ? { ssh } : {}) });
196
+ if (done.timedOut) fetched = 'could not fetch origin in time';
197
+ else if (done.status !== 0) fetched = 'could not fetch origin';
198
+ else fetched = '';
199
+ // What was unknown before the fetch may be known now.
200
+ if (!fetched) {
201
+ sightings.clear();
202
+ onBranch = null;
203
+ }
204
+ return fetched;
205
+ }
206
+
207
+ // The commits as git has them, in one batched call: a Map from each commit as spelled (lower case) to
208
+ // { oid, time } (the full id and the commit time in milliseconds), or to null when git does not have it, or when a
209
+ // short spelling starts more than one commit. What was read before is not read again.
210
+ function read(commits) {
211
+ const names = [...new Set((Array.isArray(commits) ? commits : []).map((commit) => String(commit == null ? '' : commit).trim().toLowerCase()).filter((commit) => HEX.test(commit)))];
212
+ const fresh = names.filter((name) => !sightings.has(name));
213
+ if (fresh.length) {
214
+ const found = look();
215
+ if (found.problem || spent || unreadable) {
216
+ for (const name of fresh) sightings.set(name, null);
217
+ } else {
218
+ // --ignore-missing goes first: git reads it before the names on standard input. With it, a name git does not
219
+ // have, or one that starts two commits, is left out instead of ending the whole batch.
220
+ const answer = ask(['rev-list', '--ignore-missing', '--no-walk=unsorted', '--stdin', '--format=%H %ct'], { input: `${fresh.join('\n')}\n` });
221
+ if (answer.timedOut) unreadable = 'git did not answer in time';
222
+ else if (answer.status !== 0) unreadable = 'git could not read the commits';
223
+ const commitsRead = [];
224
+ if (!unreadable) {
225
+ for (const line of answer.stdout.split('\n')) {
226
+ const one = /^([0-9a-f]{40,64}) (\d+)$/.exec(line.trim());
227
+ if (one) commitsRead.push({ oid: one[1], time: Number(one[2]) * 1000 });
228
+ }
229
+ }
230
+ // Each spelling is looked up in a map of the commits it could start, not compared against every commit: one
231
+ // entry for each commit and each length a spelling was given in (a spelling is 7 to 64 digits).
232
+ const lengths = [...new Set(fresh.map((name) => name.length))];
233
+ const starting = new Map();
234
+ for (const one of commitsRead) {
235
+ for (const length of lengths) {
236
+ if (length > one.oid.length) continue;
237
+ const prefix = one.oid.slice(0, length);
238
+ const held = starting.get(prefix);
239
+ if (held) held.push(one); else starting.set(prefix, [one]);
240
+ }
241
+ }
242
+ for (const name of fresh) {
243
+ const starts = starting.get(name) || [];
244
+ sightings.set(name, starts.length === 1 ? starts[0] : null);
245
+ }
246
+ // A commit asked for by a short spelling is also known by its full id, which is how a later landing may name it.
247
+ for (const one of commitsRead) if (!sightings.has(one.oid)) sightings.set(one.oid, one);
248
+ }
249
+ }
250
+ return new Map(names.map((name) => [name, sightings.get(name) || null]));
251
+ }
252
+
253
+ // The commits on the default branch, listed once: the full ids of everything reachable from it inside the window,
254
+ // or null when git could not list it (cannotCheck says why).
255
+ function branchCommits(ref) {
256
+ if (onBranch) return onBranch;
257
+ if (unreadable) return null;
258
+ const edge = Number.isFinite(since) ? since - SKEW_MS - WINDOW_MARGIN_MS : 0;
259
+ const window = edge > 0 ? [`--max-age=${Math.floor(edge / 1000)}`] : [];
260
+ const answer = ask(['rev-list', ...window, ref, '--']);
261
+ if (answer.timedOut) unreadable = 'git did not answer in time';
262
+ else if (answer.status !== 0) unreadable = 'git could not read the default branch';
263
+ if (unreadable) return null;
264
+ onBranch = new Set(answer.stdout.split('\n').map((line) => line.trim().toLowerCase()).filter(Boolean));
265
+ return onBranch;
266
+ }
267
+
268
+ return {
269
+ cannotCheck,
270
+ refresh,
271
+ read,
272
+ // What the check did that a person should hear about, in plain words: a line each, said once. Empty when it left
273
+ // nothing of theirs alone.
274
+ notes: () => told.slice(),
275
+ // True when one of these commits is on the default branch, is no older than `claimedAt` (a time in
276
+ // milliseconds, if given; five minutes of skew allowed) and is `owns(commit)`'s to claim (if given; it is
277
+ // handed the full id, and only for a commit that is already landed). No commits, or no branch to check, is false.
278
+ landed(commits, { claimedAt, owns } = {}) {
279
+ const found = look();
280
+ if (found.problem || fetched || unreadable) return false;
281
+ const seen = read(commits);
282
+ if (unreadable) return false;
283
+ const listed = [...seen.values()].some(Boolean) ? branchCommits(found.ref) : null;
284
+ if (!listed) return false;
285
+ return [...seen.keys()].some((name) => {
286
+ const sighting = seen.get(name);
287
+ if (!sighting) return false;
288
+ if (Number.isFinite(claimedAt) && sighting.time < claimedAt - SKEW_MS) return false;
289
+ if (!listed.has(sighting.oid)) return false;
290
+ return typeof owns === 'function' ? Boolean(owns(sighting.oid)) : true;
291
+ });
292
+ },
293
+ };
294
+ }
295
+
296
+ module.exports = { commitsOf, createLandingCheck };