@plot-pm/board 0.10.0 → 0.12.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,12 +14,39 @@
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]
20
41
  # pr-ready <number> take a PR out of draft
21
42
  # merge the PR
22
43
  # pr-list [--state open|merged|closed|all] [--limit N] [--rich]
44
+ # [--repo <owner/repo>] pins the list to ONE
45
+ # repository, exactly as pr-state and pr-merged
46
+ # do. A checkout with remotes on two hosts lets
47
+ # an unpinned list resolve the wrong one, and a
48
+ # caller joining it against `origin/*` refs then
49
+ # reads every branch as having no PR.
23
50
  # JSON lines: {"number":N,"title":"...",
24
51
  # "state":"...","head":"..."}
25
52
  # --rich adds: draft, checks, mergeable, review,
@@ -35,6 +62,19 @@
35
62
  # cannot answer reports (absent is not false)
36
63
  # --limit raises the host CLI's default page of
37
64
  # 30, which --state all exhausts immediately
65
+ # THREE OUTCOMES, KEPT APART — the same rule
66
+ # issue-list states below, which this path
67
+ # collapsed until 2026-08-30. An empty list
68
+ # means the host answered and there are none
69
+ # (exit 0, no rows); a failed question exits
70
+ # non-zero with EMPTY stdout, never a silent
71
+ # empty list. EXIT 5 is a SPENT QUOTA, exit 6 a
72
+ # SECONDARY limit, and exit 3 any other
73
+ # failure. The three are separate because they
74
+ # ask for different responses: 5 says wait for
75
+ # the reset, 6 says retry shortly and lower
76
+ # concurrency, 3 says look. An unrecognised
77
+ # error is never given the more specific name.
38
78
  # runs <branch> [--limit N] a branch's own recent CI runs, newest first,
39
79
  # as JSON lines: {"workflow":"CI",
40
80
  # "conclusion":"success|failure|…",
@@ -48,13 +88,52 @@
48
88
  # to be failing. Empty on bitbucket (bb has no
49
89
  # run listing) — unavailable, never "never
50
90
  # failed".
91
+ # run-for-sha <branch> <sha> the run for ONE sha — else the branch's newest
92
+ # run, with `sha` saying which it is — as a
93
+ # single JSON object, or nothing when the branch
94
+ # has no runs at all:
95
+ # {"sha":"…","status":"queued|in_progress|
96
+ # completed|waiting|requested",
97
+ # "conclusion":"success|failure|…|null",
98
+ # "url":"…","startedAt":"…"}
99
+ # PINNED TO A SHA, which is the whole reason it
100
+ # exists beside `runs`. `runs` is branch-scoped
101
+ # and sha-blind, and `gh run list --branch X`
102
+ # returns runs for every sha that branch ever
103
+ # had — the newest run is NOT necessarily for
104
+ # the newest commit. A green answer read off the
105
+ # wrong run reports success for code nobody will
106
+ # merge, which is worse than no answer: it
107
+ # invites a merge of the wrong thing. Measured
108
+ # 2026-08-30: two merge waiters reported on
109
+ # superseded runs and had to be stopped and
110
+ # re-armed.
111
+ # THE FALLBACK IS WHAT MAKES THAT VISIBLE. Were
112
+ # it to report nothing when the asked-for sha
113
+ # has no run, a run IN FLIGHT for a superseded
114
+ # commit would look exactly like no run at all,
115
+ # and a caller could not tell "CI has not
116
+ # started" from "CI is answering about the
117
+ # past". `sha` names the run's own commit, and
118
+ # comparing it to the one asked about is the
119
+ # CALLER's rule — this decides nothing.
120
+ # `status` AND `conclusion` ARE BOTH REPORTED,
121
+ # never collapsed. A run that is `completed` has
122
+ # a conclusion; one that is `waiting` or
123
+ # `action_required` has none yet, and folding
124
+ # the two would report a build blocked on a
125
+ # human click as merely pending forever.
126
+ # NOTHING on bitbucket (bb has no run listing) —
127
+ # unavailable, never "no run".
51
128
  # issue-list [--limit N] open tracker issues as JSON lines:
52
129
  # {"number":N,"title":"…","url":"…",
53
130
  # "createdAt":"…"}
54
- # READ-ONLY, and the only issue op here: Plot
55
- # never writes to the tracker (no labels, no
56
- # assignees, no close-on-merge), because a copy
57
- # of tracker state ages into a lie.
131
+ # READ-ONLY. Plot writes no label, no assignee
132
+ # and no close-on-merge, because a copy of
133
+ # tracker state ages into a lie. The one write
134
+ # that exists is `issue-status`, and it records
135
+ # a status Plot itself just caused rather than
136
+ # mirroring one the tracker holds.
58
137
  # `url` is "" when the host omits it, and a
59
138
  # consumer renders the number as plain text
60
139
  # rather than inventing an address — the rule
@@ -86,8 +165,8 @@
86
165
  # tickets*, the failure this story is named for.
87
166
  # issue-view <number> ONE open issue as a single JSON object:
88
167
  # {"number":N,"title":"…","body":"…","url":"…"}
89
- # STILL READ-ONLY — the second issue op, and it
90
- # reads. The board's *Create plan* action needs
168
+ # READ-ONLY — the second of the two issue reads.
169
+ # The board's *Create plan* action needs
91
170
  # the issue's BODY as the problem statement, and
92
171
  # issue-list deliberately omits it: the list is
93
172
  # asked on a timer for every open issue, and a
@@ -112,7 +191,84 @@
112
191
  # as issue-list. Jira answers 404 for a missing
113
192
  # key, which is exit 3 here (the tracker moved),
114
193
  # never an empty body.
194
+ # issue-status <key> <status> THE ONE WRITE TO A TRACKER: record one status
195
+ # against one issue, and nothing else. No
196
+ # create, no close, no comment, no label — a
197
+ # plan referencing an issue is Plot's record,
198
+ # and the status is the single fact the tracker
199
+ # owns a copy of.
200
+ # JIRA ONLY. Exit 4 where `Tracker` is not jira:
201
+ # this adapter cannot be asked, which is neither
202
+ # a failure nor a silent success. The other
203
+ # vendor's projects surface is written by
204
+ # `plot-update-board.sh`, under its own
205
+ # credentials and through a different API — the
206
+ # reason the tracker port has two connectors.
207
+ # Prints `written` where the transition was
208
+ # performed and `no-target` where the workflow
209
+ # offers no such transition from the issue's
210
+ # current state — which is also what a repeated
211
+ # write reports, because the same status twice
212
+ # is the same status.
213
+ # The transition id is LOOKED UP, never guessed:
214
+ # ids are per workflow and per issue, so a
215
+ # hardcoded one writes to the wrong column.
115
216
  # pr-body <number> --body B replace the PR description
217
+ # rate-limit both GitHub budgets from `gh api rate_limit`.
218
+ # SUPERSEDED BY `limit`, and kept only because
219
+ # its callers have not moved: that endpoint was
220
+ # measured 2026-09-01 reporting graphql
221
+ # 5000/5000 used=0 while a real call's header
222
+ # read Remaining 1236, Used 3764, and reproduced
223
+ # 2026-09-02 against a header's 2732. NOTHING
224
+ # ROUTES ON IT any more — `gh_route` reads the
225
+ # budget record, which is written from response
226
+ # headers. Prefer `limit`.
227
+ # limit what is this connector's limit, and how well
228
+ # does it know it? One JSON line per metered
229
+ # bucket: {"connector","bucket","limit",
230
+ # "remaining","reset","basis"} with `basis` one
231
+ # of actual|predicted|unknown, and the three
232
+ # numbers null where unreported. Reads the
233
+ # RESPONSE HEADERS of a real call, never
234
+ # `gh api rate_limit`. No output at all means
235
+ # this connector meters nothing — which is not
236
+ # the same fact as a limit of zero, and not
237
+ # `free` either.
238
+ # spend-rate [--connector C] [--account A] [--bucket B]
239
+ # what this COMPUTER has spent, read back from
240
+ # the record every spender appends to, as one
241
+ # JSON object: {"connector","account","bucket",
242
+ # "spent","spanMs","perHour","lines",
243
+ # "unreadable","limit","remaining","basis"}.
244
+ # AN OMITTED --bucket MEANS EVERY BUCKET, which
245
+ # is what "what am I spending?" asks: one
246
+ # connector meters several pools and an account
247
+ # spends all of them, so the cadence divides by
248
+ # the sum. A caller deciding whether a POOL is
249
+ # spent names it — `remaining` and `basis` in
250
+ # the aggregate describe whichever pool was
251
+ # spent last, which is a reading and not a
252
+ # verdict.
253
+ # OVER THE CONNECTOR'S WINDOW, never the whole
254
+ # file — ~1,160 lines an hour were measured
255
+ # 2026-09-01, and a rate over an ever-growing
256
+ # span approaches zero. SPENDS NOTHING: it reads
257
+ # a file and asks no host. `perHour` is null
258
+ # where the window holds no span to divide by,
259
+ # which is an absent rate and never a zero one.
260
+ # ci-limit the same question of the CI connector, which
261
+ # is a separate axis: this repo is GitHub +
262
+ # Actions, ekzweb is Bitbucket + Jenkins.
263
+ # Jenkins reports no limit, so it answers
264
+ # `predicted`.
265
+ #
266
+ # TRANSPORT IS NOT A CALLER'S CONCERN. Where a host offers a question over more
267
+ # than one transport — GitHub's REST against its GraphQL — the choice is made
268
+ # inside this script, by `gh_route`, and no op reports which one answered. The
269
+ # payload is identical either way by construction; a caller that could tell them
270
+ # apart would start depending on the route. `PLOT_HOST_FORCE_REST=1` pins the
271
+ # GitHub route to REST, and it is read in exactly one place.
116
272
  #
117
273
  # Backend resolution: $PLOT_HOST (github|bitbucket) wins — useful for tests —
118
274
  # else the `Git host` key from `## Plot Config` (via plot-config.sh), default
@@ -139,6 +295,133 @@ die() { echo "plot-host: $*" >&2; exit 1; }
139
295
  # error a person must fix, not a transient the board should retry past.
140
296
  die3() { echo "plot-host: $*" >&2; exit 3; }
141
297
 
298
+ # Exit 5 — the host refused to answer FOR NOW. A rate limit, primary or
299
+ # secondary: nothing is broken, nothing needs fixing, and the same question
300
+ # asked later will be answered.
301
+ #
302
+ # ITS OWN CODE BECAUSE IT ASKS FOR A DIFFERENT RESPONSE. Exit 3 says *something
303
+ # is wrong and a person must look*; this says *wait*. A caller that folded them
304
+ # would counsel one when it meant the other, and the fleet scan's summary word
305
+ # is exactly that choice made visible.
306
+ #
307
+ # NOT A RETRY, HERE OR ANYWHERE IN THIS ADAPTER. Whether to wait is the
308
+ # caller's decision — a board on a 5 s cadence, a scan inside a 90 s budget and
309
+ # a person at a terminal want three different answers — and a retry inside the
310
+ # adapter would hide the very state this code exists to surface, turning a
311
+ # reportable fact into an unexplained four-minute call.
312
+ die5() { echo "plot-host: $*" >&2; exit 5; }
313
+
314
+ # Exit 6 — the host refused because too many calls arrived AT ONCE. A secondary
315
+ # limit, and a different ceiling from the one exit 5 reports.
316
+ #
317
+ # ITS OWN CODE BECAUSE THE TWO RECOVER DIFFERENTLY, which is the whole reason
318
+ # this script now answers three words where it answered two. A spent quota
319
+ # recovers at the reset the response carries — minutes away — and the honest
320
+ # reaction is to stop until then and say when. A secondary limit clears in
321
+ # seconds, carries no reset, and the reaction is to retry shortly and lower
322
+ # concurrency. A caller told only *throttled* counsels one when it means the
323
+ # other: it waits minutes for a limit that cleared, or it retries in seconds
324
+ # into a bucket that is empty.
325
+ #
326
+ # BOTH WERE MEASURED HERE. 2026-08-27, eight workers against a cap of seven
327
+ # produced a 403 naming abuse detection. 2026-09-01, `gh pr view` refused with
328
+ # *"API rate limit already exceeded"* while the same account's GraphQL headers
329
+ # read 4854 of 5000 remaining — a bucket with 97 % left does not refuse on
330
+ # quota. So both causes are real, and the aggregate view could not tell them
331
+ # apart.
332
+ #
333
+ # NOT A RETRY, for the reason exit 5 states: whether to wait is the caller's
334
+ # decision, and this adapter reports rather than reacts.
335
+ die6() { echo "plot-host: $*" >&2; exit 6; }
336
+
337
+ # WHICH FAILURE, read off the wording — the same shape `bb_issue_exit_code`
338
+ # uses, and for the same reason: the exit code cannot split these cases. `gh`
339
+ # exits 1 for a rate limit and for a 503 alike, and puts the whole difference
340
+ # in its stderr.
341
+ #
342
+ # THE SPLIT FALLS ONE WAY ONLY. An unrecognised error is never given the more
343
+ # specific name. `throttled` counsels patience, and patience does not fix an
344
+ # outage — so anything this does not recognise stays `failed`, which counsels
345
+ # looking. That is the same direction `bb_issue_exit_code` refuses to guess in,
346
+ # where an unrecognised error must be 3 rather than 4.
347
+ #
348
+ # THREE ANSWERS, BECAUSE THE TWO LIMITS ARE TWO CEILINGS. This returned
349
+ # `throttled` for every match of one regex until 2026-09-02, so *"API rate
350
+ # limit exceeded"* and *"You have exceeded a secondary rate limit"* came back
351
+ # the same word and nothing downstream could tell them apart. `secondary` is
352
+ # now its own answer: `die6` carries it, and the board names which limit was
353
+ # hit rather than printing one reset over both.
354
+ #
355
+ # THE SECONDARY TEST RUNS FIRST, AND THE ORDER IS THE CLASSIFICATION. GitHub's
356
+ # secondary message contains the phrase *"rate limit"* too — *"You have
357
+ # exceeded a secondary rate limit"* — so a quota test applied first claims
358
+ # every secondary refusal and the distinction is lost at the point it is made.
359
+ host_failure_kind() { # $1=stderr text → throttled|secondary|failed
360
+ if LC_ALL=C grep -qiE 'secondary rate|exceeded a secondary|abuse detection|abuse-detection|too many requests|\b429\b' <<<"$1"; then
361
+ echo secondary
362
+ elif LC_ALL=C grep -qiE 'rate limit|ratelimit' <<<"$1"; then
363
+ echo throttled
364
+ else
365
+ echo failed
366
+ fi
367
+ }
368
+
369
+ # A failed `pr-list`, reported and never swallowed.
370
+ #
371
+ # THREE OUTCOMES, KEPT APART — the rule `issue-list` states in full and this
372
+ # path collapsed until 2026-08-30. An empty list means the host answered and
373
+ # there are none; a non-zero exit with empty stdout means the question failed.
374
+ # Printing nothing while exiting non-zero is what says which.
375
+ #
376
+ # NO EMPTY-LIST FALLBACK, for the reason `issue-list` gives: `host_miss_or_fail`
377
+ # exists for a lookup whose subject is absent — one PR that does not exist. A
378
+ # LIST has no absent subject, so if the call failed, the answer is unknown.
379
+ pr_list_failed() { # $1=stderr text
380
+ local err="$1"
381
+ case "$(host_failure_kind "$err")" in
382
+ secondary)
383
+ die6 "pr-list: host refused a burst — ${err:-the host refused the request and said nothing}"
384
+ ;;
385
+ throttled)
386
+ die5 "pr-list: host throttled — ${err:-the host refused the request and said nothing}"
387
+ ;;
388
+ esac
389
+ die3 "pr-list: ${err:-the host failed the request and said nothing}"
390
+ }
391
+
392
+ # Run one `pr-list` host call, or die reporting which failure it was.
393
+ #
394
+ # SIX CALL SITES SHARE THIS, and that is the point rather than a tidy-up.
395
+ # `pr-list` branches on backend × rich × Jenkins into six separate invocations,
396
+ # each of which had its own unchecked assignment; a fix applied by hand six
397
+ # times is a fix that drifts, and the arm that drifts is the one nobody's repo
398
+ # exercises. `plot-pr-merged.sh` makes the same argument for being sourced
399
+ # rather than copied: two implementations of one gate fail toward permissive,
400
+ # and permissive here means the silent empty list this exists to remove.
401
+ #
402
+ # The output goes to STDOUT for the caller to capture; only the error text is
403
+ # spooled, because it is needed twice — once to classify and once to report.
404
+ #
405
+ # EVERY CALL SITE MUST WRITE `|| exit $?`, AND IT IS NOT OPTIONAL. This is
406
+ # invoked as `_raw="$(pr_list_call …)"` — a COMMAND SUBSTITUTION, which is a
407
+ # subshell — so the `exit` inside `die5`/`die3` leaves that subshell only. The
408
+ # outer script would carry on with `_raw` empty and `jq` would emit nothing:
409
+ # the silent empty list this whole helper exists to remove, rebuilt one layer
410
+ # further in and harder to see. The same trap `bb_states_for` documents a few
411
+ # hundred lines below, where a `die` in a subshell turned an unknown state into
412
+ # "no PRs matched".
413
+ pr_list_call() { # "$@"=the host command → payload on stdout, or dies
414
+ local out rc err tmp="/tmp/plot-host-prlist-err.$$"
415
+ out="$("$@" 2>"$tmp")"; rc=$?
416
+ err="$(cat "$tmp" 2>/dev/null)"; rm -f "$tmp"
417
+ [ "$rc" -eq 0 ] || pr_list_failed "$err"
418
+ # A non-empty stderr on a SUCCESSFUL call is a warning, not a verdict — `gh`
419
+ # writes deprecation notices there. Passed through so it is not lost, while
420
+ # the payload is still returned.
421
+ [ -n "$err" ] && echo "$err" >&2
422
+ printf '%s' "$out"
423
+ }
424
+
142
425
  # --- Jenkins CI integration ------------------------------------------------
143
426
  # A repo may declare `CI: jenkins` independently of `Git host`. When it does,
144
427
  # build status (`checks`) is resolved through `jen` — a multibranch job's
@@ -303,10 +586,52 @@ jenkins_build_map() {
303
586
  # `LC_ALL=C` on the match: the CLI localises its messages, and a matcher that
304
587
  # only works in English would silently reclassify every miss as an outage for
305
588
  # anyone else.
589
+ #
590
+ # A MISSING CLI IS NOT A MISS, AND THE BARE `not found` ALTERNATIVE COULD NOT
591
+ # TELL THEM APART. Measured 2026-09-06 with `gh` off `PATH`: the shell says
592
+ # `bash: gh: command not found`, which matched — so `pr-merged` answered
593
+ # `not-merged` where `plot-pr-merged.sh` answered `unaskable` about the same
594
+ # branch. `not-merged` reads to `rules/landed.ts` as `none` (the host spoke and
595
+ # said nothing merged), so `mayRemove` may permit a removal; `unaskable`
596
+ # refuses. `plot-release-refs.sh` deletes remote refs on that answer and a
597
+ # deleted ref is not re-creatable, so the two answers differ in the one
598
+ # direction this estate has said it will not fail in.
599
+ #
600
+ # The bare alternative stays — it is what recognises a Bitbucket or Jira miss,
601
+ # whose wording is neither `no pull requests found` nor `could not find`. What
602
+ # it excludes is the shell's own phrasing for an absent binary, which is a
603
+ # transport failure wearing a miss's words.
306
604
  is_lookup_miss() {
605
+ case "$1" in *'command not found'*) return 1 ;; esac
307
606
  LC_ALL=C grep -qiE 'no (pull request|pullrequest)s? (found|match)|could not find.*pull request|not found' <<<"$1"
308
607
  }
309
608
 
609
+ # Did the host refuse this call for RATE, rather than answer it?
610
+ #
611
+ # THE GAP NO BUDGET READING CLOSES, and this is what closes it instead. A
612
+ # secondary limit bounds requests AT ONCE rather than requests an hour, so it
613
+ # fires while the pool is nearly full — measured 2026-09-01, `gh pr view`
614
+ # refused while the same account's GraphQL header read 4854 of 5000. No reading
615
+ # of any bucket predicts that, which is why the refusal itself is the evidence.
616
+ #
617
+ # Measured 2026-09-01. A polling burst tripped GitHub's secondary limit on
618
+ # GraphQL while BOTH buckets read `5000/5000`, so `graphql_budget_spent` was
619
+ # false, the cheap path was chosen, and every `gh pr` call returned
620
+ # `API rate limit already exceeded`. REST answered the same questions normally
621
+ # throughout. The estate's whole reap stalled on it — 18 worktrees read
622
+ # `rule could not be asked` and every one was kept.
623
+ #
624
+ # THE CHEAP PATH STAYS THE DEFAULT. This does not prefer REST; the trade is
625
+ # measured at one GraphQL call against ~186 REST calls for a 93-branch scan. It
626
+ # only says that a call REFUSED for rate has not been answered, so the second
627
+ # path is worth trying before reporting an outage.
628
+ #
629
+ # `LC_ALL=C` for the reason `is_lookup_miss` gives: the CLI localises, and an
630
+ # English-only matcher would misread every other locale.
631
+ is_rate_refusal() {
632
+ LC_ALL=C grep -qiE 'rate limit|secondary rate|abuse detection|403.*forbidden' <<<"$1"
633
+ }
634
+
310
635
  # Emits the miss payload on a genuine miss and exits non-zero on anything else,
311
636
  # after putting the CLI's own words on stderr. Callers get: stdout parseable or
312
637
  # empty, exit code decisive.
@@ -345,19 +670,38 @@ host_miss_or_fail() {
345
670
  # there unused, and the same question can still be answered — degraded and more
346
671
  # expensive, but answered.
347
672
  #
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:
673
+ # THIS GATE READ `gh api rate_limit` UNTIL 2026-09-02, AND IT COULD NOT SEE THE
674
+ # CONDITION IT GATES ON. Measured 2026-09-01 in a quiet moment, same account,
675
+ # seconds apart:
676
+ #
677
+ # gh api rate_limit graphql: 5000/5000, used 0
678
+ # a real call's header X-Ratelimit-Remaining: 1236, Used: 3764
679
+ #
680
+ # Reproduced 2026-09-02: `rate_limit` reported 5000/5000 used 0 while the header
681
+ # on the same account read `Remaining: 2732, Used: 2268`. So the `-eq 0` test
682
+ # below has never been able to fire, and the fallback it guards has never been
683
+ # reached by a budget reading.
684
+ #
685
+ # **A gate that cannot see the condition it gates on is worse than no gate,
686
+ # because it reports safety.** Measured 2026-09-01: a polling burst tripped
687
+ # GitHub's secondary limit on GraphQL while both buckets read `5000/5000`, this
688
+ # gate was therefore false, the cheap path was chosen, and every `gh pr` call
689
+ # returned `API rate limit already exceeded`. REST answered normally throughout.
690
+ # The estate's whole reap stalled — 18 worktrees read `rule could not be asked`
691
+ # and every one was kept.
353
692
  #
354
- # graphql: 5000/5000 used=0 reset_in=3599s
355
- # core: 5000/5000 used=0 reset_in=3599s
693
+ # SO IT READS THE RECORD, AND THE RECORD IS WRITTEN FROM RESPONSE HEADERS. Every
694
+ # `gh` call this script makes files its spend against the bucket it spent, and a
695
+ # call that harvested `X-RateLimit-*` files the connector's own numbers with it.
696
+ # Three properties follow, and each answers an objection to the old reading:
356
697
  #
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.
698
+ # FREE it reads a file. No host is asked, so the "the query is free"
699
+ # argument the old docblock made is no longer needed nothing is
700
+ # spent at all, rather than one call against the bucket in question.
701
+ # CURRENT the newest live line describes the last call that actually
702
+ # happened, not a separate endpoint's view of it.
703
+ # BY NAME the line names its own bucket, so `graphql` is asked about
704
+ # `graphql` and a full `core` cannot answer for it.
361
705
  #
362
706
  # THE CHEAP PATH STAYS THE DEFAULT, and the reason is measured. For 93 branches:
363
707
  # ONE GraphQL call (`pr-list` with the check rollup) versus ~186 REST calls,
@@ -367,23 +711,131 @@ host_miss_or_fail() {
367
711
  # ACTUALLY spent.
368
712
  #
369
713
  # 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.
714
+ # where it bites. A record with no `graphql` line yet, a reading the connector
715
+ # did not number, and an unreadable file all report `unknown` and reading any
716
+ # of them as "exhausted" would send every branch down the expensive path
717
+ # forever. So only a `remaining` that IS a number and IS zero returns true.
718
+ #
719
+ # IT IS STILL BLIND TO THE SECONDARY LIMIT, and that is stated rather than
720
+ # fixed here. A burst refusal arrives while the bucket has 4854 of 5000 left —
721
+ # measured 2026-09-01 — so no reading of any bucket predicts it. What answers
722
+ # that is the rate-refusal re-entry in `pr-state`, which reads the refusal
723
+ # itself; `bug/a-spent-bucket-waits-for-its-reset` owns the reaction.
374
724
  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.
725
+ local rate remaining basis
726
+ # THE RECORD, NEVER THE HOST. `budget_rate` reads one file and asks nothing,
727
+ # so this costs no request against the bucket it is asking about which the
728
+ # old reading did, on every lookup.
729
+ rate="$(budget_rate github "$(budget_account github)" graphql 2>/dev/null)" || return 1
730
+ remaining="$(jq -r '.remaining' <<<"$rate" 2>/dev/null)" || return 1
731
+ basis="$(jq -r '.basis' <<<"$rate" 2>/dev/null)" || return 1
732
+ # ONLY AN `actual` READING MAY CLOSE THIS GATE. A `predicted` number is the
733
+ # adapter's estimate of a ceiling and carries no spend against it; an
734
+ # `unknown` one is the absence of a reading. Neither is evidence that a
735
+ # bucket is empty, and treating either as one takes the expensive path on a
736
+ # guess.
737
+ [ "$basis" = actual ] || return 1
738
+ # `== 0` and not `<= 0`: only a number can be spent. `null`, an empty string
739
+ # and a malformed payload all fall through to false, which is the cheap path
740
+ # — the honest direction to be wrong in.
384
741
  [[ "$remaining" =~ ^[0-9]+$ ]] && [ "$remaining" -eq 0 ]
385
742
  }
386
743
 
744
+ # THE ROUTER. One function decides REST versus GraphQL for the GitHub
745
+ # connector, and it is the only place in this script that decides. Every GitHub
746
+ # op asks it; no op re-derives the answer.
747
+ #
748
+ # `gh_route <op>` prints `graphql` or `rest`. The op is the argument because
749
+ # the routing question is *"how should THIS question be asked?"*, and not every
750
+ # question has two answers.
751
+ #
752
+ # ONE ROUTER PER CONNECTOR, NOT ONE FOR ALL OF THEM. REST-versus-GraphQL is a
753
+ # GitHub distinction: Bitbucket has no such split, Jenkins has neither
754
+ # transport, and GitLab will have its own. A router lifted above the adapters
755
+ # would make every future connector implement a fork that exists for one
756
+ # vendor. So this function is named `gh_`, sits beside the other `gh_` helpers,
757
+ # and `bb`, `jen` and `jira` never reach it.
758
+ #
759
+ # NO CALLER LEARNS THE ROUTE. The answer is consumed inside this script and
760
+ # never reaches stdout, and both paths normalise to one vocabulary — see
761
+ # `rest_pr_to_state`, which exists so a REST answer is indistinguishable from a
762
+ # GraphQL one.
763
+ #
764
+ # THE CHEAP PATH IS THE DEFAULT, and this function does not change that. For 93
765
+ # branches: one GraphQL call against ~186 REST calls, because REST's list
766
+ # endpoint carries no check rollup and no `mergeable_state`. So `graphql` is
767
+ # what falls out when nothing says otherwise, and `rest` needs a reason.
768
+ #
769
+ # ONE `case`, NOT THREE PREDICATES. An earlier draft split "which transports
770
+ # does this op have?" into two helper functions the router called. That is a
771
+ # second site that decides, which is the exact thing this slice removes — a
772
+ # reader would have had to hold three functions in mind to answer one question.
773
+ # The transports an op has are stated here, once, beside the rule that reads
774
+ # them.
775
+ #
776
+ # THE ARMS, AND WHY EACH IS WHERE IT IS:
777
+ #
778
+ # pr-state both transports. The only op with a REST fallback
779
+ # written, so the only one where the budget can change the
780
+ # answer.
781
+ # rate-limit|limit REST-only BY NATURE rather than by omission. Both are
782
+ # reached through `gh api`, which is REST's transport.
783
+ # `limit` spends a GraphQL call to read its own headers —
784
+ # the call it makes is GraphQL, the route to it is not.
785
+ # * GraphQL-only. Ten ops, and this is where "every op
786
+ # consults the router" is honest rather than decorative:
787
+ # their answer is a statement about what THIS SCRIPT has
788
+ # implemented, not about what GitHub offers, which serves
789
+ # nearly everything both ways. Saying so here puts the
790
+ # knowledge where slice 7 can act on it, instead of leaving
791
+ # it implicit in the absence of a branch at the call site.
792
+ # Writing the missing REST paths is new capability and
793
+ # belongs to no slice in this plan.
794
+ #
795
+ # `PLOT_HOST_FORCE_REST=1` IS NOT A DEBUG SWITCH. It is how the rate-refusal
796
+ # re-entry reaches the second path without a second copy of the REST code:
797
+ # `pr-state` re-enters itself with the variable set after GraphQL is refused
798
+ # for rate. An operator may also set it, and that is supported, but the
799
+ # re-entry is why it exists. It is consulted before the budget because it is
800
+ # free and the budget read is not.
801
+ #
802
+ # THE BUDGET IS CONSULTED LAST, AND IT READS THE RECORD BY BUCKET NAME. Every
803
+ # `gh` call files its spend against the pool it spent, and a call that harvested
804
+ # `X-RateLimit-*` files the connector's own numbers with it — so `graphql` is
805
+ # asked about `graphql`, and a `core` with 4990 left cannot answer for it.
806
+ #
807
+ # It read `gh api rate_limit` until 2026-09-02, and that reading could not see
808
+ # the condition it gates on: the endpoint was measured 2026-09-01 reporting
809
+ # `graphql: 5000/5000, used 0` while a real response header reported
810
+ # `Remaining: 1236, Used: 3764`, and reproduced 2026-09-02 against a header's
811
+ # 2732. So the `-eq 0` test never fired, and a gate that cannot see its
812
+ # condition is worse than no gate because it reports safety.
813
+ #
814
+ # Last, because it is the only one of the three reasons that can be wrong. It
815
+ # no longer costs a call to establish — the record is a file.
816
+ gh_route() { # $1=op → graphql|rest
817
+ case "${1:-}" in
818
+ # REST-only: no GraphQL route exists to choose, so nothing is consulted.
819
+ rate-limit|limit)
820
+ printf 'rest\n'
821
+ ;;
822
+ # Both transports implemented — the one op where the reasons apply.
823
+ pr-state)
824
+ if [ "${PLOT_HOST_FORCE_REST:-0}" = "1" ] || graphql_budget_spent; then
825
+ printf 'rest\n'
826
+ else
827
+ printf 'graphql\n'
828
+ fi
829
+ ;;
830
+ # GraphQL-only. `PLOT_HOST_FORCE_REST` cannot move these: there is nowhere
831
+ # to move them TO, and answering `rest` would name a path that does not
832
+ # exist. An op that grows a REST fallback gets an arm above.
833
+ *)
834
+ printf 'graphql\n'
835
+ ;;
836
+ esac
837
+ }
838
+
387
839
  # BOTH PATHS MUST PRODUCE ONE VOCABULARY, or the adapter's contract forks in two
388
840
  # and every caller has to learn which route answered.
389
841
  #
@@ -562,6 +1014,8 @@ bb_assert_issue_version() {
562
1014
  BB_CAP_CHECKED=""
563
1015
  BB_CAP_HAS_JSON=""
564
1016
  BB_CAP_IDENTITY="" # "craftamap/0.6.0" or "quatico/1.2.3" or "unknown/<ver>"
1017
+ BB_CAP_PROBE_RC="" # the exit code `bb pr list --help --json` returned
1018
+ BB_CAP_PROBE_MATCH="" # the line that matched a rejection pattern, if one did
565
1019
 
566
1020
  # Identify which bb is on PATH. Returns a string like "quatico/1.9.0" or
567
1021
  # "craftamap/0.6.0" or "unknown/<version>" or "unknown/unknown".
@@ -610,26 +1064,52 @@ bb_test_json_support() {
610
1064
  local out rc
611
1065
  out="$(bb pr list --help --json 2>&1)"; rc=$?
612
1066
 
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
1067
+ # WHAT WAS TESTED, kept for the refusal below. The diagnostic names the
1068
+ # command, its exit code and the line that matched — three observations a
1069
+ # reader can reproduce — rather than a provenance verdict `bb_identify`
1070
+ # cannot determine. A message that guesses wrong costs more than one that
1071
+ # says less.
1072
+ BB_CAP_PROBE_RC="$rc"
1073
+ BB_CAP_PROBE_MATCH=""
617
1074
 
618
- # If help succeeded (even partially), assume --json is supported
619
- if [ "$rc" = 0 ]; then
620
- return 0
1075
+ # THE MEASUREMENT ANSWERS FIRST, AND THE TEXT ONLY WHEN IT IS ABSENT.
1076
+ #
1077
+ # `rc = 0` proves the flag PARSED: no CLI accepts an unknown flag and exits 0.
1078
+ # That is a measurement. The grep below is a heuristic over prose, and asking
1079
+ # the heuristic first is what issue #668 is.
1080
+ #
1081
+ # bb 1.9.0 documents that the flag is cheap — "a bare `--json`, costs nothing
1082
+ # extra" — and `--json.*not` matched `--json, costs no`t`hing`, so a bb that
1083
+ # WORKS was rejected for explaining the flag it supports. The bug therefore
1084
+ # scaled with documentation quality: bb 1.0.0 lacks the sentence and passed.
1085
+ [ "$rc" = 0 ] && return 0
1086
+
1087
+ # ANCHORED TO THE FLAG, WITH NO `.*` BRIDGE, so prose cannot reach them.
1088
+ # These are what a CLI actually prints when it rejects a flag — Cobra, getopt
1089
+ # and Go's `flag` respectively. craftamap 0.6.0 exits NON-ZERO on `--json`, so
1090
+ # it never reached the early return above and its exact message still matches.
1091
+ if grep -qiE 'unknown flag: --json|unknown option .?--json|flag provided but not defined: -?-json' <<<"$out"; then
1092
+ BB_CAP_PROBE_MATCH="$(grep -iEm1 'unknown flag: --json|unknown option .?--json|flag provided but not defined: -?-json' <<<"$out")"
1093
+ return 1
621
1094
  fi
622
1095
 
623
- # Non-zero exit with no clear rejection inspect further
624
- # A "not a bitbucket repo" error is about the repo, not the flag
1096
+ # A "not a bitbucket repo" error is about the repo, not the flag.
625
1097
  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
1098
  return 0
629
1099
  fi
630
1100
 
631
- # Unknown failure treat as unsupported to be safe
632
- return 1
1101
+ # AN UNRECOGNISED FAILURE ACCEPTS, and this is a behaviour change the plan
1102
+ # argued for in round 1. Refusing on wording outside the three formats above
1103
+ # is the same mistake as the bug this function is fixing, one arm along: a
1104
+ # guess refusing a CLI that may work. A help call failing for some other
1105
+ # reason is likelier an environment problem, and letting it through means the
1106
+ # first real call fails with BB'S OWN ERROR — more accurate than any guess
1107
+ # made here.
1108
+ #
1109
+ # The cost is stated rather than hidden: a genuinely incapable `bb` with
1110
+ # unfamiliar wording now produces a downstream error instead of one clear
1111
+ # message. That is the direction to fail, because the other direction is #668.
1112
+ return 0
633
1113
  }
634
1114
 
635
1115
  # Require that bb supports --json. Called once before the first bb PR call.
@@ -651,7 +1131,19 @@ bb_require_json() {
651
1131
 
652
1132
  if ! bb_test_json_support; then
653
1133
  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"
1134
+ # THE OBSERVATION FIRST, THE IDENTITY WHERE ONE IS KNOWN.
1135
+ #
1136
+ # The plan asked for the observation and for dropping the provenance CLAIM
1137
+ # — `bb_identify` answers `unknown/<ver>` for Quatico's bb, so a message
1138
+ # resting on it advised a working install to reinstall itself.
1139
+ #
1140
+ # But identity is not always unknown: craftamap reports a real version, and
1141
+ # `host.test.mjs:1761` asserts the diagnostic names it. Both are right, and
1142
+ # they do not conflict — an identity that IS determinable is a fact worth
1143
+ # printing, and `bb_identify` already answers `unknown/<ver>` rather than
1144
+ # inventing one where it is not. So this reports what was tested AND who
1145
+ # answered, and it no longer tells anyone what to install.
1146
+ 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
1147
  fi
656
1148
 
657
1149
  BB_CAP_HAS_JSON=1
@@ -751,6 +1243,25 @@ jira_curl() {
751
1243
  -H 'Accept: application/json' \
752
1244
  -w '\n%{http_code}' \
753
1245
  "$base$path" "$@"
1246
+ local rc=$?
1247
+ # JIRA IS COUNTED HERE AND NOT BY A WRAPPER, because it is reached through
1248
+ # `curl` rather than a CLI of its own — and shadowing `curl` would count every
1249
+ # unrelated use of it. This is the one place plot speaks to a Jira, so it is
1250
+ # the one place that records the spend, which is the same argument the `gh`
1251
+ # wrapper makes one level up.
1252
+ #
1253
+ # The account is the Jira user, which is already in the environment and costs
1254
+ # nothing to read — the one connector here whose account needs no lookup.
1255
+ budget_record_jira
1256
+ return $rc
1257
+ }
1258
+
1259
+ # Records one Jira call. Jira meters, publishes no header this adapter reads,
1260
+ # and this slice does not add header parsing — so the reading is `unknown`,
1261
+ # which is never read as free.
1262
+ budget_record_jira() {
1263
+ [ -z "${PLOT_BUDGET_OFF:-}" ] || return 0
1264
+ budget_append jira "${JIRA_EMAIL:-unknown}" api 1 - - - unknown
754
1265
  }
755
1266
 
756
1267
  # Split a jira_curl response into (body, status) and enforce the three outcomes.
@@ -852,10 +1363,466 @@ backend() {
852
1363
  esac
853
1364
  }
854
1365
 
1366
+
1367
+ # --- the connector counts what it spends -----------------------------------
1368
+ #
1369
+ # EVERY HOST CALL APPENDS ONE LINE, AND SO DOES EVERY REFUSAL. A record that
1370
+ # omits failures under-counts exactly when the count matters most: a refused
1371
+ # call spent quota — GitHub debits the request before it decides to refuse it —
1372
+ # and a budget blind to refusals reads a throttled account as an idle one.
1373
+ #
1374
+ # INSTRUMENTED BY SHADOWING, NOT BY EDITING 40 CALL SITES. `gh`, `bb` and `jen`
1375
+ # below are shell FUNCTIONS, and a function shadows a PATH executable for every
1376
+ # caller in this file. `command gh` reaches the real binary, so the wrapper is
1377
+ # the one place the counting happens and no call site changes.
1378
+ #
1379
+ # THE ALTERNATIVE WAS MEASURED AND REJECTED. This script makes ~40 host CLI
1380
+ # invocations across 14 backend branches; recording at each would be 40 edits
1381
+ # that must all stay right, and the arm that drifts is the one nobody's repo
1382
+ # exercises — the same argument `pr_list_call` makes for gathering six call
1383
+ # sites into one helper, and `plot-pr-merged.sh` makes for being sourced rather
1384
+ # than copied. A wrapper also counts a call site written NEXT year, which no
1385
+ # number of careful edits can.
1386
+ #
1387
+ # IT CHANGES NO BEHAVIOUR. The wrapper forwards argv untouched, passes stdin
1388
+ # through, preserves stdout, stderr and the exit code exactly, and appends
1389
+ # afterwards. A call that succeeds today succeeds identically with a line
1390
+ # written beside it.
1391
+ . "$here/plot-budget.sh"
1392
+
1393
+ # WHO IS SPENDING — read from the CLI's own config, never from an API call.
1394
+ #
1395
+ # `gh api user` would answer authoritatively and cost one request against the
1396
+ # very bucket this is counting, on every invocation of this script. So the
1397
+ # account is read from `gh`'s config file, which `gh auth login` wrote and which
1398
+ # costs a file read. It is cached in an exported variable so a script that makes
1399
+ # several calls reads it once.
1400
+ #
1401
+ # AN UNKNOWN ACCOUNT IS RECORDED AS `unknown`, not skipped. Two checkouts whose
1402
+ # account cannot be read still share one real budget, and dropping their lines
1403
+ # would under-count the machine — the failure this plan exists to remove. They
1404
+ # group together under one honest name instead.
1405
+ budget_account() {
1406
+ if [ -n "${PLOT_BUDGET_ACCOUNT:-}" ]; then printf '%s\n' "$PLOT_BUDGET_ACCOUNT"; return; fi
1407
+ local who=''
1408
+ case "$1" in
1409
+ github)
1410
+ # `gh`'s hosts.yml names the active user under the host it belongs to.
1411
+ # `yq` is not a dependency here, and the file's shape is two levels of
1412
+ # indentation, so the top-level `user:` key is read directly.
1413
+ who="$(awk '/^[[:space:]]+user:/ {print $2; exit}' "${GH_CONFIG_DIR:-$HOME/.config/gh}/hosts.yml" 2>/dev/null)"
1414
+ ;;
1415
+ bitbucket)
1416
+ # `bb_identify` already resolves who bb is, and it caches; but it runs a
1417
+ # `bb` call, which is the thing being counted. The remote's owner is the
1418
+ # free approximation and is the half of the key that groups correctly:
1419
+ # two checkouts of one workspace share a budget.
1420
+ who="$(git config --get remote.origin.url 2>/dev/null | sed -E 's#^.*[:/]([^/]+)/[^/]+(\.git)?$#\1#')"
1421
+ ;;
1422
+ esac
1423
+ printf '%s\n' "${who:-unknown}"
1424
+ }
1425
+
1426
+ # The bucket a call spent, in the connector's OWN word.
1427
+ #
1428
+ # TWO SOURCES, AND THE HEADER OUTRANKS THE ARGV. A response reports the bucket
1429
+ # it spent in `X-RateLimit-Resource` — `core`, `graphql`, and `code_search` for
1430
+ # a search, which no reading of the command line would ever name. So a call that
1431
+ # harvested a header records what the header said, and this function answers
1432
+ # only for the calls that could not.
1433
+ #
1434
+ # `gh` OFFERS NO HEADERS ON MOST OF ITS SURFACE. `gh pr list` and `gh issue
1435
+ # list` are `gh`'s own GraphQL wrappers: they print a rendered payload and there
1436
+ # is no flag that adds the response headers to it. `--verbose` writes the whole
1437
+ # exchange to STDOUT, which would corrupt every caller's parse. So the argv is
1438
+ # what remains, and it is enough for the split that matters: `gh api graphql` and
1439
+ # every `gh pr`/`gh issue` command spend `graphql`, while `gh api <path>`,
1440
+ # `gh run` and `gh repo` spend `core`.
1441
+ #
1442
+ # AN UNRECOGNISED VERB NAMES NO BUCKET, and that empty string is the honest
1443
+ # answer rather than a default. Guessing `core` for a command nobody has
1444
+ # classified would file its spend against a pool it never touched, and the
1445
+ # gate below reads that pool.
1446
+ #
1447
+ # NOT NORMALISED, EVER. A connector nobody has written an adapter for names a
1448
+ # third thing, and `BudgetKey` carries the bucket as an unvalidated string for
1449
+ # exactly that reason — see `packages/domain/src/entities/limit.ts`: *"A closed
1450
+ # set here is the edit that gets forgotten when GitLab arrives."*
1451
+ gh_bucket_of_argv() {
1452
+ case "${1:-}" in
1453
+ # `gh api graphql` is the ONE `gh api` form that is not REST, and it is
1454
+ # named by its first positional rather than by a flag.
1455
+ api) if [ "${2:-}" = graphql ]; then printf 'graphql\n'; else printf 'core\n'; fi ;;
1456
+ # `gh`'s PR and issue commands are its GraphQL wrappers throughout.
1457
+ pr|issue) printf 'graphql\n' ;;
1458
+ # REST, all of them: `gh run list` reads the Actions API, `gh repo view`
1459
+ # the repository one, and `gh auth` the user one.
1460
+ run|repo|auth|release|workflow|search) printf 'core\n' ;;
1461
+ *) printf '\n' ;;
1462
+ esac
1463
+ }
1464
+
1465
+ # The bucket a NON-GitHub connector spends.
1466
+ #
1467
+ # One bucket per connector, which is the truth for `bb`, `jen` and `jira`: each
1468
+ # meters one pool or none, and neither reports a resource name. GitHub is the
1469
+ # connector that meters several, and `gh_bucket_of_argv` above is what names
1470
+ # them.
1471
+ budget_bucket() {
1472
+ case "$1" in
1473
+ github) printf 'core\n' ;;
1474
+ bitbucket) printf 'api\n' ;;
1475
+ *) printf '\n' ;;
1476
+ esac
1477
+ }
1478
+
1479
+ # What the connector's basis is, WITHOUT spending a call to find out.
1480
+ #
1481
+ # `plot-host.sh limit` reads a real response's headers, and it costs one
1482
+ # request. Calling it from inside the wrapper would double every host call this
1483
+ # script makes — the failure mode in miniature. So the wrapper records the
1484
+ # basis it can state for free, and the numbers stay absent:
1485
+ #
1486
+ # github `unknown` — GitHub HAS an actual reading, and this wrapper does
1487
+ # not hold it. Recording a `predicted` 5000 here would tag a
1488
+ # constant as an estimate nobody made; recording `actual` would tag
1489
+ # a guess as a measurement. `unknown` is the honest word, and
1490
+ # `unknown` is NEVER read as free — `headroom()` returns null for
1491
+ # it by construction.
1492
+ # bitbucket `predicted 1000` — the adapter's own number from experience, the
1493
+ # same one `limit` reports, and it costs nothing to state.
1494
+ #
1495
+ # A CALL THAT HARVESTED ITS OWN HEADERS OVERRIDES THIS, and that is the whole
1496
+ # of what slice 7 adds: `gh_api_harvest` puts the response's own
1497
+ # `X-RateLimit-*` into `PLOT_BUDGET_HARVEST`, and `budget_record_call` reads
1498
+ # that in preference to this. The reading is then FREE — the call was going to
1499
+ # happen anyway — and CURRENT, because it describes the call that just
1500
+ # happened rather than a separate endpoint's view of it.
1501
+ budget_reading() { # $1=backend → "<limit>\t<remaining>\t<reset>\t<basis>"
1502
+ # THE FIELDS ARE ARGUMENTS, NOT A FORMAT. Three of the four are the absent
1503
+ # marker `-`, and `printf '-\t...'` reads that leading `-` as an option flag:
1504
+ # bash answers `printf: -\: invalid option` and writes nothing. Measured
1505
+ # 2026-09-02, that broke the GitHub arm — the common path — in 10 host tests.
1506
+ case "$1" in
1507
+ bitbucket) printf '%s\t%s\t%s\t%s\n' 1000 - - predicted ;;
1508
+ *) printf '%s\t%s\t%s\t%s\n' - - - unknown ;;
1509
+ esac
1510
+ }
1511
+
1512
+ # The reading the LAST harvested response carried, as
1513
+ # "<bucket>\t<limit>\t<remaining>\t<reset>", or empty where nothing was
1514
+ # harvested.
1515
+ #
1516
+ # A SHELL VARIABLE RATHER THAN A RETURN, because the harvest happens inside a
1517
+ # command substitution — `out="$(gh_api_harvest …)"` — and a subshell's
1518
+ # variables do not survive it. So the harvester writes the reading to a file
1519
+ # whose path the parent chose, and the parent reads it back. A pipe would have
1520
+ # the same problem in the other direction.
1521
+ PLOT_BUDGET_HARVEST=""
1522
+
1523
+ # Records one call against one connector, whatever it cost and however it ended.
1524
+ #
1525
+ # `PLOT_BUDGET_OFF=1` disables recording entirely. It is for the tests that must
1526
+ # prove the record changes NOTHING about a call's behaviour, and for an operator
1527
+ # whose home directory is read-only; it is not a performance switch.
1528
+ #
1529
+ # THE BUCKET IS THE THIRD ARGUMENT AND IT IS NOT OPTIONAL FOR GITHUB. One
1530
+ # connector meters several pools independently, and until this slice every
1531
+ # GitHub call was filed against one bucket named `api` — so a spent GraphQL pool
1532
+ # and a full REST one summed to a number describing neither. Measured
1533
+ # 2026-09-01 from the response headers: `core` 4990 of 5000, `graphql` 0 of
1534
+ # 5000.
1535
+ budget_record_call() { # $1=connector $2=backend-for-account $3=bucket
1536
+ [ -z "${PLOT_BUDGET_OFF:-}" ] || return 0
1537
+ local reading account bucket
1538
+ account="$(budget_account "${2:-$1}")"
1539
+ bucket="${3:-}"
1540
+ [ -n "$bucket" ] || bucket="$(budget_bucket "$1")"
1541
+ # A HARVESTED HEADER OUTRANKS EVERYTHING, including the argv's guess at the
1542
+ # bucket: `X-RateLimit-Resource` names `code_search` for a search that no
1543
+ # reading of the command line would have classified as anything but `core`.
1544
+ if [ -n "${PLOT_BUDGET_HARVEST:-}" ]; then
1545
+ IFS=$'\t' read -r _hbkt _blim _brem _brst <<<"$PLOT_BUDGET_HARVEST"
1546
+ PLOT_BUDGET_HARVEST=""
1547
+ [ -z "$_hbkt" ] || bucket="$_hbkt"
1548
+ # `actual` is the one basis a caller is entitled to trust, and a harvested
1549
+ # header is the only thing in this script that earns it: the connector said
1550
+ # it, about the call that just happened.
1551
+ budget_append "$1" "$account" "$bucket" 1 "$_blim" "$_brem" "$_brst" actual
1552
+ return 0
1553
+ fi
1554
+ reading="$(budget_reading "$1")"
1555
+ IFS=$'\t' read -r _blim _brem _brst _bbas <<<"$reading"
1556
+ budget_append "$1" "$account" "$bucket" 1 "$_blim" "$_brem" "$_brst" "$_bbas"
1557
+ }
1558
+
1559
+ # Reads `X-RateLimit-*` out of a header block, into the harvest variable.
1560
+ #
1561
+ # plot_harvest_headers <file-holding-the-header-block>
1562
+ #
1563
+ # CASE-INSENSITIVE ON THE NAME. `gh` prints `X-Ratelimit-Limit` while GitHub
1564
+ # documents `X-RateLimit-Limit`, and a case-sensitive match reads a present
1565
+ # header as absent — which records `unknown` against a host that answered
1566
+ # perfectly.
1567
+ #
1568
+ # A MISSING HEADER LEAVES THE HARVEST EMPTY, so the caller records `unknown`
1569
+ # rather than a number. A proxy or an enterprise instance that strips them has
1570
+ # not reported a full budget and has not reported an empty one: `unknown` is
1571
+ # never `free`, and `headroom()` returns null for it by construction.
1572
+ plot_harvest_headers() {
1573
+ local file="${1:-}" lim rem rst res
1574
+ PLOT_BUDGET_HARVEST=""
1575
+ [ -f "$file" ] || return 0
1576
+ _hv() { LC_ALL=C grep -im1 "^$1:" "$file" | sed 's/^[^:]*:[[:space:]]*//' | tr -d '\r'; }
1577
+ lim="$(_hv 'X-RateLimit-Limit')"
1578
+ rem="$(_hv 'X-RateLimit-Remaining')"
1579
+ rst="$(_hv 'X-RateLimit-Reset')"
1580
+ res="$(_hv 'X-RateLimit-Resource')"
1581
+ # A LIMIT THAT IS NOT A NUMBER IS NO READING AT ALL. The whole reading is
1582
+ # dropped rather than half-kept: a bucket name beside an absent count would
1583
+ # file a spend against a pool the record then reports as unknown, which is
1584
+ # the shape a caller cannot act on.
1585
+ [[ "$lim" =~ ^[0-9]+$ ]] || return 0
1586
+ [[ "$rem" =~ ^[0-9]+$ ]] || rem='-'
1587
+ [[ "$rst" =~ ^[0-9]+$ ]] || rst='-'
1588
+ PLOT_BUDGET_HARVEST="$(printf '%s\t%s\t%s\t%s' "$res" "$lim" "$rem" "$rst")"
1589
+ }
1590
+
1591
+ # `gh api` WITH ITS OWN HEADERS HARVESTED, and the body printed unchanged.
1592
+ #
1593
+ # out="$(gh_api_harvest <args…>)"
1594
+ #
1595
+ # THE READING IS FREE BECAUSE THE CALL WAS GOING TO HAPPEN ANYWAY. `--include`
1596
+ # adds a header block to a request this script was already making, so no extra
1597
+ # request is spent — which is the objection that justified `gh api rate_limit`,
1598
+ # and it does not apply here. It is also CURRENT: it describes the call that
1599
+ # just happened rather than a separate endpoint's view of it. Measured
1600
+ # 2026-09-02 on this account, seconds apart: `gh api rate_limit` reported
1601
+ # graphql 5000/5000 used 0 while the same account's header read
1602
+ # `Remaining: 2732, Used: 2268`.
1603
+ #
1604
+ # STDOUT IS THE BODY AND NOTHING ELSE. The header block is split off here, so
1605
+ # every caller parses exactly what it parsed before. `--verbose` was the
1606
+ # alternative and it is unusable: `gh` writes the whole exchange to STDOUT,
1607
+ # which would corrupt the payload of every call that reads one.
1608
+ #
1609
+ # THE EXIT CODE AND STDERR ARE THE REAL CALL'S. A harvest that changed either
1610
+ # would make the reading cost correctness, which is the one price a bookkeeping
1611
+ # read may not charge.
1612
+ gh_api_harvest() {
1613
+ local raw rc hdr_tmp err_tmp
1614
+ hdr_tmp="$(mktemp "${TMPDIR:-/tmp}/plot-host-hdr.XXXXXX")" || {
1615
+ # No temp file, no harvest — and the call still happens, recorded by the
1616
+ # wrapper from its argv alone. Bookkeeping never fails its caller.
1617
+ gh api "$@"
1618
+ return $?
1619
+ }
1620
+ err_tmp="$(mktemp "${TMPDIR:-/tmp}/plot-host-herr.XXXXXX")" || {
1621
+ rm -f "$hdr_tmp"
1622
+ gh api "$@"
1623
+ return $?
1624
+ }
1625
+ # `command gh`, NOT THE WRAPPER, and the reason is ordering. The wrapper
1626
+ # records the call the moment it returns, which is before any header has been
1627
+ # read — so a wrapped call here would file an `unknown` line and this
1628
+ # function's reading would arrive too late to be the one recorded. Recording
1629
+ # is therefore done below, once, with the header in hand.
1630
+ # `--include` LAST, AFTER THE ENDPOINT. `gh` accepts it in either position,
1631
+ # and the trailing one keeps the argv a reader — or a test's stub — sees
1632
+ # identical up to the flag: `api repos/owner/repo/pulls/7 --include`, not
1633
+ # `api --include repos/…`. The endpoint is what identifies the call.
1634
+ raw="$(command gh api "$@" --include 2>"$err_tmp")"
1635
+ rc=$?
1636
+ # THE EXIT CODE AND STDERR ARE THE REAL CALL'S. A harvest that changed either
1637
+ # would make the reading cost correctness, which is the one price a
1638
+ # bookkeeping read may not charge — so the CLI's own words are replayed on
1639
+ # this function's stderr exactly as they arrived.
1640
+ cat "$err_tmp" >&2
1641
+ rm -f "$err_tmp"
1642
+ if [ $rc -ne 0 ]; then
1643
+ rm -f "$hdr_tmp"
1644
+ # A FAILED CALL IS STILL A SPENT CALL on every refusal GitHub meters, so it
1645
+ # is recorded like any other — from the argv, with no numbers.
1646
+ budget_record_call github github "$(gh_bucket_of_argv api "$@")"
1647
+ return $rc
1648
+ fi
1649
+ # THE STATUS LINE IS WHAT PROVES A HEADER BLOCK IS THERE, and the check is not
1650
+ # defensive padding. `sed '1,/^$/d'` on a body with NO header block deletes
1651
+ # everything up to the first blank line IN THE BODY — pretty-printed JSON has
1652
+ # none, so the whole payload would go. A `gh` too old for `--include`, or one
1653
+ # that ignores it, produces exactly that input.
1654
+ if [[ "$raw" != HTTP/* ]]; then
1655
+ budget_record_call github github "$(gh_bucket_of_argv api "$@")"
1656
+ printf '%s\n' "$raw"
1657
+ return 0
1658
+ fi
1659
+ # `--include` prints the status line, the headers, a BLANK line, then the
1660
+ # body. The blank line is the split, and it carries a CR — the headers are
1661
+ # CRLF-terminated while the body is not — so the match tolerates one.
1662
+ printf '%s\n' "$raw" | LC_ALL=C sed -n '1,/^[[:space:]]*$/p' >"$hdr_tmp"
1663
+ plot_harvest_headers "$hdr_tmp"
1664
+ rm -f "$hdr_tmp"
1665
+ # THE ARGV NAMES THE BUCKET WHERE THE HEADER DID NOT. A response that carried
1666
+ # no `X-RateLimit-Resource` still spent something, and `gh api <path>` is
1667
+ # `core` by construction — so the call is recorded either way, and only the
1668
+ # numbers are absent.
1669
+ budget_record_call github github "$(gh_bucket_of_argv api "$@")"
1670
+ # The body is everything after the first blank line. `sed` rather than a
1671
+ # bash parameter expansion: the body may be megabytes of JSON, and the
1672
+ # expansion would hold two copies of it.
1673
+ printf '%s\n' "$raw" | LC_ALL=C sed '1,/^[[:space:]]*$/d'
1674
+ return 0
1675
+ }
1676
+
1677
+ # THE WRAPPERS. Each forwards argv untouched and returns the real CLI's exit
1678
+ # code unchanged; the append happens after, and `budget_append` never fails its
1679
+ # caller. `command` is what reaches past the function to the binary.
1680
+ #
1681
+ # THE BUCKET IS READ FROM THE ARGV THIS WRAPPER ALREADY HOLDS. `gh api graphql`
1682
+ # and `gh pr view` spend `graphql`; `gh api repos/…` and `gh run list` spend
1683
+ # `core`. A harvested header overrides it where one was taken — see
1684
+ # `budget_record_call`.
1685
+ # ── The concurrency bound ────────────────────────────────────────────────────
1686
+ #
1687
+ # HOW MANY CALLS THIS ACCOUNT MAY HAVE OPEN AT ONCE. The wrappers below claim a
1688
+ # slot before the CLI runs and give it back after, so eight workers running this
1689
+ # script at once compete for one account's cap rather than all calling together
1690
+ # — which is exactly what 2026-08-27 measured: eight workers, a 403 naming abuse
1691
+ # detection, and both buckets reading `5000/5000 used=0`.
1692
+ #
1693
+ # **DISCOVERED, NOT CONFIGURED.** `seven` has no independent source — the two
1694
+ # comments in this file that cite it cite the one incident, where eight failed
1695
+ # and seven is the inference — so no number is compiled in here. The bound is
1696
+ # derived from the ceiling the record already holds, by the arithmetic
1697
+ # `boundFromLimit` states: a limit is requests per HOUR and a bound is requests
1698
+ # at one MOMENT, so an account allowed `limit` an hour can sustain
1699
+ # `limit / (3600 / 4)` of them simultaneously at four seconds a call.
1700
+ #
1701
+ # **AND A CONNECTOR THAT REPORTS NOTHING IS UNBOUNDED**, which is what every
1702
+ # caller was before this slice. `unknown` is not a number, and a bound invented
1703
+ # here would be the compiled-in seven under another name.
1704
+ PLOT_SLOT_SECONDS=4
1705
+
1706
+ # The bound for one connector and account, from the record's own reading.
1707
+ # Prints nothing and exits 1 where nothing licenses a bound.
1708
+ host_concurrency_bound() { # $1=connector $2=account
1709
+ local rate limit basis
1710
+ rate="$(budget_rate "$1" "$2" '' 2>/dev/null)" || return 1
1711
+ basis="$(printf '%s' "$rate" | LC_ALL=C sed -n 's/.*"basis":"\([a-z]*\)".*/\1/p')"
1712
+ [ "$basis" = actual ] || [ "$basis" = predicted ] || return 1
1713
+ limit="$(printf '%s' "$rate" | LC_ALL=C sed -n 's/.*"limit":\([0-9]*\).*/\1/p')"
1714
+ case "$limit" in ''|*[!0-9]*) return 1 ;; esac
1715
+ [ "$limit" -gt 0 ] || return 1
1716
+ local bound=$(( limit * PLOT_SLOT_SECONDS / 3600 ))
1717
+ # NEVER ZERO. A bound of zero is a connector that can never be called again,
1718
+ # which no reading licenses.
1719
+ [ "$bound" -ge 1 ] || bound=1
1720
+ printf '%s\n' "$bound"
1721
+ }
1722
+
1723
+ # How long a caller keeps asking for a slot before it proceeds anyway, and how
1724
+ # often it asks. Thirty seconds is the queue eight deep draining at four seconds
1725
+ # a call; a caller still waiting after it PROCEEDS rather than refusing, because
1726
+ # a script that waited forever would hang a worker where the plan asks only that
1727
+ # the cadence degrade. The cost of one extra simultaneous call is a secondary
1728
+ # refusal that lowers the bound — evidence, through the mechanism this slice is
1729
+ # built on.
1730
+ PLOT_SLOT_WAIT_MAX_S=30
1731
+ PLOT_SLOT_POLL_S=1
1732
+
1733
+ # The slot this process holds, so the wrappers can give it back. Empty where
1734
+ # none was taken — an unbounded connector, or a wait that ran out.
1735
+ PLOT_SLOT_INDEX=''
1736
+ PLOT_SLOT_ACCOUNT=''
1737
+
1738
+ # Claims a slot for the call about to be made, waiting where the account is
1739
+ # busy. Never refuses: at worst it proceeds unbounded, which is what every
1740
+ # caller did before this slice.
1741
+ host_slot_take() { # $1=connector $2=backend-for-account
1742
+ PLOT_SLOT_INDEX=''; PLOT_SLOT_ACCOUNT=''
1743
+ [ -z "${PLOT_BUDGET_OFF:-}" ] || return 0
1744
+ local account bound waited=0 got
1745
+ account="$(budget_account "${2:-$1}")" || return 0
1746
+ bound="$(host_concurrency_bound "$1" "$account")" || return 0
1747
+ local rc
1748
+ while :; do
1749
+ # CAPTURED IMMEDIATELY, because `$?` after an `if` reports the `if`. The
1750
+ # three exits mean three different things and collapsing any two of them is
1751
+ # the defect this whole plan is about.
1752
+ got="$(budget_slot_acquire "$account" "$bound")"; rc=$?
1753
+ if [ "$rc" -eq 0 ]; then
1754
+ PLOT_SLOT_INDEX="$got"; PLOT_SLOT_ACCOUNT="$account"; return 0
1755
+ fi
1756
+ # Exit 2 is *the claims could not be managed*, which is not the account
1757
+ # being busy — and a script that stopped calling because a directory could
1758
+ # not be created would go dark on a disk fault. It proceeds.
1759
+ [ "$rc" -eq 1 ] || return 0
1760
+ [ "$waited" -lt "$PLOT_SLOT_WAIT_MAX_S" ] || return 0
1761
+ sleep "$PLOT_SLOT_POLL_S"
1762
+ waited=$(( waited + PLOT_SLOT_POLL_S ))
1763
+ done
1764
+ }
1765
+
1766
+ # Gives the slot back. Called on every path out of a wrapper, taken or not.
1767
+ host_slot_give() {
1768
+ [ -n "$PLOT_SLOT_INDEX" ] || return 0
1769
+ budget_slot_release "$PLOT_SLOT_ACCOUNT" "$PLOT_SLOT_INDEX"
1770
+ PLOT_SLOT_INDEX=''; PLOT_SLOT_ACCOUNT=''
1771
+ }
1772
+
1773
+ gh() {
1774
+ host_slot_take github github
1775
+ command gh "$@"
1776
+ local rc=$?
1777
+ host_slot_give
1778
+ budget_record_call github github "$(gh_bucket_of_argv "$@")"
1779
+ return $rc
1780
+ }
1781
+
1782
+ bb() {
1783
+ host_slot_take bitbucket bitbucket
1784
+ command bb "$@"
1785
+ local rc=$?
1786
+ host_slot_give
1787
+ budget_record_call bitbucket bitbucket
1788
+ return $rc
1789
+ }
1790
+
1791
+ jen() {
1792
+ host_slot_take jenkins ''
1793
+ command jen "$@"
1794
+ local rc=$?
1795
+ host_slot_give
1796
+ # Jenkins is the CI axis, a connector of its own — this repo is GitHub +
1797
+ # Actions while `ekzweb` is Bitbucket + Jenkins, so a `jen` call spends
1798
+ # against a server the git host knows nothing about.
1799
+ budget_record_call jenkins ''
1800
+ return $rc
1801
+ }
1802
+
855
1803
  op="${1:-}"; [ -n "$op" ] || die "usage: plot-host.sh <op> [args...] (see header)"
856
1804
  shift
857
1805
  be="$(backend)" || exit 1
858
1806
 
1807
+ # EVERY GITHUB OP CONSULTS THE ROUTER, ONCE, HERE. `gh_route` is asked before
1808
+ # the op runs and its answer is read from `$route` by whichever arm needs it —
1809
+ # so an op cannot spend a GitHub call without the route for that call having
1810
+ # been decided, and there is no second place where an op could decide it.
1811
+ #
1812
+ # ASKED ONCE PER RUN, NOT ONCE PER CALL. `pr-state`'s arm reads the budget, and
1813
+ # that read is free (`gh api rate_limit` consumes neither bucket, measured
1814
+ # 2026-08-27) but not instant. One process answers one op, so one reading is
1815
+ # the whole of what that process needs.
1816
+ #
1817
+ # ONLY WHEN THE BACKEND IS GITHUB. The route is a GitHub distinction and this
1818
+ # is the connector boundary: a Bitbucket or Jenkins run never reaches
1819
+ # `gh_route`, never reads a budget, and `$route` stays empty for it. That
1820
+ # emptiness is the honest value — there is no route where there is no fork.
1821
+ route=""
1822
+ if [ "$be" = "github" ]; then
1823
+ route="$(gh_route "$op")"
1824
+ fi
1825
+
859
1826
  case "$op" in
860
1827
  backend)
861
1828
  echo "$be"
@@ -882,11 +1849,11 @@ case "$op" in
882
1849
  esac
883
1850
  done
884
1851
  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
1852
+ # THE ROUTE IS NOT CHOSEN HERE. `gh_route` chooses it, for every op,
1853
+ # and this site only reads the answer see that function for why the
1854
+ # cheap path is the default (~186 REST calls against one GraphQL call
1855
+ # for a 93-branch scan) and for what the fallback honestly buys.
1856
+ if [ "$route" = "rest" ]; then
890
1857
  # THE GRAPHQL PATH IS NOT ATTEMPTED FIRST once its budget is known to be
891
1858
  # gone. Trying it anyway would spend a call that is already refused, to
892
1859
  # learn what was just read for free.
@@ -896,7 +1863,7 @@ case "$op" in
896
1863
  # place that knows how to read either.
897
1864
  rest_repo="$(gh_rest_repo)" || exit $?
898
1865
  if [[ "$ref" =~ ^[0-9]+$ ]]; then
899
- if out="$(gh api "repos/$rest_repo/pulls/$ref" 2>/tmp/plot-host-err.$$)"; then
1866
+ if out="$(gh_api_harvest "repos/$rest_repo/pulls/$ref" 2>/tmp/plot-host-err.$$)"; then
900
1867
  rm -f "/tmp/plot-host-err.$$"
901
1868
  rest_pr_to_state <<<"$out"
902
1869
  else
@@ -913,7 +1880,7 @@ case "$op" in
913
1880
  # `state=all`, because the default is `open` and a merged PR would
914
1881
  # otherwise read as NONE — wrong in the reassuring direction.
915
1882
  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
1883
+ 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
1884
  rm -f "/tmp/plot-host-err.$$"
918
1885
  if [ "$(jq -r 'length' <<<"$out" 2>/dev/null)" = "0" ]; then
919
1886
  echo '{"number":0,"state":"NONE","draft":false,"url":"","mergeCommit":""}'
@@ -934,6 +1901,14 @@ case "$op" in
934
1901
  jq -c '{number:.number,state:.state,draft:.isDraft,url:.url,mergeCommit:(.mergeCommit.oid // "")}' <<<"$out"
935
1902
  else
936
1903
  err="$(cat "/tmp/plot-host-err.$$" 2>/dev/null)"; rm -f "/tmp/plot-host-err.$$"
1904
+ # REFUSED FOR RATE IS NOT ANSWERED. The budget gate above could not see
1905
+ # this coming — `rate_limit` does not report the secondary limit — so the
1906
+ # cheap path was chosen and then declined. The second path is the one
1907
+ # thing left to try before calling the host unreachable, and re-entering
1908
+ # the op is how it is reached without a second copy of the REST code.
1909
+ if is_rate_refusal "$err"; then
1910
+ PLOT_HOST_FORCE_REST=1 "$0" pr-state "$ref" ${repo_args[@]+"${repo_args[@]}"} && exit 0
1911
+ fi
937
1912
  host_miss_or_fail "$err" \
938
1913
  '{"number":0,"state":"NONE","draft":false,"url":"","mergeCommit":""}' || exit $?
939
1914
  fi
@@ -995,6 +1970,77 @@ case "$op" in
995
1970
  fi
996
1971
  ;;
997
1972
 
1973
+ # HAS ANY PR FOR THIS BRANCH MERGED?
1974
+ #
1975
+ # The one operation `plot-reap.sh` and `plot-release-refs.sh` reached past
1976
+ # this adapter for. `pr-state` returns `mergeCommit` and not `mergedAt`, and
1977
+ # `mergeCommit` is empty for a branch whose merge this port never saw — so
1978
+ # the gate that decides whether work has landed had to source its own
1979
+ # implementation rather than ask here.
1980
+ #
1981
+ # THREE ANSWERS, ON EXIT 0. `unknown` is a payload, not a failure: a host
1982
+ # that cannot be asked must not answer `not-merged`, because every caller is
1983
+ # deciding whether to remove something. Exit 3 is still reserved for the call
1984
+ # itself failing in a way this adapter does not recognise as a miss.
1985
+ pr-merged)
1986
+ ref="${1:?pr-merged needs a branch}"; shift || true
1987
+ repo_args=()
1988
+ while [ $# -gt 0 ]; do
1989
+ case "$1" in
1990
+ --repo) repo_args=(-R "${2:?}"); shift 2 ;;
1991
+ *) die "pr-merged: unknown arg $1" ;;
1992
+ esac
1993
+ done
1994
+ if [ "$be" = "github" ]; then
1995
+ # --state all, because a merged PR reports CLOSED and the default `open`
1996
+ # would hide every one of them. --limit 100 rather than 1: the newest PR
1997
+ # is not the merge, exactly as the state is not the merge.
1998
+ if out="$(gh ${repo_args[@]+"${repo_args[@]}"} pr list --head "$ref" --state all --limit 100 --json mergedAt 2>/tmp/plot-host-err.$$)"; then
1999
+ rm -f "/tmp/plot-host-err.$$"
2000
+ if jq -e 'any(.[]; .mergedAt != null)' >/dev/null 2>&1 <<<"$out"; then
2001
+ echo "merged"
2002
+ else
2003
+ echo "not-merged"
2004
+ fi
2005
+ else
2006
+ err="$(cat "/tmp/plot-host-err.$$" 2>/dev/null)"; rm -f "/tmp/plot-host-err.$$"
2007
+ # A LOOKUP MISS IS AN ANSWER — the branch has no PR, so nothing merged.
2008
+ # Anything else is the host failing to be asked, and that is `unknown`
2009
+ # rather than exit 3: the caller asked a question with a third value
2010
+ # for exactly this case, and the answer fails safe toward keeping.
2011
+ if [ -z "$err" ] || is_lookup_miss "$err"; then
2012
+ echo "not-merged"
2013
+ else
2014
+ echo "plot-host: $err" >&2
2015
+ echo "unknown"
2016
+ fi
2017
+ fi
2018
+ else
2019
+ bb_require_json
2020
+ # Bitbucket has no `--head` filter, so the branch is matched locally.
2021
+ # MERGED is bb's own state word here rather than a timestamp: the API
2022
+ # exposes no `mergedAt`, so this is the closest fact the backend holds,
2023
+ # and it is a positive statement about the merge rather than an inference
2024
+ # from CLOSED — `DECLINED` is bb's closed-unmerged word and is distinct.
2025
+ if out="$(bb ${repo_args[@]+"${repo_args[@]}"} pr list --state merged --json 2>/tmp/plot-host-err.$$)"; then
2026
+ rm -f "/tmp/plot-host-err.$$"
2027
+ if jq -e --arg b "$ref" 'any(.[]; .source.branch.name==$b)' >/dev/null 2>&1 <<<"$out"; then
2028
+ echo "merged"
2029
+ else
2030
+ echo "not-merged"
2031
+ fi
2032
+ else
2033
+ err="$(cat "/tmp/plot-host-err.$$" 2>/dev/null)"; rm -f "/tmp/plot-host-err.$$"
2034
+ if [ -z "$err" ] || is_lookup_miss "$err"; then
2035
+ echo "not-merged"
2036
+ else
2037
+ echo "plot-host: $err" >&2
2038
+ echo "unknown"
2039
+ fi
2040
+ fi
2041
+ fi
2042
+ ;;
2043
+
998
2044
  pr-create)
999
2045
  title=""; body=""; base=""; head=""; draft=0
1000
2046
  while [ $# -gt 0 ]; do
@@ -1067,6 +2113,13 @@ case "$op" in
1067
2113
  pr-list)
1068
2114
  state="open"
1069
2115
  rich=0
2116
+ # PIN THE LIST TO ONE REPOSITORY, the same `--repo` `pr-state` and
2117
+ # `pr-merged` already take. A checkout may carry several remotes on several
2118
+ # hosts, and an unpinned `gh pr list` resolves whichever of them it prefers
2119
+ # — so a caller comparing `origin/*` refs would join its refs against
2120
+ # another repository's PRs and report every branch as having none. The
2121
+ # caller knows which remote its refs came from; this op cannot guess it.
2122
+ repo_args=()
1070
2123
  # `gh pr list` and `bb pr list` both cap at 30 by default. That is invisible
1071
2124
  # with --state open (few repos have 30 open PRs) and bites immediately with
1072
2125
  # --state all, where the newest 30 crowd out every older merged PR. A caller
@@ -1078,6 +2131,7 @@ case "$op" in
1078
2131
  --state) state="${2:?}"; shift 2 ;;
1079
2132
  --limit) limit="${2:?}"; shift 2 ;;
1080
2133
  --rich) rich=1; shift ;;
2134
+ --repo) repo_args=(-R "${2:?}"); shift 2 ;;
1081
2135
  *) die "pr-list: unknown arg $1" ;;
1082
2136
  esac
1083
2137
  done
@@ -1196,8 +2250,8 @@ case "$op" in
1196
2250
  # $jstatus != "ok" → Jenkins could not answer; every row `unknown`.
1197
2251
  # $jentry == null → the branch has no Jenkins job; `none`.
1198
2252
  # 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)"
2253
+ _gh_raw="$(pr_list_call gh ${repo_args[@]+"${repo_args[@]}"} pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} \
2254
+ --json number,title,state,headRefName,isDraft,mergeable,mergeStateStatus,reviewDecision,url)" || exit $?
1201
2255
  pr_list_report_truncation github "$limit" "$state" \
1202
2256
  "$(jq 'length' <<<"$_gh_raw" 2>/dev/null || echo 0)"
1203
2257
  printf '%s' "$_gh_raw" \
@@ -1225,8 +2279,8 @@ case "$op" in
1225
2279
  }'
1226
2280
  else
1227
2281
  # 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)"
2282
+ _gh_raw="$(pr_list_call gh ${repo_args[@]+"${repo_args[@]}"} pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} \
2283
+ --json number,title,state,headRefName,isDraft,statusCheckRollup,mergeable,mergeStateStatus,reviewDecision,url)" || exit $?
1230
2284
  pr_list_report_truncation github "$limit" "$state" \
1231
2285
  "$(jq 'length' <<<"$_gh_raw" 2>/dev/null || echo 0)"
1232
2286
  printf '%s' "$_gh_raw" \
@@ -1256,8 +2310,8 @@ case "$op" in
1256
2310
  }'
1257
2311
  fi
1258
2312
  else
1259
- _gh_raw="$(gh pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} \
1260
- --json number,title,state,headRefName)"
2313
+ _gh_raw="$(pr_list_call gh ${repo_args[@]+"${repo_args[@]}"} pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} \
2314
+ --json number,title,state,headRefName)" || exit $?
1261
2315
  pr_list_report_truncation github "$limit" "$state" \
1262
2316
  "$(jq 'length' <<<"$_gh_raw" 2>/dev/null || echo 0)"
1263
2317
  printf '%s' "$_gh_raw" \
@@ -1296,7 +2350,7 @@ case "$op" in
1296
2350
  # the GitHub arm uses, which is why it lives above the backend branch.
1297
2351
  # `bb`'s standing `unknown` becomes a real value where Jenkins answers.
1298
2352
  for _s in $bb_states; do
1299
- _bb_raw="$(bb pr list --state "$_s" --json)"
2353
+ _bb_raw="$(pr_list_call bb ${repo_args[@]+"${repo_args[@]}"} pr list --state "$_s" --json)" || exit $?
1300
2354
  pr_list_report_truncation bitbucket "$limit" "$_s" \
1301
2355
  "$(jq 'length' <<<"$_bb_raw" 2>/dev/null || echo 0)"
1302
2356
  printf '%s' "$_bb_raw" \
@@ -1325,7 +2379,7 @@ case "$op" in
1325
2379
  else
1326
2380
  # Bitbucket without Jenkins: checks remain unknown
1327
2381
  for _s in $bb_states; do
1328
- _bb_raw="$(bb pr list --state "$_s" --json)"
2382
+ _bb_raw="$(pr_list_call bb ${repo_args[@]+"${repo_args[@]}"} pr list --state "$_s" --json)" || exit $?
1329
2383
  pr_list_report_truncation bitbucket "$limit" "$_s" \
1330
2384
  "$(jq 'length' <<<"$_bb_raw" 2>/dev/null || echo 0)"
1331
2385
  printf '%s' "$_bb_raw" \
@@ -1334,7 +2388,7 @@ case "$op" in
1334
2388
  fi
1335
2389
  else
1336
2390
  for _s in $bb_states; do
1337
- _bb_raw="$(bb pr list --state "$_s" --json)"
2391
+ _bb_raw="$(pr_list_call bb ${repo_args[@]+"${repo_args[@]}"} pr list --state "$_s" --json)" || exit $?
1338
2392
  pr_list_report_truncation bitbucket "$limit" "$_s" \
1339
2393
  "$(jq 'length' <<<"$_bb_raw" 2>/dev/null || echo 0)"
1340
2394
  printf '%s' "$_bb_raw" \
@@ -1383,6 +2437,84 @@ case "$op" in
1383
2437
  fi
1384
2438
  ;;
1385
2439
 
2440
+ run-for-sha)
2441
+ # The newest run for ONE sha — the BuildMonitor's only host question.
2442
+ #
2443
+ # WHY THIS IS NOT `runs`. `runs` is branch-scoped and reports no sha at all,
2444
+ # so a caller cannot tell which commit an answer is about. `gh run list
2445
+ # --branch X` returns runs for every sha the branch ever had, and the newest
2446
+ # run is not necessarily for the newest commit — a branch pushed twice in
2447
+ # quick succession has the first sha's run finishing after the second sha's
2448
+ # started. Reading a conclusion off that run answers about the past.
2449
+ #
2450
+ # WHY THAT MATTERS MORE THAN IT SOUNDS. A green result for superseded code
2451
+ # is worse than no result: it invites a merge of the wrong thing. Measured
2452
+ # 2026-08-30, in the session that wrote the plan: two merge waiters reported
2453
+ # on superseded runs and had to be stopped and re-armed.
2454
+ #
2455
+ # FACTS, NEVER A VERDICT (Principle 3). It reports `status` and `conclusion`
2456
+ # as the host gives them and compares nothing. Whether `action_required`
2457
+ # means "blocked" or `null` means "still going" is the monitor's rule, not
2458
+ # this collector's — and keeping the two words separate is what lets the
2459
+ # monitor tell a build awaiting a human click from one merely running. A
2460
+ # collector that folded them into one word would make that distinction
2461
+ # unrecoverable downstream.
2462
+ #
2463
+ # ONE OBJECT OR NOTHING. Empty output means the host has no run for this sha
2464
+ # — which is a real, common answer (the run has not been created yet) and
2465
+ # deliberately NOT an error: a monitor polling a fresh push sees it on every
2466
+ # pass until CI wakes up.
2467
+ #
2468
+ # Bitbucket reports nothing rather than something invented, exactly as
2469
+ # `runs` does: `bb` has no run listing, and silence here reads as
2470
+ # unavailable, never as "this sha has no build".
2471
+ branch="${1:?run-for-sha needs a branch}"; shift
2472
+ sha="${1:?run-for-sha needs a sha}"; shift
2473
+ # Enough runs to find the sha among its neighbours. A branch accumulates
2474
+ # runs per push and per workflow, so the sha being asked about can sit
2475
+ # several entries down even when it is the current head.
2476
+ limit=20
2477
+ while [ $# -gt 0 ]; do
2478
+ case "$1" in
2479
+ --limit) limit="${2:?}"; shift 2 ;;
2480
+ *) die "run-for-sha: unknown arg $1" ;;
2481
+ esac
2482
+ done
2483
+ if [ "$be" = "github" ]; then
2484
+ # `headSha` is the field that makes this answerable at all; `runs` omits
2485
+ # it, which is why that op cannot be reused here.
2486
+ #
2487
+ # NEWEST FIRST, then the FIRST match is taken: `gh run list` returns runs
2488
+ # newest-first, and a sha can carry several (a rerun, or several
2489
+ # workflows). The newest is the live answer; older ones for the same sha
2490
+ # are superseded by the same argument that superseded runs for older shas.
2491
+ # THE SHA ASKED ABOUT IF THERE IS ONE, ELSE THE NEWEST RUN ON THE BRANCH —
2492
+ # and `sha` in the output says WHICH, because a caller that could not tell
2493
+ # the two apart would be back to the branch-scoped guessing this op exists
2494
+ # to end.
2495
+ #
2496
+ # WHY IT FALLS BACK AT ALL, rather than reporting nothing. Filtering to
2497
+ # the asked-for sha and stopping makes the most important case invisible:
2498
+ # a run IN FLIGHT for a commit the branch has already moved past reports
2499
+ # identically to no run at all, so a caller cannot distinguish *CI has not
2500
+ # started yet* from *CI is busy answering about the past*. The second is
2501
+ # the state that had two merge waiters reporting on superseded runs on
2502
+ # 2026-08-30, and it is exactly what a caller needs to see.
2503
+ #
2504
+ # IT STILL DECIDES NOTHING (Principle 3). It reports the run it found and
2505
+ # the sha that run is for; whether that sha being different from the one
2506
+ # asked about means "superseded" is the caller's rule. This collects.
2507
+ gh run list --branch "$branch" --limit "$limit" \
2508
+ --json headSha,conclusion,status,startedAt,url 2>/dev/null \
2509
+ | jq -c --arg sha "$sha" \
2510
+ '(map(select(.headSha == $sha)) | .[0]) // .[0]
2511
+ | select(. != null)
2512
+ | {sha:.headSha, status:.status,
2513
+ conclusion:(if (.conclusion // "") == "" then null else .conclusion end),
2514
+ url:.url, startedAt:.startedAt}' 2>/dev/null || true
2515
+ fi
2516
+ ;;
2517
+
1386
2518
  issue-list)
1387
2519
  # Open tracker issues — the board's inbox, and READ-ONLY in both
1388
2520
  # directions. Nothing here writes a label, an assignee or a close: the
@@ -1612,6 +2744,55 @@ case "$op" in
1612
2744
  fi
1613
2745
  ;;
1614
2746
 
2747
+ issue-status)
2748
+ # THE ONE WRITE TO A TRACKER, and the amendment this op records: Plot writes
2749
+ # a STATUS to the tracker it was told about, and writes nothing else. No
2750
+ # ticket is created, none is closed, no comment, label or assignee is
2751
+ # touched. A plan referencing an issue stays Plot's record; the status is
2752
+ # the one fact the tracker owns a copy of, because it is the one a person
2753
+ # reads in the tracker rather than in Plot.
2754
+ #
2755
+ # JIRA ONLY, and deliberately so rather than by omission. This vendor's
2756
+ # projects surface has a script of its own (`plot-update-board.sh`) reached
2757
+ # by its own connector, and the two write through different APIs under
2758
+ # different credentials — which is why the tracker port has two connectors
2759
+ # rather than one arm with a branch. A repo whose `Tracker` is not jira gets
2760
+ # exit 4 here: this adapter cannot be asked, which is not a failure and not
2761
+ # a silent success.
2762
+ #
2763
+ # IDEMPOTENT BY NATURE. The same status written twice is the same status,
2764
+ # and Jira answers a transition to the state an issue already holds by
2765
+ # naming no such transition — which this reports as `no-target` rather than
2766
+ # as a failure.
2767
+ key="${1:?issue-status needs an issue key}"; shift
2768
+ want="${1:?issue-status needs a status}"; shift
2769
+ [ "$(tracker_scheme)" = "jira" ] || exit 4
2770
+ jira_require_config
2771
+ # THE TRANSITION IS LOOKED UP, NEVER GUESSED. Jira transitions are per
2772
+ # workflow and per issue: the id for "In Progress" differs between projects
2773
+ # and the transition may not be available from the issue's current state at
2774
+ # all. A hardcoded id writes a status to the wrong column silently.
2775
+ raw="$(jira_curl "/rest/api/2/issue/$key/transitions")"; curl_rc=$?
2776
+ body_json="$(jira_check "$raw" "$curl_rc")" || exit $?
2777
+ tid="$(printf '%s' "$body_json" | jq -r --arg want "$want" \
2778
+ '[.transitions[]? | select((.name // "" | ascii_downcase) == ($want | ascii_downcase)
2779
+ or ((.to.name // "" | ascii_downcase) == ($want | ascii_downcase)))]
2780
+ | .[0].id // ""')"
2781
+ if [ -z "$tid" ]; then
2782
+ # NO SUCH TRANSITION IS AN ANSWER, not a failure. The tracker was reached
2783
+ # and holds nowhere to put this status — either the workflow has no such
2784
+ # state or the issue is already in it. Reporting it as a failure would
2785
+ # make a repeated write look like an outage.
2786
+ printf '%s\n' 'no-target'
2787
+ exit 0
2788
+ fi
2789
+ raw="$(jira_curl "/rest/api/2/issue/$key/transitions" \
2790
+ -X POST -H 'Content-Type: application/json' \
2791
+ --data "$(jq -cn --arg id "$tid" '{transition:{id:$id}}')")"; curl_rc=$?
2792
+ jira_check "$raw" "$curl_rc" >/dev/null || exit $?
2793
+ printf '%s\n' 'written'
2794
+ ;;
2795
+
1615
2796
  pr-body)
1616
2797
  num="${1:?pr-body needs a PR number}"; shift
1617
2798
  body=""
@@ -1680,7 +2861,203 @@ case "$op" in
1680
2861
  fi
1681
2862
  ;;
1682
2863
 
2864
+ limit)
2865
+ # WHAT IS THIS CONNECTOR'S LIMIT, AND HOW WELL DOES IT KNOW IT?
2866
+ #
2867
+ # One JSON line per bucket the connector meters:
2868
+ # {"connector":"github","bucket":"graphql","limit":5000,
2869
+ # "remaining":1236,"reset":1788269670,"basis":"actual"}
2870
+ #
2871
+ # `basis` is `actual` where the connector reported the numbers, `predicted`
2872
+ # where this adapter supplied them from experience, and `unknown` where it
2873
+ # reports nothing and there is nothing to predict. `limit`, `remaining` and
2874
+ # `reset` are numbers or null; `reset` is epoch SECONDS.
2875
+ #
2876
+ # NOT `gh api rate_limit`, AND THAT IS THE WHOLE POINT OF THIS OP. Measured
2877
+ # 2026-09-01 in a quiet moment, same account, seconds apart:
2878
+ #
2879
+ # gh api rate_limit graphql: 5000/5000, used 0
2880
+ # a real call's header X-Ratelimit-Remaining: 1236, Used: 3764
2881
+ #
2882
+ # 3764 calls spent, reported as zero. The endpoint is wrong when nothing is
2883
+ # wrong, so `graphql_budget_spent()` above — which reads it and tests
2884
+ # `-eq 0` — has never been able to fire. The authority is the header on a
2885
+ # response that actually came back.
2886
+ #
2887
+ # THIS CALL SPENDS ONE REQUEST, AND SAYS SO. It asks the cheapest real
2888
+ # question there is — `{viewer{login}}` — and reports what its response
2889
+ # carried. One request against the bucket it reports is an honest cost; a
2890
+ # free reading of the wrong number is not.
2891
+ #
2892
+ # THE OTHER READINGS ARE FREE, and this op is the one that is not. Every
2893
+ # `gh api` call the adapter makes harvests its own headers through
2894
+ # `gh_api_harvest`, on a request that was going to happen anyway, and files
2895
+ # the reading in the budget record. So a caller that merely wants to know
2896
+ # whether a pool is spent reads the record and spends nothing; this op is
2897
+ # for a caller that wants a reading NOW, on a connector that may not have
2898
+ # been called yet.
2899
+ #
2900
+ # `gh pr list` CANNOT BE HARVESTED. It is `gh`'s own GraphQL wrapper: no
2901
+ # flag adds the response headers, and `--verbose` writes the whole exchange
2902
+ # to stdout, which would corrupt every caller's parse. So a GraphQL call
2903
+ # names its bucket from the argv and records no numbers — `unknown`, which
2904
+ # is never read as free.
2905
+ #
2906
+ # ONE BUCKET, NOT TWO. A response reports the bucket IT spent, in
2907
+ # `X-RateLimit-Resource`. Reporting `core` from a GraphQL response would be
2908
+ # inventing a reading nobody took — the mistake `rate_limit` makes by
2909
+ # answering for both at once.
2910
+ if [ "$be" = "github" ]; then
2911
+ _hdr_tmp="/tmp/plot-host-limit.$$"
2912
+ # `command gh`, NOT THE WRAPPER, and for the ordering reason
2913
+ # `gh_api_harvest` gives: the wrapper records the moment the call returns,
2914
+ # before any header has been read, so a wrapped call here would file an
2915
+ # `unknown` line AND this arm would file the real one — two lines for one
2916
+ # request, which over-counts the very spend the record exists to measure.
2917
+ if command gh api graphql -f query='{viewer{login}}' --include >"$_hdr_tmp" 2>/dev/null; then
2918
+ # ONE HEADER READER, NOT TWO. `plot_harvest_headers` is what every
2919
+ # harvested call already reads its bucket and numbers with, and a second
2920
+ # implementation here is the drift `plot-pr-merged.sh` argues against —
2921
+ # the copy that goes permissive is the one that fails unrepairably. It
2922
+ # matches header names case-insensitively because `gh` prints
2923
+ # `X-Ratelimit-Limit` while GitHub documents `X-RateLimit-Limit`.
2924
+ plot_harvest_headers "$_hdr_tmp"
2925
+ rm -f "$_hdr_tmp"
2926
+ # A number or null — never a quoted "unknown", and never 0 standing in
2927
+ # for absent. `jq -n` with `--argjson` refuses a non-number, so each
2928
+ # value is tested first and passed as the literal `null` otherwise.
2929
+ _num() { [[ "$1" =~ ^[0-9]+$ ]] && echo "$1" || echo null; }
2930
+ if [ -n "${PLOT_BUDGET_HARVEST:-}" ]; then
2931
+ IFS=$'\t' read -r _res _lim _rem _rst <<<"$PLOT_BUDGET_HARVEST"
2932
+ # THE READING GOES INTO THE RECORD TOO. This call spent a request and
2933
+ # got the connector's own numbers back, so leaving them unrecorded
2934
+ # would throw away the one `actual` reading in the run — and
2935
+ # `graphql_budget_spent` reads the record.
2936
+ budget_record_call github github "${_res:-graphql}"
2937
+ jq -cn \
2938
+ --arg bucket "${_res:-graphql}" \
2939
+ --argjson limit "$(_num "$_lim")" \
2940
+ --argjson remaining "$(_num "$_rem")" \
2941
+ --argjson reset "$(_num "$_rst")" \
2942
+ '{connector:"github",bucket:$bucket,limit:$limit,remaining:$remaining,reset:$reset,basis:"actual"}'
2943
+ else
2944
+ # The call answered and carried no limit header. GitHub always sends
2945
+ # them, so this is a proxy or an enterprise instance that strips them:
2946
+ # unknown, and never free.
2947
+ echo '{"connector":"github","bucket":"","limit":null,"remaining":null,"reset":null,"basis":"unknown"}'
2948
+ fi
2949
+ else
2950
+ rm -f "$_hdr_tmp"
2951
+ # A FAILED CALL IS STILL A SPENT CALL on every refusal GitHub meters, so
2952
+ # it is recorded like any other — against `graphql`, which is what the
2953
+ # query above spends, with no numbers.
2954
+ budget_record_call github github graphql
2955
+ # The host could not be asked. Exit 3 rather than printing `unknown`:
2956
+ # *could not ask* and *asked, and it reports no limit* are different
2957
+ # facts, and the port keeps them apart as `failed` versus an answered
2958
+ # `unknown` reading.
2959
+ die3 "limit: could not read the host's rate-limit headers"
2960
+ fi
2961
+ elif [ "$be" = "bitbucket" ]; then
2962
+ # Bitbucket meters, and sends no `X-RateLimit-*`. So the number is this
2963
+ # adapter's, from experience — 1000 requests/hour for an authenticated
2964
+ # account — and it is tagged `predicted` because that is what it is.
2965
+ #
2966
+ # A PREDICTION IS NOT A LIE AND NOT A FAILURE. It is answered: the adapter
2967
+ # is telling the truth about what it knows. A caller reads the basis and
2968
+ # decides how much to trust it; a `throttled` observed during the session
2969
+ # is what corrects it.
2970
+ echo '{"connector":"bitbucket","bucket":"api","limit":1000,"remaining":null,"reset":null,"basis":"predicted"}'
2971
+ else
2972
+ echo "{\"connector\":\"$be\",\"bucket\":\"\",\"limit\":null,\"remaining\":null,\"reset\":null,\"basis\":\"unknown\"}"
2973
+ fi
2974
+ ;;
2975
+
2976
+ ci-limit)
2977
+ # The CI connector's limit, which is a THIRD axis and does not follow the
2978
+ # git host. This repo runs GitHub Actions on a GitHub remote; `ekzweb` runs
2979
+ # Jenkins against Bitbucket. `ci_backend()` already resolves it separately.
2980
+ #
2981
+ # JENKINS IS THE `predicted` CASE THE DESIGN NAMES. A Jenkins instance
2982
+ # reports no rate limit — there is no header and no endpoint to ask — so the
2983
+ # ceiling is this adapter's estimate of what a shared controller tolerates,
2984
+ # tagged for what it is. It is NOT unlimited: a Jenkins that is hammered
2985
+ # refuses, and the refusal is what corrects the estimate.
2986
+ _ci="$(ci_backend)"
2987
+ case "$_ci" in
2988
+ jenkins)
2989
+ echo '{"connector":"jenkins","bucket":"","limit":60,"remaining":null,"reset":null,"basis":"predicted"}'
2990
+ ;;
2991
+ '' | none)
2992
+ # No CI connector configured, so there is nothing to meter. An empty
2993
+ # answer, not a limit of zero.
2994
+ ;;
2995
+ *)
2996
+ # `ci_backend()` validates nothing, and neither does this — the list is
2997
+ # open, and GitLab and Trello are named as next. A connector nobody has
2998
+ # written an estimate for answers `unknown`, which is the honest word.
2999
+ echo "{\"connector\":\"$_ci\",\"bucket\":\"\",\"limit\":null,\"remaining\":null,\"reset\":null,\"basis\":\"unknown\"}"
3000
+ ;;
3001
+ esac
3002
+ ;;
3003
+
3004
+ spend-rate)
3005
+ # WHAT HAS THIS COMPUTER SPENT, AND HOW FAST? Read back from the record
3006
+ # every spender appends to — eleven scripts, a board, and a person at a
3007
+ # terminal — so a caller can divide its cadence by what the account is
3008
+ # actually spending rather than by a headcount of boards it cannot take.
3009
+ #
3010
+ # ONE JSON OBJECT:
3011
+ # {"connector":"github","account":"jwloka","bucket":"api","spent":41,
3012
+ # "spanMs":214000,"perHour":689.72,"lines":41,"unreadable":0,
3013
+ # "limit":null,"remaining":null,"resetAt":null,"basis":"unknown"}
3014
+ #
3015
+ # `resetAt` IS WHEN THE BUCKET REFILLS, epoch milliseconds, and it is what a
3016
+ # caller reacting to a refusal waits for. The record has stored it since the
3017
+ # headers were first harvested; until this slice no reader could get it back
3018
+ # out, so the one component that needed it had to ask `gh api rate_limit` —
3019
+ # a call that is both metered and, measured 2026-09-01, wrong.
3020
+ #
3021
+ # OVER THE CONNECTOR'S WINDOW, NEVER THE WHOLE FILE. Measured 2026-09-01,
3022
+ # one board at 5 s and eleven scripts at 90 s append ~1,160 lines an hour:
3023
+ # a rate divided by an ever-growing span approaches zero, and a cadence
3024
+ # derived from it would relax forever — the opposite of what the record is
3025
+ # for. The window starts at the latest reset that has already PASSED, or an
3026
+ # hour back where no connector stated one.
3027
+ #
3028
+ # IT SPENDS NOTHING. This reads a file; no host is asked. That is the whole
3029
+ # reason the record exists rather than a `rate_limit` call per decision —
3030
+ # and `rate_limit` was measured 2026-09-01 reporting 5000 while the headers
3031
+ # read 0, so it would be both expensive and wrong.
3032
+ #
3033
+ # `perHour` IS null WHERE THERE IS NO SPAN TO DIVIDE BY — one line, or
3034
+ # several written inside one millisecond. An invented rate would be exactly
3035
+ # the dishonest cadence input this slice exists to remove.
3036
+ _sr_connector=""; _sr_account=""; _sr_bucket=""
3037
+ while [ $# -gt 0 ]; do
3038
+ case "$1" in
3039
+ --connector) _sr_connector="${2:?}"; shift 2 ;;
3040
+ --account) _sr_account="${2:?}"; shift 2 ;;
3041
+ --bucket) _sr_bucket="${2:?}"; shift 2 ;;
3042
+ *) die "spend-rate: unknown arg $1" ;;
3043
+ esac
3044
+ done
3045
+ # Defaults name THIS connector and THIS account, which is what a caller
3046
+ # asking "what am I spending?" means. An explicit triple is for a caller
3047
+ # asking about a connector it is not itself using.
3048
+ [ -n "$_sr_connector" ] || _sr_connector="$be"
3049
+ [ -n "$_sr_account" ] || _sr_account="$(budget_account "$be")"
3050
+ # AN OMITTED `--bucket` MEANS EVERY BUCKET, which is what *what am I
3051
+ # spending?* asks. One connector meters several pools independently, and the
3052
+ # cadence this feeds is about how fast the ACCOUNT is going — it spends both,
3053
+ # so summing them is the honest input and naming one would ignore the
3054
+ # traffic on the other. A caller deciding whether a POOL is spent names it.
3055
+ _sr_rate="$(budget_rate "$_sr_connector" "$_sr_account" "$_sr_bucket")"
3056
+ jq -c --arg connector "$_sr_connector" --arg account "$_sr_account" --arg bucket "$_sr_bucket" \
3057
+ '{connector:$connector,account:$account,bucket:$bucket} + .' <<<"$_sr_rate"
3058
+ ;;
3059
+
1683
3060
  *)
1684
- die "unknown op '$op' (backend|default-branch|pr-state|pr-create|pr-merge|pr-list|issue-list|issue-view|pr-body|rate-limit)"
3061
+ die "unknown op '$op' (backend|default-branch|pr-state|pr-create|pr-merge|pr-list|issue-list|issue-view|issue-status|pr-body|rate-limit|limit|ci-limit|spend-rate)"
1685
3062
  ;;
1686
3063
  esac