@plot-pm/board 0.10.0 → 0.11.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/plot-host.sh CHANGED
@@ -14,6 +14,27 @@
14
14
  # "draft":true|false,"url":"..."}
15
15
  # NONE = no PR found (exit 0 — callers branch on
16
16
  # state, not exit codes)
17
+ # pr-merged <branch> has ANY PR for this branch merged? prints one
18
+ # word: merged|not-merged|unknown
19
+ # READS `mergedAt`, NEVER `state`: a merged PR
20
+ # reports CLOSED, so `state` would refuse every
21
+ # squash-merged branch — the whole population
22
+ # the callers of this question exist for. And
23
+ # never ancestry: squash-merge rewrites the
24
+ # commits, so the branch stays "ahead of main"
25
+ # forever and `merge-base --is-ancestor` can
26
+ # never clear it.
27
+ # ASKS ABOUT ANY PR, NOT THE NEWEST. Measured
28
+ # 2026-08-27: three branches whose work was on
29
+ # main reported unlanded, each masked by a newer
30
+ # unmerged PR the fleet had opened itself on the
31
+ # already-merged branch.
32
+ # `unknown` IS ITS OWN ANSWER and exits 0 — a
33
+ # host that cannot be asked must not answer
34
+ # not-merged, because every caller of this is
35
+ # deciding whether to REMOVE something and
36
+ # silence is never permission. A call that
37
+ # failed outright still exits 3.
17
38
  # pr-create --title T [--body B] [--base BR] [--head BR] [--draft]
18
39
  # create a PR, print its URL
19
40
  # pr-merge <number> [--squash] [--delete-branch]
@@ -35,6 +56,19 @@
35
56
  # cannot answer reports (absent is not false)
36
57
  # --limit raises the host CLI's default page of
37
58
  # 30, which --state all exhausts immediately
59
+ # THREE OUTCOMES, KEPT APART — the same rule
60
+ # issue-list states below, which this path
61
+ # collapsed until 2026-08-30. An empty list
62
+ # means the host answered and there are none
63
+ # (exit 0, no rows); a failed question exits
64
+ # non-zero with EMPTY stdout, never a silent
65
+ # empty list. EXIT 5 is a SPENT QUOTA, exit 6 a
66
+ # SECONDARY limit, and exit 3 any other
67
+ # failure. The three are separate because they
68
+ # ask for different responses: 5 says wait for
69
+ # the reset, 6 says retry shortly and lower
70
+ # concurrency, 3 says look. An unrecognised
71
+ # error is never given the more specific name.
38
72
  # runs <branch> [--limit N] a branch's own recent CI runs, newest first,
39
73
  # as JSON lines: {"workflow":"CI",
40
74
  # "conclusion":"success|failure|…",
@@ -48,6 +82,43 @@
48
82
  # to be failing. Empty on bitbucket (bb has no
49
83
  # run listing) — unavailable, never "never
50
84
  # failed".
85
+ # run-for-sha <branch> <sha> the run for ONE sha — else the branch's newest
86
+ # run, with `sha` saying which it is — as a
87
+ # single JSON object, or nothing when the branch
88
+ # has no runs at all:
89
+ # {"sha":"…","status":"queued|in_progress|
90
+ # completed|waiting|requested",
91
+ # "conclusion":"success|failure|…|null",
92
+ # "url":"…","startedAt":"…"}
93
+ # PINNED TO A SHA, which is the whole reason it
94
+ # exists beside `runs`. `runs` is branch-scoped
95
+ # and sha-blind, and `gh run list --branch X`
96
+ # returns runs for every sha that branch ever
97
+ # had — the newest run is NOT necessarily for
98
+ # the newest commit. A green answer read off the
99
+ # wrong run reports success for code nobody will
100
+ # merge, which is worse than no answer: it
101
+ # invites a merge of the wrong thing. Measured
102
+ # 2026-08-30: two merge waiters reported on
103
+ # superseded runs and had to be stopped and
104
+ # re-armed.
105
+ # THE FALLBACK IS WHAT MAKES THAT VISIBLE. Were
106
+ # it to report nothing when the asked-for sha
107
+ # has no run, a run IN FLIGHT for a superseded
108
+ # commit would look exactly like no run at all,
109
+ # and a caller could not tell "CI has not
110
+ # started" from "CI is answering about the
111
+ # past". `sha` names the run's own commit, and
112
+ # comparing it to the one asked about is the
113
+ # CALLER's rule — this decides nothing.
114
+ # `status` AND `conclusion` ARE BOTH REPORTED,
115
+ # never collapsed. A run that is `completed` has
116
+ # a conclusion; one that is `waiting` or
117
+ # `action_required` has none yet, and folding
118
+ # the two would report a build blocked on a
119
+ # human click as merely pending forever.
120
+ # NOTHING on bitbucket (bb has no run listing) —
121
+ # unavailable, never "no run".
51
122
  # issue-list [--limit N] open tracker issues as JSON lines:
52
123
  # {"number":N,"title":"…","url":"…",
53
124
  # "createdAt":"…"}
@@ -113,6 +184,61 @@
113
184
  # key, which is exit 3 here (the tracker moved),
114
185
  # never an empty body.
115
186
  # pr-body <number> --body B replace the PR description
187
+ # rate-limit both GitHub budgets from `gh api rate_limit`.
188
+ # SUPERSEDED BY `limit`, and kept only because
189
+ # its callers have not moved: that endpoint was
190
+ # measured 2026-09-01 reporting graphql
191
+ # 5000/5000 used=0 while a real call's header
192
+ # read Remaining 1236, Used 3764, and reproduced
193
+ # 2026-09-02 against a header's 2732. NOTHING
194
+ # ROUTES ON IT any more — `gh_route` reads the
195
+ # budget record, which is written from response
196
+ # headers. Prefer `limit`.
197
+ # limit what is this connector's limit, and how well
198
+ # does it know it? One JSON line per metered
199
+ # bucket: {"connector","bucket","limit",
200
+ # "remaining","reset","basis"} with `basis` one
201
+ # of actual|predicted|unknown, and the three
202
+ # numbers null where unreported. Reads the
203
+ # RESPONSE HEADERS of a real call, never
204
+ # `gh api rate_limit`. No output at all means
205
+ # this connector meters nothing — which is not
206
+ # the same fact as a limit of zero, and not
207
+ # `free` either.
208
+ # spend-rate [--connector C] [--account A] [--bucket B]
209
+ # what this COMPUTER has spent, read back from
210
+ # the record every spender appends to, as one
211
+ # JSON object: {"connector","account","bucket",
212
+ # "spent","spanMs","perHour","lines",
213
+ # "unreadable","limit","remaining","basis"}.
214
+ # AN OMITTED --bucket MEANS EVERY BUCKET, which
215
+ # is what "what am I spending?" asks: one
216
+ # connector meters several pools and an account
217
+ # spends all of them, so the cadence divides by
218
+ # the sum. A caller deciding whether a POOL is
219
+ # spent names it — `remaining` and `basis` in
220
+ # the aggregate describe whichever pool was
221
+ # spent last, which is a reading and not a
222
+ # verdict.
223
+ # OVER THE CONNECTOR'S WINDOW, never the whole
224
+ # file — ~1,160 lines an hour were measured
225
+ # 2026-09-01, and a rate over an ever-growing
226
+ # span approaches zero. SPENDS NOTHING: it reads
227
+ # a file and asks no host. `perHour` is null
228
+ # where the window holds no span to divide by,
229
+ # which is an absent rate and never a zero one.
230
+ # ci-limit the same question of the CI connector, which
231
+ # is a separate axis: this repo is GitHub +
232
+ # Actions, ekzweb is Bitbucket + Jenkins.
233
+ # Jenkins reports no limit, so it answers
234
+ # `predicted`.
235
+ #
236
+ # TRANSPORT IS NOT A CALLER'S CONCERN. Where a host offers a question over more
237
+ # than one transport — GitHub's REST against its GraphQL — the choice is made
238
+ # inside this script, by `gh_route`, and no op reports which one answered. The
239
+ # payload is identical either way by construction; a caller that could tell them
240
+ # apart would start depending on the route. `PLOT_HOST_FORCE_REST=1` pins the
241
+ # GitHub route to REST, and it is read in exactly one place.
116
242
  #
117
243
  # Backend resolution: $PLOT_HOST (github|bitbucket) wins — useful for tests —
118
244
  # else the `Git host` key from `## Plot Config` (via plot-config.sh), default
@@ -139,6 +265,133 @@ die() { echo "plot-host: $*" >&2; exit 1; }
139
265
  # error a person must fix, not a transient the board should retry past.
140
266
  die3() { echo "plot-host: $*" >&2; exit 3; }
141
267
 
268
+ # Exit 5 — the host refused to answer FOR NOW. A rate limit, primary or
269
+ # secondary: nothing is broken, nothing needs fixing, and the same question
270
+ # asked later will be answered.
271
+ #
272
+ # ITS OWN CODE BECAUSE IT ASKS FOR A DIFFERENT RESPONSE. Exit 3 says *something
273
+ # is wrong and a person must look*; this says *wait*. A caller that folded them
274
+ # would counsel one when it meant the other, and the fleet scan's summary word
275
+ # is exactly that choice made visible.
276
+ #
277
+ # NOT A RETRY, HERE OR ANYWHERE IN THIS ADAPTER. Whether to wait is the
278
+ # caller's decision — a board on a 5 s cadence, a scan inside a 90 s budget and
279
+ # a person at a terminal want three different answers — and a retry inside the
280
+ # adapter would hide the very state this code exists to surface, turning a
281
+ # reportable fact into an unexplained four-minute call.
282
+ die5() { echo "plot-host: $*" >&2; exit 5; }
283
+
284
+ # Exit 6 — the host refused because too many calls arrived AT ONCE. A secondary
285
+ # limit, and a different ceiling from the one exit 5 reports.
286
+ #
287
+ # ITS OWN CODE BECAUSE THE TWO RECOVER DIFFERENTLY, which is the whole reason
288
+ # this script now answers three words where it answered two. A spent quota
289
+ # recovers at the reset the response carries — minutes away — and the honest
290
+ # reaction is to stop until then and say when. A secondary limit clears in
291
+ # seconds, carries no reset, and the reaction is to retry shortly and lower
292
+ # concurrency. A caller told only *throttled* counsels one when it means the
293
+ # other: it waits minutes for a limit that cleared, or it retries in seconds
294
+ # into a bucket that is empty.
295
+ #
296
+ # BOTH WERE MEASURED HERE. 2026-08-27, eight workers against a cap of seven
297
+ # produced a 403 naming abuse detection. 2026-09-01, `gh pr view` refused with
298
+ # *"API rate limit already exceeded"* while the same account's GraphQL headers
299
+ # read 4854 of 5000 remaining — a bucket with 97 % left does not refuse on
300
+ # quota. So both causes are real, and the aggregate view could not tell them
301
+ # apart.
302
+ #
303
+ # NOT A RETRY, for the reason exit 5 states: whether to wait is the caller's
304
+ # decision, and this adapter reports rather than reacts.
305
+ die6() { echo "plot-host: $*" >&2; exit 6; }
306
+
307
+ # WHICH FAILURE, read off the wording — the same shape `bb_issue_exit_code`
308
+ # uses, and for the same reason: the exit code cannot split these cases. `gh`
309
+ # exits 1 for a rate limit and for a 503 alike, and puts the whole difference
310
+ # in its stderr.
311
+ #
312
+ # THE SPLIT FALLS ONE WAY ONLY. An unrecognised error is never given the more
313
+ # specific name. `throttled` counsels patience, and patience does not fix an
314
+ # outage — so anything this does not recognise stays `failed`, which counsels
315
+ # looking. That is the same direction `bb_issue_exit_code` refuses to guess in,
316
+ # where an unrecognised error must be 3 rather than 4.
317
+ #
318
+ # THREE ANSWERS, BECAUSE THE TWO LIMITS ARE TWO CEILINGS. This returned
319
+ # `throttled` for every match of one regex until 2026-09-02, so *"API rate
320
+ # limit exceeded"* and *"You have exceeded a secondary rate limit"* came back
321
+ # the same word and nothing downstream could tell them apart. `secondary` is
322
+ # now its own answer: `die6` carries it, and the board names which limit was
323
+ # hit rather than printing one reset over both.
324
+ #
325
+ # THE SECONDARY TEST RUNS FIRST, AND THE ORDER IS THE CLASSIFICATION. GitHub's
326
+ # secondary message contains the phrase *"rate limit"* too — *"You have
327
+ # exceeded a secondary rate limit"* — so a quota test applied first claims
328
+ # every secondary refusal and the distinction is lost at the point it is made.
329
+ host_failure_kind() { # $1=stderr text → throttled|secondary|failed
330
+ if LC_ALL=C grep -qiE 'secondary rate|exceeded a secondary|abuse detection|abuse-detection|too many requests|\b429\b' <<<"$1"; then
331
+ echo secondary
332
+ elif LC_ALL=C grep -qiE 'rate limit|ratelimit' <<<"$1"; then
333
+ echo throttled
334
+ else
335
+ echo failed
336
+ fi
337
+ }
338
+
339
+ # A failed `pr-list`, reported and never swallowed.
340
+ #
341
+ # THREE OUTCOMES, KEPT APART — the rule `issue-list` states in full and this
342
+ # path collapsed until 2026-08-30. An empty list means the host answered and
343
+ # there are none; a non-zero exit with empty stdout means the question failed.
344
+ # Printing nothing while exiting non-zero is what says which.
345
+ #
346
+ # NO EMPTY-LIST FALLBACK, for the reason `issue-list` gives: `host_miss_or_fail`
347
+ # exists for a lookup whose subject is absent — one PR that does not exist. A
348
+ # LIST has no absent subject, so if the call failed, the answer is unknown.
349
+ pr_list_failed() { # $1=stderr text
350
+ local err="$1"
351
+ case "$(host_failure_kind "$err")" in
352
+ secondary)
353
+ die6 "pr-list: host refused a burst — ${err:-the host refused the request and said nothing}"
354
+ ;;
355
+ throttled)
356
+ die5 "pr-list: host throttled — ${err:-the host refused the request and said nothing}"
357
+ ;;
358
+ esac
359
+ die3 "pr-list: ${err:-the host failed the request and said nothing}"
360
+ }
361
+
362
+ # Run one `pr-list` host call, or die reporting which failure it was.
363
+ #
364
+ # SIX CALL SITES SHARE THIS, and that is the point rather than a tidy-up.
365
+ # `pr-list` branches on backend × rich × Jenkins into six separate invocations,
366
+ # each of which had its own unchecked assignment; a fix applied by hand six
367
+ # times is a fix that drifts, and the arm that drifts is the one nobody's repo
368
+ # exercises. `plot-pr-merged.sh` makes the same argument for being sourced
369
+ # rather than copied: two implementations of one gate fail toward permissive,
370
+ # and permissive here means the silent empty list this exists to remove.
371
+ #
372
+ # The output goes to STDOUT for the caller to capture; only the error text is
373
+ # spooled, because it is needed twice — once to classify and once to report.
374
+ #
375
+ # EVERY CALL SITE MUST WRITE `|| exit $?`, AND IT IS NOT OPTIONAL. This is
376
+ # invoked as `_raw="$(pr_list_call …)"` — a COMMAND SUBSTITUTION, which is a
377
+ # subshell — so the `exit` inside `die5`/`die3` leaves that subshell only. The
378
+ # outer script would carry on with `_raw` empty and `jq` would emit nothing:
379
+ # the silent empty list this whole helper exists to remove, rebuilt one layer
380
+ # further in and harder to see. The same trap `bb_states_for` documents a few
381
+ # hundred lines below, where a `die` in a subshell turned an unknown state into
382
+ # "no PRs matched".
383
+ pr_list_call() { # "$@"=the host command → payload on stdout, or dies
384
+ local out rc err tmp="/tmp/plot-host-prlist-err.$$"
385
+ out="$("$@" 2>"$tmp")"; rc=$?
386
+ err="$(cat "$tmp" 2>/dev/null)"; rm -f "$tmp"
387
+ [ "$rc" -eq 0 ] || pr_list_failed "$err"
388
+ # A non-empty stderr on a SUCCESSFUL call is a warning, not a verdict — `gh`
389
+ # writes deprecation notices there. Passed through so it is not lost, while
390
+ # the payload is still returned.
391
+ [ -n "$err" ] && echo "$err" >&2
392
+ printf '%s' "$out"
393
+ }
394
+
142
395
  # --- Jenkins CI integration ------------------------------------------------
143
396
  # A repo may declare `CI: jenkins` independently of `Git host`. When it does,
144
397
  # build status (`checks`) is resolved through `jen` — a multibranch job's
@@ -307,6 +560,32 @@ is_lookup_miss() {
307
560
  LC_ALL=C grep -qiE 'no (pull request|pullrequest)s? (found|match)|could not find.*pull request|not found' <<<"$1"
308
561
  }
309
562
 
563
+ # Did the host refuse this call for RATE, rather than answer it?
564
+ #
565
+ # THE GAP NO BUDGET READING CLOSES, and this is what closes it instead. A
566
+ # secondary limit bounds requests AT ONCE rather than requests an hour, so it
567
+ # fires while the pool is nearly full — measured 2026-09-01, `gh pr view`
568
+ # refused while the same account's GraphQL header read 4854 of 5000. No reading
569
+ # of any bucket predicts that, which is why the refusal itself is the evidence.
570
+ #
571
+ # Measured 2026-09-01. A polling burst tripped GitHub's secondary limit on
572
+ # GraphQL while BOTH buckets read `5000/5000`, so `graphql_budget_spent` was
573
+ # false, the cheap path was chosen, and every `gh pr` call returned
574
+ # `API rate limit already exceeded`. REST answered the same questions normally
575
+ # throughout. The estate's whole reap stalled on it — 18 worktrees read
576
+ # `rule could not be asked` and every one was kept.
577
+ #
578
+ # THE CHEAP PATH STAYS THE DEFAULT. This does not prefer REST; the trade is
579
+ # measured at one GraphQL call against ~186 REST calls for a 93-branch scan. It
580
+ # only says that a call REFUSED for rate has not been answered, so the second
581
+ # path is worth trying before reporting an outage.
582
+ #
583
+ # `LC_ALL=C` for the reason `is_lookup_miss` gives: the CLI localises, and an
584
+ # English-only matcher would misread every other locale.
585
+ is_rate_refusal() {
586
+ LC_ALL=C grep -qiE 'rate limit|secondary rate|abuse detection|403.*forbidden' <<<"$1"
587
+ }
588
+
310
589
  # Emits the miss payload on a genuine miss and exits non-zero on anything else,
311
590
  # after putting the CLI's own words on stderr. Callers get: stdout parseable or
312
591
  # empty, exit code decisive.
@@ -345,19 +624,38 @@ host_miss_or_fail() {
345
624
  # there unused, and the same question can still be answered — degraded and more
346
625
  # expensive, but answered.
347
626
  #
348
- # BE HONEST ABOUT WHAT THIS BUYS. It is a second path when one bucket is
349
- # GENUINELY spent, which is a real state a long-running board reaches. It is NOT
350
- # immunity from throttling. The outage this repo actually had on 2026-08-27 was
351
- # GitHub's SECONDARY limit — concurrent-request throttling, eight workers
352
- # against a cap of seven — and during it both budgets read:
627
+ # THIS GATE READ `gh api rate_limit` UNTIL 2026-09-02, AND IT COULD NOT SEE THE
628
+ # CONDITION IT GATES ON. Measured 2026-09-01 in a quiet moment, same account,
629
+ # seconds apart:
630
+ #
631
+ # gh api rate_limit graphql: 5000/5000, used 0
632
+ # a real call's header X-Ratelimit-Remaining: 1236, Used: 3764
633
+ #
634
+ # Reproduced 2026-09-02: `rate_limit` reported 5000/5000 used 0 while the header
635
+ # on the same account read `Remaining: 2732, Used: 2268`. So the `-eq 0` test
636
+ # below has never been able to fire, and the fallback it guards has never been
637
+ # reached by a budget reading.
638
+ #
639
+ # **A gate that cannot see the condition it gates on is worse than no gate,
640
+ # because it reports safety.** Measured 2026-09-01: a polling burst tripped
641
+ # GitHub's secondary limit on GraphQL while both buckets read `5000/5000`, this
642
+ # gate was therefore false, the cheap path was chosen, and every `gh pr` call
643
+ # returned `API rate limit already exceeded`. REST answered normally throughout.
644
+ # The estate's whole reap stalled — 18 worktrees read `rule could not be asked`
645
+ # and every one was kept.
353
646
  #
354
- # graphql: 5000/5000 used=0 reset_in=3599s
355
- # core: 5000/5000 used=0 reset_in=3599s
647
+ # SO IT READS THE RECORD, AND THE RECORD IS WRITTEN FROM RESPONSE HEADERS. Every
648
+ # `gh` call this script makes files its spend against the bucket it spent, and a
649
+ # call that harvested `X-RateLimit-*` files the connector's own numbers with it.
650
+ # Three properties follow, and each answers an objection to the old reading:
356
651
  #
357
- # Both full, nothing spent, every call refused. `rate_limit` does not report the
358
- # secondary limit and cannot, so this gate would have read 5000 available at the
359
- # exact moment nothing worked. Backing off on the 403 itself is a separate
360
- # change and is not this one.
652
+ # FREE it reads a file. No host is asked, so the "the query is free"
653
+ # argument the old docblock made is no longer needed nothing is
654
+ # spent at all, rather than one call against the bucket in question.
655
+ # CURRENT the newest live line describes the last call that actually
656
+ # happened, not a separate endpoint's view of it.
657
+ # BY NAME the line names its own bucket, so `graphql` is asked about
658
+ # `graphql` and a full `core` cannot answer for it.
361
659
  #
362
660
  # THE CHEAP PATH STAYS THE DEFAULT, and the reason is measured. For 93 branches:
363
661
  # ONE GraphQL call (`pr-list` with the check rollup) versus ~186 REST calls,
@@ -367,23 +665,131 @@ host_miss_or_fail() {
367
665
  # ACTUALLY spent.
368
666
  #
369
667
  # UNKNOWN IS NOT ZERO. #485 pinned that rule where it was informational; here is
370
- # where it bites. A host that cannot be asked reports `unknown`, and reading
371
- # that as "exhausted" would send every branch down the expensive path forever,
372
- # for as long as the budget query kept failing. So anything that is not a
373
- # number that is not greater than zero leaves the default alone.
668
+ # where it bites. A record with no `graphql` line yet, a reading the connector
669
+ # did not number, and an unreadable file all report `unknown` and reading any
670
+ # of them as "exhausted" would send every branch down the expensive path
671
+ # forever. So only a `remaining` that IS a number and IS zero returns true.
672
+ #
673
+ # IT IS STILL BLIND TO THE SECONDARY LIMIT, and that is stated rather than
674
+ # fixed here. A burst refusal arrives while the bucket has 4854 of 5000 left —
675
+ # measured 2026-09-01 — so no reading of any bucket predicts it. What answers
676
+ # that is the rate-refusal re-entry in `pr-state`, which reads the refusal
677
+ # itself; `bug/a-spent-bucket-waits-for-its-reset` owns the reaction.
374
678
  graphql_budget_spent() {
375
- local rate remaining
376
- # The budget query is FREE `gh api rate_limit` consumes neither bucket
377
- # (measured 2026-08-27: three consecutive readings, all used=0)so asking
378
- # before each lookup costs nothing against either limit.
379
- rate="$(gh api rate_limit 2>/dev/null)" || return 1
380
- remaining="$(jq -r '.resources.graphql.remaining // "unknown"' <<<"$rate" 2>/dev/null)" || return 1
381
- # `== 0` and not `<= 0`: only a number can be spent. `unknown`, an empty
382
- # string and a malformed payload all fall through to false, which is the
383
- # cheap path the honest direction to be wrong in.
679
+ local rate remaining basis
680
+ # THE RECORD, NEVER THE HOST. `budget_rate` reads one file and asks nothing,
681
+ # so this costs no request against the bucket it is asking about which the
682
+ # old reading did, on every lookup.
683
+ rate="$(budget_rate github "$(budget_account github)" graphql 2>/dev/null)" || return 1
684
+ remaining="$(jq -r '.remaining' <<<"$rate" 2>/dev/null)" || return 1
685
+ basis="$(jq -r '.basis' <<<"$rate" 2>/dev/null)" || return 1
686
+ # ONLY AN `actual` READING MAY CLOSE THIS GATE. A `predicted` number is the
687
+ # adapter's estimate of a ceiling and carries no spend against it; an
688
+ # `unknown` one is the absence of a reading. Neither is evidence that a
689
+ # bucket is empty, and treating either as one takes the expensive path on a
690
+ # guess.
691
+ [ "$basis" = actual ] || return 1
692
+ # `== 0` and not `<= 0`: only a number can be spent. `null`, an empty string
693
+ # and a malformed payload all fall through to false, which is the cheap path
694
+ # — the honest direction to be wrong in.
384
695
  [[ "$remaining" =~ ^[0-9]+$ ]] && [ "$remaining" -eq 0 ]
385
696
  }
386
697
 
698
+ # THE ROUTER. One function decides REST versus GraphQL for the GitHub
699
+ # connector, and it is the only place in this script that decides. Every GitHub
700
+ # op asks it; no op re-derives the answer.
701
+ #
702
+ # `gh_route <op>` prints `graphql` or `rest`. The op is the argument because
703
+ # the routing question is *"how should THIS question be asked?"*, and not every
704
+ # question has two answers.
705
+ #
706
+ # ONE ROUTER PER CONNECTOR, NOT ONE FOR ALL OF THEM. REST-versus-GraphQL is a
707
+ # GitHub distinction: Bitbucket has no such split, Jenkins has neither
708
+ # transport, and GitLab will have its own. A router lifted above the adapters
709
+ # would make every future connector implement a fork that exists for one
710
+ # vendor. So this function is named `gh_`, sits beside the other `gh_` helpers,
711
+ # and `bb`, `jen` and `jira` never reach it.
712
+ #
713
+ # NO CALLER LEARNS THE ROUTE. The answer is consumed inside this script and
714
+ # never reaches stdout, and both paths normalise to one vocabulary — see
715
+ # `rest_pr_to_state`, which exists so a REST answer is indistinguishable from a
716
+ # GraphQL one.
717
+ #
718
+ # THE CHEAP PATH IS THE DEFAULT, and this function does not change that. For 93
719
+ # branches: one GraphQL call against ~186 REST calls, because REST's list
720
+ # endpoint carries no check rollup and no `mergeable_state`. So `graphql` is
721
+ # what falls out when nothing says otherwise, and `rest` needs a reason.
722
+ #
723
+ # ONE `case`, NOT THREE PREDICATES. An earlier draft split "which transports
724
+ # does this op have?" into two helper functions the router called. That is a
725
+ # second site that decides, which is the exact thing this slice removes — a
726
+ # reader would have had to hold three functions in mind to answer one question.
727
+ # The transports an op has are stated here, once, beside the rule that reads
728
+ # them.
729
+ #
730
+ # THE ARMS, AND WHY EACH IS WHERE IT IS:
731
+ #
732
+ # pr-state both transports. The only op with a REST fallback
733
+ # written, so the only one where the budget can change the
734
+ # answer.
735
+ # rate-limit|limit REST-only BY NATURE rather than by omission. Both are
736
+ # reached through `gh api`, which is REST's transport.
737
+ # `limit` spends a GraphQL call to read its own headers —
738
+ # the call it makes is GraphQL, the route to it is not.
739
+ # * GraphQL-only. Ten ops, and this is where "every op
740
+ # consults the router" is honest rather than decorative:
741
+ # their answer is a statement about what THIS SCRIPT has
742
+ # implemented, not about what GitHub offers, which serves
743
+ # nearly everything both ways. Saying so here puts the
744
+ # knowledge where slice 7 can act on it, instead of leaving
745
+ # it implicit in the absence of a branch at the call site.
746
+ # Writing the missing REST paths is new capability and
747
+ # belongs to no slice in this plan.
748
+ #
749
+ # `PLOT_HOST_FORCE_REST=1` IS NOT A DEBUG SWITCH. It is how the rate-refusal
750
+ # re-entry reaches the second path without a second copy of the REST code:
751
+ # `pr-state` re-enters itself with the variable set after GraphQL is refused
752
+ # for rate. An operator may also set it, and that is supported, but the
753
+ # re-entry is why it exists. It is consulted before the budget because it is
754
+ # free and the budget read is not.
755
+ #
756
+ # THE BUDGET IS CONSULTED LAST, AND IT READS THE RECORD BY BUCKET NAME. Every
757
+ # `gh` call files its spend against the pool it spent, and a call that harvested
758
+ # `X-RateLimit-*` files the connector's own numbers with it — so `graphql` is
759
+ # asked about `graphql`, and a `core` with 4990 left cannot answer for it.
760
+ #
761
+ # It read `gh api rate_limit` until 2026-09-02, and that reading could not see
762
+ # the condition it gates on: the endpoint was measured 2026-09-01 reporting
763
+ # `graphql: 5000/5000, used 0` while a real response header reported
764
+ # `Remaining: 1236, Used: 3764`, and reproduced 2026-09-02 against a header's
765
+ # 2732. So the `-eq 0` test never fired, and a gate that cannot see its
766
+ # condition is worse than no gate because it reports safety.
767
+ #
768
+ # Last, because it is the only one of the three reasons that can be wrong. It
769
+ # no longer costs a call to establish — the record is a file.
770
+ gh_route() { # $1=op → graphql|rest
771
+ case "${1:-}" in
772
+ # REST-only: no GraphQL route exists to choose, so nothing is consulted.
773
+ rate-limit|limit)
774
+ printf 'rest\n'
775
+ ;;
776
+ # Both transports implemented — the one op where the reasons apply.
777
+ pr-state)
778
+ if [ "${PLOT_HOST_FORCE_REST:-0}" = "1" ] || graphql_budget_spent; then
779
+ printf 'rest\n'
780
+ else
781
+ printf 'graphql\n'
782
+ fi
783
+ ;;
784
+ # GraphQL-only. `PLOT_HOST_FORCE_REST` cannot move these: there is nowhere
785
+ # to move them TO, and answering `rest` would name a path that does not
786
+ # exist. An op that grows a REST fallback gets an arm above.
787
+ *)
788
+ printf 'graphql\n'
789
+ ;;
790
+ esac
791
+ }
792
+
387
793
  # BOTH PATHS MUST PRODUCE ONE VOCABULARY, or the adapter's contract forks in two
388
794
  # and every caller has to learn which route answered.
389
795
  #
@@ -562,6 +968,8 @@ bb_assert_issue_version() {
562
968
  BB_CAP_CHECKED=""
563
969
  BB_CAP_HAS_JSON=""
564
970
  BB_CAP_IDENTITY="" # "craftamap/0.6.0" or "quatico/1.2.3" or "unknown/<ver>"
971
+ BB_CAP_PROBE_RC="" # the exit code `bb pr list --help --json` returned
972
+ BB_CAP_PROBE_MATCH="" # the line that matched a rejection pattern, if one did
565
973
 
566
974
  # Identify which bb is on PATH. Returns a string like "quatico/1.9.0" or
567
975
  # "craftamap/0.6.0" or "unknown/<version>" or "unknown/unknown".
@@ -610,26 +1018,52 @@ bb_test_json_support() {
610
1018
  local out rc
611
1019
  out="$(bb pr list --help --json 2>&1)"; rc=$?
612
1020
 
613
- # craftamap 0.6.0 rejects --json with `Error: unknown flag: --json`
614
- if grep -qiE 'unknown flag.*--json|invalid.*--json|--json.*not' <<<"$out"; then
615
- return 1
616
- fi
1021
+ # WHAT WAS TESTED, kept for the refusal below. The diagnostic names the
1022
+ # command, its exit code and the line that matched — three observations a
1023
+ # reader can reproduce — rather than a provenance verdict `bb_identify`
1024
+ # cannot determine. A message that guesses wrong costs more than one that
1025
+ # says less.
1026
+ BB_CAP_PROBE_RC="$rc"
1027
+ BB_CAP_PROBE_MATCH=""
617
1028
 
618
- # If help succeeded (even partially), assume --json is supported
619
- if [ "$rc" = 0 ]; then
620
- return 0
1029
+ # THE MEASUREMENT ANSWERS FIRST, AND THE TEXT ONLY WHEN IT IS ABSENT.
1030
+ #
1031
+ # `rc = 0` proves the flag PARSED: no CLI accepts an unknown flag and exits 0.
1032
+ # That is a measurement. The grep below is a heuristic over prose, and asking
1033
+ # the heuristic first is what issue #668 is.
1034
+ #
1035
+ # bb 1.9.0 documents that the flag is cheap — "a bare `--json`, costs nothing
1036
+ # extra" — and `--json.*not` matched `--json, costs no`t`hing`, so a bb that
1037
+ # WORKS was rejected for explaining the flag it supports. The bug therefore
1038
+ # scaled with documentation quality: bb 1.0.0 lacks the sentence and passed.
1039
+ [ "$rc" = 0 ] && return 0
1040
+
1041
+ # ANCHORED TO THE FLAG, WITH NO `.*` BRIDGE, so prose cannot reach them.
1042
+ # These are what a CLI actually prints when it rejects a flag — Cobra, getopt
1043
+ # and Go's `flag` respectively. craftamap 0.6.0 exits NON-ZERO on `--json`, so
1044
+ # it never reached the early return above and its exact message still matches.
1045
+ if grep -qiE 'unknown flag: --json|unknown option .?--json|flag provided but not defined: -?-json' <<<"$out"; then
1046
+ BB_CAP_PROBE_MATCH="$(grep -iEm1 'unknown flag: --json|unknown option .?--json|flag provided but not defined: -?-json' <<<"$out")"
1047
+ return 1
621
1048
  fi
622
1049
 
623
- # Non-zero exit with no clear rejection inspect further
624
- # A "not a bitbucket repo" error is about the repo, not the flag
1050
+ # A "not a bitbucket repo" error is about the repo, not the flag.
625
1051
  if grep -qiE 'not a bitbucket repo|repository not found' <<<"$out"; then
626
- # Could not test properly, but that is a repo issue, not a capability one.
627
- # We will discover the real failure when the actual call is made.
628
1052
  return 0
629
1053
  fi
630
1054
 
631
- # Unknown failure treat as unsupported to be safe
632
- return 1
1055
+ # AN UNRECOGNISED FAILURE ACCEPTS, and this is a behaviour change the plan
1056
+ # argued for in round 1. Refusing on wording outside the three formats above
1057
+ # is the same mistake as the bug this function is fixing, one arm along: a
1058
+ # guess refusing a CLI that may work. A help call failing for some other
1059
+ # reason is likelier an environment problem, and letting it through means the
1060
+ # first real call fails with BB'S OWN ERROR — more accurate than any guess
1061
+ # made here.
1062
+ #
1063
+ # The cost is stated rather than hidden: a genuinely incapable `bb` with
1064
+ # unfamiliar wording now produces a downstream error instead of one clear
1065
+ # message. That is the direction to fail, because the other direction is #668.
1066
+ return 0
633
1067
  }
634
1068
 
635
1069
  # Require that bb supports --json. Called once before the first bb PR call.
@@ -651,7 +1085,19 @@ bb_require_json() {
651
1085
 
652
1086
  if ! bb_test_json_support; then
653
1087
  BB_CAP_HAS_JSON=0
654
- die3 "bb on PATH ($BB_CAP_IDENTITY) does not support --json for PR commands — install Quatico's bb or ensure it is first on PATH"
1088
+ # THE OBSERVATION FIRST, THE IDENTITY WHERE ONE IS KNOWN.
1089
+ #
1090
+ # The plan asked for the observation and for dropping the provenance CLAIM
1091
+ # — `bb_identify` answers `unknown/<ver>` for Quatico's bb, so a message
1092
+ # resting on it advised a working install to reinstall itself.
1093
+ #
1094
+ # But identity is not always unknown: craftamap reports a real version, and
1095
+ # `host.test.mjs:1761` asserts the diagnostic names it. Both are right, and
1096
+ # they do not conflict — an identity that IS determinable is a fact worth
1097
+ # printing, and `bb_identify` already answers `unknown/<ver>` rather than
1098
+ # inventing one where it is not. So this reports what was tested AND who
1099
+ # answered, and it no longer tells anyone what to install.
1100
+ die3 "bb ($BB_CAP_IDENTITY): bb pr list --help --json exited ${BB_CAP_PROBE_RC:-?}${BB_CAP_PROBE_MATCH:+, matched: $BB_CAP_PROBE_MATCH} — this bb does not support --json for PR commands"
655
1101
  fi
656
1102
 
657
1103
  BB_CAP_HAS_JSON=1
@@ -751,6 +1197,25 @@ jira_curl() {
751
1197
  -H 'Accept: application/json' \
752
1198
  -w '\n%{http_code}' \
753
1199
  "$base$path" "$@"
1200
+ local rc=$?
1201
+ # JIRA IS COUNTED HERE AND NOT BY A WRAPPER, because it is reached through
1202
+ # `curl` rather than a CLI of its own — and shadowing `curl` would count every
1203
+ # unrelated use of it. This is the one place plot speaks to a Jira, so it is
1204
+ # the one place that records the spend, which is the same argument the `gh`
1205
+ # wrapper makes one level up.
1206
+ #
1207
+ # The account is the Jira user, which is already in the environment and costs
1208
+ # nothing to read — the one connector here whose account needs no lookup.
1209
+ budget_record_jira
1210
+ return $rc
1211
+ }
1212
+
1213
+ # Records one Jira call. Jira meters, publishes no header this adapter reads,
1214
+ # and this slice does not add header parsing — so the reading is `unknown`,
1215
+ # which is never read as free.
1216
+ budget_record_jira() {
1217
+ [ -z "${PLOT_BUDGET_OFF:-}" ] || return 0
1218
+ budget_append jira "${JIRA_EMAIL:-unknown}" api 1 - - - unknown
754
1219
  }
755
1220
 
756
1221
  # Split a jira_curl response into (body, status) and enforce the three outcomes.
@@ -852,10 +1317,466 @@ backend() {
852
1317
  esac
853
1318
  }
854
1319
 
1320
+
1321
+ # --- the connector counts what it spends -----------------------------------
1322
+ #
1323
+ # EVERY HOST CALL APPENDS ONE LINE, AND SO DOES EVERY REFUSAL. A record that
1324
+ # omits failures under-counts exactly when the count matters most: a refused
1325
+ # call spent quota — GitHub debits the request before it decides to refuse it —
1326
+ # and a budget blind to refusals reads a throttled account as an idle one.
1327
+ #
1328
+ # INSTRUMENTED BY SHADOWING, NOT BY EDITING 40 CALL SITES. `gh`, `bb` and `jen`
1329
+ # below are shell FUNCTIONS, and a function shadows a PATH executable for every
1330
+ # caller in this file. `command gh` reaches the real binary, so the wrapper is
1331
+ # the one place the counting happens and no call site changes.
1332
+ #
1333
+ # THE ALTERNATIVE WAS MEASURED AND REJECTED. This script makes ~40 host CLI
1334
+ # invocations across 14 backend branches; recording at each would be 40 edits
1335
+ # that must all stay right, and the arm that drifts is the one nobody's repo
1336
+ # exercises — the same argument `pr_list_call` makes for gathering six call
1337
+ # sites into one helper, and `plot-pr-merged.sh` makes for being sourced rather
1338
+ # than copied. A wrapper also counts a call site written NEXT year, which no
1339
+ # number of careful edits can.
1340
+ #
1341
+ # IT CHANGES NO BEHAVIOUR. The wrapper forwards argv untouched, passes stdin
1342
+ # through, preserves stdout, stderr and the exit code exactly, and appends
1343
+ # afterwards. A call that succeeds today succeeds identically with a line
1344
+ # written beside it.
1345
+ . "$here/plot-budget.sh"
1346
+
1347
+ # WHO IS SPENDING — read from the CLI's own config, never from an API call.
1348
+ #
1349
+ # `gh api user` would answer authoritatively and cost one request against the
1350
+ # very bucket this is counting, on every invocation of this script. So the
1351
+ # account is read from `gh`'s config file, which `gh auth login` wrote and which
1352
+ # costs a file read. It is cached in an exported variable so a script that makes
1353
+ # several calls reads it once.
1354
+ #
1355
+ # AN UNKNOWN ACCOUNT IS RECORDED AS `unknown`, not skipped. Two checkouts whose
1356
+ # account cannot be read still share one real budget, and dropping their lines
1357
+ # would under-count the machine — the failure this plan exists to remove. They
1358
+ # group together under one honest name instead.
1359
+ budget_account() {
1360
+ if [ -n "${PLOT_BUDGET_ACCOUNT:-}" ]; then printf '%s\n' "$PLOT_BUDGET_ACCOUNT"; return; fi
1361
+ local who=''
1362
+ case "$1" in
1363
+ github)
1364
+ # `gh`'s hosts.yml names the active user under the host it belongs to.
1365
+ # `yq` is not a dependency here, and the file's shape is two levels of
1366
+ # indentation, so the top-level `user:` key is read directly.
1367
+ who="$(awk '/^[[:space:]]+user:/ {print $2; exit}' "${GH_CONFIG_DIR:-$HOME/.config/gh}/hosts.yml" 2>/dev/null)"
1368
+ ;;
1369
+ bitbucket)
1370
+ # `bb_identify` already resolves who bb is, and it caches; but it runs a
1371
+ # `bb` call, which is the thing being counted. The remote's owner is the
1372
+ # free approximation and is the half of the key that groups correctly:
1373
+ # two checkouts of one workspace share a budget.
1374
+ who="$(git config --get remote.origin.url 2>/dev/null | sed -E 's#^.*[:/]([^/]+)/[^/]+(\.git)?$#\1#')"
1375
+ ;;
1376
+ esac
1377
+ printf '%s\n' "${who:-unknown}"
1378
+ }
1379
+
1380
+ # The bucket a call spent, in the connector's OWN word.
1381
+ #
1382
+ # TWO SOURCES, AND THE HEADER OUTRANKS THE ARGV. A response reports the bucket
1383
+ # it spent in `X-RateLimit-Resource` — `core`, `graphql`, and `code_search` for
1384
+ # a search, which no reading of the command line would ever name. So a call that
1385
+ # harvested a header records what the header said, and this function answers
1386
+ # only for the calls that could not.
1387
+ #
1388
+ # `gh` OFFERS NO HEADERS ON MOST OF ITS SURFACE. `gh pr list` and `gh issue
1389
+ # list` are `gh`'s own GraphQL wrappers: they print a rendered payload and there
1390
+ # is no flag that adds the response headers to it. `--verbose` writes the whole
1391
+ # exchange to STDOUT, which would corrupt every caller's parse. So the argv is
1392
+ # what remains, and it is enough for the split that matters: `gh api graphql` and
1393
+ # every `gh pr`/`gh issue` command spend `graphql`, while `gh api <path>`,
1394
+ # `gh run` and `gh repo` spend `core`.
1395
+ #
1396
+ # AN UNRECOGNISED VERB NAMES NO BUCKET, and that empty string is the honest
1397
+ # answer rather than a default. Guessing `core` for a command nobody has
1398
+ # classified would file its spend against a pool it never touched, and the
1399
+ # gate below reads that pool.
1400
+ #
1401
+ # NOT NORMALISED, EVER. A connector nobody has written an adapter for names a
1402
+ # third thing, and `BudgetKey` carries the bucket as an unvalidated string for
1403
+ # exactly that reason — see `packages/domain/src/entities/limit.ts`: *"A closed
1404
+ # set here is the edit that gets forgotten when GitLab arrives."*
1405
+ gh_bucket_of_argv() {
1406
+ case "${1:-}" in
1407
+ # `gh api graphql` is the ONE `gh api` form that is not REST, and it is
1408
+ # named by its first positional rather than by a flag.
1409
+ api) if [ "${2:-}" = graphql ]; then printf 'graphql\n'; else printf 'core\n'; fi ;;
1410
+ # `gh`'s PR and issue commands are its GraphQL wrappers throughout.
1411
+ pr|issue) printf 'graphql\n' ;;
1412
+ # REST, all of them: `gh run list` reads the Actions API, `gh repo view`
1413
+ # the repository one, and `gh auth` the user one.
1414
+ run|repo|auth|release|workflow|search) printf 'core\n' ;;
1415
+ *) printf '\n' ;;
1416
+ esac
1417
+ }
1418
+
1419
+ # The bucket a NON-GitHub connector spends.
1420
+ #
1421
+ # One bucket per connector, which is the truth for `bb`, `jen` and `jira`: each
1422
+ # meters one pool or none, and neither reports a resource name. GitHub is the
1423
+ # connector that meters several, and `gh_bucket_of_argv` above is what names
1424
+ # them.
1425
+ budget_bucket() {
1426
+ case "$1" in
1427
+ github) printf 'core\n' ;;
1428
+ bitbucket) printf 'api\n' ;;
1429
+ *) printf '\n' ;;
1430
+ esac
1431
+ }
1432
+
1433
+ # What the connector's basis is, WITHOUT spending a call to find out.
1434
+ #
1435
+ # `plot-host.sh limit` reads a real response's headers, and it costs one
1436
+ # request. Calling it from inside the wrapper would double every host call this
1437
+ # script makes — the failure mode in miniature. So the wrapper records the
1438
+ # basis it can state for free, and the numbers stay absent:
1439
+ #
1440
+ # github `unknown` — GitHub HAS an actual reading, and this wrapper does
1441
+ # not hold it. Recording a `predicted` 5000 here would tag a
1442
+ # constant as an estimate nobody made; recording `actual` would tag
1443
+ # a guess as a measurement. `unknown` is the honest word, and
1444
+ # `unknown` is NEVER read as free — `headroom()` returns null for
1445
+ # it by construction.
1446
+ # bitbucket `predicted 1000` — the adapter's own number from experience, the
1447
+ # same one `limit` reports, and it costs nothing to state.
1448
+ #
1449
+ # A CALL THAT HARVESTED ITS OWN HEADERS OVERRIDES THIS, and that is the whole
1450
+ # of what slice 7 adds: `gh_api_harvest` puts the response's own
1451
+ # `X-RateLimit-*` into `PLOT_BUDGET_HARVEST`, and `budget_record_call` reads
1452
+ # that in preference to this. The reading is then FREE — the call was going to
1453
+ # happen anyway — and CURRENT, because it describes the call that just
1454
+ # happened rather than a separate endpoint's view of it.
1455
+ budget_reading() { # $1=backend → "<limit>\t<remaining>\t<reset>\t<basis>"
1456
+ # THE FIELDS ARE ARGUMENTS, NOT A FORMAT. Three of the four are the absent
1457
+ # marker `-`, and `printf '-\t...'` reads that leading `-` as an option flag:
1458
+ # bash answers `printf: -\: invalid option` and writes nothing. Measured
1459
+ # 2026-09-02, that broke the GitHub arm — the common path — in 10 host tests.
1460
+ case "$1" in
1461
+ bitbucket) printf '%s\t%s\t%s\t%s\n' 1000 - - predicted ;;
1462
+ *) printf '%s\t%s\t%s\t%s\n' - - - unknown ;;
1463
+ esac
1464
+ }
1465
+
1466
+ # The reading the LAST harvested response carried, as
1467
+ # "<bucket>\t<limit>\t<remaining>\t<reset>", or empty where nothing was
1468
+ # harvested.
1469
+ #
1470
+ # A SHELL VARIABLE RATHER THAN A RETURN, because the harvest happens inside a
1471
+ # command substitution — `out="$(gh_api_harvest …)"` — and a subshell's
1472
+ # variables do not survive it. So the harvester writes the reading to a file
1473
+ # whose path the parent chose, and the parent reads it back. A pipe would have
1474
+ # the same problem in the other direction.
1475
+ PLOT_BUDGET_HARVEST=""
1476
+
1477
+ # Records one call against one connector, whatever it cost and however it ended.
1478
+ #
1479
+ # `PLOT_BUDGET_OFF=1` disables recording entirely. It is for the tests that must
1480
+ # prove the record changes NOTHING about a call's behaviour, and for an operator
1481
+ # whose home directory is read-only; it is not a performance switch.
1482
+ #
1483
+ # THE BUCKET IS THE THIRD ARGUMENT AND IT IS NOT OPTIONAL FOR GITHUB. One
1484
+ # connector meters several pools independently, and until this slice every
1485
+ # GitHub call was filed against one bucket named `api` — so a spent GraphQL pool
1486
+ # and a full REST one summed to a number describing neither. Measured
1487
+ # 2026-09-01 from the response headers: `core` 4990 of 5000, `graphql` 0 of
1488
+ # 5000.
1489
+ budget_record_call() { # $1=connector $2=backend-for-account $3=bucket
1490
+ [ -z "${PLOT_BUDGET_OFF:-}" ] || return 0
1491
+ local reading account bucket
1492
+ account="$(budget_account "${2:-$1}")"
1493
+ bucket="${3:-}"
1494
+ [ -n "$bucket" ] || bucket="$(budget_bucket "$1")"
1495
+ # A HARVESTED HEADER OUTRANKS EVERYTHING, including the argv's guess at the
1496
+ # bucket: `X-RateLimit-Resource` names `code_search` for a search that no
1497
+ # reading of the command line would have classified as anything but `core`.
1498
+ if [ -n "${PLOT_BUDGET_HARVEST:-}" ]; then
1499
+ IFS=$'\t' read -r _hbkt _blim _brem _brst <<<"$PLOT_BUDGET_HARVEST"
1500
+ PLOT_BUDGET_HARVEST=""
1501
+ [ -z "$_hbkt" ] || bucket="$_hbkt"
1502
+ # `actual` is the one basis a caller is entitled to trust, and a harvested
1503
+ # header is the only thing in this script that earns it: the connector said
1504
+ # it, about the call that just happened.
1505
+ budget_append "$1" "$account" "$bucket" 1 "$_blim" "$_brem" "$_brst" actual
1506
+ return 0
1507
+ fi
1508
+ reading="$(budget_reading "$1")"
1509
+ IFS=$'\t' read -r _blim _brem _brst _bbas <<<"$reading"
1510
+ budget_append "$1" "$account" "$bucket" 1 "$_blim" "$_brem" "$_brst" "$_bbas"
1511
+ }
1512
+
1513
+ # Reads `X-RateLimit-*` out of a header block, into the harvest variable.
1514
+ #
1515
+ # plot_harvest_headers <file-holding-the-header-block>
1516
+ #
1517
+ # CASE-INSENSITIVE ON THE NAME. `gh` prints `X-Ratelimit-Limit` while GitHub
1518
+ # documents `X-RateLimit-Limit`, and a case-sensitive match reads a present
1519
+ # header as absent — which records `unknown` against a host that answered
1520
+ # perfectly.
1521
+ #
1522
+ # A MISSING HEADER LEAVES THE HARVEST EMPTY, so the caller records `unknown`
1523
+ # rather than a number. A proxy or an enterprise instance that strips them has
1524
+ # not reported a full budget and has not reported an empty one: `unknown` is
1525
+ # never `free`, and `headroom()` returns null for it by construction.
1526
+ plot_harvest_headers() {
1527
+ local file="${1:-}" lim rem rst res
1528
+ PLOT_BUDGET_HARVEST=""
1529
+ [ -f "$file" ] || return 0
1530
+ _hv() { LC_ALL=C grep -im1 "^$1:" "$file" | sed 's/^[^:]*:[[:space:]]*//' | tr -d '\r'; }
1531
+ lim="$(_hv 'X-RateLimit-Limit')"
1532
+ rem="$(_hv 'X-RateLimit-Remaining')"
1533
+ rst="$(_hv 'X-RateLimit-Reset')"
1534
+ res="$(_hv 'X-RateLimit-Resource')"
1535
+ # A LIMIT THAT IS NOT A NUMBER IS NO READING AT ALL. The whole reading is
1536
+ # dropped rather than half-kept: a bucket name beside an absent count would
1537
+ # file a spend against a pool the record then reports as unknown, which is
1538
+ # the shape a caller cannot act on.
1539
+ [[ "$lim" =~ ^[0-9]+$ ]] || return 0
1540
+ [[ "$rem" =~ ^[0-9]+$ ]] || rem='-'
1541
+ [[ "$rst" =~ ^[0-9]+$ ]] || rst='-'
1542
+ PLOT_BUDGET_HARVEST="$(printf '%s\t%s\t%s\t%s' "$res" "$lim" "$rem" "$rst")"
1543
+ }
1544
+
1545
+ # `gh api` WITH ITS OWN HEADERS HARVESTED, and the body printed unchanged.
1546
+ #
1547
+ # out="$(gh_api_harvest <args…>)"
1548
+ #
1549
+ # THE READING IS FREE BECAUSE THE CALL WAS GOING TO HAPPEN ANYWAY. `--include`
1550
+ # adds a header block to a request this script was already making, so no extra
1551
+ # request is spent — which is the objection that justified `gh api rate_limit`,
1552
+ # and it does not apply here. It is also CURRENT: it describes the call that
1553
+ # just happened rather than a separate endpoint's view of it. Measured
1554
+ # 2026-09-02 on this account, seconds apart: `gh api rate_limit` reported
1555
+ # graphql 5000/5000 used 0 while the same account's header read
1556
+ # `Remaining: 2732, Used: 2268`.
1557
+ #
1558
+ # STDOUT IS THE BODY AND NOTHING ELSE. The header block is split off here, so
1559
+ # every caller parses exactly what it parsed before. `--verbose` was the
1560
+ # alternative and it is unusable: `gh` writes the whole exchange to STDOUT,
1561
+ # which would corrupt the payload of every call that reads one.
1562
+ #
1563
+ # THE EXIT CODE AND STDERR ARE THE REAL CALL'S. A harvest that changed either
1564
+ # would make the reading cost correctness, which is the one price a bookkeeping
1565
+ # read may not charge.
1566
+ gh_api_harvest() {
1567
+ local raw rc hdr_tmp err_tmp
1568
+ hdr_tmp="$(mktemp "${TMPDIR:-/tmp}/plot-host-hdr.XXXXXX")" || {
1569
+ # No temp file, no harvest — and the call still happens, recorded by the
1570
+ # wrapper from its argv alone. Bookkeeping never fails its caller.
1571
+ gh api "$@"
1572
+ return $?
1573
+ }
1574
+ err_tmp="$(mktemp "${TMPDIR:-/tmp}/plot-host-herr.XXXXXX")" || {
1575
+ rm -f "$hdr_tmp"
1576
+ gh api "$@"
1577
+ return $?
1578
+ }
1579
+ # `command gh`, NOT THE WRAPPER, and the reason is ordering. The wrapper
1580
+ # records the call the moment it returns, which is before any header has been
1581
+ # read — so a wrapped call here would file an `unknown` line and this
1582
+ # function's reading would arrive too late to be the one recorded. Recording
1583
+ # is therefore done below, once, with the header in hand.
1584
+ # `--include` LAST, AFTER THE ENDPOINT. `gh` accepts it in either position,
1585
+ # and the trailing one keeps the argv a reader — or a test's stub — sees
1586
+ # identical up to the flag: `api repos/owner/repo/pulls/7 --include`, not
1587
+ # `api --include repos/…`. The endpoint is what identifies the call.
1588
+ raw="$(command gh api "$@" --include 2>"$err_tmp")"
1589
+ rc=$?
1590
+ # THE EXIT CODE AND STDERR ARE THE REAL CALL'S. A harvest that changed either
1591
+ # would make the reading cost correctness, which is the one price a
1592
+ # bookkeeping read may not charge — so the CLI's own words are replayed on
1593
+ # this function's stderr exactly as they arrived.
1594
+ cat "$err_tmp" >&2
1595
+ rm -f "$err_tmp"
1596
+ if [ $rc -ne 0 ]; then
1597
+ rm -f "$hdr_tmp"
1598
+ # A FAILED CALL IS STILL A SPENT CALL on every refusal GitHub meters, so it
1599
+ # is recorded like any other — from the argv, with no numbers.
1600
+ budget_record_call github github "$(gh_bucket_of_argv api "$@")"
1601
+ return $rc
1602
+ fi
1603
+ # THE STATUS LINE IS WHAT PROVES A HEADER BLOCK IS THERE, and the check is not
1604
+ # defensive padding. `sed '1,/^$/d'` on a body with NO header block deletes
1605
+ # everything up to the first blank line IN THE BODY — pretty-printed JSON has
1606
+ # none, so the whole payload would go. A `gh` too old for `--include`, or one
1607
+ # that ignores it, produces exactly that input.
1608
+ if [[ "$raw" != HTTP/* ]]; then
1609
+ budget_record_call github github "$(gh_bucket_of_argv api "$@")"
1610
+ printf '%s\n' "$raw"
1611
+ return 0
1612
+ fi
1613
+ # `--include` prints the status line, the headers, a BLANK line, then the
1614
+ # body. The blank line is the split, and it carries a CR — the headers are
1615
+ # CRLF-terminated while the body is not — so the match tolerates one.
1616
+ printf '%s\n' "$raw" | LC_ALL=C sed -n '1,/^[[:space:]]*$/p' >"$hdr_tmp"
1617
+ plot_harvest_headers "$hdr_tmp"
1618
+ rm -f "$hdr_tmp"
1619
+ # THE ARGV NAMES THE BUCKET WHERE THE HEADER DID NOT. A response that carried
1620
+ # no `X-RateLimit-Resource` still spent something, and `gh api <path>` is
1621
+ # `core` by construction — so the call is recorded either way, and only the
1622
+ # numbers are absent.
1623
+ budget_record_call github github "$(gh_bucket_of_argv api "$@")"
1624
+ # The body is everything after the first blank line. `sed` rather than a
1625
+ # bash parameter expansion: the body may be megabytes of JSON, and the
1626
+ # expansion would hold two copies of it.
1627
+ printf '%s\n' "$raw" | LC_ALL=C sed '1,/^[[:space:]]*$/d'
1628
+ return 0
1629
+ }
1630
+
1631
+ # THE WRAPPERS. Each forwards argv untouched and returns the real CLI's exit
1632
+ # code unchanged; the append happens after, and `budget_append` never fails its
1633
+ # caller. `command` is what reaches past the function to the binary.
1634
+ #
1635
+ # THE BUCKET IS READ FROM THE ARGV THIS WRAPPER ALREADY HOLDS. `gh api graphql`
1636
+ # and `gh pr view` spend `graphql`; `gh api repos/…` and `gh run list` spend
1637
+ # `core`. A harvested header overrides it where one was taken — see
1638
+ # `budget_record_call`.
1639
+ # ── The concurrency bound ────────────────────────────────────────────────────
1640
+ #
1641
+ # HOW MANY CALLS THIS ACCOUNT MAY HAVE OPEN AT ONCE. The wrappers below claim a
1642
+ # slot before the CLI runs and give it back after, so eight workers running this
1643
+ # script at once compete for one account's cap rather than all calling together
1644
+ # — which is exactly what 2026-08-27 measured: eight workers, a 403 naming abuse
1645
+ # detection, and both buckets reading `5000/5000 used=0`.
1646
+ #
1647
+ # **DISCOVERED, NOT CONFIGURED.** `seven` has no independent source — the two
1648
+ # comments in this file that cite it cite the one incident, where eight failed
1649
+ # and seven is the inference — so no number is compiled in here. The bound is
1650
+ # derived from the ceiling the record already holds, by the arithmetic
1651
+ # `boundFromLimit` states: a limit is requests per HOUR and a bound is requests
1652
+ # at one MOMENT, so an account allowed `limit` an hour can sustain
1653
+ # `limit / (3600 / 4)` of them simultaneously at four seconds a call.
1654
+ #
1655
+ # **AND A CONNECTOR THAT REPORTS NOTHING IS UNBOUNDED**, which is what every
1656
+ # caller was before this slice. `unknown` is not a number, and a bound invented
1657
+ # here would be the compiled-in seven under another name.
1658
+ PLOT_SLOT_SECONDS=4
1659
+
1660
+ # The bound for one connector and account, from the record's own reading.
1661
+ # Prints nothing and exits 1 where nothing licenses a bound.
1662
+ host_concurrency_bound() { # $1=connector $2=account
1663
+ local rate limit basis
1664
+ rate="$(budget_rate "$1" "$2" '' 2>/dev/null)" || return 1
1665
+ basis="$(printf '%s' "$rate" | LC_ALL=C sed -n 's/.*"basis":"\([a-z]*\)".*/\1/p')"
1666
+ [ "$basis" = actual ] || [ "$basis" = predicted ] || return 1
1667
+ limit="$(printf '%s' "$rate" | LC_ALL=C sed -n 's/.*"limit":\([0-9]*\).*/\1/p')"
1668
+ case "$limit" in ''|*[!0-9]*) return 1 ;; esac
1669
+ [ "$limit" -gt 0 ] || return 1
1670
+ local bound=$(( limit * PLOT_SLOT_SECONDS / 3600 ))
1671
+ # NEVER ZERO. A bound of zero is a connector that can never be called again,
1672
+ # which no reading licenses.
1673
+ [ "$bound" -ge 1 ] || bound=1
1674
+ printf '%s\n' "$bound"
1675
+ }
1676
+
1677
+ # How long a caller keeps asking for a slot before it proceeds anyway, and how
1678
+ # often it asks. Thirty seconds is the queue eight deep draining at four seconds
1679
+ # a call; a caller still waiting after it PROCEEDS rather than refusing, because
1680
+ # a script that waited forever would hang a worker where the plan asks only that
1681
+ # the cadence degrade. The cost of one extra simultaneous call is a secondary
1682
+ # refusal that lowers the bound — evidence, through the mechanism this slice is
1683
+ # built on.
1684
+ PLOT_SLOT_WAIT_MAX_S=30
1685
+ PLOT_SLOT_POLL_S=1
1686
+
1687
+ # The slot this process holds, so the wrappers can give it back. Empty where
1688
+ # none was taken — an unbounded connector, or a wait that ran out.
1689
+ PLOT_SLOT_INDEX=''
1690
+ PLOT_SLOT_ACCOUNT=''
1691
+
1692
+ # Claims a slot for the call about to be made, waiting where the account is
1693
+ # busy. Never refuses: at worst it proceeds unbounded, which is what every
1694
+ # caller did before this slice.
1695
+ host_slot_take() { # $1=connector $2=backend-for-account
1696
+ PLOT_SLOT_INDEX=''; PLOT_SLOT_ACCOUNT=''
1697
+ [ -z "${PLOT_BUDGET_OFF:-}" ] || return 0
1698
+ local account bound waited=0 got
1699
+ account="$(budget_account "${2:-$1}")" || return 0
1700
+ bound="$(host_concurrency_bound "$1" "$account")" || return 0
1701
+ local rc
1702
+ while :; do
1703
+ # CAPTURED IMMEDIATELY, because `$?` after an `if` reports the `if`. The
1704
+ # three exits mean three different things and collapsing any two of them is
1705
+ # the defect this whole plan is about.
1706
+ got="$(budget_slot_acquire "$account" "$bound")"; rc=$?
1707
+ if [ "$rc" -eq 0 ]; then
1708
+ PLOT_SLOT_INDEX="$got"; PLOT_SLOT_ACCOUNT="$account"; return 0
1709
+ fi
1710
+ # Exit 2 is *the claims could not be managed*, which is not the account
1711
+ # being busy — and a script that stopped calling because a directory could
1712
+ # not be created would go dark on a disk fault. It proceeds.
1713
+ [ "$rc" -eq 1 ] || return 0
1714
+ [ "$waited" -lt "$PLOT_SLOT_WAIT_MAX_S" ] || return 0
1715
+ sleep "$PLOT_SLOT_POLL_S"
1716
+ waited=$(( waited + PLOT_SLOT_POLL_S ))
1717
+ done
1718
+ }
1719
+
1720
+ # Gives the slot back. Called on every path out of a wrapper, taken or not.
1721
+ host_slot_give() {
1722
+ [ -n "$PLOT_SLOT_INDEX" ] || return 0
1723
+ budget_slot_release "$PLOT_SLOT_ACCOUNT" "$PLOT_SLOT_INDEX"
1724
+ PLOT_SLOT_INDEX=''; PLOT_SLOT_ACCOUNT=''
1725
+ }
1726
+
1727
+ gh() {
1728
+ host_slot_take github github
1729
+ command gh "$@"
1730
+ local rc=$?
1731
+ host_slot_give
1732
+ budget_record_call github github "$(gh_bucket_of_argv "$@")"
1733
+ return $rc
1734
+ }
1735
+
1736
+ bb() {
1737
+ host_slot_take bitbucket bitbucket
1738
+ command bb "$@"
1739
+ local rc=$?
1740
+ host_slot_give
1741
+ budget_record_call bitbucket bitbucket
1742
+ return $rc
1743
+ }
1744
+
1745
+ jen() {
1746
+ host_slot_take jenkins ''
1747
+ command jen "$@"
1748
+ local rc=$?
1749
+ host_slot_give
1750
+ # Jenkins is the CI axis, a connector of its own — this repo is GitHub +
1751
+ # Actions while `ekzweb` is Bitbucket + Jenkins, so a `jen` call spends
1752
+ # against a server the git host knows nothing about.
1753
+ budget_record_call jenkins ''
1754
+ return $rc
1755
+ }
1756
+
855
1757
  op="${1:-}"; [ -n "$op" ] || die "usage: plot-host.sh <op> [args...] (see header)"
856
1758
  shift
857
1759
  be="$(backend)" || exit 1
858
1760
 
1761
+ # EVERY GITHUB OP CONSULTS THE ROUTER, ONCE, HERE. `gh_route` is asked before
1762
+ # the op runs and its answer is read from `$route` by whichever arm needs it —
1763
+ # so an op cannot spend a GitHub call without the route for that call having
1764
+ # been decided, and there is no second place where an op could decide it.
1765
+ #
1766
+ # ASKED ONCE PER RUN, NOT ONCE PER CALL. `pr-state`'s arm reads the budget, and
1767
+ # that read is free (`gh api rate_limit` consumes neither bucket, measured
1768
+ # 2026-08-27) but not instant. One process answers one op, so one reading is
1769
+ # the whole of what that process needs.
1770
+ #
1771
+ # ONLY WHEN THE BACKEND IS GITHUB. The route is a GitHub distinction and this
1772
+ # is the connector boundary: a Bitbucket or Jenkins run never reaches
1773
+ # `gh_route`, never reads a budget, and `$route` stays empty for it. That
1774
+ # emptiness is the honest value — there is no route where there is no fork.
1775
+ route=""
1776
+ if [ "$be" = "github" ]; then
1777
+ route="$(gh_route "$op")"
1778
+ fi
1779
+
859
1780
  case "$op" in
860
1781
  backend)
861
1782
  echo "$be"
@@ -882,11 +1803,11 @@ case "$op" in
882
1803
  esac
883
1804
  done
884
1805
  if [ "$be" = "github" ]; then
885
- # THE ROUTE IS CHOSEN ONCE, HERE, and the cheap path is the default. See
886
- # `graphql_budget_spent` above for why REST is the exception rather than
887
- # the rule (~186 calls versus one for a 93-branch scan) and for what this
888
- # fallback honestly does and does not buy.
889
- if graphql_budget_spent; then
1806
+ # THE ROUTE IS NOT CHOSEN HERE. `gh_route` chooses it, for every op,
1807
+ # and this site only reads the answer see that function for why the
1808
+ # cheap path is the default (~186 REST calls against one GraphQL call
1809
+ # for a 93-branch scan) and for what the fallback honestly buys.
1810
+ if [ "$route" = "rest" ]; then
890
1811
  # THE GRAPHQL PATH IS NOT ATTEMPTED FIRST once its budget is known to be
891
1812
  # gone. Trying it anyway would spend a call that is already refused, to
892
1813
  # learn what was just read for free.
@@ -896,7 +1817,7 @@ case "$op" in
896
1817
  # place that knows how to read either.
897
1818
  rest_repo="$(gh_rest_repo)" || exit $?
898
1819
  if [[ "$ref" =~ ^[0-9]+$ ]]; then
899
- if out="$(gh api "repos/$rest_repo/pulls/$ref" 2>/tmp/plot-host-err.$$)"; then
1820
+ if out="$(gh_api_harvest "repos/$rest_repo/pulls/$ref" 2>/tmp/plot-host-err.$$)"; then
900
1821
  rm -f "/tmp/plot-host-err.$$"
901
1822
  rest_pr_to_state <<<"$out"
902
1823
  else
@@ -913,7 +1834,7 @@ case "$op" in
913
1834
  # `state=all`, because the default is `open` and a merged PR would
914
1835
  # otherwise read as NONE — wrong in the reassuring direction.
915
1836
  rest_owner="${rest_repo%%/*}"
916
- if out="$(gh api "repos/$rest_repo/pulls?head=$rest_owner:$ref&state=all&per_page=1" 2>/tmp/plot-host-err.$$)"; then
1837
+ if out="$(gh_api_harvest "repos/$rest_repo/pulls?head=$rest_owner:$ref&state=all&per_page=1" 2>/tmp/plot-host-err.$$)"; then
917
1838
  rm -f "/tmp/plot-host-err.$$"
918
1839
  if [ "$(jq -r 'length' <<<"$out" 2>/dev/null)" = "0" ]; then
919
1840
  echo '{"number":0,"state":"NONE","draft":false,"url":"","mergeCommit":""}'
@@ -934,6 +1855,14 @@ case "$op" in
934
1855
  jq -c '{number:.number,state:.state,draft:.isDraft,url:.url,mergeCommit:(.mergeCommit.oid // "")}' <<<"$out"
935
1856
  else
936
1857
  err="$(cat "/tmp/plot-host-err.$$" 2>/dev/null)"; rm -f "/tmp/plot-host-err.$$"
1858
+ # REFUSED FOR RATE IS NOT ANSWERED. The budget gate above could not see
1859
+ # this coming — `rate_limit` does not report the secondary limit — so the
1860
+ # cheap path was chosen and then declined. The second path is the one
1861
+ # thing left to try before calling the host unreachable, and re-entering
1862
+ # the op is how it is reached without a second copy of the REST code.
1863
+ if is_rate_refusal "$err"; then
1864
+ PLOT_HOST_FORCE_REST=1 "$0" pr-state "$ref" ${repo_args[@]+"${repo_args[@]}"} && exit 0
1865
+ fi
937
1866
  host_miss_or_fail "$err" \
938
1867
  '{"number":0,"state":"NONE","draft":false,"url":"","mergeCommit":""}' || exit $?
939
1868
  fi
@@ -995,6 +1924,77 @@ case "$op" in
995
1924
  fi
996
1925
  ;;
997
1926
 
1927
+ # HAS ANY PR FOR THIS BRANCH MERGED?
1928
+ #
1929
+ # The one operation `plot-reap.sh` and `plot-release-refs.sh` reached past
1930
+ # this adapter for. `pr-state` returns `mergeCommit` and not `mergedAt`, and
1931
+ # `mergeCommit` is empty for a branch whose merge this port never saw — so
1932
+ # the gate that decides whether work has landed had to source its own
1933
+ # implementation rather than ask here.
1934
+ #
1935
+ # THREE ANSWERS, ON EXIT 0. `unknown` is a payload, not a failure: a host
1936
+ # that cannot be asked must not answer `not-merged`, because every caller is
1937
+ # deciding whether to remove something. Exit 3 is still reserved for the call
1938
+ # itself failing in a way this adapter does not recognise as a miss.
1939
+ pr-merged)
1940
+ ref="${1:?pr-merged needs a branch}"; shift || true
1941
+ repo_args=()
1942
+ while [ $# -gt 0 ]; do
1943
+ case "$1" in
1944
+ --repo) repo_args=(-R "${2:?}"); shift 2 ;;
1945
+ *) die "pr-merged: unknown arg $1" ;;
1946
+ esac
1947
+ done
1948
+ if [ "$be" = "github" ]; then
1949
+ # --state all, because a merged PR reports CLOSED and the default `open`
1950
+ # would hide every one of them. --limit 100 rather than 1: the newest PR
1951
+ # is not the merge, exactly as the state is not the merge.
1952
+ if out="$(gh ${repo_args[@]+"${repo_args[@]}"} pr list --head "$ref" --state all --limit 100 --json mergedAt 2>/tmp/plot-host-err.$$)"; then
1953
+ rm -f "/tmp/plot-host-err.$$"
1954
+ if jq -e 'any(.[]; .mergedAt != null)' >/dev/null 2>&1 <<<"$out"; then
1955
+ echo "merged"
1956
+ else
1957
+ echo "not-merged"
1958
+ fi
1959
+ else
1960
+ err="$(cat "/tmp/plot-host-err.$$" 2>/dev/null)"; rm -f "/tmp/plot-host-err.$$"
1961
+ # A LOOKUP MISS IS AN ANSWER — the branch has no PR, so nothing merged.
1962
+ # Anything else is the host failing to be asked, and that is `unknown`
1963
+ # rather than exit 3: the caller asked a question with a third value
1964
+ # for exactly this case, and the answer fails safe toward keeping.
1965
+ if [ -z "$err" ] || is_lookup_miss "$err"; then
1966
+ echo "not-merged"
1967
+ else
1968
+ echo "plot-host: $err" >&2
1969
+ echo "unknown"
1970
+ fi
1971
+ fi
1972
+ else
1973
+ bb_require_json
1974
+ # Bitbucket has no `--head` filter, so the branch is matched locally.
1975
+ # MERGED is bb's own state word here rather than a timestamp: the API
1976
+ # exposes no `mergedAt`, so this is the closest fact the backend holds,
1977
+ # and it is a positive statement about the merge rather than an inference
1978
+ # from CLOSED — `DECLINED` is bb's closed-unmerged word and is distinct.
1979
+ if out="$(bb ${repo_args[@]+"${repo_args[@]}"} pr list --state merged --json 2>/tmp/plot-host-err.$$)"; then
1980
+ rm -f "/tmp/plot-host-err.$$"
1981
+ if jq -e --arg b "$ref" 'any(.[]; .source.branch.name==$b)' >/dev/null 2>&1 <<<"$out"; then
1982
+ echo "merged"
1983
+ else
1984
+ echo "not-merged"
1985
+ fi
1986
+ else
1987
+ err="$(cat "/tmp/plot-host-err.$$" 2>/dev/null)"; rm -f "/tmp/plot-host-err.$$"
1988
+ if [ -z "$err" ] || is_lookup_miss "$err"; then
1989
+ echo "not-merged"
1990
+ else
1991
+ echo "plot-host: $err" >&2
1992
+ echo "unknown"
1993
+ fi
1994
+ fi
1995
+ fi
1996
+ ;;
1997
+
998
1998
  pr-create)
999
1999
  title=""; body=""; base=""; head=""; draft=0
1000
2000
  while [ $# -gt 0 ]; do
@@ -1196,8 +2196,8 @@ case "$op" in
1196
2196
  # $jstatus != "ok" → Jenkins could not answer; every row `unknown`.
1197
2197
  # $jentry == null → the branch has no Jenkins job; `none`.
1198
2198
  # otherwise → the joined colour's `checks`, job named on fail.
1199
- _gh_raw="$(gh pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} \
1200
- --json number,title,state,headRefName,isDraft,mergeable,mergeStateStatus,reviewDecision,url)"
2199
+ _gh_raw="$(pr_list_call gh pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} \
2200
+ --json number,title,state,headRefName,isDraft,mergeable,mergeStateStatus,reviewDecision,url)" || exit $?
1201
2201
  pr_list_report_truncation github "$limit" "$state" \
1202
2202
  "$(jq 'length' <<<"$_gh_raw" 2>/dev/null || echo 0)"
1203
2203
  printf '%s' "$_gh_raw" \
@@ -1225,8 +2225,8 @@ case "$op" in
1225
2225
  }'
1226
2226
  else
1227
2227
  # GitHub without Jenkins (or Jenkins not configured): use GitHub rollup
1228
- _gh_raw="$(gh pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} \
1229
- --json number,title,state,headRefName,isDraft,statusCheckRollup,mergeable,mergeStateStatus,reviewDecision,url)"
2228
+ _gh_raw="$(pr_list_call gh pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} \
2229
+ --json number,title,state,headRefName,isDraft,statusCheckRollup,mergeable,mergeStateStatus,reviewDecision,url)" || exit $?
1230
2230
  pr_list_report_truncation github "$limit" "$state" \
1231
2231
  "$(jq 'length' <<<"$_gh_raw" 2>/dev/null || echo 0)"
1232
2232
  printf '%s' "$_gh_raw" \
@@ -1256,8 +2256,8 @@ case "$op" in
1256
2256
  }'
1257
2257
  fi
1258
2258
  else
1259
- _gh_raw="$(gh pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} \
1260
- --json number,title,state,headRefName)"
2259
+ _gh_raw="$(pr_list_call gh pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} \
2260
+ --json number,title,state,headRefName)" || exit $?
1261
2261
  pr_list_report_truncation github "$limit" "$state" \
1262
2262
  "$(jq 'length' <<<"$_gh_raw" 2>/dev/null || echo 0)"
1263
2263
  printf '%s' "$_gh_raw" \
@@ -1296,7 +2296,7 @@ case "$op" in
1296
2296
  # the GitHub arm uses, which is why it lives above the backend branch.
1297
2297
  # `bb`'s standing `unknown` becomes a real value where Jenkins answers.
1298
2298
  for _s in $bb_states; do
1299
- _bb_raw="$(bb pr list --state "$_s" --json)"
2299
+ _bb_raw="$(pr_list_call bb pr list --state "$_s" --json)" || exit $?
1300
2300
  pr_list_report_truncation bitbucket "$limit" "$_s" \
1301
2301
  "$(jq 'length' <<<"$_bb_raw" 2>/dev/null || echo 0)"
1302
2302
  printf '%s' "$_bb_raw" \
@@ -1325,7 +2325,7 @@ case "$op" in
1325
2325
  else
1326
2326
  # Bitbucket without Jenkins: checks remain unknown
1327
2327
  for _s in $bb_states; do
1328
- _bb_raw="$(bb pr list --state "$_s" --json)"
2328
+ _bb_raw="$(pr_list_call bb pr list --state "$_s" --json)" || exit $?
1329
2329
  pr_list_report_truncation bitbucket "$limit" "$_s" \
1330
2330
  "$(jq 'length' <<<"$_bb_raw" 2>/dev/null || echo 0)"
1331
2331
  printf '%s' "$_bb_raw" \
@@ -1334,7 +2334,7 @@ case "$op" in
1334
2334
  fi
1335
2335
  else
1336
2336
  for _s in $bb_states; do
1337
- _bb_raw="$(bb pr list --state "$_s" --json)"
2337
+ _bb_raw="$(pr_list_call bb pr list --state "$_s" --json)" || exit $?
1338
2338
  pr_list_report_truncation bitbucket "$limit" "$_s" \
1339
2339
  "$(jq 'length' <<<"$_bb_raw" 2>/dev/null || echo 0)"
1340
2340
  printf '%s' "$_bb_raw" \
@@ -1383,6 +2383,84 @@ case "$op" in
1383
2383
  fi
1384
2384
  ;;
1385
2385
 
2386
+ run-for-sha)
2387
+ # The newest run for ONE sha — the BuildMonitor's only host question.
2388
+ #
2389
+ # WHY THIS IS NOT `runs`. `runs` is branch-scoped and reports no sha at all,
2390
+ # so a caller cannot tell which commit an answer is about. `gh run list
2391
+ # --branch X` returns runs for every sha the branch ever had, and the newest
2392
+ # run is not necessarily for the newest commit — a branch pushed twice in
2393
+ # quick succession has the first sha's run finishing after the second sha's
2394
+ # started. Reading a conclusion off that run answers about the past.
2395
+ #
2396
+ # WHY THAT MATTERS MORE THAN IT SOUNDS. A green result for superseded code
2397
+ # is worse than no result: it invites a merge of the wrong thing. Measured
2398
+ # 2026-08-30, in the session that wrote the plan: two merge waiters reported
2399
+ # on superseded runs and had to be stopped and re-armed.
2400
+ #
2401
+ # FACTS, NEVER A VERDICT (Principle 3). It reports `status` and `conclusion`
2402
+ # as the host gives them and compares nothing. Whether `action_required`
2403
+ # means "blocked" or `null` means "still going" is the monitor's rule, not
2404
+ # this collector's — and keeping the two words separate is what lets the
2405
+ # monitor tell a build awaiting a human click from one merely running. A
2406
+ # collector that folded them into one word would make that distinction
2407
+ # unrecoverable downstream.
2408
+ #
2409
+ # ONE OBJECT OR NOTHING. Empty output means the host has no run for this sha
2410
+ # — which is a real, common answer (the run has not been created yet) and
2411
+ # deliberately NOT an error: a monitor polling a fresh push sees it on every
2412
+ # pass until CI wakes up.
2413
+ #
2414
+ # Bitbucket reports nothing rather than something invented, exactly as
2415
+ # `runs` does: `bb` has no run listing, and silence here reads as
2416
+ # unavailable, never as "this sha has no build".
2417
+ branch="${1:?run-for-sha needs a branch}"; shift
2418
+ sha="${1:?run-for-sha needs a sha}"; shift
2419
+ # Enough runs to find the sha among its neighbours. A branch accumulates
2420
+ # runs per push and per workflow, so the sha being asked about can sit
2421
+ # several entries down even when it is the current head.
2422
+ limit=20
2423
+ while [ $# -gt 0 ]; do
2424
+ case "$1" in
2425
+ --limit) limit="${2:?}"; shift 2 ;;
2426
+ *) die "run-for-sha: unknown arg $1" ;;
2427
+ esac
2428
+ done
2429
+ if [ "$be" = "github" ]; then
2430
+ # `headSha` is the field that makes this answerable at all; `runs` omits
2431
+ # it, which is why that op cannot be reused here.
2432
+ #
2433
+ # NEWEST FIRST, then the FIRST match is taken: `gh run list` returns runs
2434
+ # newest-first, and a sha can carry several (a rerun, or several
2435
+ # workflows). The newest is the live answer; older ones for the same sha
2436
+ # are superseded by the same argument that superseded runs for older shas.
2437
+ # THE SHA ASKED ABOUT IF THERE IS ONE, ELSE THE NEWEST RUN ON THE BRANCH —
2438
+ # and `sha` in the output says WHICH, because a caller that could not tell
2439
+ # the two apart would be back to the branch-scoped guessing this op exists
2440
+ # to end.
2441
+ #
2442
+ # WHY IT FALLS BACK AT ALL, rather than reporting nothing. Filtering to
2443
+ # the asked-for sha and stopping makes the most important case invisible:
2444
+ # a run IN FLIGHT for a commit the branch has already moved past reports
2445
+ # identically to no run at all, so a caller cannot distinguish *CI has not
2446
+ # started yet* from *CI is busy answering about the past*. The second is
2447
+ # the state that had two merge waiters reporting on superseded runs on
2448
+ # 2026-08-30, and it is exactly what a caller needs to see.
2449
+ #
2450
+ # IT STILL DECIDES NOTHING (Principle 3). It reports the run it found and
2451
+ # the sha that run is for; whether that sha being different from the one
2452
+ # asked about means "superseded" is the caller's rule. This collects.
2453
+ gh run list --branch "$branch" --limit "$limit" \
2454
+ --json headSha,conclusion,status,startedAt,url 2>/dev/null \
2455
+ | jq -c --arg sha "$sha" \
2456
+ '(map(select(.headSha == $sha)) | .[0]) // .[0]
2457
+ | select(. != null)
2458
+ | {sha:.headSha, status:.status,
2459
+ conclusion:(if (.conclusion // "") == "" then null else .conclusion end),
2460
+ url:.url, startedAt:.startedAt}' 2>/dev/null || true
2461
+ fi
2462
+ ;;
2463
+
1386
2464
  issue-list)
1387
2465
  # Open tracker issues — the board's inbox, and READ-ONLY in both
1388
2466
  # directions. Nothing here writes a label, an assignee or a close: the
@@ -1680,7 +2758,203 @@ case "$op" in
1680
2758
  fi
1681
2759
  ;;
1682
2760
 
2761
+ limit)
2762
+ # WHAT IS THIS CONNECTOR'S LIMIT, AND HOW WELL DOES IT KNOW IT?
2763
+ #
2764
+ # One JSON line per bucket the connector meters:
2765
+ # {"connector":"github","bucket":"graphql","limit":5000,
2766
+ # "remaining":1236,"reset":1788269670,"basis":"actual"}
2767
+ #
2768
+ # `basis` is `actual` where the connector reported the numbers, `predicted`
2769
+ # where this adapter supplied them from experience, and `unknown` where it
2770
+ # reports nothing and there is nothing to predict. `limit`, `remaining` and
2771
+ # `reset` are numbers or null; `reset` is epoch SECONDS.
2772
+ #
2773
+ # NOT `gh api rate_limit`, AND THAT IS THE WHOLE POINT OF THIS OP. Measured
2774
+ # 2026-09-01 in a quiet moment, same account, seconds apart:
2775
+ #
2776
+ # gh api rate_limit graphql: 5000/5000, used 0
2777
+ # a real call's header X-Ratelimit-Remaining: 1236, Used: 3764
2778
+ #
2779
+ # 3764 calls spent, reported as zero. The endpoint is wrong when nothing is
2780
+ # wrong, so `graphql_budget_spent()` above — which reads it and tests
2781
+ # `-eq 0` — has never been able to fire. The authority is the header on a
2782
+ # response that actually came back.
2783
+ #
2784
+ # THIS CALL SPENDS ONE REQUEST, AND SAYS SO. It asks the cheapest real
2785
+ # question there is — `{viewer{login}}` — and reports what its response
2786
+ # carried. One request against the bucket it reports is an honest cost; a
2787
+ # free reading of the wrong number is not.
2788
+ #
2789
+ # THE OTHER READINGS ARE FREE, and this op is the one that is not. Every
2790
+ # `gh api` call the adapter makes harvests its own headers through
2791
+ # `gh_api_harvest`, on a request that was going to happen anyway, and files
2792
+ # the reading in the budget record. So a caller that merely wants to know
2793
+ # whether a pool is spent reads the record and spends nothing; this op is
2794
+ # for a caller that wants a reading NOW, on a connector that may not have
2795
+ # been called yet.
2796
+ #
2797
+ # `gh pr list` CANNOT BE HARVESTED. It is `gh`'s own GraphQL wrapper: no
2798
+ # flag adds the response headers, and `--verbose` writes the whole exchange
2799
+ # to stdout, which would corrupt every caller's parse. So a GraphQL call
2800
+ # names its bucket from the argv and records no numbers — `unknown`, which
2801
+ # is never read as free.
2802
+ #
2803
+ # ONE BUCKET, NOT TWO. A response reports the bucket IT spent, in
2804
+ # `X-RateLimit-Resource`. Reporting `core` from a GraphQL response would be
2805
+ # inventing a reading nobody took — the mistake `rate_limit` makes by
2806
+ # answering for both at once.
2807
+ if [ "$be" = "github" ]; then
2808
+ _hdr_tmp="/tmp/plot-host-limit.$$"
2809
+ # `command gh`, NOT THE WRAPPER, and for the ordering reason
2810
+ # `gh_api_harvest` gives: the wrapper records the moment the call returns,
2811
+ # before any header has been read, so a wrapped call here would file an
2812
+ # `unknown` line AND this arm would file the real one — two lines for one
2813
+ # request, which over-counts the very spend the record exists to measure.
2814
+ if command gh api graphql -f query='{viewer{login}}' --include >"$_hdr_tmp" 2>/dev/null; then
2815
+ # ONE HEADER READER, NOT TWO. `plot_harvest_headers` is what every
2816
+ # harvested call already reads its bucket and numbers with, and a second
2817
+ # implementation here is the drift `plot-pr-merged.sh` argues against —
2818
+ # the copy that goes permissive is the one that fails unrepairably. It
2819
+ # matches header names case-insensitively because `gh` prints
2820
+ # `X-Ratelimit-Limit` while GitHub documents `X-RateLimit-Limit`.
2821
+ plot_harvest_headers "$_hdr_tmp"
2822
+ rm -f "$_hdr_tmp"
2823
+ # A number or null — never a quoted "unknown", and never 0 standing in
2824
+ # for absent. `jq -n` with `--argjson` refuses a non-number, so each
2825
+ # value is tested first and passed as the literal `null` otherwise.
2826
+ _num() { [[ "$1" =~ ^[0-9]+$ ]] && echo "$1" || echo null; }
2827
+ if [ -n "${PLOT_BUDGET_HARVEST:-}" ]; then
2828
+ IFS=$'\t' read -r _res _lim _rem _rst <<<"$PLOT_BUDGET_HARVEST"
2829
+ # THE READING GOES INTO THE RECORD TOO. This call spent a request and
2830
+ # got the connector's own numbers back, so leaving them unrecorded
2831
+ # would throw away the one `actual` reading in the run — and
2832
+ # `graphql_budget_spent` reads the record.
2833
+ budget_record_call github github "${_res:-graphql}"
2834
+ jq -cn \
2835
+ --arg bucket "${_res:-graphql}" \
2836
+ --argjson limit "$(_num "$_lim")" \
2837
+ --argjson remaining "$(_num "$_rem")" \
2838
+ --argjson reset "$(_num "$_rst")" \
2839
+ '{connector:"github",bucket:$bucket,limit:$limit,remaining:$remaining,reset:$reset,basis:"actual"}'
2840
+ else
2841
+ # The call answered and carried no limit header. GitHub always sends
2842
+ # them, so this is a proxy or an enterprise instance that strips them:
2843
+ # unknown, and never free.
2844
+ echo '{"connector":"github","bucket":"","limit":null,"remaining":null,"reset":null,"basis":"unknown"}'
2845
+ fi
2846
+ else
2847
+ rm -f "$_hdr_tmp"
2848
+ # A FAILED CALL IS STILL A SPENT CALL on every refusal GitHub meters, so
2849
+ # it is recorded like any other — against `graphql`, which is what the
2850
+ # query above spends, with no numbers.
2851
+ budget_record_call github github graphql
2852
+ # The host could not be asked. Exit 3 rather than printing `unknown`:
2853
+ # *could not ask* and *asked, and it reports no limit* are different
2854
+ # facts, and the port keeps them apart as `failed` versus an answered
2855
+ # `unknown` reading.
2856
+ die3 "limit: could not read the host's rate-limit headers"
2857
+ fi
2858
+ elif [ "$be" = "bitbucket" ]; then
2859
+ # Bitbucket meters, and sends no `X-RateLimit-*`. So the number is this
2860
+ # adapter's, from experience — 1000 requests/hour for an authenticated
2861
+ # account — and it is tagged `predicted` because that is what it is.
2862
+ #
2863
+ # A PREDICTION IS NOT A LIE AND NOT A FAILURE. It is answered: the adapter
2864
+ # is telling the truth about what it knows. A caller reads the basis and
2865
+ # decides how much to trust it; a `throttled` observed during the session
2866
+ # is what corrects it.
2867
+ echo '{"connector":"bitbucket","bucket":"api","limit":1000,"remaining":null,"reset":null,"basis":"predicted"}'
2868
+ else
2869
+ echo "{\"connector\":\"$be\",\"bucket\":\"\",\"limit\":null,\"remaining\":null,\"reset\":null,\"basis\":\"unknown\"}"
2870
+ fi
2871
+ ;;
2872
+
2873
+ ci-limit)
2874
+ # The CI connector's limit, which is a THIRD axis and does not follow the
2875
+ # git host. This repo runs GitHub Actions on a GitHub remote; `ekzweb` runs
2876
+ # Jenkins against Bitbucket. `ci_backend()` already resolves it separately.
2877
+ #
2878
+ # JENKINS IS THE `predicted` CASE THE DESIGN NAMES. A Jenkins instance
2879
+ # reports no rate limit — there is no header and no endpoint to ask — so the
2880
+ # ceiling is this adapter's estimate of what a shared controller tolerates,
2881
+ # tagged for what it is. It is NOT unlimited: a Jenkins that is hammered
2882
+ # refuses, and the refusal is what corrects the estimate.
2883
+ _ci="$(ci_backend)"
2884
+ case "$_ci" in
2885
+ jenkins)
2886
+ echo '{"connector":"jenkins","bucket":"","limit":60,"remaining":null,"reset":null,"basis":"predicted"}'
2887
+ ;;
2888
+ '' | none)
2889
+ # No CI connector configured, so there is nothing to meter. An empty
2890
+ # answer, not a limit of zero.
2891
+ ;;
2892
+ *)
2893
+ # `ci_backend()` validates nothing, and neither does this — the list is
2894
+ # open, and GitLab and Trello are named as next. A connector nobody has
2895
+ # written an estimate for answers `unknown`, which is the honest word.
2896
+ echo "{\"connector\":\"$_ci\",\"bucket\":\"\",\"limit\":null,\"remaining\":null,\"reset\":null,\"basis\":\"unknown\"}"
2897
+ ;;
2898
+ esac
2899
+ ;;
2900
+
2901
+ spend-rate)
2902
+ # WHAT HAS THIS COMPUTER SPENT, AND HOW FAST? Read back from the record
2903
+ # every spender appends to — eleven scripts, a board, and a person at a
2904
+ # terminal — so a caller can divide its cadence by what the account is
2905
+ # actually spending rather than by a headcount of boards it cannot take.
2906
+ #
2907
+ # ONE JSON OBJECT:
2908
+ # {"connector":"github","account":"jwloka","bucket":"api","spent":41,
2909
+ # "spanMs":214000,"perHour":689.72,"lines":41,"unreadable":0,
2910
+ # "limit":null,"remaining":null,"resetAt":null,"basis":"unknown"}
2911
+ #
2912
+ # `resetAt` IS WHEN THE BUCKET REFILLS, epoch milliseconds, and it is what a
2913
+ # caller reacting to a refusal waits for. The record has stored it since the
2914
+ # headers were first harvested; until this slice no reader could get it back
2915
+ # out, so the one component that needed it had to ask `gh api rate_limit` —
2916
+ # a call that is both metered and, measured 2026-09-01, wrong.
2917
+ #
2918
+ # OVER THE CONNECTOR'S WINDOW, NEVER THE WHOLE FILE. Measured 2026-09-01,
2919
+ # one board at 5 s and eleven scripts at 90 s append ~1,160 lines an hour:
2920
+ # a rate divided by an ever-growing span approaches zero, and a cadence
2921
+ # derived from it would relax forever — the opposite of what the record is
2922
+ # for. The window starts at the latest reset that has already PASSED, or an
2923
+ # hour back where no connector stated one.
2924
+ #
2925
+ # IT SPENDS NOTHING. This reads a file; no host is asked. That is the whole
2926
+ # reason the record exists rather than a `rate_limit` call per decision —
2927
+ # and `rate_limit` was measured 2026-09-01 reporting 5000 while the headers
2928
+ # read 0, so it would be both expensive and wrong.
2929
+ #
2930
+ # `perHour` IS null WHERE THERE IS NO SPAN TO DIVIDE BY — one line, or
2931
+ # several written inside one millisecond. An invented rate would be exactly
2932
+ # the dishonest cadence input this slice exists to remove.
2933
+ _sr_connector=""; _sr_account=""; _sr_bucket=""
2934
+ while [ $# -gt 0 ]; do
2935
+ case "$1" in
2936
+ --connector) _sr_connector="${2:?}"; shift 2 ;;
2937
+ --account) _sr_account="${2:?}"; shift 2 ;;
2938
+ --bucket) _sr_bucket="${2:?}"; shift 2 ;;
2939
+ *) die "spend-rate: unknown arg $1" ;;
2940
+ esac
2941
+ done
2942
+ # Defaults name THIS connector and THIS account, which is what a caller
2943
+ # asking "what am I spending?" means. An explicit triple is for a caller
2944
+ # asking about a connector it is not itself using.
2945
+ [ -n "$_sr_connector" ] || _sr_connector="$be"
2946
+ [ -n "$_sr_account" ] || _sr_account="$(budget_account "$be")"
2947
+ # AN OMITTED `--bucket` MEANS EVERY BUCKET, which is what *what am I
2948
+ # spending?* asks. One connector meters several pools independently, and the
2949
+ # cadence this feeds is about how fast the ACCOUNT is going — it spends both,
2950
+ # so summing them is the honest input and naming one would ignore the
2951
+ # traffic on the other. A caller deciding whether a POOL is spent names it.
2952
+ _sr_rate="$(budget_rate "$_sr_connector" "$_sr_account" "$_sr_bucket")"
2953
+ jq -c --arg connector "$_sr_connector" --arg account "$_sr_account" --arg bucket "$_sr_bucket" \
2954
+ '{connector:$connector,account:$account,bucket:$bucket} + .' <<<"$_sr_rate"
2955
+ ;;
2956
+
1683
2957
  *)
1684
- die "unknown op '$op' (backend|default-branch|pr-state|pr-create|pr-merge|pr-list|issue-list|issue-view|pr-body|rate-limit)"
2958
+ die "unknown op '$op' (backend|default-branch|pr-state|pr-create|pr-merge|pr-list|issue-list|issue-view|pr-body|rate-limit|limit|ci-limit|spend-rate)"
1685
2959
  ;;
1686
2960
  esac