@feigi/fleet-ctl 3.83.0 → 3.83.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/package.json
CHANGED
package/scripts/fleet-tick.mjs
CHANGED
|
@@ -54,13 +54,13 @@
|
|
|
54
54
|
// whatever its state. Also, for each in-flight `review=` token
|
|
55
55
|
// whose PR the open list does not carry, and for each ticket's
|
|
56
56
|
// latest settled `impl-*=PR#<M>` PR the open list does not carry,
|
|
57
|
-
// that PR's state (`gh
|
|
58
|
-
// be running against a finished PR, so its dangling
|
|
59
|
-
// a review unit, and a PR CLOSED unmerged prints an
|
|
60
|
-
// for its ticket. And, for each ticket holding the
|
|
61
|
-
// row on a tier mismatch, owed a replacement, or whose
|
|
62
|
-
// read CLOSED or unread, whether its issue is CLOSED (`gh
|
|
63
|
-
// view`): a closed ticket's mismatch holds nothing, its owed
|
|
57
|
+
// that PR's state (one `gh api graphql` read per hundred PRs): a
|
|
58
|
+
// review cannot be running against a finished PR, so its dangling
|
|
59
|
+
// token is not a review unit, and a PR CLOSED unmerged prints an
|
|
60
|
+
// ESCALATE line for its ticket. And, for each ticket holding the
|
|
61
|
+
// implementer row on a tier mismatch, owed a replacement, or whose
|
|
62
|
+
// keyed PR read CLOSED or unread, whether its issue is CLOSED (`gh
|
|
63
|
+
// issue view`): a closed ticket's mismatch holds nothing, its owed
|
|
64
64
|
// replacement is not in flight, and its PR prints no line.
|
|
65
65
|
// main The main checkout against the run-start baseline
|
|
66
66
|
// `.fleet/main-checkout.sha`, through main-checkout.mjs.
|
|
@@ -958,7 +958,7 @@ export function foldLedger({ rows, dispatched }, open, { lenient = false } = {})
|
|
|
958
958
|
export const pastPinDue = (st, head) => st !== undefined && st.pastPinHalt && !st.inFlight && st.reviewedHead !== null
|
|
959
959
|
&& !String(head).toLowerCase().startsWith(st.reviewedHead);
|
|
960
960
|
|
|
961
|
-
// The states
|
|
961
|
+
// The states a PR-state probe answers: anything else is no answer.
|
|
962
962
|
const PR_STATES = ["OPEN", "MERGED", "CLOSED"];
|
|
963
963
|
|
|
964
964
|
export function deriveRun({ rows, dispatched, drain, retired = [] }, prs, closed = new Set(), prStates = new Map()) {
|
|
@@ -1007,7 +1007,7 @@ export function deriveRun({ rows, dispatched, drain, retired = [] }, prs, closed
|
|
|
1007
1007
|
// A mismatch on the keyed member itself is no replacement of M and still
|
|
1008
1008
|
// prints. A keyed PR the open list carries is normal flow; one it does not
|
|
1009
1009
|
// is named in `prKeyed` for the caller's state probe, `prStates` its
|
|
1010
|
-
// answers (
|
|
1010
|
+
// answers (none for a PR whose probe could not answer, as by default).
|
|
1011
1011
|
// CLOSED escalates, an unanswered probe escalates as unchecked, OPEN and
|
|
1012
1012
|
// MERGED say nothing, and a ticket whose issue the caller found CLOSED
|
|
1013
1013
|
// (`closed`) says nothing either way.
|
|
@@ -1493,6 +1493,7 @@ import { isCLI } from "./is-cli.mjs";
|
|
|
1493
1493
|
import { parseArgs } from "node:util";
|
|
1494
1494
|
import { makeDie, isDigits } from "./arg.mjs";
|
|
1495
1495
|
import { gitEnv } from "./git-env.mjs";
|
|
1496
|
+
import { ghAliasedRead } from "./gh-aliased-read.mjs";
|
|
1496
1497
|
import { fleetFile, FleetDirUnresolvable, scratchRoot } from "./fleet-dir.mjs";
|
|
1497
1498
|
import { statePath, readState, writeState, assessBeat, isStalled, isReported, stallReport, stallOwner } from "./fleet-state.mjs";
|
|
1498
1499
|
import { checkMainCheckout, describe } from "./main-checkout.mjs";
|
|
@@ -1815,34 +1816,36 @@ function closedTickets(mismatched, owed, surfacing) {
|
|
|
1815
1816
|
return closed;
|
|
1816
1817
|
}
|
|
1817
1818
|
|
|
1818
|
-
// The state of each PR the tick asks gh about, one `gh
|
|
1819
|
-
// per
|
|
1820
|
-
// ledger reads as in flight yet the open list does not carry,
|
|
1821
|
-
// each ticket's keyed PR the open list does not carry (deriveRun's
|
|
1822
|
-
// `prKeyed`). The answer is OPEN, MERGED or CLOSED
|
|
1823
|
-
//
|
|
1824
|
-
//
|
|
1819
|
+
// The state of each PR the tick asks gh about, one aliased `gh api graphql`
|
|
1820
|
+
// read per hundred PRs however many readings need them: `reviews`, the PRs
|
|
1821
|
+
// whose review the ledger reads as in flight yet the open list does not carry,
|
|
1822
|
+
// and `keyed`, each ticket's keyed PR the open list does not carry (deriveRun's
|
|
1823
|
+
// `prKeyed`). The answer is OPEN, MERGED or CLOSED. A probe that could not
|
|
1824
|
+
// answer — a failed call, an alias GitHub errored on or answered with nothing,
|
|
1825
|
+
// a node carrying no state, a state that is none of the three — leaves its PR
|
|
1826
|
+
// out of the map and is disclosed, per PR, with what it leaves standing; a
|
|
1827
|
+
// call killed at GH_TIMEOUT_MS is disclosed with that bound.
|
|
1825
1828
|
function prStates(reviews, keyed) {
|
|
1826
1829
|
const states = new Map();
|
|
1827
1830
|
const reviewed = new Set(reviews);
|
|
1828
1831
|
// Two tickets can be keyed on one PR: each is named, on its one probe.
|
|
1829
1832
|
const ticketsOf = new Map();
|
|
1830
1833
|
for (const k of keyed) ticketsOf.set(k.pr, [...(ticketsOf.get(k.pr) ?? []), k.n]);
|
|
1831
|
-
|
|
1834
|
+
const numbers = [...new Set([...reviews, ...ticketsOf.keys()])].sort((a, b) => a - b);
|
|
1835
|
+
const read = ghAliasedRead(numbers, { field: "pullRequest", selection: "{state}", timeoutMs: GH_TIMEOUT_MS });
|
|
1836
|
+
for (const number of numbers) {
|
|
1837
|
+
const r = read.get(number);
|
|
1838
|
+
const st = r.node?.state;
|
|
1839
|
+
if (PR_STATES.includes(st)) {
|
|
1840
|
+
states.set(number, st);
|
|
1841
|
+
continue;
|
|
1842
|
+
}
|
|
1832
1843
|
const unconfirmed = [
|
|
1833
1844
|
...(reviewed.has(number) ? [`review on PR#${number} unconfirmed finished, its unit stands`] : []),
|
|
1834
1845
|
...(ticketsOf.get(number) ?? []).map((n) => `#${n}'s PR#${number} unconfirmed closed, printed as pr-unchecked`),
|
|
1835
1846
|
].join("; ");
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
if (r.error || r.status !== 0) {
|
|
1839
|
-
console.error(`${NAME}: ${failure(r, `gh pr view ${number}`)} — ${unconfirmed}`);
|
|
1840
|
-
continue;
|
|
1841
|
-
}
|
|
1842
|
-
let st = null;
|
|
1843
|
-
try { st = JSON.parse(r.stdout).state; } catch { /* no state: disclosed below */ }
|
|
1844
|
-
if (PR_STATES.includes(st)) states.set(number, st);
|
|
1845
|
-
else console.error(`${NAME}: gh pr view ${number} printed no PR state — ${unconfirmed}`);
|
|
1847
|
+
const why = r.unreachable === "ETIMEDOUT" ? TIMED_OUT : r.unreachable ?? (r.notFound ? "no such pull request" : "no PR state");
|
|
1848
|
+
console.error(`${NAME}: gh api graphql gave no answer for PR#${number} (${why}) — ${unconfirmed}`);
|
|
1846
1849
|
}
|
|
1847
1850
|
return states;
|
|
1848
1851
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// One GraphQL read of the current repository for many numbers at once: each
|
|
2
|
+
// number is an aliased `<field>(number:N){…}` of the one query, a hundred to a
|
|
3
|
+
// call, so a caller that would ask `gh` about every number one `gh … view` at a
|
|
4
|
+
// time asks once per hundred instead. The caller names the field
|
|
5
|
+
// (`pullRequest`, `issueOrPullRequest`) and what to select on it; what an
|
|
6
|
+
// answer means is the caller's.
|
|
7
|
+
//
|
|
8
|
+
// `gh api graphql` exits 1 when any one alias errors yet still prints the full
|
|
9
|
+
// body, `data.repository` holding every alias GitHub answered and `errors[]`
|
|
10
|
+
// naming each failed one by `path: ["repository", "<alias>"]`. So a body that
|
|
11
|
+
// carries an object `data.repository` is read alias by alias at any exit
|
|
12
|
+
// status, never as "the whole call failed".
|
|
13
|
+
//
|
|
14
|
+
// `-F owner={owner} -F name={repo}` is filled in from the environment gh sees:
|
|
15
|
+
// an inherited GH_REPO outranks the repository the cwd is bound to, and a
|
|
16
|
+
// GIT_DIR naming another repository moves it too. gitEnv({ GH_REPO: "" })
|
|
17
|
+
// scrubs both, so the read is of the repository the cwd is in.
|
|
18
|
+
|
|
19
|
+
import { spawnSync } from "node:child_process";
|
|
20
|
+
import { gitEnv } from "./git-env.mjs";
|
|
21
|
+
|
|
22
|
+
const BATCH = 100;
|
|
23
|
+
const MAX_BUFFER = 64 * 1024 * 1024;
|
|
24
|
+
|
|
25
|
+
/** A child that did not answer, as one short reason. */
|
|
26
|
+
export function how(r) {
|
|
27
|
+
if (r.error) return r.error.code ?? r.error.message;
|
|
28
|
+
if (r.signal) return `killed by ${r.signal}`;
|
|
29
|
+
const said = String(r.stderr ?? "").trim().split("\n").at(-1);
|
|
30
|
+
return said ? `exit ${r.status}: ${said}` : `exit ${r.status}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What GitHub says of each of `numbers`, as a Map keyed by number:
|
|
35
|
+
*
|
|
36
|
+
* `{ node }` — the alias GitHub answered with an object, as parsed.
|
|
37
|
+
* `{ notFound }` — null with a NOT_FOUND error naming the alias at path
|
|
38
|
+
* length 2: GitHub's own "this number names nothing".
|
|
39
|
+
* `{ unreachable }` — no usable answer, with the reason. Any other error
|
|
40
|
+
* naming an alias, an error naming no alias (it may
|
|
41
|
+
* belong to any of the batch), a null no NOT_FOUND
|
|
42
|
+
* explains, a missing alias, and an unparseable or
|
|
43
|
+
* absent body all land here: a failed lookup is not an
|
|
44
|
+
* answer.
|
|
45
|
+
*
|
|
46
|
+
* `field` and `selection` build each alias, `n<N>:<field>(number:<N>)<selection>`;
|
|
47
|
+
* `timeoutMs` bounds each `gh` call.
|
|
48
|
+
*/
|
|
49
|
+
export function ghAliasedRead(numbers, { field, selection, timeoutMs }) {
|
|
50
|
+
const out = new Map();
|
|
51
|
+
for (let i = 0; i < numbers.length; i += BATCH) {
|
|
52
|
+
const batch = numbers.slice(i, i + BATCH);
|
|
53
|
+
const fields = batch.map((n) => `n${n}:${field}(number:${n})${selection}`).join(" ");
|
|
54
|
+
const r = spawnSync("gh", ["api", "graphql", "-F", "owner={owner}", "-F", "name={repo}",
|
|
55
|
+
"-f", `query=query($owner:String!,$name:String!){repository(owner:$owner,name:$name){${fields}}}`], {
|
|
56
|
+
encoding: "utf8", maxBuffer: MAX_BUFFER, timeout: timeoutMs, env: gitEnv({ GH_REPO: "" }),
|
|
57
|
+
});
|
|
58
|
+
let body;
|
|
59
|
+
let unparseable;
|
|
60
|
+
try {
|
|
61
|
+
body = JSON.parse(r.stdout);
|
|
62
|
+
} catch (e) {
|
|
63
|
+
if (r.status === 0) unparseable = e.message;
|
|
64
|
+
}
|
|
65
|
+
const repo = body?.data?.repository;
|
|
66
|
+
const errors = body?.errors ?? [];
|
|
67
|
+
const answered = repo !== null && typeof repo === "object" && !Array.isArray(repo) && Array.isArray(errors);
|
|
68
|
+
const nothing = new Set();
|
|
69
|
+
const refused = new Map();
|
|
70
|
+
let batchWide;
|
|
71
|
+
if (answered) {
|
|
72
|
+
for (const e of errors) {
|
|
73
|
+
const path = e?.path;
|
|
74
|
+
const alias = Array.isArray(path) && path[0] === "repository" && typeof path[1] === "string" ? path[1] : null;
|
|
75
|
+
const said = `${e?.type ?? "error"}${e?.message ? `: ${e.message}` : ""}`;
|
|
76
|
+
if (alias === null) batchWide ??= said;
|
|
77
|
+
else if (e.type === "NOT_FOUND" && path.length === 2) nothing.add(alias);
|
|
78
|
+
else refused.set(alias, said);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
for (const n of batch) {
|
|
82
|
+
const alias = `n${n}`;
|
|
83
|
+
const gone = (why) => out.set(n, { unreachable: why });
|
|
84
|
+
if (!answered) gone(unparseable === undefined ? how(r) : `${how(r)}: unparseable body: ${unparseable}`);
|
|
85
|
+
else if (refused.has(alias)) gone(refused.get(alias));
|
|
86
|
+
else if (batchWide !== undefined) gone(batchWide);
|
|
87
|
+
else if (!Object.hasOwn(repo, alias)) gone(how(r));
|
|
88
|
+
else if (repo[alias] === null) {
|
|
89
|
+
if (nothing.has(alias)) out.set(n, { notFound: true });
|
|
90
|
+
else gone("null with no NOT_FOUND error");
|
|
91
|
+
} else out.set(n, { node: repo[alias] });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
@@ -109,6 +109,7 @@ import { fileURLToPath } from "node:url";
|
|
|
109
109
|
import { defineFlags, isDigits, makeDie, writeAll } from "./arg.mjs";
|
|
110
110
|
import { fleetFile, FleetDirUnresolvable, SCRATCH_KEY_SHAPE, scratchRoot } from "./fleet-dir.mjs";
|
|
111
111
|
import { gitEnv } from "./git-env.mjs";
|
|
112
|
+
import { ghAliasedRead, how } from "./gh-aliased-read.mjs";
|
|
112
113
|
import { isCLI } from "./is-cli.mjs";
|
|
113
114
|
import { liveOnLedger, parseMember, REVIEWER } from "./ledger-grammar.mjs";
|
|
114
115
|
|
|
@@ -116,7 +117,6 @@ const NAME = "reclaim-scratch";
|
|
|
116
117
|
const die = makeDie(NAME);
|
|
117
118
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
118
119
|
export const RETENTION_MS = 7 * DAY_MS;
|
|
119
|
-
const BATCH = 100;
|
|
120
120
|
const GH_TIMEOUT_MS = 30_000;
|
|
121
121
|
const MAX_BUFFER = 64 * 1024 * 1024;
|
|
122
122
|
|
|
@@ -151,13 +151,6 @@ const REPORT_PR_ENTRY = /^(?:fix-pr|finisher-pr|review-pr)-([1-9][0-9]*)(?:-[a-z
|
|
|
151
151
|
const REPORT_IMPL_ENTRY = /^impl-([1-9][0-9]*)(?:-[a-z])?(?:\.check)?\.json$/;
|
|
152
152
|
const RUN_ROOT = /^run-/;
|
|
153
153
|
|
|
154
|
-
function how(r) {
|
|
155
|
-
if (r.error) return r.error.code ?? r.error.message;
|
|
156
|
-
if (r.signal) return `killed by ${r.signal}`;
|
|
157
|
-
const said = String(r.stderr ?? "").trim().split("\n").at(-1);
|
|
158
|
-
return said ? `exit ${r.status}: ${said}` : `exit ${r.status}`;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
154
|
/** The path of something under `path` (itself included) modified at or after
|
|
162
155
|
* `cutoff`, or null when everything is older. Symlinks are not followed. A
|
|
163
156
|
* directory that cannot be listed counts its own mtime only, and so does one
|
|
@@ -302,59 +295,18 @@ function claimWorktree(listing, n) {
|
|
|
302
295
|
|
|
303
296
|
// `{type, state}` per number GitHub answered (type "Issue" or "PullRequest",
|
|
304
297
|
// or null for a number that names nothing), or `{unreachable}` for a number
|
|
305
|
-
// that has no usable answer.
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
// answered by a node, or by null with a NOT_FOUND error naming it — GitHub's
|
|
309
|
-
// own "this number names nothing". Any other error naming an alias, a null
|
|
310
|
-
// no NOT_FOUND explains, and an error naming no alias (it may belong to any
|
|
311
|
-
// of the batch) leave the number unreachable: a failed lookup is not an
|
|
312
|
-
// answer.
|
|
298
|
+
// that has no usable answer. What counts as an answer is gh-aliased-read.mjs's:
|
|
299
|
+
// a number GitHub nulls with NOT_FOUND names nothing, and a failed lookup is
|
|
300
|
+
// not an answer.
|
|
313
301
|
function githubStates(numbers) {
|
|
314
302
|
const out = new Map();
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
});
|
|
322
|
-
let body;
|
|
323
|
-
let unparseable;
|
|
324
|
-
try {
|
|
325
|
-
body = JSON.parse(r.stdout);
|
|
326
|
-
} catch (e) {
|
|
327
|
-
body = undefined;
|
|
328
|
-
if (r.status === 0) unparseable = e.message;
|
|
329
|
-
}
|
|
330
|
-
const repo = body?.data?.repository;
|
|
331
|
-
const errors = body?.errors ?? [];
|
|
332
|
-
const answered = repo !== null && typeof repo === "object" && !Array.isArray(repo) && Array.isArray(errors);
|
|
333
|
-
const nothing = new Set();
|
|
334
|
-
const refused = new Map();
|
|
335
|
-
let batchWide;
|
|
336
|
-
if (answered) {
|
|
337
|
-
for (const e of errors) {
|
|
338
|
-
const path = e?.path;
|
|
339
|
-
const alias = Array.isArray(path) && path[0] === "repository" && typeof path[1] === "string" ? path[1] : null;
|
|
340
|
-
const said = `${e?.type ?? "error"}${e?.message ? `: ${e.message}` : ""}`;
|
|
341
|
-
if (alias === null) batchWide ??= said;
|
|
342
|
-
else if (e.type === "NOT_FOUND" && path.length === 2) nothing.add(alias);
|
|
343
|
-
else refused.set(alias, said);
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
for (const n of batch) {
|
|
347
|
-
const alias = `n${n}`;
|
|
348
|
-
const gone = (why) => out.set(n, { unreachable: `gh api graphql gave no answer for #${n} (${why})` });
|
|
349
|
-
if (!answered) gone(unparseable === undefined ? how(r) : `${how(r)}: unparseable body: ${unparseable}`);
|
|
350
|
-
else if (refused.has(alias)) gone(refused.get(alias));
|
|
351
|
-
else if (batchWide !== undefined) gone(batchWide);
|
|
352
|
-
else if (!Object.hasOwn(repo, alias)) gone(how(r));
|
|
353
|
-
else if (repo[alias] === null) {
|
|
354
|
-
if (nothing.has(alias)) out.set(n, { type: null });
|
|
355
|
-
else gone("null with no NOT_FOUND error");
|
|
356
|
-
} else out.set(n, { type: repo[alias].__typename, state: String(repo[alias].state).toUpperCase() });
|
|
357
|
-
}
|
|
303
|
+
const read = ghAliasedRead(numbers, {
|
|
304
|
+
field: "issueOrPullRequest", selection: "{__typename ... on Issue{state} ... on PullRequest{state}}", timeoutMs: GH_TIMEOUT_MS,
|
|
305
|
+
});
|
|
306
|
+
for (const [n, r] of read) {
|
|
307
|
+
if (r.unreachable !== undefined) out.set(n, { unreachable: `gh api graphql gave no answer for #${n} (${r.unreachable})` });
|
|
308
|
+
else if (r.notFound) out.set(n, { type: null });
|
|
309
|
+
else out.set(n, { type: r.node.__typename, state: String(r.node.state).toUpperCase() });
|
|
358
310
|
}
|
|
359
311
|
return out;
|
|
360
312
|
}
|
|
@@ -95,8 +95,8 @@ The full text of each row `fleet-tick.mjs` prints and what it asks of the contro
|
|
|
95
95
|
carried on at the right tier — and once that one settles, the line reads it
|
|
96
96
|
the same way. A tier mismatch on the member that settled `PR#<M>` itself
|
|
97
97
|
replaces nothing, so the line stands beside that hold.
|
|
98
|
-
- `ESCALATE pr-unchecked #<N> PR#<M>` — the same ticket, but
|
|
99
|
-
|
|
98
|
+
- `ESCALATE pr-unchecked #<N> PR#<M>` — the same ticket, but gh could not
|
|
99
|
+
answer for PR M, so whether M closed is unknown. The maintainer's on
|
|
100
100
|
the same terms as `pr-closed`; the next tick probes again.
|
|
101
101
|
- `HOLD (…)` — the row is held and says why: draining, a tier mismatch, an
|
|
102
102
|
unchecked tier, the In-flight bound reached (`HOLD (in-flight <n>/<B>)` —
|