@plot-pm/board 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/plot-host.sh ADDED
@@ -0,0 +1,1686 @@
1
+ #!/usr/bin/env bash
2
+ # Plot helper: Git-host adapter — ONE interface over the host CLI, so every
3
+ # other helper and skill stays host-agnostic. GitHub (gh) and Bitbucket (bb)
4
+ # are supported; nothing outside this script may call gh or bb directly for
5
+ # the operations below.
6
+ #
7
+ # Usage: plot-host.sh <op> [args...]
8
+ #
9
+ # Ops (the ~6 operations plot actually needs):
10
+ # backend print the resolved backend: github|bitbucket
11
+ # default-branch print the repo's default branch name
12
+ # pr-state <number|branch> [--repo <owner/repo>] one JSON object:
13
+ # {"number":N,"state":"OPEN|MERGED|CLOSED|NONE",
14
+ # "draft":true|false,"url":"..."}
15
+ # NONE = no PR found (exit 0 — callers branch on
16
+ # state, not exit codes)
17
+ # pr-create --title T [--body B] [--base BR] [--head BR] [--draft]
18
+ # create a PR, print its URL
19
+ # pr-merge <number> [--squash] [--delete-branch]
20
+ # pr-ready <number> take a PR out of draft
21
+ # merge the PR
22
+ # pr-list [--state open|merged|closed|all] [--limit N] [--rich]
23
+ # JSON lines: {"number":N,"title":"...",
24
+ # "state":"...","head":"..."}
25
+ # --rich adds: draft, checks, mergeable, review,
26
+ # url, failing_checks — `failing_checks` names
27
+ # WHICH checks failed, the detail `checks`
28
+ # collapses to one word, from the same response
29
+ # at no extra call; [] on bitbucket and wherever
30
+ # nothing failed
31
+ # — `url` so a consumer never has to
32
+ # construct one (it is "" only if the host omits
33
+ # it); `mergeable` is mergeable|conflicting|
34
+ # unknown, and "unknown" is what a host that
35
+ # cannot answer reports (absent is not false)
36
+ # --limit raises the host CLI's default page of
37
+ # 30, which --state all exhausts immediately
38
+ # runs <branch> [--limit N] a branch's own recent CI runs, newest first,
39
+ # as JSON lines: {"workflow":"CI",
40
+ # "conclusion":"success|failure|…",
41
+ # "startedAt":"…","url":"…"}
42
+ # EVIDENCE, never a verdict — what proved the
43
+ # 2026-08-17 Playwright 403 transient was that
44
+ # the same branch was green two minutes earlier,
45
+ # and a real failure presents identically.
46
+ # Nothing here compares runs or concludes.
47
+ # METERED: ask only for a branch already known
48
+ # to be failing. Empty on bitbucket (bb has no
49
+ # run listing) — unavailable, never "never
50
+ # failed".
51
+ # issue-list [--limit N] open tracker issues as JSON lines:
52
+ # {"number":N,"title":"…","url":"…",
53
+ # "createdAt":"…"}
54
+ # READ-ONLY, and the only issue op here: Plot
55
+ # never writes to the tracker (no labels, no
56
+ # assignees, no close-on-merge), because a copy
57
+ # of tracker state ages into a lie.
58
+ # `url` is "" when the host omits it, and a
59
+ # consumer renders the number as plain text
60
+ # rather than inventing an address — the rule
61
+ # pr-list's `url` already follows. On bitbucket
62
+ # `url` and `createdAt` are BOTH "": `bb issue
63
+ # list` prints neither.
64
+ # BITBUCKET NOW ANSWERS by parsing `bb issue
65
+ # list` (no --json), pinned to bb 0.6.0. EXIT 4
66
+ # narrows rather than disappears: it is the
67
+ # tracker-DISABLED case (bb answers 404/410),
68
+ # which stays *this host cannot answer* where an
69
+ # empty list would say *there are none*. A call
70
+ # that failed on an enabled tracker, or any error
71
+ # wording this adapter does not recognise, exits
72
+ # 3 — guessing 4 would turn a broken call into a
73
+ # confident "no tickets". A failed lookup exits
74
+ # non-zero with an empty stdout, never a silent
75
+ # empty list — an outage is not an answer.
76
+ # JIRA ANSWERS when `Tracker: jira` is declared,
77
+ # through the REST API (no CLI), DISPATCHED ON
78
+ # `Tracker` and INDEPENDENT of `Git host` — a
79
+ # Bitbucket repo tracking in Jira is normal. The
80
+ # inbox is the caller's open unresolved tickets
81
+ # (JQL overridable via PLOT_JIRA_JQL). There is
82
+ # NO exit-4 for Jira: a configured Jira can be
83
+ # asked, so a 401/403/5xx or a network failure is
84
+ # the question FAILING (exit 3), never an empty
85
+ # inbox — an auth gap must not read *you have no
86
+ # tickets*, the failure this story is named for.
87
+ # issue-view <number> ONE open issue as a single JSON object:
88
+ # {"number":N,"title":"…","body":"…","url":"…"}
89
+ # STILL READ-ONLY — the second issue op, and it
90
+ # reads. The board's *Create plan* action needs
91
+ # the issue's BODY as the problem statement, and
92
+ # issue-list deliberately omits it: the list is
93
+ # asked on a timer for every open issue, and a
94
+ # body per issue per refresh is a cost the row
95
+ # does not need to decide *is this worth a plan?*
96
+ # So the body is fetched once, per click, for the
97
+ # one issue somebody chose — a call whose cadence
98
+ # is a human's.
99
+ # Same three outcomes as issue-list, same codes:
100
+ # BITBUCKET NOW ANSWERS via `bb issue view`
101
+ # (pinned to 0.6.0); `url` comes from the view's
102
+ # footer. EXIT 4 is the tracker-DISABLED case,
103
+ # EXIT 3 a lookup that failed or an unrecognised
104
+ # error. An issue that does not exist is a
105
+ # FAILURE here, not an empty body: the caller
106
+ # named a number it read off this same adapter,
107
+ # so its absence is a fact worth surfacing rather
108
+ # than a blank to plan on.
109
+ # JIRA ANSWERS via GET /rest/api/2/issue/<key>
110
+ # (v2 for a plain-string body, not v3's ADF tree)
111
+ # when `Tracker: jira` — same `Tracker` dispatch
112
+ # as issue-list. Jira answers 404 for a missing
113
+ # key, which is exit 3 here (the tracker moved),
114
+ # never an empty body.
115
+ # pr-body <number> --body B replace the PR description
116
+ #
117
+ # Backend resolution: $PLOT_HOST (github|bitbucket) wins — useful for tests —
118
+ # else the `Git host` key from `## Plot Config` (via plot-config.sh), default
119
+ # github. The bb CLI is any Bitbucket Cloud CLI exposing pr view/create/merge
120
+ # /list with --json (developed against https://bitbucket.org — adjust the
121
+ # mapping here if your bb differs; this file is the only place that knows).
122
+ #
123
+ # Documented degradations on bitbucket:
124
+ # - no auto-merge / merge queues — pr-merge merges immediately or fails
125
+ # - "closed" state is Bitbucket's DECLINED (normalized to CLOSED here)
126
+ # - pr-state by BRANCH resolves via pr-list filtering (one extra call)
127
+ # - draft flag support depends on the bb CLI version (--draft / --ready)
128
+ # Small-model consumption: structured output, no interpretation, exit 0 with
129
+ # state NONE rather than nonzero on lookup misses.
130
+
131
+ set -uo pipefail
132
+
133
+ here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
134
+
135
+ die() { echo "plot-host: $*" >&2; exit 1; }
136
+
137
+ # Exit 3 — reserved for "the op itself cannot proceed", distinct from `die`'s
138
+ # generic 1. A Jenkins overlay with no instance to ask is such a case: a config
139
+ # error a person must fix, not a transient the board should retry past.
140
+ die3() { echo "plot-host: $*" >&2; exit 3; }
141
+
142
+ # --- Jenkins CI integration ------------------------------------------------
143
+ # A repo may declare `CI: jenkins` independently of `Git host`. When it does,
144
+ # build status (`checks`) is resolved through `jen` — a multibranch job's
145
+ # branches in one call, joined locally to the PR list the host provides.
146
+ #
147
+ # The colour table (plan "i-can-see-whether-my-build-passed", measured spike):
148
+ # blue → passing (last build succeeded)
149
+ # red → failing (last build FAILED)
150
+ # yellow → failing (UNSTABLE: ran, tests failed, no error)
151
+ # *_anime → pending (a build is RUNNING)
152
+ # disabled → none (no build to report)
153
+ # absent → none (branch exists, job not yet built)
154
+ #
155
+ # `yellow` maps to `failing`, deliberately: a branch whose tests failed must
156
+ # not read green on a board used to decide readiness. It reports `failing`
157
+ # and names the job in `failing_checks`, so the difference is visible.
158
+ #
159
+ # Branch names arrive URL-encoded (measured: 27 of 45 names in a production
160
+ # job). Decoded before the join; Done-when 6 is the test.
161
+ #
162
+ # `jen` exits 0 while printing "NOT reachable" — Done-when 4 checks the output
163
+ # rather than $?. When Jenkins is unreachable, `checks:"unknown"` is returned
164
+ # per row rather than blanking the entire list, and exit 3 is reserved for
165
+ # when the operation itself cannot proceed.
166
+
167
+ # Resolve CI backend: $PLOT_CI (for tests), then `CI` key, default "".
168
+ # Non-jenkins values (github-actions, none, "") mean "no separate CI fetch".
169
+ # The CI system this repo runs its builds on: `jenkins`, `github-actions`, or
170
+ # `none`. Independent of the Git host — a Bitbucket repo can build on Jenkins,
171
+ # and this key is what pairs the two. `PLOT_CI` overrides for tests.
172
+ ci_backend() {
173
+ if [ -n "${PLOT_CI:-}" ]; then
174
+ printf '%s\n' "$PLOT_CI" | tr '[:upper:]' '[:lower:]'
175
+ return
176
+ fi
177
+ bash "$here/plot-config.sh" get "CI" "" | tr '[:upper:]' '[:lower:]'
178
+ }
179
+
180
+ # Fetch Jenkins build statuses for every branch in a multibranch job, in ONE
181
+ # call, and return them as a JSON object keyed by DECODED branch name.
182
+ #
183
+ # $1 = Jenkins instance value from config: `<slug>` or `<slug>/<job/path>`.
184
+ # The slug is what `jen -I` takes; the remainder (after the first `/`) is
185
+ # the multibranch job's container path. The plan said the job path "derives
186
+ # from the branch name", but a multibranch container (`webbloqs/…`) is the
187
+ # PARENT of the branch and cannot — so the container travels here, on the
188
+ # instance value, WITHOUT a new config key (the brief forbids one). A job
189
+ # path in `PLOT_JENKINS_JOB` overrides, for a caller that has it separately.
190
+ #
191
+ # Output on stdout: a SINGLE object `{"status":"ok|failed|unknown","map":{…}}`.
192
+ # Status is returned IN the payload rather than in a variable because the caller
193
+ # reads this through `$(…)`, a subshell whose variable assignments never reach
194
+ # the parent — the earlier draft set a global here and it was always empty.
195
+ #
196
+ # ok — Jenkins answered; `map` holds branch→{color,checks,job}
197
+ # failed — Jenkins is unreachable (`jen auth status` says so, while EXITING
198
+ # 0 — Done-when 4: the wording decides, never `$?`), or the listing
199
+ # was empty/garbled. `map` is {}; the caller renders rows `unknown`.
200
+ # unknown — the auth wording was unrecognised; degrade to failure-shaped
201
+ # (cannot verify), never to ok. `map` is {}.
202
+ jenkins_build_map() {
203
+ local instance="$1"
204
+ local slug job
205
+ slug="${instance%%/*}"
206
+ if [ -n "${PLOT_JENKINS_JOB:-}" ]; then
207
+ job="$PLOT_JENKINS_JOB"
208
+ elif [ "$instance" = "$slug" ]; then
209
+ # A bare-host instance carries no job path; list at the root scope. A repo
210
+ # whose multibranch job sits at the root joins; otherwise no branch matches
211
+ # and every row reads `none` — honest, and the open point's fallback.
212
+ job=""
213
+ else
214
+ job="${instance#*/}"
215
+ fi
216
+
217
+ # Auth FIRST — `jen` exits 0 even when Jenkins is unreachable, so the exit
218
+ # code is worthless here and the `Jenkins auth:` line is the only witness.
219
+ # `NOT reachable` must be tested BEFORE `reachable` (it contains it).
220
+ # The `Jenkins auth:` line carries the verdict. Its FAILURE wording is stable
221
+ # (`NOT reachable`, measured 2026-08-18) and tested first because it contains
222
+ # the success word. Its SUCCESS wording is `OK` on jen 0.2.0 (measured live
223
+ # 2026-08-26: `Jenkins auth: OK — user@host`) — older notes said `reachable`,
224
+ # so both are accepted. Anything else on that line is UNRECOGNISED → degrade to
225
+ # failure-shaped (cannot verify), never to ok — the probe's `classify` rule.
226
+ local auth_out=""
227
+ auth_out=$(jen -I "$slug" auth status 2>&1) || true
228
+ if printf '%s' "$auth_out" | grep -qiE 'jenkins auth:[[:space:]]*not reachable'; then
229
+ printf '{"status":"failed","map":{}}\n'; return 0
230
+ fi
231
+ if ! printf '%s' "$auth_out" | grep -qiE 'jenkins auth:[[:space:]]*(ok|reachable)'; then
232
+ printf '{"status":"unknown","map":{}}\n'; return 0
233
+ fi
234
+
235
+ # One call, every branch — the spike's whole point (Done-when 5).
236
+ local out=""
237
+ out=$(jen -I "$slug" job list ${job:+"$job"} --json 2>&1) || true
238
+ if [ -z "$out" ] || ! printf '%s' "$out" | jq -e 'type=="array"' >/dev/null 2>&1; then
239
+ printf '{"status":"failed","map":{}}\n'; return 0
240
+ fi
241
+
242
+ # Transform to `branch → {color, checks, job}`, decoding percent-encoded names
243
+ # (Done-when 6: `feature%2Ffoo` → `feature/foo`, else every slashed branch
244
+ # misses AS `none`). The plan's colour table, mapped to the FOUR `checks` words
245
+ # the adapter already reports and the board already renders:
246
+ # blue → green (success; the board's success word — its
247
+ # `checkWord()` maps anything but green|
248
+ # pending|failing|none to `unknown`, so the
249
+ # plan's prose word "passing" would render
250
+ # as *cannot read the checks*. The state,
251
+ # not the word, is what the plan settles.)
252
+ # red | yellow → failing (yellow is UNSTABLE — tests failed; NOT green)
253
+ # *_anime → pending (a build is running)
254
+ # disabled | absent → none (no build to report — absent is not failed)
255
+ printf '%s' "$out" | jq -c --arg job "$job" '
256
+ def urldecode:
257
+ gsub("%(?<h>[0-9A-Fa-f]{2})";
258
+ "\(.h | explode | reduce .[] as $c (0; . * 16 + (if $c >= 97 then $c - 87 elif $c >= 65 then $c - 55 else $c - 48 end)) | [.] | implode)");
259
+ def color_to_checks:
260
+ if . == null or . == "" then "none"
261
+ elif endswith("_anime") then "pending"
262
+ elif . == "blue" then "green"
263
+ elif . == "red" or . == "yellow" then "failing"
264
+ else "none"
265
+ end;
266
+ # `job` NAMES the specific failing build for `failing_checks` — the whole
267
+ # point of naming it is so a reader knows which Jenkins job to open. That is
268
+ # the branch job, qualified by its container: `<container>/<branch>` (or the
269
+ # branch alone when the container is the root). NOT the container path, which
270
+ # every branch would share and none of which is the one that failed.
271
+ { status: "ok",
272
+ map: ([.[]
273
+ | (.name | urldecode) as $branch
274
+ | { key: $branch,
275
+ value: { color: .color,
276
+ checks: (.color | color_to_checks),
277
+ job: (if $job == "" then $branch else "\($job)/\($branch)" end) } }]
278
+ | from_entries) }
279
+ '
280
+ }
281
+
282
+ # A LOOKUP MISS AND A TRANSPORT FAILURE ARE TWO ANSWERS, AND THE CLI GIVES ONE
283
+ # EXIT CODE FOR BOTH.
284
+ #
285
+ # Measured 2026-08-17, against a real `gh`:
286
+ #
287
+ # gh pr view no-such-branch → exit 1, stderr "no pull requests found for ..."
288
+ # gh pr view 1 (host unreachable) → exit 1, stderr "none of the git remotes ..."
289
+ #
290
+ # So the exit code cannot decide it and stderr is the only place the difference
291
+ # survives. Before this, both fell into one `|| echo '{"state":"NONE"}'` — and
292
+ # the board, reading NONE with exit 0, could not tell "this branch has no PR"
293
+ # from "GitHub answered 503". On 2026-08-17 GitHub returned 503 all afternoon
294
+ # and every branch read as having no PR, which is the reassuring direction to be
295
+ # wrong in and therefore the worst one.
296
+ #
297
+ # The RULE, and the direction it fails in: a miss is recognised by its message,
298
+ # and everything else is a transport failure. An unrecognised miss-phrasing
299
+ # therefore reports "cannot ask" — noisy but honest — rather than "no PR",
300
+ # which would be silent and false. A blocklist here would go stale into silence
301
+ # the first time the CLI rewords itself.
302
+ #
303
+ # `LC_ALL=C` on the match: the CLI localises its messages, and a matcher that
304
+ # only works in English would silently reclassify every miss as an outage for
305
+ # anyone else.
306
+ is_lookup_miss() {
307
+ LC_ALL=C grep -qiE 'no (pull request|pullrequest)s? (found|match)|could not find.*pull request|not found' <<<"$1"
308
+ }
309
+
310
+ # Emits the miss payload on a genuine miss and exits non-zero on anything else,
311
+ # after putting the CLI's own words on stderr. Callers get: stdout parseable or
312
+ # empty, exit code decisive.
313
+ # A FAILURE WITH NO DIAGNOSTIC AT ALL IS TREATED AS A MISS, and that is a
314
+ # deliberate exception to the allowlist above rather than an oversight.
315
+ #
316
+ # Three things can arrive here, not two:
317
+ #
318
+ # a recognised miss phrasing → miss (the CLI said so)
319
+ # an unrecognised message → failure (something happened; report it)
320
+ # NO message whatsoever → miss (this line)
321
+ #
322
+ # A transport failure is loud by nature — a socket error, an HTTP status, an
323
+ # auth message. Silence is what a lookup miss has always looked like through a
324
+ # CLI that does not explain itself, and `test/reconcile/host.test.mjs` has
325
+ # pinned that expectation since before this change. Reading empty stderr as an
326
+ # outage would give every caller of a quiet or wrapped CLI a permanent
327
+ # "cannot ask" for branches that simply have no PR.
328
+ #
329
+ # The cost is stated rather than hidden: a transport failure that manages to say
330
+ # nothing at all is still reported as NONE. That is the one case this change
331
+ # does not fix, and it is narrower than the one it does.
332
+ host_miss_or_fail() {
333
+ local err="$1" payload="$2"
334
+ if [ -z "$err" ] || is_lookup_miss "$err"; then
335
+ echo "$payload"
336
+ return 0
337
+ fi
338
+ echo "plot-host: $err" >&2
339
+ return 3
340
+ }
341
+
342
+ # WHY A SECOND PATH EXISTS AT ALL: GitHub meters GraphQL and REST separately,
343
+ # so a spent GraphQL bucket leaves a full REST one. `gh pr view` spends GraphQL;
344
+ # `gh api` spends REST. When the first budget is gone the second is sitting
345
+ # there unused, and the same question can still be answered — degraded and more
346
+ # expensive, but answered.
347
+ #
348
+ # BE HONEST ABOUT WHAT THIS BUYS. It is a second path when one bucket is
349
+ # GENUINELY spent, which is a real state a long-running board reaches. It is NOT
350
+ # immunity from throttling. The outage this repo actually had on 2026-08-27 was
351
+ # GitHub's SECONDARY limit — concurrent-request throttling, eight workers
352
+ # against a cap of seven — and during it both budgets read:
353
+ #
354
+ # graphql: 5000/5000 used=0 reset_in=3599s
355
+ # core: 5000/5000 used=0 reset_in=3599s
356
+ #
357
+ # Both full, nothing spent, every call refused. `rate_limit` does not report the
358
+ # secondary limit and cannot, so this gate would have read 5000 available at the
359
+ # exact moment nothing worked. Backing off on the 403 itself is a separate
360
+ # change and is not this one.
361
+ #
362
+ # THE CHEAP PATH STAYS THE DEFAULT, and the reason is measured. For 93 branches:
363
+ # ONE GraphQL call (`pr-list` with the check rollup) versus ~186 REST calls,
364
+ # because REST's list endpoint returns `mergeable_state: null` and no rollup, so
365
+ # full data costs two calls per PR. "Use REST whenever possible" trades one cheap
366
+ # call for a hundred and eighty. This returns true only when GraphQL is
367
+ # ACTUALLY spent.
368
+ #
369
+ # UNKNOWN IS NOT ZERO. #485 pinned that rule where it was informational; here is
370
+ # where it bites. A host that cannot be asked reports `unknown`, and reading
371
+ # that as "exhausted" would send every branch down the expensive path forever,
372
+ # for as long as the budget query kept failing. So anything that is not a
373
+ # number that is not greater than zero leaves the default alone.
374
+ graphql_budget_spent() {
375
+ local rate remaining
376
+ # The budget query is FREE — `gh api rate_limit` consumes neither bucket
377
+ # (measured 2026-08-27: three consecutive readings, all used=0) — so asking
378
+ # before each lookup costs nothing against either limit.
379
+ rate="$(gh api rate_limit 2>/dev/null)" || return 1
380
+ remaining="$(jq -r '.resources.graphql.remaining // "unknown"' <<<"$rate" 2>/dev/null)" || return 1
381
+ # `== 0` and not `<= 0`: only a number can be spent. `unknown`, an empty
382
+ # string and a malformed payload all fall through to false, which is the
383
+ # cheap path — the honest direction to be wrong in.
384
+ [[ "$remaining" =~ ^[0-9]+$ ]] && [ "$remaining" -eq 0 ]
385
+ }
386
+
387
+ # BOTH PATHS MUST PRODUCE ONE VOCABULARY, or the adapter's contract forks in two
388
+ # and every caller has to learn which route answered.
389
+ #
390
+ # This is stricter than it looks. `plot-fleet-scan.sh` reads the state with a
391
+ # regex over the JSON TEXT (`sed -n 's/.*"state":"\([A-Z]*\)".*/\1/p'`), so a
392
+ # lowercase REST `state` would read as NO ANSWER AT ALL rather than as a wrong
393
+ # one — a silent failure, not a loud one.
394
+ #
395
+ # REST'S `state` IS NOT GRAPHQL'S, and that is the trap this function exists
396
+ # for. A merged PR reports `state: "closed"` over REST, with the merge in a
397
+ # SEPARATE field; GraphQL says `MERGED` outright. An adapter that merely
398
+ # uppercases `.state` reports every merged PR as CLOSED — the same confusion
399
+ # `plot-reap.sh` was built around, and the reason it reads `mergedAt` rather
400
+ # than `state`.
401
+ #
402
+ # THE MERGE SIGNAL HAS TWO SPELLINGS, because the two endpoints disagree:
403
+ #
404
+ # /repos/{repo}/pulls/{number} → `merged` (boolean)
405
+ # /repos/{repo}/pulls?head=owner:branch → `merged_at` (timestamp, or null)
406
+ #
407
+ # The list row carries no `merged` key at all — measured against this repo
408
+ # 2026-08-28, where PR #494's row has `has("merged") == false`. Reading only
409
+ # `.merged` there reports every merged branch as CLOSED, so both are consulted
410
+ # and either one suffices.
411
+ # REST NEEDS AN `owner/repo` IN THE PATH, where the GraphQL path needed nothing:
412
+ # `gh pr view 7` infers the repo from the git remote, but `gh api repos/.../pulls/7`
413
+ # has to be told. So the fallback needs one more fact than the path it replaces.
414
+ #
415
+ # `--repo` may already carry it (the caller passed `-R owner/repo`), in which
416
+ # case no call is needed at all. Otherwise it has to be resolved.
417
+ #
418
+ # TODO(decision): resolve the repo when `--repo` was not supplied.
419
+ gh_rest_repo() {
420
+ if [ ${#repo_args[@]} -gt 0 ]; then
421
+ echo "${repo_args[1]}"
422
+ return 0
423
+ fi
424
+ echo "TODO"
425
+ }
426
+
427
+ rest_pr_to_state() {
428
+ jq -c '
429
+ (if (.merged == true) or (.merged_at != null) then "MERGED"
430
+ else (.state // "" | ascii_upcase) end) as $state
431
+ | { number: .number,
432
+ state: $state,
433
+ draft: (.draft // false),
434
+ url: (.html_url // ""),
435
+ mergeCommit: (.merge_commit_sha // "") }
436
+ '
437
+ }
438
+
439
+ # Bitbucket's --state vocabulary is not GitHub's, and this adapter used to
440
+ # translate in one direction only: every response mapper turns DECLINED into
441
+ # CLOSED, while the request carried the caller's GitHub word unchanged. `bb`
442
+ # then rejects `--state all` and `--state closed` outright, so every
443
+ # history-wide query failed with an invalid --state error.
444
+ #
445
+ # `all` becomes SEPARATE CALLS, not repeated flags. `bb` 1.0.0 accepts
446
+ # `--state open --state merged` and silently keeps only the last — measured
447
+ # 2026-08-18: that pair returned 50 PRs, all MERGED, with the 3 open ones
448
+ # gone. No error, a plausible list. One call per state avoids depending on a
449
+ # `bb` fix, and the three states partition the set (74 PRs, 74 unique ids,
450
+ # 0 duplicates on the repo measured).
451
+ #
452
+ # `superseded` is deliberately NOT part of `all`: such a PR is replaced by a
453
+ # newer one for the same branch, and a board with one row per branch would
454
+ # show that branch twice. `gh`'s `all` has no equivalent, so nothing is lost.
455
+ # A caller wanting it asks for it by name.
456
+ bb_states_for() {
457
+ case "$1" in
458
+ all) printf 'open\nmerged\ndeclined\n' ;;
459
+ closed) printf 'declined\n' ;;
460
+ open|merged|declined|superseded) printf '%s\n' "$1" ;;
461
+ *) die "unknown --state '$1' for the bitbucket backend (open|merged|closed|declined|superseded|all)" ;;
462
+ esac
463
+ }
464
+
465
+ # --- Bitbucket issue support (bb issue list / view) -------------------------
466
+ #
467
+ # `bb` gained issue commands, and this adapter refused them for a year on the
468
+ # strength of a message that was true when written and stopped being true after
469
+ # an upstream release. The two ops now ANSWER for Bitbucket by parsing `bb`'s
470
+ # text — it has no `--json` for issues — and the parse is DECLARED against the
471
+ # format it targets so a column reshuffle upstream fails loudly rather than
472
+ # mis-reading a title.
473
+ #
474
+ # Everything below was MEASURED against the installed CLI on 2026-08-26:
475
+ #
476
+ # $ bb --version → bb version 0.6.0
477
+ # $ bb issue list (row) #%03d <STATE> <title> by <reporter>
478
+ # $ bb issue list (head) :: Showing N of M issues in ORG/SLUG
479
+ # $ bb issue view (line 1, bold) <title>
480
+ # $ bb issue list (error) An error occurred: <message> ← on STDOUT, ANSI
481
+ #
482
+ # The format is craftamap/bb's, read from its source at the 0.6.0 tag
483
+ # (cmd/commands/issue/{list,view}): the row is printed with literal spaces, not
484
+ # a tabwriter, and the ID is zero-padded to three digits.
485
+ BB_ISSUE_VERSION="0.6.0"
486
+
487
+ # Strip ANSI SGR escapes. `bb` colours everything — the ID green, the state
488
+ # badge on a coloured background, the reporter grey — and an unstripped line
489
+ # carries `\033[...m` runs that would land inside a parsed title. `bb` also
490
+ # writes its ERRORS coloured, on stdout, so the same stripper runs before the
491
+ # error text is matched: an ANSI-wrapped "An error occurred" must be recognised
492
+ # as an error, not parsed as an issue (the trap the GitHub arm's stdout-is-data
493
+ # shape walks straight into).
494
+ bb_strip_ansi() {
495
+ # ESC [ ... <final-byte>. LC_ALL=C so the byte class is bytes, not locale
496
+ # graphemes — the escape is 7-bit regardless of the title's encoding.
497
+ LC_ALL=C sed $'s/\033\\[[0-9;]*[A-Za-z]//g'
498
+ }
499
+
500
+ # `bb` prints failure as `... An error occurred: <message>` on STDOUT and exits
501
+ # 1 for EVERYTHING — a disabled tracker, a network outage, "Are you sure this is
502
+ # a bitbucket repo?". The exit code cannot split them, so this matches the
503
+ # WORDING, and the split falls one way only:
504
+ #
505
+ # recognised "tracker cannot be asked" wording → exit 4 (cannot be asked)
506
+ # ANY other error text → exit 3 (the call failed)
507
+ #
508
+ # Guessing 4 from an unrecognised message would turn a broken call into a
509
+ # confident "you have no tickets" — the exact failure this branch exists to fix
510
+ # — so an unrecognised error MUST be 3. Bitbucket answers 404/410 for a repo
511
+ # whose issue tracker is disabled (and for a repo that is not a Bitbucket repo
512
+ # at all); both are *this host cannot be asked about issues*, which is what 4
513
+ # means. Reads the already-stripped, already-lowercased error text.
514
+ bb_issue_exit_code() {
515
+ local err="$1"
516
+ if LC_ALL=C grep -qiE 'are you sure this is a bitbucket repo|\b40[34]\b|not found|no issue tracker|issue tracker.*(disabled|not enabled)|repository not found' <<<"$err"; then
517
+ echo 4
518
+ else
519
+ echo 3
520
+ fi
521
+ }
522
+
523
+ # Assert the format the parse below was written against. `bb` puts its version
524
+ # on stdout as `bb version X.Y.Z (sha)`; a version this parse was not tested on
525
+ # fails LOUDLY rather than silently mis-reading a column that may have moved.
526
+ # `PLOT_BB_SKIP_VERSION_CHECK` exists for the test harness, whose stub bb has no
527
+ # meaningful version — the parse is exercised against captured fixture text.
528
+ bb_assert_issue_version() {
529
+ [ -n "${PLOT_BB_SKIP_VERSION_CHECK:-}" ] && return 0
530
+ local v
531
+ v="$(bb --version 2>/dev/null | bb_strip_ansi | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)"
532
+ if [ "$v" != "$BB_ISSUE_VERSION" ]; then
533
+ echo "plot-host: bb issue parse targets $BB_ISSUE_VERSION but found '${v:-unknown}' — refusing to mis-read a format that may have moved" >&2
534
+ return 3
535
+ fi
536
+ return 0
537
+ }
538
+
539
+ # --- bb capability check (--json support) ------------------------------------
540
+ #
541
+ # TWO TOOLS SHARE THE NAME `bb`. craftamap/bb is a Go binary that does NOT
542
+ # support `--json` for PR commands. Quatico's `bb` (a shell wrapper) does.
543
+ # Their version numbers name different products: craftamap 0.6.0 is not
544
+ # "older than" Quatico 1.0.0 — they are unrelated.
545
+ #
546
+ # The adapter passes `--json` to `bb pr list`. Against craftamap that is
547
+ # `Error: unknown flag: --json`, swallowed by any 2>/dev/null, and jq exits 0
548
+ # on empty input — so every Bitbucket PR list reads as *no PRs*.
549
+ #
550
+ # Worse: craftamap 0.6.0 panics (SIGSEGV) under an HTTP 429. A segfaulting CLI
551
+ # is indistinguishable from a quiet one when stderr is discarded.
552
+ #
553
+ # The capability is per-FLAG, not per-version. On one machine, on one day:
554
+ # plugin cache (bb 1.0.0) : --json yes, checks no
555
+ # plugin marketplace (1.9.0): --json yes, checks yes
556
+ # craftamap fallback (0.6.0): --json NO
557
+ #
558
+ # So: CHECK THE CAPABILITY, ONCE PER RUN. `BB_CAP_*` are cached on first call.
559
+ # A bb without `--json` exits 3 with the reason naming WHICH bb answered.
560
+
561
+ # Cached capability state — empty until first call to bb_require_json.
562
+ BB_CAP_CHECKED=""
563
+ BB_CAP_HAS_JSON=""
564
+ BB_CAP_IDENTITY="" # "craftamap/0.6.0" or "quatico/1.2.3" or "unknown/<ver>"
565
+
566
+ # Identify which bb is on PATH. Returns a string like "quatico/1.9.0" or
567
+ # "craftamap/0.6.0" or "unknown/<version>" or "unknown/unknown".
568
+ #
569
+ # The identification is BEHAVIOURAL, not by reading a vendor field that may not
570
+ # exist: craftamap/bb prints `bb version X.Y.Z (sha)` and Quatico's prints
571
+ # `bb version X.Y.Z` (no sha) with a distinctive banner. The shape decides.
572
+ bb_identify() {
573
+ local ver_out ver_stripped
574
+ ver_out="$(bb --version 2>&1)" || {
575
+ # bb not found or exited non-zero — cannot proceed
576
+ echo "unknown/unavailable"
577
+ return
578
+ }
579
+ ver_stripped="$(bb_strip_ansi <<<"$ver_out")"
580
+
581
+ local version
582
+ version="$(grep -oE '[0-9]+\.[0-9]+\.[0-9]+' <<<"$ver_stripped" | head -1)"
583
+ [ -z "$version" ] && version="unknown"
584
+
585
+ # craftamap/bb includes a git sha in parentheses: `bb version 0.6.0 (abc1234)`
586
+ # Quatico's does not — it prints only `bb version X.Y.Z`.
587
+ if grep -qE '\([0-9a-f]+\)' <<<"$ver_stripped"; then
588
+ echo "craftamap/$version"
589
+ elif grep -qi 'quatico\|plugin' <<<"$ver_stripped"; then
590
+ echo "quatico/$version"
591
+ else
592
+ # Unknown provenance — report the version so a human can tell
593
+ echo "unknown/$version"
594
+ fi
595
+ }
596
+
597
+ # Test whether the bb on PATH supports `--json` for PR commands.
598
+ # Returns 0 if it does, non-zero otherwise.
599
+ #
600
+ # The test is BEHAVIOURAL: call `bb pr list --json --help` (or similar) and
601
+ # see if it rejects `--json`. We cannot rely on version numbers because two
602
+ # products share the name and their versions are unrelated.
603
+ #
604
+ # Note: `bb pr list --json --state open` would contact the network; we want a
605
+ # purely local check. craftamap's `bb pr list --help --json` exits 1 with
606
+ # "unknown flag: --json" on stderr (and sometimes stdout). Quatico's accepts it.
607
+ bb_test_json_support() {
608
+ # Ask for help with --json — a bb that does not understand it will complain.
609
+ # Capture both streams since bb writes errors to stdout.
610
+ local out rc
611
+ out="$(bb pr list --help --json 2>&1)"; rc=$?
612
+
613
+ # craftamap 0.6.0 rejects --json with `Error: unknown flag: --json`
614
+ if grep -qiE 'unknown flag.*--json|invalid.*--json|--json.*not' <<<"$out"; then
615
+ return 1
616
+ fi
617
+
618
+ # If help succeeded (even partially), assume --json is supported
619
+ if [ "$rc" = 0 ]; then
620
+ return 0
621
+ fi
622
+
623
+ # Non-zero exit with no clear rejection — inspect further
624
+ # A "not a bitbucket repo" error is about the repo, not the flag
625
+ 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
+ return 0
629
+ fi
630
+
631
+ # Unknown failure — treat as unsupported to be safe
632
+ return 1
633
+ }
634
+
635
+ # Require that bb supports --json. Called once before the first bb PR call.
636
+ # Exits 3 with a diagnostic if bb cannot do what the adapter needs.
637
+ # Caches the result so subsequent calls are free.
638
+ bb_require_json() {
639
+ # Skip check if already done
640
+ [ -n "$BB_CAP_CHECKED" ] && return 0
641
+ BB_CAP_CHECKED=1
642
+
643
+ # Allow tests to skip this (their stubs may not implement --help)
644
+ if [ -n "${PLOT_BB_SKIP_CAP_CHECK:-}" ]; then
645
+ BB_CAP_HAS_JSON=1
646
+ BB_CAP_IDENTITY="stub/test"
647
+ return 0
648
+ fi
649
+
650
+ BB_CAP_IDENTITY="$(bb_identify)"
651
+
652
+ if ! bb_test_json_support; then
653
+ BB_CAP_HAS_JSON=0
654
+ die3 "bb on PATH ($BB_CAP_IDENTITY) does not support --json for PR commands — install Quatico's bb or ensure it is first on PATH"
655
+ fi
656
+
657
+ BB_CAP_HAS_JSON=1
658
+ return 0
659
+ }
660
+
661
+ # --- Jira issue support (REST, no CLI) --------------------------------------
662
+ #
663
+ # `Tracker` is a `## Plot Config` key INDEPENDENT of `Git host`: a Bitbucket
664
+ # repo tracking in Jira is the normal enterprise case. So the two issue ops
665
+ # dispatch on `tracker()`, NOT `backend()` — the PR ops keep dispatching on
666
+ # `backend()`, and `jira` is never a value of it (it is not a git host).
667
+ #
668
+ # Absent (or `plot`/`github-issues`/an unrecognised scheme) means today's
669
+ # behaviour: the issue ops resolve through `backend()` exactly as before. This
670
+ # is opt-in; a GitHub repo that declares no `Tracker` is unaffected (Done-when 2).
671
+ #
672
+ # The value carries the base URL after the scheme (`jira https://acme.atlassian.net`),
673
+ # the same shape `plot-plan-meta.sh` reads the FIRST token of to gate the key
674
+ # form and the Jenkins arm reads the container path off `Jenkins instance` —
675
+ # so no new config key is needed. `PLOT_TRACKER` overrides for tests.
676
+ #
677
+ # NO CLI DEPENDENCY, deliberately (the plan settles this): `gh` and `bb` are
678
+ # already two binaries an adopter installs, and Jira is the tracker most likely
679
+ # behind corporate SSO — a third binary would make it the hardest path to adopt.
680
+ # The REST API is reached with `curl` and shaped with `jq`, as the other ops
681
+ # shell out and shape.
682
+ #
683
+ # READ-ONLY in both directions, like every issue op: only GET is ever issued,
684
+ # never a POST/PUT that would write a label, an assignee or a transition. A plan
685
+ # referencing an issue is Plot's record, not the tracker's.
686
+ #
687
+ # The v2 REST endpoints are used, not v3, for ONE reason: v3 returns `description`
688
+ # as an ADF document (a nested JSON tree), while v2 returns it as a plain string.
689
+ # The board wants the body as a problem statement for /plot-idea, and the
690
+ # Bitbucket arm already treats the body as a best-effort text lift — a string is
691
+ # the honest match, and walking an ADF tree in jq would be ceremony for no gain.
692
+ # `summary` is a plain string in both. See the PR for this judgement call.
693
+
694
+ # Resolve the tracker scheme and its base URL from config. Prints two lines:
695
+ # the lowercased scheme (`jira`, `github-issues`, `plot`, …) and the base URL
696
+ # (possibly empty). `PLOT_TRACKER` overrides — a test passes `jira https://…`.
697
+ tracker_raw() {
698
+ if [ -n "${PLOT_TRACKER:-}" ]; then
699
+ printf '%s\n' "$PLOT_TRACKER"
700
+ return
701
+ fi
702
+ bash "$here/plot-config.sh" get "Tracker" ""
703
+ }
704
+
705
+ tracker_scheme() {
706
+ tracker_raw | awk '{print tolower($1)}'
707
+ }
708
+
709
+ tracker_base_url() {
710
+ # The base URL is the SECOND token; a bare `jira` with no URL yields "".
711
+ # PLOT_JIRA_BASE_URL overrides, for a caller that has the URL separately.
712
+ if [ -n "${PLOT_JIRA_BASE_URL:-}" ]; then
713
+ printf '%s\n' "$PLOT_JIRA_BASE_URL"
714
+ return
715
+ fi
716
+ tracker_raw | awk '{print $2}' | sed 's:/*$::'
717
+ }
718
+
719
+ # The env var scheme for Jira auth. The plan left the EXACT names open, to be
720
+ # confirmed against a real instance; these follow Jira Cloud's documented Basic
721
+ # scheme (email + API token, base64'd into an Authorization header):
722
+ # JIRA_EMAIL the account email
723
+ # JIRA_API_TOKEN a Jira Cloud API token (id.atlassian.com/manage/api-tokens)
724
+ # A missing token is a CONFIG error the op cannot proceed past — exit 3, never
725
+ # an empty inbox. An empty inbox says *you have no tickets*, the exact failure
726
+ # this whole story is named for; an auth gap must never wear that mask.
727
+ #
728
+ # This guard is called in the MAIN shell, BEFORE the `$(jira_curl …)` capture —
729
+ # `die3` exits the whole script only from there, not from inside a command
730
+ # substitution where it would end only the subshell and leak a second error.
731
+ jira_require_config() {
732
+ if [ -z "$(tracker_base_url)" ]; then
733
+ die3 "Tracker is jira but no base URL is configured (write 'Tracker: jira https://your.atlassian.net' or set PLOT_JIRA_BASE_URL)"
734
+ fi
735
+ if [ -z "${JIRA_EMAIL:-}" ] || [ -z "${JIRA_API_TOKEN:-}" ]; then
736
+ die3 "Jira needs JIRA_EMAIL and JIRA_API_TOKEN in the environment — an unauthenticated Jira must not read as an empty inbox"
737
+ fi
738
+ }
739
+
740
+ jira_curl() {
741
+ # $1 = path (e.g. /rest/api/2/search/jql?...), remaining args appended to curl.
742
+ # Config is assumed present — jira_require_config ran in the caller's shell.
743
+ local path="$1"; shift
744
+ local base
745
+ base="$(tracker_base_url)"
746
+ # -sS: quiet progress, but keep errors. -w writes the HTTP status on its own
747
+ # line AFTER the body so the caller can split the two. --user does the Basic
748
+ # base64 for us; the token never appears in argv of any child process here.
749
+ curl -sS \
750
+ --user "${JIRA_EMAIL}:${JIRA_API_TOKEN}" \
751
+ -H 'Accept: application/json' \
752
+ -w '\n%{http_code}' \
753
+ "$base$path" "$@"
754
+ }
755
+
756
+ # Split a jira_curl response into (body, status) and enforce the three outcomes.
757
+ # Prints the JSON body on stdout on success; on failure prints nothing on stdout,
758
+ # the diagnostic on stderr, and returns 3. A transport failure (curl non-zero:
759
+ # DNS, TLS, connection refused) and an HTTP error (401/403/404/5xx) are BOTH the
760
+ # question failing — exit 3. There is no exit-4 case for Jira: a configured Jira
761
+ # CAN be asked; if it cannot be reached, that is a failure to answer, not a host
762
+ # that structurally has no tracker (the bitbucket-DISABLED case exit 4 is for).
763
+ jira_check() {
764
+ local raw="$1" curl_rc="$2"
765
+ local status body
766
+ if [ "$curl_rc" -ne 0 ]; then
767
+ echo "plot-host: jira request failed (curl exit $curl_rc) — a network failure is not an empty inbox" >&2
768
+ return 3
769
+ fi
770
+ # The status is the last line; the body is everything before it.
771
+ status="$(printf '%s' "$raw" | tail -n1)"
772
+ body="$(printf '%s' "$raw" | sed '$d')"
773
+ if [ "$status" -lt 200 ] || [ "$status" -ge 300 ]; then
774
+ # Name the status AND Jira's own errorMessages if it sent any (it returns
775
+ # {"errorMessages":[…],"errors":{…}} on 4xx). 401/403 are the auth failures
776
+ # this story exists to keep out of the inbox; 5xx is an outage. Both are 3.
777
+ local detail
778
+ detail="$(printf '%s' "$body" | jq -r 'try (.errorMessages | join("; ")) catch empty' 2>/dev/null)"
779
+ echo "plot-host: jira HTTP $status${detail:+ — $detail}" >&2
780
+ return 3
781
+ fi
782
+ printf '%s' "$body"
783
+ }
784
+
785
+ # --- pr-list truncation detection (#333) ------------------------------------
786
+ #
787
+ # `pr-list` returns a bulk page that two consumers (plot-fleet-scan.sh:474 and
788
+ # fleet.ts:1552) JOIN LOCALLY. If the host truncated that page, every branch
789
+ # beyond it joins to nothing and reads as "no PR" — the fabricated verdict the
790
+ # scan refuses everywhere else. Measured 2026-08-26 against a real Bitbucket:
791
+ # `bb` returned 50 merged PRs (ids 836→787) against a repo numbering to 836, so
792
+ # ~780 older merged PRs were invisible to the join.
793
+ #
794
+ # THE DETECTOR IS AGAINST THE REQUESTED LIMIT, NEVER THE CONSTANT 50. A future
795
+ # `bb` page size of 100 must not make a truncated 100-row list report complete —
796
+ # this plan's own defect restored. So the rule names no page size:
797
+ #
798
+ # github (HONOURS --limit) : a state is possibly truncated when it returned
799
+ # AT LEAST the requested limit — the host may have
800
+ # had more that the limit hid. Fewer rows than the
801
+ # limit PROVES completeness.
802
+ # bitbucket (IGNORES --limit): `bb pr list` has no --limit and cannot report a
803
+ # total or a cursor, so it can NEVER prove
804
+ # completeness for a --limit call. Any non-empty
805
+ # page is therefore possibly truncated. An empty
806
+ # page had nothing to truncate.
807
+ #
808
+ # No --limit was requested → the caller accepted the host's default page and is
809
+ # owed no report, so no existing no-limit caller's behaviour changes.
810
+ #
811
+ # THE REPORT GOES TO STDERR, not a stdout sentinel. Both consumers parse every
812
+ # stdout line as a PR record (fleet.ts casts each line to a PrRecord with no
813
+ # discriminator check), so a sentinel line would enter the join as a phantom
814
+ # {number:undefined} — a NEW silent corruption while fixing an old one, and the
815
+ # plan holds both callers untouched. stderr is the channel the plan asks the
816
+ # fallback to announce itself on, and the one an untouched caller already drops.
817
+ #
818
+ # WHY STDERR-ONLY IS THE WHOLE FIX HERE: closing the ~780-PR gap by asking `bb`
819
+ # per id is unaffordable — ~10s per call, no bulk primitive — so no per-branch
820
+ # fallback is shipped. The honest-truncated half makes the incompleteness
821
+ # VISIBLE (an operator sees why a pulse is short) and machine-readable for a
822
+ # future diff that teaches the scan to fall back, without moving the failure
823
+ # into a minutes-long pulse. See the plan's Done-when item 3.
824
+ #
825
+ # $1 backend $2 requested limit (may be "") $3 state word $4 row count
826
+ pr_list_report_truncation() {
827
+ local be="$1" limit="$2" state="$3" count="$4"
828
+ [ -n "$limit" ] || return 0 # no --limit → no completeness claim owed
829
+ [ "$count" -gt 0 ] 2>/dev/null || return 0 # an empty page had nothing to hide
830
+ if [ "$be" = "github" ]; then
831
+ # github honours the limit: complete unless the page came back AT the limit.
832
+ [ "$count" -ge "$limit" ] 2>/dev/null || return 0
833
+ fi
834
+ # bitbucket: any non-empty page for a --limit call is unprovable, so it falls
835
+ # through to the report. Named per state so a future caller can resolve exactly
836
+ # the states that were capped, not a whole-call flag that over-reports.
837
+ echo "plot-host: $be pr-list state=$state possibly truncated ($count rows, requested limit $limit unprovable) — a join against this page may read older branches as 'no PR' (#333)" >&2
838
+ }
839
+
840
+ backend() {
841
+ if [ -n "${PLOT_HOST:-}" ]; then
842
+ case "$PLOT_HOST" in
843
+ github|bitbucket) echo "$PLOT_HOST"; return ;;
844
+ *) die "unknown PLOT_HOST '$PLOT_HOST' (github|bitbucket)" ;;
845
+ esac
846
+ fi
847
+ local v
848
+ v="$(bash "$here/plot-config.sh" get "Git host" "github" | tr '[:upper:]' '[:lower:]')"
849
+ case "$v" in
850
+ bitbucket|bb) echo "bitbucket" ;;
851
+ *) echo "github" ;;
852
+ esac
853
+ }
854
+
855
+ op="${1:-}"; [ -n "$op" ] || die "usage: plot-host.sh <op> [args...] (see header)"
856
+ shift
857
+ be="$(backend)" || exit 1
858
+
859
+ case "$op" in
860
+ backend)
861
+ echo "$be"
862
+ ;;
863
+
864
+ default-branch)
865
+ if [ "$be" = "github" ]; then
866
+ gh repo view --json defaultBranchRef --jq '.defaultBranchRef.name'
867
+ else
868
+ # Symbolic ref of origin/HEAD is host-neutral and offline; fall back to
869
+ # the bb API only when the local clone has no origin/HEAD.
870
+ git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|^origin/||' \
871
+ || bb repo view --json 2>/dev/null | jq -r '.mainbranch.name'
872
+ fi
873
+ ;;
874
+
875
+ pr-state)
876
+ ref="${1:?pr-state needs a PR number or branch}"; shift || true
877
+ repo_args=()
878
+ while [ $# -gt 0 ]; do
879
+ case "$1" in
880
+ --repo) repo_args=(-R "${2:?}"); shift 2 ;;
881
+ *) die "pr-state: unknown arg $1" ;;
882
+ esac
883
+ done
884
+ if [ "$be" = "github" ]; then
885
+ # THE ROUTE IS CHOSEN ONCE, HERE, and the cheap path is the default. See
886
+ # `graphql_budget_spent` above for why REST is the exception rather than
887
+ # the rule (~186 calls versus one for a 93-branch scan) and for what this
888
+ # fallback honestly does and does not buy.
889
+ if graphql_budget_spent; then
890
+ # THE GRAPHQL PATH IS NOT ATTEMPTED FIRST once its budget is known to be
891
+ # gone. Trying it anyway would spend a call that is already refused, to
892
+ # learn what was just read for free.
893
+ #
894
+ # A number and a branch name need DIFFERENT endpoints, and their
895
+ # payload shapes differ too — see `rest_pr_to_state`, which is the one
896
+ # place that knows how to read either.
897
+ rest_repo="$(gh_rest_repo)" || exit $?
898
+ if [[ "$ref" =~ ^[0-9]+$ ]]; then
899
+ if out="$(gh api "repos/$rest_repo/pulls/$ref" 2>/tmp/plot-host-err.$$)"; then
900
+ rm -f "/tmp/plot-host-err.$$"
901
+ rest_pr_to_state <<<"$out"
902
+ else
903
+ err="$(cat "/tmp/plot-host-err.$$" 2>/dev/null)"; rm -f "/tmp/plot-host-err.$$"
904
+ host_miss_or_fail "$err" \
905
+ '{"number":0,"state":"NONE","draft":false,"url":"","mergeCommit":""}' || exit $?
906
+ fi
907
+ else
908
+ # `?head=owner:branch` is the list form. AN EMPTY ARRAY IS AN ANSWER —
909
+ # the branch has no PR — while a call that never arrived is not, which
910
+ # is why the empty case is handled here and the failure goes through
911
+ # `host_miss_or_fail` like every other transport error.
912
+ #
913
+ # `state=all`, because the default is `open` and a merged PR would
914
+ # otherwise read as NONE — wrong in the reassuring direction.
915
+ rest_owner="${rest_repo%%/*}"
916
+ if out="$(gh api "repos/$rest_repo/pulls?head=$rest_owner:$ref&state=all&per_page=1" 2>/tmp/plot-host-err.$$)"; then
917
+ rm -f "/tmp/plot-host-err.$$"
918
+ if [ "$(jq -r 'length' <<<"$out" 2>/dev/null)" = "0" ]; then
919
+ echo '{"number":0,"state":"NONE","draft":false,"url":"","mergeCommit":""}'
920
+ else
921
+ jq -c '.[0]' <<<"$out" | rest_pr_to_state
922
+ fi
923
+ else
924
+ err="$(cat "/tmp/plot-host-err.$$" 2>/dev/null)"; rm -f "/tmp/plot-host-err.$$"
925
+ host_miss_or_fail "$err" \
926
+ '{"number":0,"state":"NONE","draft":false,"url":"","mergeCommit":""}' || exit $?
927
+ fi
928
+ fi
929
+ # mergeCommit is what lets a caller ask "which release contains this?" —
930
+ # `git tag --contains <sha>` answers exactly, where dates cannot. It is ""
931
+ # for anything unmerged, which is the honest answer rather than a guess.
932
+ elif out="$(gh ${repo_args[@]+"${repo_args[@]}"} pr view "$ref" --json number,state,isDraft,url,mergeCommit 2>/tmp/plot-host-err.$$)"; then
933
+ rm -f "/tmp/plot-host-err.$$"
934
+ jq -c '{number:.number,state:.state,draft:.isDraft,url:.url,mergeCommit:(.mergeCommit.oid // "")}' <<<"$out"
935
+ else
936
+ err="$(cat "/tmp/plot-host-err.$$" 2>/dev/null)"; rm -f "/tmp/plot-host-err.$$"
937
+ host_miss_or_fail "$err" \
938
+ '{"number":0,"state":"NONE","draft":false,"url":"","mergeCommit":""}' || exit $?
939
+ fi
940
+ else
941
+ # Establish that bb supports --json BEFORE calling it — Done-when 5.
942
+ bb_require_json
943
+ if [[ "$ref" =~ ^[0-9]+$ ]]; then
944
+ if out="$(bb ${repo_args[@]+"${repo_args[@]}"} pr view "$ref" --json 2>/tmp/plot-host-err.$$)"; then
945
+ rm -f "/tmp/plot-host-err.$$"
946
+ jq -c '{number:.id,state:(if .state=="DECLINED" then "CLOSED" else .state end),draft:(.draft // false),url:.links.html.href}' <<<"$out"
947
+ else
948
+ err="$(cat "/tmp/plot-host-err.$$" 2>/dev/null)"; rm -f "/tmp/plot-host-err.$$"
949
+ host_miss_or_fail "$err" '{"number":0,"state":"NONE","draft":false,"url":""}' || exit $?
950
+ fi
951
+ else
952
+ # The list CALL succeeding and the branch being absent FROM the list are
953
+ # two different things, and only the second is a miss: an empty list that
954
+ # arrived is evidence, an list that never arrived is not. The `jq` NONE
955
+ # below is therefore kept (it reads a real answer) while the failure path
956
+ # goes through `host_miss_or_fail` like the others.
957
+ # One call per state, walked newest-relevant first: an open PR for a
958
+ # branch outranks a merged one, which outranks a declined one.
959
+ #
960
+ # STOP AT THE FIRST STATE THAT ANSWERS. Because the ordering already
961
+ # decides the winner, a later state can never overturn an earlier one —
962
+ # so asking for it is pure cost. And the cost is not small: measured
963
+ # against a real Bitbucket on 2026-08-18, one `bb` call takes ~10s, so
964
+ # walking all three unconditionally made every branch lookup ~26s. The
965
+ # board's fleet scan calls this once per branch and exceeded its own
966
+ # timeout on a five-branch plan.
967
+ #
968
+ # A declined-only or PR-less branch still pays for all three; those are
969
+ # the cases where the third call is the one carrying the answer.
970
+ out=""; bb_rc=0
971
+ bb_all_states="$(bb_states_for all)" || exit 1
972
+ for _s in $bb_all_states; do
973
+ if _part="$(bb ${repo_args[@]+"${repo_args[@]}"} pr list --state "$_s" --json 2>/tmp/plot-host-err.$$)"; then
974
+ out="$out$_part"
975
+ # `jq -e` exits non-zero on null/false, so this asks "did this state
976
+ # contain the branch?" without a second parse of the whole page.
977
+ if jq -e --arg b "$ref" 'any(.[]; .source.branch.name==$b)' >/dev/null 2>&1 <<<"$_part"; then
978
+ break
979
+ fi
980
+ else
981
+ bb_rc=1; break
982
+ fi
983
+ done
984
+ if [ "$bb_rc" = 0 ]; then
985
+ rm -f "/tmp/plot-host-err.$$"
986
+ out="$(jq -c -s 'add // []' <<<"$out")"
987
+ jq -c --arg b "$ref" '[.[] | select(.source.branch.name==$b)][0] // null
988
+ | if .==null then {number:0,state:"NONE",draft:false,url:""}
989
+ else {number:.id,state:(if .state=="DECLINED" then "CLOSED" else .state end),draft:(.draft // false),url:.links.html.href} end' <<<"$out"
990
+ else
991
+ err="$(cat "/tmp/plot-host-err.$$" 2>/dev/null)"; rm -f "/tmp/plot-host-err.$$"
992
+ host_miss_or_fail "$err" '{"number":0,"state":"NONE","draft":false,"url":""}' || exit $?
993
+ fi
994
+ fi
995
+ fi
996
+ ;;
997
+
998
+ pr-create)
999
+ title=""; body=""; base=""; head=""; draft=0
1000
+ while [ $# -gt 0 ]; do
1001
+ case "$1" in
1002
+ --title) title="${2:?}"; shift 2 ;;
1003
+ --body) body="${2:?}"; shift 2 ;;
1004
+ --base) base="${2:?}"; shift 2 ;;
1005
+ --head) head="${2:?}"; shift 2 ;;
1006
+ --draft) draft=1; shift ;;
1007
+ *) die "pr-create: unknown arg $1" ;;
1008
+ esac
1009
+ done
1010
+ [ -n "$title" ] || die "pr-create needs --title"
1011
+ if [ "$be" = "github" ]; then
1012
+ args=(pr create --title "$title" --body "$body")
1013
+ [ -n "$base" ] && args+=(--base "$base")
1014
+ [ -n "$head" ] && args+=(--head "$head")
1015
+ [ "$draft" = 1 ] && args+=(--draft)
1016
+ gh "${args[@]}"
1017
+ else
1018
+ args=(pr create --title "$title" --body "$body")
1019
+ [ -n "$base" ] && args+=(--base "$base")
1020
+ [ -n "$head" ] && args+=(--head "$head")
1021
+ [ "$draft" = 1 ] && args+=(--draft)
1022
+ bb "${args[@]}"
1023
+ fi
1024
+ ;;
1025
+
1026
+ pr-merge)
1027
+ num="${1:?pr-merge needs a PR number}"; shift
1028
+ squash=0; delbranch=0
1029
+ while [ $# -gt 0 ]; do
1030
+ case "$1" in
1031
+ --squash) squash=1; shift ;;
1032
+ --delete-branch) delbranch=1; shift ;;
1033
+ *) die "pr-merge: unknown arg $1" ;;
1034
+ esac
1035
+ done
1036
+ if [ "$be" = "github" ]; then
1037
+ args=(pr merge "$num")
1038
+ [ "$squash" = 1 ] && args+=(--squash) || args+=(--merge)
1039
+ [ "$delbranch" = 1 ] && args+=(--delete-branch)
1040
+ gh "${args[@]}"
1041
+ else
1042
+ args=(pr merge "$num")
1043
+ [ "$squash" = 1 ] && args+=(--squash)
1044
+ [ "$delbranch" = 1 ] && args+=(--delete-branch)
1045
+ bb "${args[@]}"
1046
+ fi
1047
+ ;;
1048
+
1049
+ pr-ready)
1050
+ # TAKE A PR OUT OF DRAFT. One call, and the ONE place that talks to the host
1051
+ # CLI keeps that property — `plot-approve.sh` needs this before it merges,
1052
+ # and reaching for `gh` there would put a second host caller in the estate.
1053
+ #
1054
+ # Bitbucket's support depends on the bb CLI version (see the caveat list at
1055
+ # the top of this file, which already names `--draft`/`--ready`), so a
1056
+ # failure here surfaces as the CLI's own message rather than being
1057
+ # swallowed: the caller must be able to tell *the host refused* from *the
1058
+ # PR is now ready*.
1059
+ num="${1:?pr-ready needs a PR number}"; shift
1060
+ if [ "$be" = "github" ]; then
1061
+ gh pr ready "$num"
1062
+ else
1063
+ bb pr update "$num" --ready
1064
+ fi
1065
+ ;;
1066
+
1067
+ pr-list)
1068
+ state="open"
1069
+ rich=0
1070
+ # `gh pr list` and `bb pr list` both cap at 30 by default. That is invisible
1071
+ # with --state open (few repos have 30 open PRs) and bites immediately with
1072
+ # --state all, where the newest 30 crowd out every older merged PR. A caller
1073
+ # that wants history says how much; the default stays the host's, so no
1074
+ # existing caller's result changes.
1075
+ limit=""
1076
+ while [ $# -gt 0 ]; do
1077
+ case "$1" in
1078
+ --state) state="${2:?}"; shift 2 ;;
1079
+ --limit) limit="${2:?}"; shift 2 ;;
1080
+ --rich) rich=1; shift ;;
1081
+ *) die "pr-list: unknown arg $1" ;;
1082
+ esac
1083
+ done
1084
+ limit_args=()
1085
+ [ -n "$limit" ] && limit_args=(--limit "$limit")
1086
+
1087
+ # --- Jenkins CI integration (orthogonal to Git host) ---
1088
+ # When `CI: jenkins` is configured, build status comes from Jenkins rather
1089
+ # than the Git host. One call per refresh, joined locally — Done-when 5.
1090
+ #
1091
+ # `CI` and `Git host` are SEPARATE keys, so this runs ABOVE the backend
1092
+ # branch and overlays whichever backend produced the rows (Done-when 3).
1093
+ #
1094
+ # Two failure directions, kept apart deliberately:
1095
+ # - The OP CANNOT PROCEED (`CI: jenkins` but no instance to ask) — a config
1096
+ # error only a person can fix. EXIT 3, the code the header reserves for
1097
+ # "the op itself cannot proceed". Blanking would look like a transient.
1098
+ # - Jenkins is UNREACHABLE — the rows survive as `checks:"unknown"` and the
1099
+ # op still exits 0. A hard exit here would blank the WHOLE PR list, since
1100
+ # the board rejects a non-zero pr-list and keeps its last good map; one
1101
+ # dead Jenkins must not darken every row. This reconciles Done-when 4's
1102
+ # "exits 3" with the brief's "prefer unknown on the affected rows".
1103
+ ci="$(ci_backend)"
1104
+ jen_map=""
1105
+ jen_status=""
1106
+ if [ "$ci" = "jenkins" ] && [ "$rich" = 1 ]; then
1107
+ jen_instance=$(bash "$here/plot-config.sh" get "Jenkins instance" "" 2>/dev/null || echo "")
1108
+ [ -n "$jen_instance" ] || jen_instance="${JENKINS_INSTANCE:-}"
1109
+ if [ -z "$jen_instance" ]; then
1110
+ die3 "CI is jenkins but no Jenkins instance is configured (set a 'Jenkins instance' key)"
1111
+ fi
1112
+ jen_payload=$(jenkins_build_map "$jen_instance")
1113
+ jen_status=$(printf '%s' "$jen_payload" | jq -r '.status // "failed"' 2>/dev/null || echo "failed")
1114
+ jen_map=$(printf '%s' "$jen_payload" | jq -c '.map // {}' 2>/dev/null || echo "{}")
1115
+ # A failed/unknown Jenkins is still an active overlay: it marks rows
1116
+ # `unknown` rather than leaving them at the backend's answer. So the arm is
1117
+ # "on" whenever CI is jenkins, and $jen_status carries whether it answered.
1118
+ if [ "$jen_status" != "ok" ]; then
1119
+ echo "plot-host: jenkins unreachable ($jen_status) — checks reported as unknown" >&2
1120
+ fi
1121
+ fi
1122
+
1123
+ if [ "$be" = "github" ]; then
1124
+ if [ "$rich" = 1 ]; then
1125
+ # `checks` has FOUR states, and two of them mean "a person is the
1126
+ # blocker" rather than "a machine is busy":
1127
+ #
1128
+ # none — empty rollup. GitHub starts no workflows for bot PRs
1129
+ # until a human approves the run. Reporting this as pending
1130
+ # would show CI running while nothing runs, and nobody
1131
+ # would look.
1132
+ # failing — includes ACTION_REQUIRED, which is the same situation
1133
+ # seen from the other side: the run exists but waits on a
1134
+ # human. It is deliberately NOT pending.
1135
+ # pending — genuinely queued or in progress. Only this one means a
1136
+ # machine is working.
1137
+ # green — everything concluded successfully.
1138
+ #
1139
+ # One red check among green ones counts red: `any` is checked before
1140
+ # the pending branch, so a mixed rollup never reads as "still running".
1141
+ #
1142
+ # `mergeable` is a SEPARATE question from `checks`, and asking it is
1143
+ # what lets a consumer tell two situations apart that look identical
1144
+ # through `checks` alone. GitHub starts no workflow for a PR that does
1145
+ # not merge cleanly, so a conflicting PR reports an EMPTY rollup —
1146
+ # `checks:"none"`, exactly like a bot PR whose run awaits a human click.
1147
+ # One wants a rebase, the other wants a click, and `checks` cannot say
1148
+ # which. Measured on PR #149 and #160: `mergeable=CONFLICTING`,
1149
+ # `mergeStateStatus=DIRTY`, `statusCheckRollup` genuinely empty.
1150
+ #
1151
+ # Three values, and `unknown` is a real answer rather than a gap in the
1152
+ # data: GitHub computes mergeability lazily, so a PR opened seconds ago
1153
+ # legitimately reports UNKNOWN until the background job finishes. A
1154
+ # consumer must not read that as clean.
1155
+ #
1156
+ # `mergeStateStatus` is consulted only to CORROBORATE — DIRTY is its
1157
+ # word for the same conflict — and never to overrule: it needs a scope
1158
+ # some tokens lack and is absent for them, where `mergeable` is not.
1159
+ #
1160
+ # `review` stays informational: a repo that does not review through the
1161
+ # host emits "" here, and no consumer may turn that into a gate.
1162
+ # `url` comes from the host, never from a consumer. A board or a report
1163
+ # that templated github.com from a config key would produce a plausible
1164
+ # link and a wrong one for GitHub Enterprise or self-hosted Bitbucket —
1165
+ # this script is the ONE place that knows what a host URL looks like
1166
+ # (Principle 3), and pr-state already reads it from exactly here.
1167
+ #
1168
+ # `failing_checks` is WHICH checks failed, by name — the same payload
1169
+ # `checks` collapses to one word, kept rather than thrown away.
1170
+ #
1171
+ # `checks:"failing"` names a symptom and withholds which machine
1172
+ # produced it. On 2026-08-17 a markdown-only branch failed `validate`
1173
+ # because the Playwright CDN answered `403 — this service is not
1174
+ # available in your location`, and reaching that sentence took ten
1175
+ # minutes of opening logs, from a row that already held the check name
1176
+ # and did not say it.
1177
+ #
1178
+ # NAMES ONLY, and nothing here interprets them. A heuristic mapping a
1179
+ # failing check to the paths a branch changed was explicitly rejected:
1180
+ # that table is unmaintained by construction and goes silently wrong the
1181
+ # first time a workflow is restructured. Principle 3 — this collects, a
1182
+ # human concludes.
1183
+ #
1184
+ # Free: same GraphQL response, same call, no extra request.
1185
+ #
1186
+ # --- Jenkins override ---
1187
+ # When `CI: jenkins` is configured, checks come from Jenkins instead of
1188
+ # GitHub's statusCheckRollup. Done-when 3 verifies this is orthogonal:
1189
+ # a GitHub repo without Jenkins still reads its own rollup exactly as
1190
+ # before.
1191
+ if [ "$ci" = "jenkins" ]; then
1192
+ # GitHub PR list, but `checks` comes from Jenkins, joined on branch
1193
+ # name. `statusCheckRollup` is NOT even requested — the GitHub rollup
1194
+ # is irrelevant for a Jenkins team, and asking for it would be a slower
1195
+ # query for a field this arm discards.
1196
+ # $jstatus != "ok" → Jenkins could not answer; every row `unknown`.
1197
+ # $jentry == null → the branch has no Jenkins job; `none`.
1198
+ # 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)"
1201
+ pr_list_report_truncation github "$limit" "$state" \
1202
+ "$(jq 'length' <<<"$_gh_raw" 2>/dev/null || echo 0)"
1203
+ printf '%s' "$_gh_raw" \
1204
+ | jq -c --argjson jmap "$jen_map" --arg jstatus "$jen_status" '.[] |
1205
+ ($jmap[.headRefName] // null) as $jentry |
1206
+ {
1207
+ number:.number, title:.title, state:.state, head:.headRefName,
1208
+ draft:.isDraft,
1209
+ checks:(
1210
+ if $jstatus != "ok" then "unknown"
1211
+ elif $jentry == null then "none"
1212
+ else $jentry.checks
1213
+ end),
1214
+ mergeable:(
1215
+ if .mergeable=="CONFLICTING" or .mergeStateStatus=="DIRTY" then "conflicting"
1216
+ elif .mergeable=="MERGEABLE" then "mergeable"
1217
+ else "unknown" end),
1218
+ review:(.reviewDecision // ""),
1219
+ url:.url,
1220
+ failing_checks:(
1221
+ if $jentry != null and $jentry.checks == "failing"
1222
+ then [$jentry.job]
1223
+ else []
1224
+ end)
1225
+ }'
1226
+ else
1227
+ # 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)"
1230
+ pr_list_report_truncation github "$limit" "$state" \
1231
+ "$(jq 'length' <<<"$_gh_raw" 2>/dev/null || echo 0)"
1232
+ printf '%s' "$_gh_raw" \
1233
+ | jq -c '.[] | {
1234
+ number:.number, title:.title, state:.state, head:.headRefName,
1235
+ draft:.isDraft,
1236
+ checks:(
1237
+ if (.statusCheckRollup|length) == 0 then "none"
1238
+ elif any(.statusCheckRollup[]; (if (.conclusion // "") != "" then .conclusion else (.status // .state) end) as $c
1239
+ | $c=="FAILURE" or $c=="ERROR" or $c=="CANCELLED"
1240
+ or $c=="TIMED_OUT" or $c=="ACTION_REQUIRED") then "failing"
1241
+ elif any(.statusCheckRollup[]; (if (.conclusion // "") != "" then .conclusion else (.status // .state) end) as $c
1242
+ | $c=="PENDING" or $c=="IN_PROGRESS" or $c=="QUEUED"
1243
+ or $c=="WAITING" or $c==null) then "pending"
1244
+ else "green" end),
1245
+ mergeable:(
1246
+ if .mergeable=="CONFLICTING" or .mergeStateStatus=="DIRTY" then "conflicting"
1247
+ elif .mergeable=="MERGEABLE" then "mergeable"
1248
+ else "unknown" end),
1249
+ review:(.reviewDecision // ""),
1250
+ url:.url,
1251
+ failing_checks:[
1252
+ .statusCheckRollup[]? | select((if (.conclusion // "") != "" then .conclusion else (.status // .state) end) as $c
1253
+ | $c=="FAILURE" or $c=="ERROR" or $c=="CANCELLED"
1254
+ or $c=="TIMED_OUT" or $c=="ACTION_REQUIRED")
1255
+ | (.name // .context // "")] | map(select(. != ""))
1256
+ }'
1257
+ fi
1258
+ else
1259
+ _gh_raw="$(gh pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} \
1260
+ --json number,title,state,headRefName)"
1261
+ pr_list_report_truncation github "$limit" "$state" \
1262
+ "$(jq 'length' <<<"$_gh_raw" 2>/dev/null || echo 0)"
1263
+ printf '%s' "$_gh_raw" \
1264
+ | jq -c '.[] | {number:.number,title:.title,state:.state,head:.headRefName}'
1265
+ fi
1266
+ else
1267
+ # Bitbucket carries no check rollup through `bb pr list`, and no
1268
+ # mergeability verdict either. Rather than guess, --rich reports
1269
+ # checks:"unknown" and mergeable:"unknown" — a consumer must render those
1270
+ # as "unavailable", never as green and never as clean. An honest gap beats
1271
+ # an invented answer, and absent is not false.
1272
+ #
1273
+ # --- Jenkins CI fills that gap ---
1274
+ # When `CI: jenkins` is configured, checks come from Jenkins — the same
1275
+ # integration GitHub uses, which is why it is ABOVE the backend branch.
1276
+ # Bitbucket's `unknown` becomes a real value.
1277
+ #
1278
+ # `bb pr list` has no --limit: it returns a fixed page (50 at 1.0.0).
1279
+ # Forwarding it errors with `unknown flag`, and dropping it silently
1280
+ # would serve a short page as if it were the whole set — the quiet wrong
1281
+ # answer this adapter refuses elsewhere. So it is dropped AND said.
1282
+ if [ -n "$limit" ]; then
1283
+ echo "plot-host: bitbucket ignores --limit $limit; bb returns a fixed page (50 at 1.0.0)" >&2
1284
+ fi
1285
+ # Establish that bb supports --json BEFORE calling it — Done-when 5.
1286
+ bb_require_json
1287
+ # Resolve the states BEFORE the loop. `for s in $(bb_states_for …)` runs
1288
+ # the helper in a subshell, where `die` exits that subshell only: the
1289
+ # loop would then iterate an empty list and the command would succeed
1290
+ # with no output — an unknown state reading as "no PRs matched", which
1291
+ # is the exact failure this translation exists to remove.
1292
+ bb_states="$(bb_states_for "$state")" || exit 1
1293
+ if [ "$rich" = 1 ]; then
1294
+ if [ "$ci" = "jenkins" ]; then
1295
+ # Bitbucket PR list, `checks` filled from Jenkins — the SAME overlay
1296
+ # the GitHub arm uses, which is why it lives above the backend branch.
1297
+ # `bb`'s standing `unknown` becomes a real value where Jenkins answers.
1298
+ for _s in $bb_states; do
1299
+ _bb_raw="$(bb pr list --state "$_s" --json)"
1300
+ pr_list_report_truncation bitbucket "$limit" "$_s" \
1301
+ "$(jq 'length' <<<"$_bb_raw" 2>/dev/null || echo 0)"
1302
+ printf '%s' "$_bb_raw" \
1303
+ | jq -c --argjson jmap "$jen_map" --arg jstatus "$jen_status" '.[] |
1304
+ ($jmap[.source.branch.name] // null) as $jentry |
1305
+ {
1306
+ number:.id, title:.title,
1307
+ state:(if .state=="DECLINED" then "CLOSED" else .state end),
1308
+ head:.source.branch.name,
1309
+ draft:(.draft // false),
1310
+ checks:(
1311
+ if $jstatus != "ok" then "unknown"
1312
+ elif $jentry == null then "none"
1313
+ else $jentry.checks
1314
+ end),
1315
+ mergeable:"unknown",
1316
+ review:"",
1317
+ url:(.links.html.href // ""),
1318
+ failing_checks:(
1319
+ if $jentry != null and $jentry.checks == "failing"
1320
+ then [$jentry.job]
1321
+ else []
1322
+ end)
1323
+ }'
1324
+ done
1325
+ else
1326
+ # Bitbucket without Jenkins: checks remain unknown
1327
+ for _s in $bb_states; do
1328
+ _bb_raw="$(bb pr list --state "$_s" --json)"
1329
+ pr_list_report_truncation bitbucket "$limit" "$_s" \
1330
+ "$(jq 'length' <<<"$_bb_raw" 2>/dev/null || echo 0)"
1331
+ printf '%s' "$_bb_raw" \
1332
+ | jq -c '.[] | {number:.id,title:.title,state:(if .state=="DECLINED" then "CLOSED" else .state end),head:.source.branch.name,draft:(.draft // false),checks:"unknown",mergeable:"unknown",review:"",url:(.links.html.href // ""),failing_checks:[]}'
1333
+ done
1334
+ fi
1335
+ else
1336
+ for _s in $bb_states; do
1337
+ _bb_raw="$(bb pr list --state "$_s" --json)"
1338
+ pr_list_report_truncation bitbucket "$limit" "$_s" \
1339
+ "$(jq 'length' <<<"$_bb_raw" 2>/dev/null || echo 0)"
1340
+ printf '%s' "$_bb_raw" \
1341
+ | jq -c '.[] | {number:.id,title:.title,state:(if .state=="DECLINED" then "CLOSED" else .state end),head:.source.branch.name}'
1342
+ done
1343
+ fi
1344
+ fi
1345
+ ;;
1346
+
1347
+ runs)
1348
+ # A branch's OWN recent CI runs, newest first — the third line of the
1349
+ # evidence a failing check is reported with.
1350
+ #
1351
+ # Why it matters, measured: on 2026-08-17 a `403` from the Playwright CDN
1352
+ # failed a markdown-only branch, and what proved it transient was the run
1353
+ # history — the same branch was green two minutes earlier. A real failure
1354
+ # presents identically in every other respect, which is exactly why this
1355
+ # reports the history and draws no conclusion from it.
1356
+ #
1357
+ # FACTS, NEVER A VERDICT. No rule here compares runs, decides "transient",
1358
+ # or reruns anything. The plan calls the shape *foreign* deliberately: this
1359
+ # collects, a human concludes (Principle 3).
1360
+ #
1361
+ # METERED, so callers must ask only where the question arises — a branch
1362
+ # whose PR is already known to be failing. One REST call per such branch,
1363
+ # and failing branches are rare by construction; a caller that asked for
1364
+ # every branch would spend a budget the board has already exhausted once.
1365
+ #
1366
+ # Bitbucket reports nothing here rather than something invented. `bb` has no
1367
+ # run listing, and an empty history renders as "unavailable" — never as
1368
+ # "this branch has never failed before".
1369
+ branch="${1:?runs needs a branch}"; shift
1370
+ limit=10
1371
+ while [ $# -gt 0 ]; do
1372
+ case "$1" in
1373
+ --limit) limit="${2:?}"; shift 2 ;;
1374
+ *) die "runs: unknown arg $1" ;;
1375
+ esac
1376
+ done
1377
+ if [ "$be" = "github" ]; then
1378
+ gh run list --branch "$branch" --limit "$limit" \
1379
+ --json workflowName,conclusion,status,startedAt,url 2>/dev/null \
1380
+ | jq -c '.[] | {workflow:.workflowName,
1381
+ conclusion:(if (.conclusion // "") == "" then .status else .conclusion end),
1382
+ startedAt:.startedAt, url:.url}' 2>/dev/null || true
1383
+ fi
1384
+ ;;
1385
+
1386
+ issue-list)
1387
+ # Open tracker issues — the board's inbox, and READ-ONLY in both
1388
+ # directions. Nothing here writes a label, an assignee or a close: the
1389
+ # manifesto keeps issues as signals rather than commitments, and a mirror
1390
+ # of tracker state is the copy that ages into a lie.
1391
+ #
1392
+ # THREE OUTCOMES, KEPT APART. An empty list means the host answered and
1393
+ # there are none; a non-zero exit with empty stdout means the question
1394
+ # failed; exit 4 means this host cannot be asked at all. Collapsing any two
1395
+ # of them reproduces `an-outage-is-not-an-answer` — a board that says "no
1396
+ # issues" because it could not reach the tracker is stating a fact it does
1397
+ # not have.
1398
+ limit=""
1399
+ while [ $# -gt 0 ]; do
1400
+ case "$1" in
1401
+ --limit) limit="${2:?}"; shift 2 ;;
1402
+ *) die "issue-list: unknown arg $1" ;;
1403
+ esac
1404
+ done
1405
+ limit_args=()
1406
+ [ -n "$limit" ] && limit_args=(--limit "$limit")
1407
+ if [ "$(tracker_scheme)" = "jira" ]; then
1408
+ # Jira, resolved through the REST API — DISPATCHED ON `Tracker`, never on
1409
+ # `backend()`: a Bitbucket repo tracking in Jira is the normal enterprise
1410
+ # case, so the git host is irrelevant here (see the Jira helpers up top).
1411
+ #
1412
+ # The inbox is "my open tickets": assigned to me and unresolved. That is
1413
+ # the story's title — *my Jira tickets are in the inbox* — and it maps the
1414
+ # board's "open tracker issues no plan references" onto the person reading
1415
+ # the board. `PLOT_JIRA_JQL` overrides it for a team that wants a wider or
1416
+ # narrower inbox. ORDER BY created DESC so the newest ticket is first, the
1417
+ # same order `createdAt` gives the GitHub arm.
1418
+ jira_require_config
1419
+ jql="${PLOT_JIRA_JQL:-assignee = currentUser() AND resolution = EMPTY ORDER BY created DESC}"
1420
+ # maxResults bounds ONE page. The inbox is small by construction (a
1421
+ # person's open tickets), so no nextPageToken loop is needed; the caller's
1422
+ # --limit caps it, else Jira's default page. v2 `search/jql` takes the same
1423
+ # params as v3 but returns `summary`/`description` as plain strings.
1424
+ max="${limit:-50}"
1425
+ # jq builds the query string so a JQL with spaces/quotes is encoded once,
1426
+ # not hand-escaped. --data-urlencode via curl -G keeps the token out of the
1427
+ # URL and the JQL correctly encoded.
1428
+ raw="$(jira_curl "/rest/api/2/search/jql" \
1429
+ -G \
1430
+ --data-urlencode "jql=$jql" \
1431
+ --data-urlencode "fields=summary,created" \
1432
+ --data-urlencode "maxResults=$max")"; curl_rc=$?
1433
+ body="$(jira_check "$raw" "$curl_rc")" || exit $?
1434
+ # `number` is the Jira KEY (PROJ-123), a string — #447 taught the parser to
1435
+ # read that form. `url` is built from the base + /browse/<key>; Jira's
1436
+ # search payload carries no browse URL, and the base is ours to know
1437
+ # (Principle 3: this script is the one place that knows a host URL's shape).
1438
+ base="$(tracker_base_url)"
1439
+ printf '%s' "$body" | jq -c --arg base "$base" '.issues[]? | {
1440
+ number: .key,
1441
+ title: (.fields.summary // ""),
1442
+ url: ($base + "/browse/" + .key),
1443
+ createdAt: (.fields.created // "")
1444
+ }'
1445
+ elif [ "$be" = "github" ]; then
1446
+ # `gh issue list` — not `gh api /issues`. On GitHub every PR IS an issue,
1447
+ # so the REST endpoint returns both, and every open PR would arrive here
1448
+ # as a signal nobody had planned. The `gh` subcommand filters PRs out;
1449
+ # this note exists because that trap is invisible while it works.
1450
+ if out="$(gh issue list --state open ${limit_args[@]+"${limit_args[@]}"} \
1451
+ --json number,title,url,createdAt 2>/tmp/plot-host-err.$$)"; then
1452
+ rm -f "/tmp/plot-host-err.$$"
1453
+ jq -c '.[] | {number:.number,title:.title,url:(.url // ""),createdAt:(.createdAt // "")}' <<<"$out"
1454
+ else
1455
+ err="$(cat "/tmp/plot-host-err.$$" 2>/dev/null)"; rm -f "/tmp/plot-host-err.$$"
1456
+ # NO empty-list fallback. `host_miss_or_fail` exists for a lookup whose
1457
+ # subject is absent — one PR that does not exist. A LIST has no absent
1458
+ # subject: if the call failed, the answer is unknown, and printing
1459
+ # nothing while exiting non-zero is what says so.
1460
+ echo "plot-host: $err" >&2
1461
+ exit 3
1462
+ fi
1463
+ else
1464
+ # `bb issue list` — measured against bb 0.6.0. Three things the GitHub arm
1465
+ # cannot be copied for, each invisible while it works (see the helpers up
1466
+ # top): (1) `bb` prints errors to STDOUT, so stdout is NOT unconditionally
1467
+ # data; (2) it has no `--limit`, so the caller's bound is honoured HERE,
1468
+ # after parsing; (3) the list carries no per-issue URL, so `url` is "" —
1469
+ # the same answer the header documents for a host that omits it.
1470
+ bb_assert_issue_version || exit $?
1471
+ # `--state new,open` is bb's own default for `issue list` and is what
1472
+ # "open tracker issues" means; stated explicitly so a bb default change
1473
+ # cannot silently widen it. bb has no `all` for issues, so this is ONE
1474
+ # call (the request-budget comment in fleet.ts counts exactly this).
1475
+ raw="$(bb issue list --state new --state open 2>&1)"; rc=$?
1476
+ raw="$(bb_strip_ansi <<<"$raw")"
1477
+ if [ "$rc" -ne 0 ]; then
1478
+ # bb's error is on stdout, now folded into `raw`. Report it and choose
1479
+ # 4-vs-3 from its wording — never parse it as an issue (Done-when 6).
1480
+ echo "plot-host: $raw" >&2
1481
+ exit "$(bb_issue_exit_code "$(tr '[:upper:]' '[:lower:]' <<<"$raw")")"
1482
+ fi
1483
+ # A row is `#NNN <STATE> <title> by <reporter>`. Anchor on the `#NNN`
1484
+ # prefix (the header line ` :: Showing …` has none and is skipped for
1485
+ # free), lift the number, strip the known state badge word, then drop the
1486
+ # trailing ` by <reporter>` on its three-space separator. The state
1487
+ # vocabulary is bb 0.6.0's issue states — pinned by the version assert.
1488
+ count=0
1489
+ while IFS= read -r line; do
1490
+ [[ "$line" =~ ^#0*([0-9]+)[[:space:]]+(.*)$ ]] || continue
1491
+ num="${BASH_REMATCH[1]}"
1492
+ rest="${BASH_REMATCH[2]}"
1493
+ # Strip the leading state badge word (NEW/OPEN/RESOLVED/…) and its
1494
+ # padding; the title starts after it.
1495
+ rest="$(sed -E 's/^(NEW|OPEN|ON HOLD|INVALID|RESOLVED|DUPLICATE|WONTFIX|CLOSED)[[:space:]]+//' <<<"$rest")"
1496
+ # Drop the trailing reporter: bb prints three spaces then `by <name>`.
1497
+ title="$(sed -E 's/[[:space:]]{2,}by [^[:space:]].*$//' <<<"$rest")"
1498
+ # `url` is "" — bb issue list prints none; a consumer renders the number
1499
+ # as plain text, the rule the header states.
1500
+ jq -cn --argjson number "$num" --arg title "$title" \
1501
+ '{number:$number,title:$title,url:"",createdAt:""}'
1502
+ count=$((count + 1))
1503
+ # Honour the caller's --limit HERE: bb has no --limit, so a bound the
1504
+ # caller asked for is enforced by the adapter after parsing (Done-when 7).
1505
+ if [ -n "$limit" ] && [ "$count" -ge "$limit" ]; then
1506
+ break
1507
+ fi
1508
+ done <<<"$raw"
1509
+ fi
1510
+ ;;
1511
+
1512
+ issue-view)
1513
+ # ONE issue, with its body — the problem statement the board hands to
1514
+ # /plot-idea. Read-only, exactly as issue-list is: nothing here writes a
1515
+ # comment, a label or a state, because a plan referencing an issue is
1516
+ # Plot's record and not the tracker's.
1517
+ #
1518
+ # The three outcomes stay apart for the reason issue-list states, and the
1519
+ # exit codes are deliberately THE SAME ONES — a consumer that already maps
1520
+ # 4 to `unsupported` and anything else to `failed` must not need a second
1521
+ # table to read this op.
1522
+ num="${1:?issue-view needs an issue number}"; shift
1523
+ if [ "$(tracker_scheme)" = "jira" ]; then
1524
+ # Jira, dispatched on `Tracker` not `backend()` — the same rule issue-list
1525
+ # follows. `num` is a Jira KEY (PROJ-123), read off issue-list moments ago.
1526
+ #
1527
+ # v2, not v3: `description` arrives as a plain string here, where v3 returns
1528
+ # an ADF tree. The body is a problem statement for /plot-idea, so a string
1529
+ # is the right shape — see the Jira helpers up top for the full reasoning.
1530
+ #
1531
+ # A missing issue is a FAILURE, not an empty body — exactly as the GitHub
1532
+ # and Bitbucket arms treat it: the caller named a key it read off this same
1533
+ # adapter, so its absence (Jira answers 404) means the tracker moved under
1534
+ # the board. jira_check turns that 404 into exit 3 with empty stdout.
1535
+ jira_require_config
1536
+ raw="$(jira_curl "/rest/api/2/issue/$num" \
1537
+ -G --data-urlencode "fields=summary,description")"; curl_rc=$?
1538
+ body_json="$(jira_check "$raw" "$curl_rc")" || exit $?
1539
+ base="$(tracker_base_url)"
1540
+ printf '%s' "$body_json" | jq -c --arg base "$base" '{
1541
+ number: .key,
1542
+ title: (.fields.summary // ""),
1543
+ body: (.fields.description // ""),
1544
+ url: ($base + "/browse/" + .key)
1545
+ }'
1546
+ elif [ "$be" = "github" ]; then
1547
+ if out="$(gh issue view "$num" --json number,title,body,url 2>/tmp/plot-host-err.$$)"; then
1548
+ rm -f "/tmp/plot-host-err.$$"
1549
+ jq -c '{number:.number,title:(.title // ""),body:(.body // ""),url:(.url // "")}' <<<"$out"
1550
+ else
1551
+ err="$(cat "/tmp/plot-host-err.$$" 2>/dev/null)"; rm -f "/tmp/plot-host-err.$$"
1552
+ # NO miss/fail split here, and that is deliberate. `host_miss_or_fail`
1553
+ # exists where an absent subject is a NORMAL answer — a branch with no
1554
+ # PR. An issue number reaching this op was read off `issue-list`
1555
+ # moments earlier, so "it does not exist" is not a normal answer: it
1556
+ # means the tracker moved under the board. Both shapes exit non-zero
1557
+ # with the CLI's own words, so the caller says *could not be read*
1558
+ # rather than planning against an empty body.
1559
+ echo "plot-host: $err" >&2
1560
+ exit 3
1561
+ fi
1562
+ else
1563
+ # `bb issue view <n>` — measured against bb 0.6.0. The output, once ANSI
1564
+ # is stripped, is:
1565
+ #
1566
+ # <title>
1567
+ # <STATE> • <reporter> opened <createdOn>
1568
+ # Type: … • Priority: … • Assignee: …
1569
+ # [Component/Milestone/Version — conditional]
1570
+ # <body, rendered markdown, possibly many lines>
1571
+ # [comments…]
1572
+ # View this issue on Bitbucket.org: <url>
1573
+ #
1574
+ # The same error discipline as issue-list: bb writes failures to stdout
1575
+ # and exits 1 for everything, so match the wording for 4-vs-3 and never
1576
+ # parse an error as an issue.
1577
+ bb_assert_issue_version || exit $?
1578
+ raw="$(bb issue view "$num" 2>&1)"; rc=$?
1579
+ raw="$(bb_strip_ansi <<<"$raw")"
1580
+ if [ "$rc" -ne 0 ]; then
1581
+ echo "plot-host: $raw" >&2
1582
+ exit "$(bb_issue_exit_code "$(tr '[:upper:]' '[:lower:]' <<<"$raw")")"
1583
+ fi
1584
+ # Title is the first line. URL is lifted from the footer, which is bb's
1585
+ # only place that prints one for a viewed issue — so the contract's `url`
1586
+ # is real here even though issue-list's is "".
1587
+ title="$(sed -n '1p' <<<"$raw")"
1588
+ url="$(grep -oE 'https?://[^[:space:]]+' <<<"$(grep -F 'View this issue on Bitbucket' <<<"$raw")" | head -1)"
1589
+ # Body is the block between the metadata head and the footer. The head is
1590
+ # the title + the two fixed meta lines (state, Type:) plus an optional
1591
+ # Component/Milestone/Version line; the tail is the footer. This is the
1592
+ # problem statement /plot-idea receives, read once per human click. It is
1593
+ # a best-effort lift of rendered markdown, not a byte-exact round-trip:
1594
+ # bb has no --json for issues, so the version pin is what keeps it honest.
1595
+ body="$(awk '
1596
+ NR==1 { next } # title
1597
+ NR==2 { next } # state • reporter
1598
+ /^Type: / { seen_type=1; next } # Type/Priority/Assignee
1599
+ /^(Component|Milestone|Version): / && !started { next }
1600
+ /^View this issue on Bitbucket/ { exit } # footer ends the body
1601
+ { started=1; print }
1602
+ ' <<<"$raw" | sed -e 's/[[:space:]]*$//' )"
1603
+ # Trim leading/trailing blank lines the render leaves around the body.
1604
+ # Portable (no `tac`): awk buffers, then prints from the first non-blank
1605
+ # line to the last one seen.
1606
+ body="$(awk '
1607
+ { lines[NR]=$0; if ($0 ~ /[^[:space:]]/) { if (!first) first=NR; last=NR } }
1608
+ END { for (i=first; i<=last; i++) print lines[i] }
1609
+ ' <<<"$body")"
1610
+ jq -cn --argjson number "$num" --arg title "$title" --arg body "$body" --arg url "$url" \
1611
+ '{number:$number,title:$title,body:$body,url:$url}'
1612
+ fi
1613
+ ;;
1614
+
1615
+ pr-body)
1616
+ num="${1:?pr-body needs a PR number}"; shift
1617
+ body=""
1618
+ while [ $# -gt 0 ]; do
1619
+ case "$1" in
1620
+ --body) body="${2:?}"; shift 2 ;;
1621
+ *) die "pr-body: unknown arg $1" ;;
1622
+ esac
1623
+ done
1624
+ [ -n "$body" ] || die "pr-body needs --body"
1625
+ if [ "$be" = "github" ]; then
1626
+ gh pr edit "$num" --body "$body"
1627
+ else
1628
+ bb pr edit "$num" --body "$body"
1629
+ fi
1630
+ ;;
1631
+
1632
+ rate-limit)
1633
+ # Report the remaining API budget, per API. GitHub has TWO separate budgets
1634
+ # (GraphQL and REST/core), and exhausting one says nothing about the other.
1635
+ # Bitbucket has a single budget and no way to query it, so it reports unknown.
1636
+ #
1637
+ # Output: JSON object with the remaining budget per API:
1638
+ # {"graphql":{"remaining":N,"limit":N,"reset":N},"core":{"remaining":N,"limit":N,"reset":N}}
1639
+ # where N is a number, or each field is "unknown" when the host cannot answer.
1640
+ #
1641
+ # `reset` is epoch seconds — when the budget resets. A caller that wants
1642
+ # "time until reset" computes it from now.
1643
+ #
1644
+ # The call itself is FREE — `gh api rate_limit` consumes neither bucket.
1645
+ # Measured 2026-08-27: three consecutive readings, all `used=0`.
1646
+ #
1647
+ # This op REPORTS; it does not decide. A caller that wants to fall back when
1648
+ # one budget is spent reads this, compares remaining to zero, and acts.
1649
+ if [ "$be" = "github" ]; then
1650
+ if out="$(gh api rate_limit 2>/tmp/plot-host-err.$$)"; then
1651
+ rm -f "/tmp/plot-host-err.$$"
1652
+ # The payload has `resources.graphql` and `resources.core`, each with
1653
+ # `remaining`, `limit`, and `reset`. Extract the two we care about.
1654
+ jq -c '{
1655
+ graphql: {
1656
+ remaining: .resources.graphql.remaining,
1657
+ limit: .resources.graphql.limit,
1658
+ reset: .resources.graphql.reset
1659
+ },
1660
+ core: {
1661
+ remaining: .resources.core.remaining,
1662
+ limit: .resources.core.limit,
1663
+ reset: .resources.core.reset
1664
+ }
1665
+ }' <<<"$out"
1666
+ else
1667
+ err="$(cat "/tmp/plot-host-err.$$" 2>/dev/null)"; rm -f "/tmp/plot-host-err.$$"
1668
+ # The host could not be asked. Report unknown rather than failing outright,
1669
+ # because this is informational — a caller that cannot read the budget
1670
+ # should proceed with the default path, not error out.
1671
+ echo '{"graphql":{"remaining":"unknown","limit":"unknown","reset":"unknown"},"core":{"remaining":"unknown","limit":"unknown","reset":"unknown"}}'
1672
+ echo "plot-host: rate-limit query failed: $err" >&2
1673
+ fi
1674
+ else
1675
+ # Bitbucket has no budget reporting. `unknown` is not `zero` — a host that
1676
+ # cannot answer the budget question must report unknown, because zero means
1677
+ # *spent*, and a caller that reads "cannot ask" as "exhausted" will take
1678
+ # the expensive fallback path forever.
1679
+ echo '{"graphql":{"remaining":"unknown","limit":"unknown","reset":"unknown"},"core":{"remaining":"unknown","limit":"unknown","reset":"unknown"}}'
1680
+ fi
1681
+ ;;
1682
+
1683
+ *)
1684
+ die "unknown op '$op' (backend|default-branch|pr-state|pr-create|pr-merge|pr-list|issue-list|issue-view|pr-body|rate-limit)"
1685
+ ;;
1686
+ esac