@plot-pm/board 0.8.1 → 0.9.1
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 +120 -111
- package/package.json +11 -2
- package/plot-approve.sh +628 -0
- package/plot-config.sh +14 -0
- package/plot-deliver.sh +488 -0
- package/plot-dispatch.sh +2028 -0
- package/plot-fleet-scan.sh +3496 -0
- package/plot-host.sh +1686 -0
- package/plot-plan-meta.sh +133 -16
- package/plot-reap.sh +286 -0
- package/plot-release-refs.sh +234 -0
- package/plot-resolve-artifact.sh +334 -0
- package/plot-worker-state.sh +725 -0
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
|