@plot-pm/board 0.16.2 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/plot-host.sh CHANGED
@@ -71,7 +71,54 @@
71
71
  # pr-merge <number> [--squash] [--delete-branch]
72
72
  # pr-ready <number> take a PR out of draft
73
73
  # merge the PR
74
- # pr-list [--state open|merged|closed|all] [--limit N] [--rich]
74
+ # pr-list [--state open|merged|closed|all] [--limit N] [--rich|--rich-open]
75
+ # --rich-open asks the rich fields of the OPEN
76
+ # pull requests only, and emits every row in the
77
+ # --rich shape. A MERGED or CLOSED row carries
78
+ # checks:"unknown", mergeable:"unknown",
79
+ # review:"" and failing_checks:[] — the ABSENT
80
+ # values the Bitbucket arm already emits, which
81
+ # mean NOT ASKED and never "no checks".
82
+ # WHY: the four rich fields are verdicts about a
83
+ # PR's HEAD, and a terminal head is finished —
84
+ # no check will run against it and no review will
85
+ # change. Measured 2026-10-01 on this
86
+ # repository, `--rich --state all --limit 1000`
87
+ # took 43.0 s against 7.3 s without the rich
88
+ # fields, and all 1000 rows were terminal, so
89
+ # 36 s bought answers nobody can use.
90
+ # Re-measured 2026-10-02 with 3 PRs open:
91
+ # `--rich --state open` 2.6-3.3 s, `--state all`
92
+ # 8.6-10.5 s, the split read 12.2-13.5 s.
93
+ # ON GITHUB IT IS TWO CALLS and the terminal
94
+ # rows come from the plain one; a PR open in both
95
+ # payloads is emitted once, from the rich call.
96
+ # A FAILURE IN EITHER CALL PRINTS NO ROWS AND
97
+ # EXITS NON-ZERO — a half answer reported as
98
+ # whole is what would let a caller fold a store
99
+ # that lost every open PR.
100
+ # ON BITBUCKET IT ANSWERS EXACTLY AS --rich,
101
+ # because that arm asks the host no verdict: its
102
+ # rows are already `unknown` whatever the state,
103
+ # so there is no second call to save.
104
+ # THE CALLER STILL ASKS ONCE. One `pr-list`
105
+ # invocation is one host question however many
106
+ # calls this makes, so a caller's per-refresh
107
+ # arithmetic is unchanged.
108
+ # `--limit` BOUNDS EACH CALL, NOT THE UNION, so
109
+ # the row count can exceed it by up to the number
110
+ # of open pull requests. Measured 2026-10-02 with
111
+ # 5 open: `--limit 5` answered 6 rows, where
112
+ # `--rich` answered 5 — the open call returned an
113
+ # open PR that fell outside the plain call's
114
+ # newest-5 page. It is NOT bounded per call and
115
+ # then trimmed, because trimming would drop
116
+ # either a verdict the rich call just bought or a
117
+ # terminal row the store needs, and the caller
118
+ # cannot say which. Harmless where `--limit`
119
+ # exceeds the open count, which is every caller
120
+ # today: the board asks 1000 against 5 open, and
121
+ # no other caller passes this flag.
75
122
  # [--since <iso>] narrows the listing to pull
76
123
  # requests the host has seen change since that
77
124
  # stamp. Measured 2026-09-21 on this repository:
@@ -86,16 +133,19 @@
86
133
  # two seconds fast excludes the PRs updated in
87
134
  # that gap from every later window — forever,
88
135
  # because the window never reopens.
89
- # GITHUB NARROWS AND BITBUCKET'S BULK LISTING
90
- # CANNOT. `gh pr list` takes `--search`; `bb pr
91
- # list` has no query flag at all (verified
92
- # against bb 1.9.0: `unknown flag: --query`), so
93
- # only the per-branch sweep's REST `q=` can
94
- # carry it. The bulk Bitbucket listing SAYS it
95
- # could not narrow and answers in full, because
96
- # a full answer reported as a delta is what
97
- # would let a caller advance a watermark over a
98
- # window it never applied.
136
+ # BOTH HOSTS NARROW, BY DIFFERENT ROUTES.
137
+ # `gh pr list` takes `--search`. `bb pr list`
138
+ # has no query flag (verified against bb 1.9.0:
139
+ # `unknown flag: --query`), so a windowed
140
+ # Bitbucket listing goes through
141
+ # `bb_window_listing`, which asks REST with
142
+ # `q=state="..." AND updated_on>="..."` and
143
+ # walks every page. It refuses a window whose
144
+ # rows fall short of the server's `size` rather
145
+ # than printing it as whole: a full answer
146
+ # reported as a delta is what would let a caller
147
+ # advance a watermark over a window it never
148
+ # applied.
99
149
  # [--repo <owner/repo>] pins the list to ONE
100
150
  # repository, exactly as pr-state and pr-merged
101
151
  # do. A checkout with remotes on two hosts lets
@@ -169,13 +219,11 @@
169
219
  # empty: `jen job list` carries no history and
170
220
  # no timestamps, and inventing them would be a
171
221
  # collector reaching a verdict.
172
- # run-for-sha <branch> <sha> the run for ONE sha — else the branch's newest
173
- # run, with `sha` saying which it is — as a
174
- # single JSON object, or nothing when the branch
175
- # has no runs at all. Dispatched on the `CI` key
176
- # like `runs`, and EXITS 4 on jenkins: `jen job
177
- # list` names no commit, so there is nothing to
178
- # match a sha against. Output:
222
+ # run-for-sha <branch> <sha> the run for ONE sha, and nothing for any
223
+ # other sha — as a single JSON object, or
224
+ # nothing when the branch has no run for this
225
+ # sha. Dispatched on the `CI` key like `runs`.
226
+ # Output:
179
227
  # {"sha":"…","status":"queued|in_progress|
180
228
  # completed|waiting|requested",
181
229
  # "conclusion":"success|failure|…|null",
@@ -185,22 +233,12 @@
185
233
  # and sha-blind, and `gh run list --branch X`
186
234
  # returns runs for every sha that branch ever
187
235
  # had — the newest run is NOT necessarily for
188
- # the newest commit. A green answer read off the
189
- # wrong run reports success for code nobody will
190
- # merge, which is worse than no answer: it
191
- # invites a merge of the wrong thing. Measured
192
- # 2026-08-30: two merge waiters reported on
193
- # superseded runs and had to be stopped and
194
- # re-armed.
195
- # THE FALLBACK IS WHAT MAKES THAT VISIBLE. Were
196
- # it to report nothing when the asked-for sha
197
- # has no run, a run IN FLIGHT for a superseded
198
- # commit would look exactly like no run at all,
199
- # and a caller could not tell "CI has not
200
- # started" from "CI is answering about the
201
- # past". `sha` names the run's own commit, and
202
- # comparing it to the one asked about is the
203
- # CALLER's rule — this decides nothing.
236
+ # the newest commit. A run for any sha but the
237
+ # one asked about is not evidence about it, so
238
+ # reporting it would invite a merge of the
239
+ # wrong thing. Measured 2026-08-30: two merge
240
+ # waiters reported on superseded runs and had
241
+ # to be stopped and re-armed.
204
242
  # `status` AND `conclusion` ARE BOTH REPORTED,
205
243
  # never collapsed. A run that is `completed` has
206
244
  # a conclusion; one that is `waiting` or
@@ -424,7 +462,6 @@ die3() { echo "plot-host: $*" >&2; exit 3; }
424
462
  # a person at a terminal want three different answers — and a retry inside the
425
463
  # adapter would hide the very state this code exists to surface, turning a
426
464
  # reportable fact into an unexplained four-minute call.
427
- die5() { echo "plot-host: $*" >&2; exit 5; }
428
465
 
429
466
  # Exit 6 — the host refused because too many calls arrived AT ONCE. A secondary
430
467
  # limit, and a different ceiling from the one exit 5 reports.
@@ -447,7 +484,6 @@ die5() { echo "plot-host: $*" >&2; exit 5; }
447
484
  #
448
485
  # NOT A RETRY, for the reason exit 5 states: whether to wait is the caller's
449
486
  # decision, and this adapter reports rather than reacts.
450
- die6() { echo "plot-host: $*" >&2; exit 6; }
451
487
 
452
488
  # WHICH FAILURE, read off the wording — the same shape `bb_issue_exit_code`
453
489
  # uses, and for the same reason: the exit code cannot split these cases. `gh`
@@ -464,18 +500,29 @@ die6() { echo "plot-host: $*" >&2; exit 6; }
464
500
  # `throttled` for every match of one regex until 2026-09-02, so *"API rate
465
501
  # limit exceeded"* and *"You have exceeded a secondary rate limit"* came back
466
502
  # the same word and nothing downstream could tell them apart. `secondary` is
467
- # now its own answer: `die6` carries it, and the board names which limit was
503
+ # now its own answer: `pr_list_failed` exits 6 for it, and the board names which limit was
468
504
  # hit rather than printing one reset over both.
469
505
  #
470
506
  # THE SECONDARY TEST RUNS FIRST, AND THE ORDER IS THE CLASSIFICATION. GitHub's
471
507
  # secondary message contains the phrase *"rate limit"* too — *"You have
472
508
  # exceeded a secondary rate limit"* — so a quota test applied first claims
473
509
  # every secondary refusal and the distinction is lost at the point it is made.
474
- host_failure_kind() { # $1=stderr text → throttled|secondary|failed
510
+ #
511
+ # FOUR ANSWERS, NOT THREE — #1087: a GraphQL 504 matched neither limit pattern,
512
+ # so it came back `failed`, and `failed` is the one kind a command fixes —
513
+ # `gh auth login`, wrong advice for a server that took too long to answer.
514
+ # `timeout` is anchored on the HTTP status word (`HTTP 50[234]`), never a bare
515
+ # `\b504\b`, which would match a run id or a line count just as well. It runs
516
+ # AFTER both limit arms: a rate-limit message that also carries a 5xx status
517
+ # must stay `throttled`, because patience until the reset is the right advice
518
+ # for it, and `timeout` would counsel exactly the wrong patience — none.
519
+ host_failure_kind() { # $1=stderr text → secondary|throttled|timeout|failed
475
520
  if LC_ALL=C grep -qiE 'secondary rate|exceeded a secondary|abuse detection|abuse-detection|too many requests|\b429\b' <<<"$1"; then
476
521
  echo secondary
477
522
  elif LC_ALL=C grep -qiE 'rate limit|ratelimit' <<<"$1"; then
478
523
  echo throttled
524
+ elif LC_ALL=C grep -qiE "HTTP 50[234]|couldn.t respond to your request in time" <<<"$1"; then
525
+ echo timeout
479
526
  else
480
527
  echo failed
481
528
  fi
@@ -555,6 +602,16 @@ pr_list_failed() { # $1=stderr text
555
602
  echo " or run against an account with quota left. No login will help." >&2
556
603
  exit 5
557
604
  ;;
605
+ timeout)
606
+ # NO REPAIR NAMED, FOR THE SAME REASON SECONDARY NAMES NONE — #1087. A
607
+ # 504 means the server took too long, not that the credential is bad, so
608
+ # `host_repair`'s `gh auth login` would send a reader to fix a login
609
+ # that was never broken. The decision is to ask again, not to log in.
610
+ echo "plot-host: pr-list: host timed out — ${err:-the host took too long and said nothing}" >&2
611
+ echo " Nothing is wrong with the login: the server took too long to answer." >&2
612
+ echo " The next refresh asks again." >&2
613
+ exit 3
614
+ ;;
558
615
  esac
559
616
  # THE ONE KIND A COMMAND FIXES. An auth gap and a DNS blip both land here, and
560
617
  # the repair for the first is a login this connector can name — see
@@ -580,7 +637,7 @@ pr_list_failed() { # $1=stderr text
580
637
  #
581
638
  # EVERY CALL SITE MUST WRITE `|| exit $?`, AND IT IS NOT OPTIONAL. This is
582
639
  # invoked as `_raw="$(pr_list_call …)"` — a COMMAND SUBSTITUTION, which is a
583
- # subshell — so the `exit` inside `die5`/`die3` leaves that subshell only. The
640
+ # subshell — so the `exit` inside `pr_list_failed`/`die3` leaves that subshell only. The
584
641
  # outer script would carry on with `_raw` empty and `jq` would emit nothing:
585
642
  # the silent empty list this whole helper exists to remove, rebuilt one layer
586
643
  # further in and harder to see. The same trap `bb_states_for` documents a few
@@ -2281,6 +2338,47 @@ tracker_scheme() {
2281
2338
  tracker_raw | awk '{print tolower($1)}'
2282
2339
  }
2283
2340
 
2341
+ # WHO ANSWERS THE OPEN-ISSUE LIST — the domain's answer, not this script's.
2342
+ #
2343
+ # `issue-list` and `issue-view` tested `tracker_scheme = jira` and sent every
2344
+ # other scheme to the git host. That test was a DRIFTED SECOND COPY of the rule:
2345
+ # measured 2026-10-01, `PLOT_HOST=github PLOT_TRACKER=linear issue-list` exited 0
2346
+ # having called `gh issue list`, so a repository tracking in Linear was shown
2347
+ # GitHub's issues under Linear's name — a list not wrong about any single row
2348
+ # and wrong about all of them.
2349
+ #
2350
+ # So the rule is ASKED rather than re-implemented. `issueSource` has eight unit
2351
+ # tests and the board already asks it (`fleet.ts:2369-2380`); a lister list in
2352
+ # shell would be the same copy one directory over, free to drift again the first
2353
+ # time a connector is added.
2354
+ #
2355
+ # THE ONE NODE HOP THIS SCRIPT MAKES, and the cost rule permits it: `plot-host.sh`
2356
+ # runs once per operator command or once per board PR refresh, never once per
2357
+ # agent per pass, and a shipped bundle answers in 39 ms
2358
+ # (`docs/shell-and-domain.md`).
2359
+ #
2360
+ # Prints the entry's line verbatim — `tracker\t<scheme>`, `git-host`, or
2361
+ # `nobody\t<reason>` — and exits 0. Exits 1, naming the entry, when the rule
2362
+ # CANNOT BE ASKED: no node, no bundle, a non-zero exit, or a word outside the
2363
+ # three. THAT CASE NEVER FALLS THROUGH TO THE GIT HOST — the fall-through is
2364
+ # the defect, and `an-outage-is-not-an-answer` is what a list saying *none*
2365
+ # because it could not ask reproduces.
2366
+ issue_source() {
2367
+ local out rc
2368
+ out="$(tracker_raw | node "$here/board/plot-issue-source.mjs" "$be" 2>/dev/null)"; rc=$?
2369
+ if [ "$rc" -ne 0 ]; then
2370
+ echo "plot-host: cannot ask board/plot-issue-source.mjs who lists this repository's issues (exit $rc); the question failed and the git host is not asked in its place" >&2
2371
+ return 1
2372
+ fi
2373
+ case "${out%%$'\t'*}" in
2374
+ tracker|git-host|nobody) printf '%s\n' "$out" ;;
2375
+ *)
2376
+ echo "plot-host: board/plot-issue-source.mjs answered '${out}', which is not one of tracker/git-host/nobody; the question failed and the git host is not asked in its place" >&2
2377
+ return 1
2378
+ ;;
2379
+ esac
2380
+ }
2381
+
2284
2382
  tracker_base_url() {
2285
2383
  # The base URL is the SECOND token; a bare `jira` with no URL yields "".
2286
2384
  # PLOT_JIRA_BASE_URL overrides, for a caller that has the URL separately.
@@ -2610,6 +2708,29 @@ bb_list_page_length() {
2610
2708
  esac
2611
2709
  }
2612
2710
 
2711
+ # A PAGE THAT IS PROVABLY WHOLE SAYS SO, and that sentence is the licence a
2712
+ # joining caller needs. This reported only truncation until 2026-10-02: a
2713
+ # complete page was SILENT, so a caller could not tell "this page holds every
2714
+ # PR" from "this adapter has no opinion", and the only reading left to it was
2715
+ # the page's own row count against its own limit. That is a coincidence rather
2716
+ # than evidence — measured on this repository, `--state all --limit 1000`
2717
+ # returned exactly 1000 rows of 1064 PRs, so the count-based test failed, the
2718
+ # scan's `.list-complete` was withheld, and every branch the join did not name
2719
+ # fell through to one `pr-state` call each: 26 calls at 3.8 s, 54-61% of the
2720
+ # scan's wall time in slice 1's five runs (#1017).
2721
+ #
2722
+ # THE CLAIM IS THE ADAPTER'S BECAUSE THE PAGING SEMANTICS ARE. Whether a short
2723
+ # page proves anything depends on how the host pages, which is exactly what
2724
+ # this script knows and a caller does not. `pr_sweep_report` already states its
2725
+ # own stronger claim in this shape and `plot-fleet-scan.sh` already reads that
2726
+ # sentence; this is the same contract for the second path, so a listing is no
2727
+ # longer the one answer a caller has to guess at.
2728
+ #
2729
+ # BOTH SENTENCES OR NEITHER. The complete and truncated reports are the two
2730
+ # outcomes of one decision, so they are emitted from one place: a reader can
2731
+ # never see both for one state, and silence now means only that no claim was
2732
+ # owed — no `--limit`, or an empty page.
2733
+ #
2613
2734
  # $1 backend $2 requested limit (may be "") $3 state word $4 row count
2614
2735
  pr_list_report_truncation() {
2615
2736
  local be="$1" limit="$2" state="$3" count="$4" len=""
@@ -2617,17 +2738,33 @@ pr_list_report_truncation() {
2617
2738
  [ "$count" -gt 0 ] 2>/dev/null || return 0 # an empty page had nothing to hide
2618
2739
  if [ "$be" = "github" ]; then
2619
2740
  # github honours the limit: complete unless the page came back AT the limit.
2620
- [ "$count" -ge "$limit" ] 2>/dev/null || return 0
2741
+ # Measured 2026-10-02 on this repository (1064 PRs): `--limit 1100`
2742
+ # answered 1065 rows and `--limit 2000` the same 1065, so a page short of
2743
+ # its limit is the whole list and asking generously costs the rows that
2744
+ # exist rather than the rows requested.
2745
+ if [ "$count" -lt "$limit" ] 2>/dev/null; then pr_list_report_complete "$be" "$limit" "$state" "$count"; return 0; fi
2621
2746
  else
2622
2747
  # A fixed page: complete below a measured page length, unprovable otherwise.
2623
2748
  [ "$be" = "bitbucket" ] && len="$(bb_list_page_length)"
2624
- if [ -n "$len" ] && [ "$count" -lt "$len" ] 2>/dev/null; then return 0; fi
2749
+ if [ -n "$len" ] && [ "$count" -lt "$len" ] 2>/dev/null; then pr_list_report_complete "$be" "$limit" "$state" "$count"; return 0; fi
2625
2750
  fi
2626
2751
  # Named per state so a caller can resolve exactly the states that were
2627
2752
  # capped, not a whole-call flag that over-reports.
2628
2753
  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
2629
2754
  }
2630
2755
 
2756
+ # THE WORDING IS A CONTRACT between this script and `plot-fleet-scan.sh`, pinned
2757
+ # on both sides exactly as `pr-list sweep complete` is. A page that cannot prove
2758
+ # itself whole never prints it, so a match is licence and a miss is silence —
2759
+ # never a guess.
2760
+ #
2761
+ # PER STATE, like the truncation report beside it, because the scan asks for two
2762
+ # states and joins both: a caller needs to know which pages were whole, not that
2763
+ # some were.
2764
+ pr_list_report_complete() { # $1 backend $2 limit $3 state word $4 row count
2765
+ echo "plot-host: $1 pr-list state=$3 page complete ($4 rows below requested limit $2) — this page holds every pull request the host has in this state" >&2
2766
+ }
2767
+
2631
2768
  # The backends this script has an arm for, which is what "drivable" means here.
2632
2769
  #
2633
2770
  # THE LIST LIVES IN THE SCRIPT BECAUSE THE SCRIPT IS WHAT WOULD CHANGE. Adding a
@@ -3657,6 +3794,10 @@ case "$op" in
3657
3794
  pr-list)
3658
3795
  state="open"
3659
3796
  rich=0
3797
+ # Whether the rich fields are asked of the OPEN pull requests only. Set by
3798
+ # `--rich-open`, which also sets `rich`: the flag narrows what is ASKED and
3799
+ # never what is emitted.
3800
+ rich_open=0
3660
3801
  # PIN THE LIST TO ONE REPOSITORY, the same `--repo` `pr-state` and
3661
3802
  # `pr-merged` already take. A checkout may carry several remotes on several
3662
3803
  # hosts, and an unpinned `gh pr list` resolves whichever of them it prefers
@@ -3681,6 +3822,22 @@ case "$op" in
3681
3822
  --state) state="${2:?}"; shift 2 ;;
3682
3823
  --limit) limit="${2:?}"; shift 2 ;;
3683
3824
  --rich) rich=1; shift ;;
3825
+ # THE RICH FIELDS OF THE OPEN PRs AND THE PLAIN FIELDS OF THE REST.
3826
+ # Measured 2026-10-01 on this repository: `--rich --state all --limit
3827
+ # 1000` took 43.0 s and the same call without the rich fields took 7.3 s,
3828
+ # while all 1000 rows were terminal — so 36 s bought four verdicts about
3829
+ # heads nobody can act on. Re-measured 2026-10-02 with 3 PRs open:
3830
+ # `--rich --state open` is 2.6-3.3 s and `--state all` is 8.6-10.5 s.
3831
+ #
3832
+ # IT IMPLIES `--rich`, so every row carries every rich field and no
3833
+ # consumer has to ask which call produced it. A terminal row's verdicts
3834
+ # arrive at the ABSENT values the Bitbucket arm already emits, which the
3835
+ # store's schema accepts — so no `PR_INDEX_VERSION` bump follows.
3836
+ #
3837
+ # THE SPLIT IS ONLY WORTH MAKING OVER A SET THAT HOLDS TERMINAL ROWS.
3838
+ # With `--state open` asked, both calls would list the same PRs and the
3839
+ # second would be waste, so this collapses to plain `--rich` there.
3840
+ --rich-open) rich=1; rich_open=1; shift ;;
3684
3841
  --repo) repo_args=(-R "${2:?}"); shift 2 ;;
3685
3842
  # `${2:?}` REFUSES AN EMPTY VALUE, and that is the point rather than
3686
3843
  # boilerplate. `--since ""` would reach GitHub as `--search "updated:>"`,
@@ -3759,6 +3916,57 @@ case "$op" in
3759
3916
  fi
3760
3917
 
3761
3918
  if [ "$be" = "github" ]; then
3919
+ # THE SPLIT READ, AND IT IS TWO CALLS BECAUSE THE COST IS PER ROW.
3920
+ # `gh pr list` pages internally and takes no page-size flag, so 1000 rich
3921
+ # rows cost 1000 rows' worth of `statusCheckRollup` whatever the page.
3922
+ # Narrowing the SET is the only lever: the rich fields are asked of the
3923
+ # open pull requests, and everything else comes back plain.
3924
+ #
3925
+ # ORDER MATTERS — THE RICH ROWS GO FIRST. A PR that is open appears in
3926
+ # both payloads, and a consumer keyed by number takes the last line it
3927
+ # read; the second call is filtered to the terminal states instead, so no
3928
+ # number is emitted twice and no rich row is overwritten by a plain one.
3929
+ # Filtered rather than ordered, because a caller that sorts or de-dupes
3930
+ # differently must not be able to reach a different answer.
3931
+ #
3932
+ # ASKED ONLY WHERE THE SET CAN HOLD A TERMINAL ROW. With `--state open`
3933
+ # the two calls would list the same PRs, so this collapses to `--rich`.
3934
+ if [ "$rich_open" = 1 ] && [ "$state" != "open" ]; then
3935
+ # THE OPEN HALF, RICH. Asked with the caller's own `--limit` and
3936
+ # `--since`: a window the caller set applies to both halves, or the two
3937
+ # would answer about different populations.
3938
+ #
3939
+ # `|| exit $?` ON BOTH, and the rule `pr_list_call`'s header states is
3940
+ # what makes it load-bearing here too: this runs in a command
3941
+ # substitution, so a `die` inside leaves only that subshell and the
3942
+ # outer script would carry on with an empty payload. A failing open call
3943
+ # and a failing all call must each exit non-zero and print no rows.
3944
+ _rich_open_out="$("$0" pr-list --rich --state open \
3945
+ ${limit:+--limit "$limit"} ${since:+--since "$since"} \
3946
+ ${repo_args[1]+--repo "${repo_args[1]}"} \
3947
+ $(for _b in $branches; do printf -- '--branch %s ' "$_b"; done))" || exit $?
3948
+ # THE REST, PLAIN, AND THE TERMINAL ROWS ARE THE ONES KEPT. `--state
3949
+ # all` is asked rather than merged+closed separately: GitHub answers it
3950
+ # in one call, and two calls would double a cost this exists to halve.
3951
+ _plain_all_out="$("$0" pr-list --state "$state" \
3952
+ ${limit:+--limit "$limit"} ${since:+--since "$since"} \
3953
+ ${repo_args[1]+--repo "${repo_args[1]}"} \
3954
+ $(for _b in $branches; do printf -- '--branch %s ' "$_b"; done))" || exit $?
3955
+ [ -n "$_rich_open_out" ] && printf '%s\n' "$_rich_open_out"
3956
+ # THE ABSENT VALUES ARE THE BITBUCKET ARM'S, not invented here: a
3957
+ # terminal row carries every field a rich row carries, so one shape
3958
+ # reaches every consumer. `checks:"unknown"` means NOT ASKED and never
3959
+ # *no checks* — the board's fold takes the verdict it already holds for
3960
+ # the same number, and a row with none reads as unavailable.
3961
+ #
3962
+ # `draft`, `url` and `updatedAt` are REAL here — the plain GitHub arm
3963
+ # asks for them — so only the four verdicts are absent.
3964
+ [ -n "$_plain_all_out" ] && printf '%s\n' "$_plain_all_out" \
3965
+ | jq -c 'select(.state=="MERGED" or .state=="CLOSED")
3966
+ | . + {checks:"unknown", mergeable:"unknown", review:"",
3967
+ failing_checks:[]}'
3968
+ exit 0
3969
+ fi
3762
3970
  if [ "$rich" = 1 ]; then
3763
3971
  # `checks` has FOUR states, and two of them mean "a person is the
3764
3972
  # blocker" rather than "a machine is busy":
@@ -3898,12 +4106,25 @@ case "$op" in
3898
4106
  }'
3899
4107
  fi
3900
4108
  else
4109
+ # `isDraft`, `url` AND `updatedAt` TRAVEL ON THE PLAIN ARM TOO, and they
4110
+ # are cheap: measured 2026-09-21, `number,updatedAt` over 937 PRs is
4111
+ # 4715 ms against 5417 ms for the base fields, where
4112
+ # `statusCheckRollup` alone is 18 842 ms. They are scalar columns on the
4113
+ # PR node; the rollup is a per-row resolution, and that is the whole
4114
+ # 36 s `--rich-open` removes.
4115
+ #
4116
+ # ASKED HERE BECAUSE `--rich-open`'S TERMINAL ROWS COME THROUGH THIS
4117
+ # ARM, and a terminal row must carry every field a rich row carries —
4118
+ # `updatedAt` most of all, since it is the field a durable store
4119
+ # advances its watermark by. Without it a split full read would leave
4120
+ # the store unable to narrow and the daily full read would never fall
4121
+ # due against anything but a cold window.
3901
4122
  _gh_raw="$(pr_list_call gh ${repo_args[@]+"${repo_args[@]}"} pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} ${search_args[@]+"${search_args[@]}"} \
3902
- --json number,title,state,headRefName,author)" || exit $?
4123
+ --json number,title,state,headRefName,isDraft,url,updatedAt,author)" || exit $?
3903
4124
  pr_list_report_truncation github "$limit" "$state" \
3904
4125
  "$(jq 'length' <<<"$_gh_raw" 2>/dev/null || echo 0)"
3905
4126
  printf '%s' "$_gh_raw" \
3906
- | jq -c '.[] | {number:.number,title:.title,state:.state,head:.headRefName,author:(.author.login // "")}'
4127
+ | jq -c '.[] | {number:.number,title:.title,state:.state,head:.headRefName,draft:.isDraft,url:.url,updatedAt:(.updatedAt // ""),author:(.author.login // "")}'
3907
4128
  fi
3908
4129
  else
3909
4130
  # `author` IS THE `nickname`, the one handle field a Bitbucket user
@@ -4114,7 +4335,7 @@ case "$op" in
4114
4335
  ;;
4115
4336
 
4116
4337
  run-for-sha)
4117
- # The newest run for ONE sha — the BuildMonitor's only host question.
4338
+ # The run for ONE sha, or nothing — the BuildMonitor's only host question.
4118
4339
  #
4119
4340
  # WHY THIS IS NOT `runs`. `runs` is branch-scoped and reports no sha at all,
4120
4341
  # so a caller cannot tell which commit an answer is about. `gh run list
@@ -4141,9 +4362,9 @@ case "$op" in
4141
4362
  # deliberately NOT an error: a monitor polling a fresh push sees it on every
4142
4363
  # pass until CI wakes up.
4143
4364
  #
4144
- # Bitbucket reports nothing rather than something invented, exactly as
4145
- # `runs` does: `bb` has no run listing, and silence here reads as
4146
- # unavailable, never as "this sha has no build".
4365
+ # A Bitbucket remote exits 4 (`bb` has no run listing), as does a failing
4366
+ # `gh` or Jenkins that cannot be asked: exit 4 reads as unavailable, never
4367
+ # as "this sha has no build".
4147
4368
  branch="${1:?run-for-sha needs a branch}"; shift
4148
4369
  sha="${1:?run-for-sha needs a sha}"; shift
4149
4370
  # Enough runs to find the sha among its neighbours. A branch accumulates
@@ -4232,10 +4453,9 @@ case "$op" in
4232
4453
  echo "plot-host: run-for-sha — Jenkins did not answer for '$_jen_host'" >&2
4233
4454
  exit 4
4234
4455
  fi
4235
- # THE SAME FALLBACK RULE AS THE GITHUB ARM, and it is inherited rather
4236
- # than invented: the asked-for sha if a build carries it, else the
4237
- # newest build, and `sha` says WHICH. A caller that could not tell the
4238
- # two apart would be back to the branch-scoped guessing this op ends.
4456
+ # THE SAME MATCH RULE AS THE GITHUB ARM: only the asked-for sha, never
4457
+ # another build's. A build for any other commit is not evidence about
4458
+ # this one, so no match means no output.
4239
4459
  #
4240
4460
  # `result` is null while a build runs, which is Jenkins' own word for
4241
4461
  # *in flight* — mapped to the `status`/`conclusion` split the contract
@@ -4247,7 +4467,7 @@ case "$op" in
4247
4467
  conclusion: (if .building then null else (.result // null) end),
4248
4468
  url: (.url // ""),
4249
4469
  startedAt: (if .timestamp then (.timestamp / 1000 | todate) else "" end) } ]
4250
- | ((map(select(.sha == $sha)) | .[0]) // .[0])
4470
+ | (map(select(.sha == $sha)) | .[0])
4251
4471
  | select(. != null)' 2>/dev/null || true
4252
4472
  # THIS ARM ANSWERS AND THE OP IS OVER. Everything below the `esac` is
4253
4473
  # the GitHub path — the old jenkins arm reached it only because it
@@ -4277,30 +4497,24 @@ case "$op" in
4277
4497
  # newest-first, and a sha can carry several (a rerun, or several
4278
4498
  # workflows). The newest is the live answer; older ones for the same sha
4279
4499
  # are superseded by the same argument that superseded runs for older shas.
4280
- # THE SHA ASKED ABOUT IF THERE IS ONE, ELSE THE NEWEST RUN ON THE BRANCH —
4281
- # and `sha` in the output says WHICH, because a caller that could not tell
4282
- # the two apart would be back to the branch-scoped guessing this op exists
4283
- # to end.
4284
4500
  #
4285
- # WHY IT FALLS BACK AT ALL, rather than reporting nothing. Filtering to
4286
- # the asked-for sha and stopping makes the most important case invisible:
4287
- # a run IN FLIGHT for a commit the branch has already moved past reports
4288
- # identically to no run at all, so a caller cannot distinguish *CI has not
4289
- # started yet* from *CI is busy answering about the past*. The second is
4290
- # the state that had two merge waiters reporting on superseded runs on
4291
- # 2026-08-30, and it is exactly what a caller needs to see.
4501
+ # ONLY THE ASKED-FOR SHA, NEVER ANOTHER ONE'S RUN. A run for any other
4502
+ # commit is not evidence about this one — reporting it would read as a
4503
+ # live answer for a commit the branch has already moved past, which is
4504
+ # worse than no answer. No match means no output, read the same as a
4505
+ # branch with no runs at all.
4292
4506
  #
4293
- # IT STILL DECIDES NOTHING (Principle 3). It reports the run it found and
4294
- # the sha that run is for; whether that sha being different from the one
4295
- # asked about means "superseded" is the caller's rule. This collects.
4296
- gh run list --branch "$branch" --limit "$limit" \
4297
- --json headSha,conclusion,status,startedAt,url 2>/dev/null \
4298
- | jq -c --arg sha "$sha" \
4299
- '(map(select(.headSha == $sha)) | .[0]) // .[0]
4300
- | select(. != null)
4301
- | {sha:.headSha, status:.status,
4302
- conclusion:(if (.conclusion // "") == "" then null else .conclusion end),
4303
- url:.url, startedAt:.startedAt}' 2>/dev/null || true
4507
+ # A FAILING `gh` IS NOT AN EMPTY HISTORY. An expired token, a rate limit or
4508
+ # a network failure exits 4 here, the way the Jenkins arm does, so the
4509
+ # monitor reads *could not ask* and not *no run yet*.
4510
+ _gh_runs=$(gh run list --branch "$branch" --limit "$limit" \
4511
+ --json headSha,conclusion,status,startedAt,url 2>/dev/null) \
4512
+ || { echo "plot-host: run-for-sha — gh run list failed for '$branch'" >&2; exit 4; }
4513
+ printf '%s' "$_gh_runs" | jq -c --arg sha "$sha" \
4514
+ '(map(select(.headSha == $sha)) | .[0]) | select(. != null)
4515
+ | {sha:.headSha, status:.status,
4516
+ conclusion:(if (.conclusion // "") == "" then null else .conclusion end),
4517
+ url:.url, startedAt:.startedAt}' || exit 4
4304
4518
  ;;
4305
4519
 
4306
4520
  issue-list)
@@ -4324,7 +4538,19 @@ case "$op" in
4324
4538
  done
4325
4539
  limit_args=()
4326
4540
  [ -n "$limit" ] && limit_args=(--limit "$limit")
4327
- if [ "$(tracker_scheme)" = "jira" ]; then
4541
+ # WHO LISTS THESE ISSUES IS ASKED ONCE, HERE, BEFORE THE ARMS — after the
4542
+ # argument parsing, so a caller's typo is still reported as a usage error
4543
+ # rather than as a tracker refusal. The arms below then dispatch on the
4544
+ # ANSWER, never on a second reading of the config.
4545
+ source_said="$(issue_source)" || exit 1
4546
+ if [ "${source_said%%$'\t'*}" = "nobody" ]; then
4547
+ # Exit 4, the code this op already documents as *this host cannot be
4548
+ # asked at all* — a configuration a person fixes, not a call to retry.
4549
+ # NOTHING HAS BEEN SPENT: no `gh`, no `bb`, no curl has run yet.
4550
+ echo "plot-host: ${source_said#*$'\t'}" >&2
4551
+ exit 4
4552
+ fi
4553
+ if [ "$source_said" = $'tracker\tjira' ]; then
4328
4554
  # Jira, resolved through the REST API — DISPATCHED ON `Tracker`, never on
4329
4555
  # `backend()`: a Bitbucket repo tracking in Jira is the normal enterprise
4330
4556
  # case, so the git host is irrelevant here (see the Jira helpers up top).
@@ -4502,7 +4728,15 @@ case "$op" in
4502
4728
  # 4 to `unsupported` and anything else to `failed` must not need a second
4503
4729
  # table to read this op.
4504
4730
  num="${1:?issue-view needs an issue number}"; shift
4505
- if [ "$(tracker_scheme)" = "jira" ]; then
4731
+ # Asked once, before the arms, exactly as issue-list does — the same
4732
+ # question with the same three answers and the same exit codes. A consumer
4733
+ # that maps 4 to `unsupported` needs no second table to read this op.
4734
+ source_said="$(issue_source)" || exit 1
4735
+ if [ "${source_said%%$'\t'*}" = "nobody" ]; then
4736
+ echo "plot-host: ${source_said#*$'\t'}" >&2
4737
+ exit 4
4738
+ fi
4739
+ if [ "$source_said" = $'tracker\tjira' ]; then
4506
4740
  # Jira, dispatched on `Tracker` not `backend()` — the same rule issue-list
4507
4741
  # follows. `num` is a Jira KEY (PROJ-123), read off issue-list moments ago.
4508
4742
  #