@feigi/fleet-ctl 3.79.4 → 3.79.6
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 +1 -1
- package/scripts/board-gather.mjs +54 -40
- package/scripts/compute-board.mjs +31 -4
package/package.json
CHANGED
package/scripts/board-gather.mjs
CHANGED
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
import { readReceipt } from "./phase0-receipt.mjs";
|
|
35
35
|
import { RECEIPT } from "./ledger-grammar.mjs";
|
|
36
36
|
import { NAME, warnOnce, execRead } from "./board-common.mjs";
|
|
37
|
-
import { mergedReadPrs } from "./compute-board.mjs";
|
|
37
|
+
import { mergedReadPrs, issuesOfPrs } from "./compute-board.mjs";
|
|
38
38
|
// The heartbeat's liveness mark and the controller record beside it, read here
|
|
39
39
|
// and never written: the heartbeat is the mark's only writer and
|
|
40
40
|
// `ledger.mjs rotate` the record's. stallOwner() reads the process table to
|
|
@@ -48,7 +48,7 @@ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
|
|
48
48
|
// The "is this a plain JSON object" predicate and its kind word, one
|
|
49
49
|
// copy for the reads in this file that reject a parsed-but-wrong payload
|
|
50
50
|
// WHOLE — gather's `--prev` payload, gather's per-entry `tickets[i]` check,
|
|
51
|
-
// and
|
|
51
|
+
// and readStates()'s `data.repository`. Each carried its own inline
|
|
52
52
|
// copy of both halves before, which is three places for one policy to drift in.
|
|
53
53
|
//
|
|
54
54
|
// TWO functions and not one, because `typeof` alone cannot name the fault the
|
|
@@ -155,48 +155,60 @@ function hitLimit(parsed, limit) {
|
|
|
155
155
|
return Array.isArray(parsed) && parsed.length >= limit;
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
-
//
|
|
159
|
-
// one aliased `pullRequest(number:)` field per
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
// before that and never seen MERGED
|
|
165
|
-
// counted as claimed. Measured 2026-09-26: 400
|
|
158
|
+
// The merged and closed states of `prNums`, and the CLOSED-ness of `issueNums`,
|
|
159
|
+
// in ONE `gh api graphql` query — one aliased `pullRequest(number:)` field per
|
|
160
|
+
// PR and one `issue(number:)` field per issue, on the cwd's repository (gh's own
|
|
161
|
+
// `{owner}`/`{repo}` placeholders, the repo every other read here resolves) — so
|
|
162
|
+
// a PR merged at any age is found, and ten PRs cost what one does. It replaced
|
|
163
|
+
// `gh pr list --state merged --limit 100`, the repo's 100 most recent merges: about
|
|
164
|
+
// six days on 2026-09-25, so a row PR merged before that and never seen MERGED
|
|
165
|
+
// rendered REVIEW, turned stale and counted as claimed. Measured 2026-09-26: 400
|
|
166
|
+
// aliases answer in one call.
|
|
166
167
|
//
|
|
167
|
-
// An empty `prNums` makes no read at all. `state` is checked, so an
|
|
168
|
-
// does not call MERGED never
|
|
168
|
+
// An empty `prNums` makes no read at all. `state` is checked per alias, so an
|
|
169
|
+
// entry gh does not call MERGED is never merged, and one it does not call CLOSED
|
|
170
|
+
// is never closed; an issue not CLOSED is not CLOSED.
|
|
169
171
|
//
|
|
170
172
|
// A body with an object `data.repository` IS an answer even at exit 1:
|
|
171
|
-
// GitHub resolves every alias it can and nulls the rest — a number that is
|
|
172
|
-
//
|
|
173
|
-
// whole body on stdout (measured
|
|
174
|
-
// the number on stderr). Refusing that
|
|
175
|
-
// typo, or a row with no impl token keyed by an
|
|
176
|
-
// row PR back in REVIEW on every tick. A null
|
|
177
|
-
// which is what a failed read means
|
|
173
|
+
// GitHub resolves every alias it can and nulls the rest — a number that is not
|
|
174
|
+
// a PR (or, for an issue alias, not an issue) comes back null beside a
|
|
175
|
+
// NOT_FOUND error, and gh exits 1 with the whole body on stdout (measured
|
|
176
|
+
// 2026-09-26, gh 2.101.0, which also names the number on stderr). Refusing that
|
|
177
|
+
// body would let one such row — a typo, or a row with no impl token keyed by an
|
|
178
|
+
// issue number — put every other row PR back in REVIEW on every tick. A null
|
|
179
|
+
// alias reads as not merged and not closed, which is what a failed read means
|
|
180
|
+
// for that one number.
|
|
178
181
|
//
|
|
179
|
-
// Real GitHub always answers EVERY requested
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
async function
|
|
186
|
-
|
|
187
|
-
|
|
182
|
+
// Real GitHub always answers EVERY requested PR alias, null or an object — never
|
|
183
|
+
// omits one — so an object naming NONE of the PR numbers is not a shape the API
|
|
184
|
+
// produces, only a corrupted or truncated one; `isJsonObject` alone cannot tell
|
|
185
|
+
// that apart from a genuine "checked, nothing matched" answer, so it is checked
|
|
186
|
+
// for too. Anything else is a failed read: every list empty, and
|
|
187
|
+
// computeBoard()'s carry-forward rule takes it from there.
|
|
188
|
+
async function readStates(prNums, issueNums) {
|
|
189
|
+
const none = { merged: [], closed: [], closedIssues: [] };
|
|
190
|
+
if (!prNums.length) return none;
|
|
191
|
+
const fields = [
|
|
192
|
+
...prNums.map((n) => `p${n}:pullRequest(number:${n}){state}`),
|
|
193
|
+
...issueNums.map((n) => `i${n}:issue(number:${n}){state}`),
|
|
194
|
+
].join(" ");
|
|
188
195
|
const r = await execRead("gh", ["api", "graphql", "-F", "owner={owner}", "-F", "name={repo}",
|
|
189
196
|
"-f", `query=query($owner:String!,$name:String!){repository(owner:$owner,name:$name){${fields}}}`]);
|
|
190
197
|
let repo;
|
|
191
198
|
try { repo = JSON.parse(r.stdout)?.data?.repository; } catch {}
|
|
192
199
|
if (!isJsonObject(repo) || !prNums.some((n) => Object.prototype.hasOwnProperty.call(repo, `p${n}`))) {
|
|
193
200
|
// `how` as runCiState() spells it; never r.error.message, which repeats
|
|
194
|
-
// the whole query — one field per
|
|
201
|
+
// the whole query — one field per number — into every failed tick's line.
|
|
195
202
|
const how = r.code ?? (r.signal ? `killed by ${r.signal}` : `exit ${r.status}`);
|
|
196
|
-
|
|
197
|
-
|
|
203
|
+
const n = prNums.length;
|
|
204
|
+
console.error(`${NAME}: gh api graphql merged read (${n} PR${n === 1 ? "" : "s"}) failed (${how}) with no usable answer`);
|
|
205
|
+
return none;
|
|
198
206
|
}
|
|
199
|
-
return
|
|
207
|
+
return {
|
|
208
|
+
merged: prNums.filter((n) => repo[`p${n}`]?.state === "MERGED"),
|
|
209
|
+
closed: prNums.filter((n) => repo[`p${n}`]?.state === "CLOSED"),
|
|
210
|
+
closedIssues: issueNums.filter((n) => repo[`i${n}`]?.state === "CLOSED"),
|
|
211
|
+
};
|
|
200
212
|
}
|
|
201
213
|
|
|
202
214
|
// labels: gh emits an array of {name,...} objects, but a malformed row can
|
|
@@ -692,7 +704,7 @@ export async function gather({ ledgerFile, prevFile, stateFile = null, scriptDir
|
|
|
692
704
|
// JSON.parse SUCCEEDS on `null`, a bare number, a string and an array,
|
|
693
705
|
// none of which is a board; `null` in particular is absent, not an
|
|
694
706
|
// object, and `typeof null` alone would let it through. Same check and
|
|
695
|
-
// same wording as
|
|
707
|
+
// same wording as readStates()'s `data.repository` read, which is this
|
|
696
708
|
// repo's precedent for rejecting a parsed-but-wrong payload at its own
|
|
697
709
|
// read — literally the same, both calling the shared predicate.
|
|
698
710
|
const p = JSON.parse(readFileSync(prevFile, "utf8"));
|
|
@@ -711,7 +723,7 @@ export async function gather({ ledgerFile, prevFile, stateFile = null, scriptDir
|
|
|
711
723
|
// (drop the bad entry, keep the rest) would make this a new, fourth policy for
|
|
712
724
|
// one payload class in this file — mapCi's null-only guard and tryParse's
|
|
713
725
|
// unguarded read are the other two — rather than the reject-whole-payload
|
|
714
|
-
// policy this guard already shares with
|
|
726
|
+
// policy this guard already shares with readStates()'s own read.
|
|
715
727
|
const bad = p.tickets.findIndex((t) => !isJsonObject(t));
|
|
716
728
|
if (bad !== -1)
|
|
717
729
|
throw new TypeError(`expected tickets[${bad}] to be a JSON object, got ${jsonKind(p.tickets[bad])}`);
|
|
@@ -805,11 +817,13 @@ export async function gather({ ledgerFile, prevFile, stateFile = null, scriptDir
|
|
|
805
817
|
// <sha>`, and the open list above drops a PR the moment it merges. It is
|
|
806
818
|
// asked only about mergedReadPrs()'s set — row PRs absent from the open
|
|
807
819
|
// list, with no token, not carried forward — in one batched read
|
|
808
|
-
// that answers whatever their merge age; see
|
|
809
|
-
//
|
|
810
|
-
// PRs this set leaves out, so a failure costs only a PR never
|
|
811
|
-
// MERGED, which reads REVIEW
|
|
812
|
-
|
|
820
|
+
// that answers whatever their merge age; see readStates(). A failed read
|
|
821
|
+
// leaves every list empty, and the carry-forward in computeBoard() covers
|
|
822
|
+
// exactly the PRs this set leaves out, so a failure costs only a PR never
|
|
823
|
+
// yet seen MERGED, which reads REVIEW — and flags no pr-closed, since the
|
|
824
|
+
// closed lists are empty too.
|
|
825
|
+
const asked = mergedReadPrs({ ledger, prs, prev });
|
|
826
|
+
const { merged, closed, closedIssues } = await readStates(asked, issuesOfPrs({ ledger, prs: asked }));
|
|
813
827
|
|
|
814
828
|
// CI per open PR. On failure, carry the previous board's value for that PR.
|
|
815
829
|
const prevCi = new Map((prev?.tickets || []).filter((t) => t.pr != null).map((t) => [t.pr, t.ci]));
|
|
@@ -869,7 +883,7 @@ export async function gather({ ledgerFile, prevFile, stateFile = null, scriptDir
|
|
|
869
883
|
// stalled, so a healthy board reads none. computeBoard() words it.
|
|
870
884
|
const now = Date.now();
|
|
871
885
|
const stallOwnerOf = isStalled(assessBeat({ beat, ticked, acted, controller, now })) ? stallOwner(controller, NAME) : null;
|
|
872
|
-
return { ledger, issues, prs, merged, ci, prev, repo, repoUrl, workspace, port, spend, beat, ticked, acted, controller, stallOwner: stallOwnerOf, poolOk,
|
|
886
|
+
return { ledger, issues, prs, merged, closed, closedIssues, ci, prev, repo, repoUrl, workspace, port, spend, beat, ticked, acted, controller, stallOwner: stallOwnerOf, poolOk,
|
|
873
887
|
poolCapped, prsCapped,
|
|
874
888
|
now, interval };
|
|
875
889
|
}
|
|
@@ -197,7 +197,8 @@ export function parseRow(row) {
|
|
|
197
197
|
const alive = live.filter((e) => !settled.has(e.name));
|
|
198
198
|
const implOutcome = lastImpl ? (implOutcomes.get(lastImpl.name) ?? null) : null;
|
|
199
199
|
const prM = implOutcome && /^PR#(\d+)$/.exec(implOutcome);
|
|
200
|
-
|
|
200
|
+
const implPr = prM ? Number(prM[1]) : null;
|
|
201
|
+
let pr = implPr;
|
|
201
202
|
// A row with NO impl token — a malformed one still
|
|
202
203
|
// counts, since that row's key is a ticket — is keyed, once THIS row
|
|
203
204
|
// carries a PR-bound signal, the way fleet-tick.mjs's rowPr-then-
|
|
@@ -217,6 +218,7 @@ export function parseRow(row) {
|
|
|
217
218
|
excluded,
|
|
218
219
|
impl: lastImpl?.name ?? null,
|
|
219
220
|
implOutcome,
|
|
221
|
+
implPr,
|
|
220
222
|
agent: alive.length ? alive[alive.length - 1].name : null,
|
|
221
223
|
pr,
|
|
222
224
|
merged: !!mergedM,
|
|
@@ -287,8 +289,12 @@ export function deriveFlags(parsed, ctx) {
|
|
|
287
289
|
if (ctx.finisher?.startsWith("halted:") && ctx.column === "REVIEW") flags.push(ctx.finisher);
|
|
288
290
|
if (ctx.finisher === "labelled" && ctx.column === "REVIEW") flags.push("unlabelled");
|
|
289
291
|
if ((ctx.finisher === "failed" || ctx.finisher === "killed") && ctx.column === "REVIEW") flags.push(`finisher:${ctx.finisher}`);
|
|
292
|
+
// A closed-unmerged PR's card reads `pr-closed` in place of `stale`: the
|
|
293
|
+
// closed PR is what a reader has to act on, not how long the card has sat.
|
|
294
|
+
const prClosed = ctx.prClosed === true && ctx.column === "REVIEW";
|
|
295
|
+
if (prClosed) flags.push("pr-closed");
|
|
290
296
|
const limit = STALE_MS[ctx.column];
|
|
291
|
-
if (limit != null && ctx.sinceEnteredStage != null && ctx.now - ctx.sinceEnteredStage > limit) {
|
|
297
|
+
if (!prClosed && limit != null && ctx.sinceEnteredStage != null && ctx.now - ctx.sinceEnteredStage > limit) {
|
|
292
298
|
flags.push("stale");
|
|
293
299
|
}
|
|
294
300
|
return flags;
|
|
@@ -315,7 +321,7 @@ function titleFor(issue, pr, issues) {
|
|
|
315
321
|
}
|
|
316
322
|
|
|
317
323
|
const FLAG_SEVERITY = {
|
|
318
|
-
"red-ci": 5, "ledger-error": 5, killed: 4, "tier-mismatch": 4, blocked: 4, "sha-off-branch": 4, unlabelled: 4,
|
|
324
|
+
"red-ci": 5, "ledger-error": 5, killed: 4, "tier-mismatch": 4, "pr-closed": 4, blocked: 4, "sha-off-branch": 4, unlabelled: 4,
|
|
319
325
|
"finisher:failed": 4, "finisher:killed": 4,
|
|
320
326
|
...Object.fromEntries(HALT_CAUSES.map((c) => [`halted:${c}`, 4])),
|
|
321
327
|
stale: 1,
|
|
@@ -432,6 +438,20 @@ export function mergedReadPrs({ ledger, prs, prev }) {
|
|
|
432
438
|
return [...need].sort((a, b) => a - b);
|
|
433
439
|
}
|
|
434
440
|
|
|
441
|
+
// The ticket issues whose row's latest settled impl PR is one of `prs`, sorted
|
|
442
|
+
// and once each. gather() reads their state in the same call as the PR read;
|
|
443
|
+
// computeBoard() takes the CLOSED ones as `closedIssues`. An Exclusion is none.
|
|
444
|
+
// An issue number outside Number.isSafeInteger is left out for the reason
|
|
445
|
+
// mergedReadPrs() leaves such a PR out: its `i1e+21` alias would fail the
|
|
446
|
+
// whole batched query, PR aliases included.
|
|
447
|
+
export function issuesOfPrs({ ledger, prs }) {
|
|
448
|
+
const set = new Set(prs);
|
|
449
|
+
const issues = (ledger.rows || []).map(parseRow).filter(Boolean)
|
|
450
|
+
.filter((p) => !p.excluded && p.implPr != null && set.has(p.implPr) && Number.isSafeInteger(p.issue))
|
|
451
|
+
.map((p) => p.issue);
|
|
452
|
+
return [...new Set(issues)].sort((a, b) => a - b);
|
|
453
|
+
}
|
|
454
|
+
|
|
435
455
|
// `merged` is gh's list of merged PR numbers, consulted only for a row
|
|
436
456
|
// PR absent from the open list `prs`; absent means none known, and a PR in
|
|
437
457
|
// neither list (closed unmerged, or a failed read) keeps REVIEW — unless
|
|
@@ -441,10 +461,16 @@ export function mergedReadPrs({ ledger, prs, prev }) {
|
|
|
441
461
|
// this carry-forward — only the "absent from both `prs` and (maybe)
|
|
442
462
|
// `merged`" branch below ever consults it. gather() asks gh only about
|
|
443
463
|
// mergedReadPrs()'s set, so `merged` never lists more than that.
|
|
464
|
+
// `closed` is the same read's CLOSED PRs, and `closedIssues` the CLOSED issues
|
|
465
|
+
// among issuesOfPrs(). A pr-closed flag needs both: a missing `closed`
|
|
466
|
+
// flags nothing, and a failed issue read reads as not CLOSED, as closedTickets()
|
|
467
|
+
// reads its own failed probe.
|
|
444
468
|
export function computeBoard(inputs) {
|
|
445
469
|
const { ledger, issues, prs, ci, prev, now } = inputs;
|
|
446
470
|
const prByNum = new Map(prs.map((p) => [p.number, p]));
|
|
447
471
|
const mergedPrs = new Set(inputs.merged || []);
|
|
472
|
+
const closedPrs = new Set(inputs.closed || []);
|
|
473
|
+
const closedIssues = new Set(inputs.closedIssues || []);
|
|
448
474
|
const prevByIssue = new Map((prev?.tickets || []).map((t) => [t.issue, t]));
|
|
449
475
|
const ruledByPr = new Map();
|
|
450
476
|
for (const r of ledger.ruled || []) {
|
|
@@ -501,7 +527,8 @@ export function computeBoard(inputs) {
|
|
|
501
527
|
rowIssues.add(p.issue);
|
|
502
528
|
const sinceEnteredStage = stageEntry(prevTicket, column, now);
|
|
503
529
|
const ciState = p.pr != null ? (ci[p.pr] ?? "unknown") : null;
|
|
504
|
-
const
|
|
530
|
+
const prClosed = pr === undefined && p.implPr != null && closedPrs.has(p.pr) && !closedIssues.has(p.issue);
|
|
531
|
+
const flags = deriveFlags(p, { ci: ciState, column, sinceEnteredStage, now, finisher: latestFinishers.get(p.pr), prClosed });
|
|
505
532
|
tickets.push({
|
|
506
533
|
issue: p.issue,
|
|
507
534
|
title: titleFor(p.issue, pr, issues),
|