@feigi/fleet-ctl 3.79.5 → 3.80.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.
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),
|
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
#!/bin/sh
|
|
2
|
-
# Release a claim
|
|
3
|
-
#
|
|
4
|
-
#
|
|
2
|
+
# Release a claim: drop the in-progress label, remove the worktree, delete the
|
|
3
|
+
# branch — but only when the claim provably carries no work. The inverse of
|
|
4
|
+
# claim-ticket.sh. Two claim shapes release, told apart by the branch on origin:
|
|
5
5
|
#
|
|
6
|
-
#
|
|
6
|
+
# - Never dispatched: no branch on origin. The branch must carry no commit
|
|
7
|
+
# beyond origin/main and no commit unique to it.
|
|
8
|
+
# - Closed unmerged: the branch is on origin, and every PR of this repository
|
|
9
|
+
# whose head is that branch reads CLOSED — none OPEN, none MERGED, at least
|
|
10
|
+
# one present. The local branch must sit at the tip origin holds. Under
|
|
11
|
+
# --apply origin's tip is read again right before the remote branch is
|
|
12
|
+
# deleted, and that delete runs before the label drops. It is a plain one,
|
|
13
|
+
# not leased on the tip: a push landing between the re-read and the delete
|
|
14
|
+
# goes with the branch. A branch on origin with no PR, or with a PR list that
|
|
15
|
+
# cannot be read, blocks.
|
|
16
|
+
#
|
|
17
|
+
# Dry-run by default; --apply mutates the tracker, the remote and the filesystem.
|
|
7
18
|
#
|
|
8
19
|
# reap.sh will not do this: no merge happened, so no remote branch was ever
|
|
9
20
|
# deleted, so the branch is not [gone] and reap's for-each-ref filter never
|
|
@@ -15,8 +26,10 @@
|
|
|
15
26
|
# Every precondition is recomputed inside THIS invocation rather than trusted
|
|
16
27
|
# from an earlier tool call, for the reason reap.sh gives: the dangerous
|
|
17
28
|
# direction is a worktree that gained work after it was checked. Two of them are
|
|
18
|
-
# recomputed a second time at the moment of the delete,
|
|
19
|
-
#
|
|
29
|
+
# recomputed a second time at the moment of the delete, and in closed mode
|
|
30
|
+
# origin's tip is re-read at the remote delete as well, which is why every
|
|
31
|
+
# delete — the remote one included — runs before the label is dropped rather
|
|
32
|
+
# than after.
|
|
20
33
|
#
|
|
21
34
|
# The dirty check's second opinion is git's own — that is what `worktree remove`
|
|
22
35
|
# without --force is for. It covers the live-directory case ONLY: `worktree
|
|
@@ -27,7 +40,8 @@
|
|
|
27
40
|
#
|
|
28
41
|
# The commit check's is this script's own: the compare-and-swap delete refuses
|
|
29
42
|
# only a ref that moved, so the `ahead` count is re-run against $base right
|
|
30
|
-
# before it
|
|
43
|
+
# before it — in closed mode, a compare of the local tip against the tip origin
|
|
44
|
+
# held instead. Why not `-d`, and what that recount does and does not cover, is
|
|
31
45
|
# argued once at the delete site below — don't restate it here.
|
|
32
46
|
set -eu
|
|
33
47
|
|
|
@@ -854,6 +868,44 @@ if [ -z "$wt" ] && [ -n "$stray" ]; then
|
|
|
854
868
|
fi
|
|
855
869
|
fi
|
|
856
870
|
|
|
871
|
+
# A pushed branch — work that survives the local delete and that a PR may
|
|
872
|
+
# already point at. Live query, so this one cannot be stale; a failure here is
|
|
873
|
+
# an unknown answer, never a "no".
|
|
874
|
+
# stderr stays on stderr, never folded into the value: the emptiness of $remote
|
|
875
|
+
# IS the answer, so an SSH host-key notice on a successful query used to read as
|
|
876
|
+
# a branch that exists. git's own message is more useful on the terminal anyway.
|
|
877
|
+
# 30s: `ls-remote` moves refs and no objects, so this is generous for the work,
|
|
878
|
+
# and it is the budget inflight.sh's own `ls-remote` runs on. `FLEET_NET_TIMEOUT`
|
|
879
|
+
# is the shorten-only override this lookup shares with the fleet's fetches — but
|
|
880
|
+
# NOT with inflight.sh's `ls-remote`, which reads `INFLIGHT_LS_REMOTE_TIMEOUT`
|
|
881
|
+
# and nothing else, so shortening this one does not shorten that one. The rule
|
|
882
|
+
# is net_budget's, in net.sh.
|
|
883
|
+
ls_budget=$(net_budget 30 "${FLEET_NET_TIMEOUT:-}")
|
|
884
|
+
ls_rc=0
|
|
885
|
+
remote=$(net_git "" "$ls_budget" ls-remote --heads origin "refs/heads/$branch") || ls_rc=$?
|
|
886
|
+
if [ "$ls_rc" -ne 0 ]; then
|
|
887
|
+
# A killed lookup and a refused one are different facts and get different
|
|
888
|
+
# words. Both leave the same verdict — the answer is unknown, so nothing is
|
|
889
|
+
# released — but only one of them names a cause this script observed.
|
|
890
|
+
if net_stalled "$ls_rc"; then
|
|
891
|
+
die "git ls-remote did not finish within ${ls_budget}s and was killed, so whether $branch was pushed is unknown"
|
|
892
|
+
fi
|
|
893
|
+
die "git ls-remote failed, so whether $branch was pushed is unknown"
|
|
894
|
+
fi
|
|
895
|
+
|
|
896
|
+
# The tip origin holds, split off ls-remote's `<sha><TAB><ref>` line on the tab
|
|
897
|
+
# itself rather than on a `[:space:]` class, which would put a locale-sensitive
|
|
898
|
+
# construct under the `export LC_ALL=C` paragraph above that says there is none.
|
|
899
|
+
# Whether this pushed branch is the one that releases is a tracker question,
|
|
900
|
+
# asked below once nothing local blocks.
|
|
901
|
+
tab=$(printf '\t')
|
|
902
|
+
remote_sha=
|
|
903
|
+
if [ -n "$remote" ]; then
|
|
904
|
+
remote_sha=${remote%%"$tab"*}
|
|
905
|
+
[ "${remote#*"$tab"}" = "refs/heads/$branch" ] ||
|
|
906
|
+
die "git ls-remote did not answer with refs/heads/$branch for $branch, so whether it was pushed is unknown"
|
|
907
|
+
fi
|
|
908
|
+
|
|
857
909
|
# An Orphaned worktree directory: this claim's directory still on disk with its
|
|
858
910
|
# registration already cleared. `stray` cannot see it BY CONSTRUCTION — it is
|
|
859
911
|
# awk over git's registry, and there is no registration left to match — so the
|
|
@@ -893,7 +945,10 @@ if [ -z "$wt" ] && [ -z "$stray" ]; then
|
|
|
893
945
|
block "worktree directory $orphan is this claim's and has no registration — inspect it and remove the directory by hand"
|
|
894
946
|
elif ! gone "$orphan"; then
|
|
895
947
|
block "cannot tell whether an orphaned worktree directory is at $orphan, so whether #$issue can be released is unknown"
|
|
896
|
-
elif [ "$has_branch" = false ] && [ -z "$blockers" ]; then
|
|
948
|
+
elif [ "$has_branch" = false ] && [ -z "$blockers" ] && [ -z "$remote" ]; then
|
|
949
|
+
# A branch of this name on origin proves the slug right, so the die below
|
|
950
|
+
# does not fire: a closed-PR claim whose local branch is already gone
|
|
951
|
+
# releases from the remote alone.
|
|
897
952
|
# A mistyped <slug>/<type> names a branch that does not exist, and without
|
|
898
953
|
# this die the run would drop the label off a ticket whose real claim is
|
|
899
954
|
# untouched — invisible to candidates.mjs and still in-flight. Refuse
|
|
@@ -927,7 +982,18 @@ if [ -z "$wt" ] && [ -z "$stray" ]; then
|
|
|
927
982
|
fi
|
|
928
983
|
fi
|
|
929
984
|
|
|
930
|
-
|
|
985
|
+
# A closed PR's commits are the branch's whole content, so `ahead` and the `git
|
|
986
|
+
# cherry` count below would refuse every one — and the PR states that tell a
|
|
987
|
+
# closed-PR claim from any other pushed branch are not read until the blockers
|
|
988
|
+
# below are known. So a branch on origin is held to one check here: the local
|
|
989
|
+
# branch must sit at the tip origin holds. Any commit it carries beyond that is
|
|
990
|
+
# unpushed, and the local delete would take it with the branch.
|
|
991
|
+
if [ "$has_branch" = true ] && [ -n "$remote" ]; then
|
|
992
|
+
local_tip=$(git rev-parse --verify "refs/heads/$branch") ||
|
|
993
|
+
die "cannot read $branch's tip, so whether it matches origin's is unknown"
|
|
994
|
+
[ "$local_tip" = "$remote_sha" ] ||
|
|
995
|
+
block "$branch is not at the tip origin holds — unpushed work would be lost"
|
|
996
|
+
elif [ "$has_branch" = true ]; then
|
|
931
997
|
# Commits ahead — a member that did work. Measured on the branch ref rather
|
|
932
998
|
# than the worktree's HEAD: the worktree above was located BY that ref, so the
|
|
933
999
|
# two agree, and this still runs when the worktree is already gone.
|
|
@@ -951,32 +1017,6 @@ if [ "$has_branch" = true ]; then
|
|
|
951
1017
|
[ "$uniq" -eq 0 ] || block "$uniq commit(s) unique to $branch (git cherry)"
|
|
952
1018
|
fi
|
|
953
1019
|
|
|
954
|
-
# A pushed branch — work that survives the local delete and that a PR may
|
|
955
|
-
# already point at. Live query, so this one cannot be stale; a failure here is
|
|
956
|
-
# an unknown answer, never a "no".
|
|
957
|
-
# stderr stays on stderr, never folded into the value: the emptiness of $remote
|
|
958
|
-
# IS the answer, so an SSH host-key notice on a successful query used to read as
|
|
959
|
-
# a branch that exists. git's own message is more useful on the terminal anyway.
|
|
960
|
-
# 30s: `ls-remote` moves refs and no objects, so this is generous for the work,
|
|
961
|
-
# and it is the budget inflight.sh's own `ls-remote` runs on. `FLEET_NET_TIMEOUT`
|
|
962
|
-
# is the shorten-only override this lookup shares with the fleet's fetches — but
|
|
963
|
-
# NOT with inflight.sh's `ls-remote`, which reads `INFLIGHT_LS_REMOTE_TIMEOUT`
|
|
964
|
-
# and nothing else, so shortening this one does not shorten that one. The rule
|
|
965
|
-
# is net_budget's, in net.sh.
|
|
966
|
-
ls_budget=$(net_budget 30 "${FLEET_NET_TIMEOUT:-}")
|
|
967
|
-
ls_rc=0
|
|
968
|
-
remote=$(net_git "" "$ls_budget" ls-remote --heads origin "refs/heads/$branch") || ls_rc=$?
|
|
969
|
-
if [ "$ls_rc" -ne 0 ]; then
|
|
970
|
-
# A killed lookup and a refused one are different facts and get different
|
|
971
|
-
# words. Both leave the same verdict — the answer is unknown, so nothing is
|
|
972
|
-
# released — but only one of them names a cause this script observed.
|
|
973
|
-
if net_stalled "$ls_rc"; then
|
|
974
|
-
die "git ls-remote did not finish within ${ls_budget}s and was killed, so whether $branch was pushed is unknown"
|
|
975
|
-
fi
|
|
976
|
-
die "git ls-remote failed, so whether $branch was pushed is unknown"
|
|
977
|
-
fi
|
|
978
|
-
[ -z "$remote" ] || block "branch $branch exists on origin"
|
|
979
|
-
|
|
980
1020
|
# `-d`, because a directory that is not there holds no uncommitted work. Deleted
|
|
981
1021
|
# by hand, a worktree leaves its admin files registered, so `worktree list
|
|
982
1022
|
# --porcelain` keeps listing the entry and the status below ran against a path
|
|
@@ -1197,10 +1237,52 @@ if [ -n "$wt" ]; then
|
|
|
1197
1237
|
fi
|
|
1198
1238
|
fi
|
|
1199
1239
|
|
|
1200
|
-
#
|
|
1201
|
-
#
|
|
1202
|
-
#
|
|
1203
|
-
#
|
|
1240
|
+
# The one pushed branch that releases: every PR of this repository whose head
|
|
1241
|
+
# is $branch reads CLOSED, and there is at least one. Read live, and failing
|
|
1242
|
+
# closed — a PR list that cannot be read is an unknown answer, as the lookup
|
|
1243
|
+
# above is. Any other pushed branch blocks, as it always did.
|
|
1244
|
+
#
|
|
1245
|
+
# Asked only once every local check has had its say and none blocked, for the
|
|
1246
|
+
# reason the report below gives: a claim already blocked is answered, and a
|
|
1247
|
+
# tracker that cannot be reached must not turn it into an exit 2 with no
|
|
1248
|
+
# receipt. Such a claim names its pushed branch as one more blocker instead,
|
|
1249
|
+
# without the PR states nothing asked for.
|
|
1250
|
+
closed_pr=false
|
|
1251
|
+
pr_seen=
|
|
1252
|
+
if [ -n "$remote" ] && [ -n "$blockers" ]; then
|
|
1253
|
+
block "branch $branch exists on origin"
|
|
1254
|
+
elif [ -n "$remote" ]; then
|
|
1255
|
+
# One `number:state` token per PR, so the loop below needs no JSON parser.
|
|
1256
|
+
# --limit 1000: gh's default page of 30 would hide a later OPEN PR.
|
|
1257
|
+
# `--head` matches a head branch NAME, from any fork, so a fork's PR whose
|
|
1258
|
+
# branch shares this one's name is dropped before it is counted — the filter
|
|
1259
|
+
# delete-merged-branch.sh applies to its own `--head` lookup. Without it, a
|
|
1260
|
+
# fork's closed PR would read as this branch's and release a branch no PR of
|
|
1261
|
+
# ours names.
|
|
1262
|
+
prs=$(gh pr list --state all --head "$branch" --limit 1000 --json number,state,isCrossRepository \
|
|
1263
|
+
--jq '.[] | select(.isCrossRepository | not) | "\(.number):\(.state)"') ||
|
|
1264
|
+
die "gh pr list failed for $branch, so whether its PR closed unmerged is unknown"
|
|
1265
|
+
pr_bad=
|
|
1266
|
+
for pr in $prs; do
|
|
1267
|
+
pr_seen="$pr_seen #${pr%%:*} ${pr#*:};"
|
|
1268
|
+
case ${pr#*:} in
|
|
1269
|
+
CLOSED) ;;
|
|
1270
|
+
*) pr_bad="$pr_bad #${pr%%:*} ${pr#*:};" ;;
|
|
1271
|
+
esac
|
|
1272
|
+
done
|
|
1273
|
+
if [ -z "$pr_seen" ]; then
|
|
1274
|
+
block "branch $branch exists on origin and no PR names it"
|
|
1275
|
+
elif [ -n "$pr_bad" ]; then
|
|
1276
|
+
block "branch $branch exists on origin and its PR(s) are not closed unmerged:$pr_bad"
|
|
1277
|
+
else
|
|
1278
|
+
closed_pr=true
|
|
1279
|
+
fi
|
|
1280
|
+
fi
|
|
1281
|
+
|
|
1282
|
+
# Report the blockers before asking GitHub anything more. The answer cannot
|
|
1283
|
+
# change — every artefact stays put either way — and reaching for the tracker
|
|
1284
|
+
# first turns an offline blocked claim into an unanswerable one, burying the
|
|
1285
|
+
# finding the caller actually needs. "label":null says it was never read.
|
|
1204
1286
|
if [ -n "$blockers" ]; then
|
|
1205
1287
|
echo "$NAME: #$issue NOT released — nothing was touched" >&2
|
|
1206
1288
|
printf '{"issue":%s,"branch":"%s","branchRewritten":%s,"worktree":"%s","worktreeRewritten":%s,"label":null,"released":false,"applied":%s,"blockers":[%s]}\n' \
|
|
@@ -1242,6 +1324,10 @@ if [ "$apply" = false ]; then
|
|
|
1242
1324
|
[ "$has_label" = true ] && echo " would: gh issue edit $issue --remove-label in-progress" >&2
|
|
1243
1325
|
[ -n "$wt" ] && printf ' would: git worktree remove %s\n' "$wt" >&2
|
|
1244
1326
|
[ "$has_branch" = true ] && echo " would: git update-ref -d refs/heads/$branch <its current tip>" >&2
|
|
1327
|
+
if [ "$closed_pr" = true ]; then
|
|
1328
|
+
echo " PR states on $branch:$pr_seen" >&2
|
|
1329
|
+
echo " would: git push origin --delete refs/heads/$branch (origin read at $remote_sha)" >&2
|
|
1330
|
+
fi
|
|
1245
1331
|
else
|
|
1246
1332
|
# Label LAST. The two local deletes are the ones that refuse — that refusal is
|
|
1247
1333
|
# the dirty check recomputed by git at the moment of the delete, so it is
|
|
@@ -1249,7 +1335,10 @@ else
|
|
|
1249
1335
|
# left in-progress already gone: with the worktree still standing the ticket
|
|
1250
1336
|
# read free to candidates.mjs while re-claiming failed on the existing branch.
|
|
1251
1337
|
# Run them first and a refusal leaves the claim exactly as it was, label and
|
|
1252
|
-
# all, which reads as still taken — the direction that costs nothing.
|
|
1338
|
+
# all, which reads as still taken — the direction that costs nothing. Closed
|
|
1339
|
+
# mode's remote delete runs after them, and ahead of the label for the same
|
|
1340
|
+
# reason.
|
|
1341
|
+
|
|
1253
1342
|
if [ -n "$wt" ]; then
|
|
1254
1343
|
# No --force, ever. A refusal is a finding to report, never something to
|
|
1255
1344
|
# force past. Quote git's own reason: this fires precisely when something
|
|
@@ -1294,8 +1383,16 @@ else
|
|
|
1294
1383
|
halt "cannot read $branch's tip at the delete"
|
|
1295
1384
|
n=$(git rev-list --count "$base_rev..$tip") ||
|
|
1296
1385
|
halt "cannot recount commits on $branch against $base at the delete"
|
|
1297
|
-
[ "$
|
|
1298
|
-
|
|
1386
|
+
if [ "$closed_pr" != true ]; then
|
|
1387
|
+
[ "$n" -eq 0 ] ||
|
|
1388
|
+
halt "$branch gained $n commit(s) since the checks — not deleted"
|
|
1389
|
+
else
|
|
1390
|
+
# A closed PR's commits are the whole content of the branch, so the
|
|
1391
|
+
# recount above refuses every one of them. What holds instead: the local
|
|
1392
|
+
# tip has not moved off the tip origin held when the checks read it.
|
|
1393
|
+
[ "$tip" = "$remote_sha" ] ||
|
|
1394
|
+
halt "$branch is no longer at the tip origin holds — not deleted"
|
|
1395
|
+
fi
|
|
1299
1396
|
|
|
1300
1397
|
# What the CAS does not give back: `git update-ref` is ref-only plumbing
|
|
1301
1398
|
# and, unlike `-D`, consults no worktree at all — so the one guard the CAS
|
|
@@ -1371,6 +1468,47 @@ else
|
|
|
1371
1468
|
done_branch=true
|
|
1372
1469
|
fi
|
|
1373
1470
|
|
|
1471
|
+
# Closed mode only: the remote branch goes after the local one and before the
|
|
1472
|
+
# label drops, for the same reason as the local deletes above.
|
|
1473
|
+
#
|
|
1474
|
+
# Origin is read again first, so a push that landed since the checks halts
|
|
1475
|
+
# the release here rather than going with the branch. Here, beside the remote
|
|
1476
|
+
# delete, and not inside the local branch delete: a closed-PR claim whose
|
|
1477
|
+
# local branch is already gone releases from the remote alone, and has no
|
|
1478
|
+
# local delete to hang the re-read on. The delete itself is a plain one: a
|
|
1479
|
+
# push landing in the gap between that re-read and this call is deleted with
|
|
1480
|
+
# the branch. A lease would close that gap, but this file spells no form of
|
|
1481
|
+
# force, so the gap stays.
|
|
1482
|
+
#
|
|
1483
|
+
# A non-zero rc does not say the delete failed to land: the watchdog can kill
|
|
1484
|
+
# the push after origin applied it. So a failed push is followed by a read of
|
|
1485
|
+
# origin, as delete-merged-branch.sh does, and the halt names what origin
|
|
1486
|
+
# holds rather than calling every non-zero rc a refusal. A branch already gone
|
|
1487
|
+
# from origin is the outcome this step exists for, whoever removed it.
|
|
1488
|
+
if [ "$closed_pr" = true ]; then
|
|
1489
|
+
recheck=$(net_git "" "$ls_budget" ls-remote --heads origin "refs/heads/$branch") ||
|
|
1490
|
+
halt "cannot re-read origin's $branch before the delete — not deleted"
|
|
1491
|
+
[ "${recheck%%"$tab"*}" = "$remote_sha" ] ||
|
|
1492
|
+
halt "origin's $branch moved since the checks — not deleted"
|
|
1493
|
+
|
|
1494
|
+
# $ls_budget: a ref-only `push --delete` moves no objects either, so it
|
|
1495
|
+
# shares the lookup's budget, as delete-merged-branch.sh's own push does.
|
|
1496
|
+
echo "\$ git push origin --delete refs/heads/$branch" >&2
|
|
1497
|
+
push_rc=0
|
|
1498
|
+
net_git "" "$ls_budget" push origin --delete "refs/heads/$branch" >&2 || push_rc=$?
|
|
1499
|
+
if [ "$push_rc" -ne 0 ]; then
|
|
1500
|
+
echo "\$ git ls-remote --heads origin refs/heads/$branch" >&2
|
|
1501
|
+
back=$(net_git "" "$ls_budget" ls-remote --heads origin "refs/heads/$branch") ||
|
|
1502
|
+
halt "git push --delete exited $push_rc and origin could not be read back, so whether origin still holds $branch is unknown"
|
|
1503
|
+
if [ -n "$back" ] && net_stalled "$push_rc"; then
|
|
1504
|
+
halt "git push --delete did not finish within ${ls_budget}s and was killed — origin still holds $branch"
|
|
1505
|
+
elif [ -n "$back" ]; then
|
|
1506
|
+
halt "git push --delete refused $branch on origin (rc $push_rc) — origin still holds it"
|
|
1507
|
+
fi
|
|
1508
|
+
fi
|
|
1509
|
+
done_branch=true
|
|
1510
|
+
fi
|
|
1511
|
+
|
|
1374
1512
|
if [ "$has_label" = true ]; then
|
|
1375
1513
|
echo "\$ gh issue edit $issue --remove-label in-progress" >&2
|
|
1376
1514
|
gh issue edit "$issue" --remove-label in-progress >/dev/null ||
|
|
@@ -58,10 +58,12 @@ So nothing in the run cleans it up. Next run, the in-flight probe's local-worktr
|
|
|
58
58
|
|
|
59
59
|
- **`ahead` + dirty catch a member that did work.** `ahead` against `origin/main`. Not redundant with `git cherry`: commit already cherry-picked upstream is upstream-equivalent (`-` line, not `+`), and only `ahead` still sees it. Pinned by a test.
|
|
60
60
|
- **`git cherry` catches commits that exist only locally.** Against `origin/main`, never a local main — same trap reap has.
|
|
61
|
-
- **Remote check catches a pushed branch.** Live `git ls-remote`, so it cannot be stale. Its failure is an unknown answer, never a "not pushed" — swallow it and a release deletes the local copy of work already on the server.
|
|
61
|
+
- **Remote check catches a pushed branch.** Live `git ls-remote`, so it cannot be stale. Its failure is an unknown answer, never a "not pushed" — swallow it and a release deletes the local copy of work already on the server. A pushed branch blocks unless it is the closed-unmerged shape below.
|
|
62
62
|
- **Label read first, `gh` failure fatal.** Worktree and branch gone with `in-progress` left behind hides the ticket from `candidates.mjs` entirely — worse than not releasing. All three artefacts or none.
|
|
63
63
|
|
|
64
|
-
**
|
|
64
|
+
**One pushed branch releases: a claim whose PR closed unmerged.** Every PR of this repository whose head is the branch reads CLOSED — none OPEN, none MERGED, at least one present. Read with `gh pr list` only once no local check blocks, so a blocked claim still reports without asking GitHub; a list that cannot be read, or names no PR, blocks. `ahead` and `git cherry` do not apply — a closed PR's commits are the branch's whole content — so the local branch must sit at the tip origin holds instead, re-checked at the local delete. The remote branch is a fourth artefact: `--apply` deletes it after the local deletes and before the label drops, re-reading origin's tip first and halting if it moved. A plain delete, not leased on that tip, so a push landing between the re-read and the delete goes with the branch. A delete whose `git push` fails is read back from origin: gone counts as done.
|
|
65
|
+
|
|
66
|
+
**The delete is `git update-ref -d`'s compare-and-swap here too, and for reap's reason** — authorized by the `ahead` and `git cherry` checks above, plus the `ahead` recount re-run against `origin/main` immediately before the delete itself (a commit can land across the `gh issue view` between the checks and the delete; `git cherry` is not re-run, because such a commit is ahead of `origin/main` by construction), plus a fresh check, re-read immediately before the delete, that no worktree holds the branch (`update-ref` consults no worktree, where `-D` refused a held branch; a worktree it cannot resolve, or a listing that will not re-read, stops the delete too), and by nothing else. In the closed-unmerged shape the tip-at-origin check and its re-check at the delete stand in for `ahead`, `git cherry` and the recount; the worktree check stays. A claim used to carry `origin/main` as its upstream, so `-d` compared against *that* and forgave a behind local main; now `claim-ticket.sh` passes `--no-track` (so `@{u}` cannot silently answer about main), a claim has no upstream until its first `push -u`, and `-d` falls back to comparing against local `HEAD`. Measured: with local `main` behind `origin/main`, `-d` refuses a pristine claim — half-releasing it, worktree deleted and branch stranded with `in-progress` still on the ticket. The two checks above are strictly stronger than what `-d` would have asked, and both measure against `origin/main` rather than a local ref.
|
|
65
67
|
|
|
66
68
|
**A checkout that lands between that fresh check and the delete halts the release after it.** `git worktree add` of an existing branch does not move the ref, so the compare-and-swap succeeds under it. The same `wt_recheck_delete` reap uses then finds the worktree, recreates the branch at the tip, and the release halts with exit 2, `released:false` and a blocker naming that worktree — the shape of a holder caught before the delete. A restore that fails halts the same way, with the tip and the repair command in the blocker.
|
|
67
69
|
|