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