@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feigi/fleet-ctl",
3
- "version": "3.83.0",
3
+ "version": "3.83.1",
4
4
  "description": "Agent fleet: run-team controller, merge bot, PR reviewer, and the ticket pipeline they share",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -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 pr view`, once per PR): a review cannot
58
- // be running against a finished PR, so its dangling token is not
59
- // a review unit, and a PR CLOSED unmerged prints an ESCALATE line
60
- // for its ticket. And, for each ticket holding the implementer
61
- // row on a tier mismatch, owed a replacement, or whose keyed PR
62
- // read CLOSED or unread, whether its issue is CLOSED (`gh issue
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 `gh pr view --json state` answers: anything else is no answer.
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 (null for a probe that could not answer, none by default).
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 pr view --json state`
1819
- // per PR however many readings need it: `reviews`, the PRs whose review the
1820
- // ledger reads as in flight yet the open list does not carry, and `keyed`,
1821
- // each ticket's keyed PR the open list does not carry (deriveRun's
1822
- // `prKeyed`). The answer is OPEN, MERGED or CLOSED, or null for a probe that
1823
- // could not answer — a nonzero exit, a reply carrying no state, a state that
1824
- // is none of the three — which is disclosed with what it leaves standing.
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
- for (const number of [...new Set([...reviews, ...ticketsOf.keys()])].sort((a, b) => a - b)) {
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
- states.set(number, null);
1837
- const r = spawnSync("gh", ["pr", "view", String(number), "--json", "state"], { encoding: "utf8", timeout: GH_TIMEOUT_MS, env: gitEnv({ GH_REPO: "" }) });
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. GitHub resolves every alias it can and nulls the
306
- // rest, and gh exits 1 with the whole body, so a body carrying an object
307
- // `data.repository` is read alias by alias at any exit status. An alias is
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
- for (let i = 0; i < numbers.length; i += BATCH) {
316
- const batch = numbers.slice(i, i + BATCH);
317
- const fields = batch.map((n) => `n${n}:issueOrPullRequest(number:${n}){__typename ... on Issue{state} ... on PullRequest{state}}`).join(" ");
318
- const r = spawnSync("gh", ["api", "graphql", "-F", "owner={owner}", "-F", "name={repo}",
319
- "-f", `query=query($owner:String!,$name:String!){repository(owner:$owner,name:$name){${fields}}}`], {
320
- encoding: "utf8", maxBuffer: MAX_BUFFER, timeout: GH_TIMEOUT_MS, env: gitEnv({ GH_REPO: "" }),
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 `gh pr view
99
- <M>` could not answer, so whether M closed is unknown. The maintainer's on
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>)` —