@plot-pm/board 0.14.4 → 0.16.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
@@ -19,7 +19,18 @@
19
19
  # than a transient to retry. Adding a host is
20
20
  # an edit to `HOST_DRIVES` and the arms below,
21
21
  # and to nothing in `packages/domain`.
22
- # default-branch print the repo's default branch name
22
+ # default-branch print the repo's default branch name. THE HOST
23
+ # IS ASKED, on both backends, and the local
24
+ # `origin/HEAD` is the fallback where it cannot
25
+ # answer — that ref is a clone-time cache and a
26
+ # default branch moved since then never updates
27
+ # it. Prints "" where neither can answer.
28
+ # account print the signed-in host user, read from the
29
+ # CLI's config file (no API request).
30
+ # `PLOT_BUDGET_ACCOUNT` overrides it. Exit 3
31
+ # where GitHub's `hosts.yml` names no user;
32
+ # exit 4 on Bitbucket, which stores no username
33
+ # for free. Never prints `unknown`.
23
34
  # pr-state <number|branch> [--repo <owner/repo>] one JSON object:
24
35
  # {"number":N,"state":"OPEN|MERGED|CLOSED|NONE",
25
36
  # "draft":true|false,"url":"..."}
@@ -380,6 +391,15 @@ set -uo pipefail
380
391
 
381
392
  here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
382
393
 
394
+ # Every temp path this script creates, and its only EXIT/INT/TERM traps.
395
+ . "$here/plot-tmp.sh"
396
+
397
+ # The host CLI's stderr for one call, read back after the call. One file per
398
+ # process, created here in the parent because the calls redirect into it from
399
+ # inside command substitutions, where an assignment would not reach the caller.
400
+ HOST_ERR=""
401
+ plot_tmpfile HOST_ERR host-err || { echo "plot-host: no temp file could be made under ${TMPDIR:-/tmp}" >&2; exit 3; }
402
+
383
403
  die() { echo "plot-host: $*" >&2; exit 1; }
384
404
 
385
405
  # Exit 3 — reserved for "the op itself cannot proceed", distinct from `die`'s
@@ -564,7 +584,9 @@ pr_list_failed() { # $1=stderr text
564
584
  # hundred lines below, where a `die` in a subshell turned an unknown state into
565
585
  # "no PRs matched".
566
586
  pr_list_call() { # "$@"=the host command → payload on stdout, or dies
567
- local out rc err tmp="/tmp/plot-host-prlist-err.$$"
587
+ local out rc err tmp=""
588
+ plot_tmpfile tmp host-prlist-err \
589
+ || { echo "plot-host: no temp file could be made under ${TMPDIR:-/tmp}" >&2; exit 3; }
568
590
  out="$("$@" 2>"$tmp")"; rc=$?
569
591
  err="$(cat "$tmp" 2>/dev/null)"; rm -f "$tmp"
570
592
  [ "$rc" -eq 0 ] || pr_list_failed "$err"
@@ -847,7 +869,7 @@ bb_branch_sweep() { # global bb args… --state <s> --json → one JSON array
847
869
  # already seen once per branch, so eleven branches parsed the first branch's
848
870
  # payload eleven times.
849
871
  local _spool
850
- _spool="$(mktemp "/tmp/plot-host-sweep.$$.XXXXXX")" || return 3
872
+ plot_tmpfile _spool host-sweep || return 3
851
873
  for _br in $PR_LIST_BRANCHES; do
852
874
  # RETURN, NOT EXIT. This runs inside the command substitution
853
875
  # `pr_list_call` wraps the sweep in, so the code must travel back as this
@@ -856,7 +878,7 @@ bb_branch_sweep() { # global bb args… --state <s> --json → one JSON array
856
878
  # as the sweep's own — the silent empty list `pr_list_call`'s header names.
857
879
  #
858
880
  # The spool is removed on EVERY exit path, including the failing one: a
859
- # sweep that gives up mid-way must not leave a payload behind in /tmp.
881
+ # sweep that gives up mid-way must not leave a payload behind in TMPDIR.
860
882
  bb_branch_query "$_br" "$_st" ${_args[@]+"${_args[@]}"} >> "$_spool" \
861
883
  || { _rc=$?; rm -f "$_spool"; return $_rc; }
862
884
  done
@@ -869,6 +891,135 @@ bb_branch_sweep() { # global bb args… --state <s> --json → one JSON array
869
891
  printf '%s' "$_acc"
870
892
  }
871
893
 
894
+ # List every pull request in ONE state updated inside the window, as one array.
895
+ #
896
+ # THE WINDOWED LISTING, and the third command in `pr_list_states`' slot beside
897
+ # `bb pr list` and `bb_branch_sweep`. It takes the same trailing
898
+ # `--state <s> --json` and prints the same JSON array, so the states loop, the
899
+ # partial-answer rule and the one error classification stay in that helper.
900
+ #
901
+ # THE QUERY IS `bb_branch_query`'s WITHOUT THE BRANCH CLAUSE:
902
+ #
903
+ # /repositories/{ws}/{repo}/pullrequests
904
+ # ?q=state="MERGED" AND updated_on>="<since>"&pagelen=50
905
+ #
906
+ # Measured 2026-09-28 on `quatico/quaweb-website`: `size: 9` in one request,
907
+ # against 895 rows for the unwindowed listing of the same state.
908
+ #
909
+ # THE STATE TRAVELS INSIDE `q=`, NEVER BESIDE IT. Bitbucket lets `q=` silently
910
+ # override a `state=` parameter, so `?q=…&state=MERGED` answers for whatever the
911
+ # `q=` says and discards the state. `bb_query_state` maps the adapter's word to
912
+ # Bitbucket's inside the expression.
913
+ #
914
+ # EVERY PAGE IS READ. `bb api` makes ONE request and returns ONE raw page, so a
915
+ # window holding more than 50 matches arrives in several. The payload's `next`
916
+ # names the following page as an absolute URL, and `bb api` prefixes its own
917
+ # base, so the base is removed before the call. The walk ends when `next` is
918
+ # absent.
919
+ #
920
+ # A SHORT WINDOW IS REFUSED, NEVER PRINTED AS WHOLE. `size` is the server's
921
+ # count of matches. When the distinct rows read fall short of it, or a `next`
922
+ # names a URL outside the API base, this prints nothing and exits 3. The board
923
+ # advances its watermark over what a window returns, so a short window printed
924
+ # as whole loses the missing rows until the next full read.
925
+ #
926
+ # THE HOST'S FAILURE TEXT AND EXIT CODE LEAVE UNTOUCHED, as in
927
+ # `bb_branch_query`: `pr_list_call` classifies once, so a `429` still reads as
928
+ # a rate limit.
929
+ BB_API_BASE="https://api.bitbucket.org/2.0"
930
+ bb_window_listing() { # global bb args… --state <s> --json → one JSON array
931
+ local _st="" _args=() _q _path _page _size=0 _next _pages=0 _rows _spool _rc
932
+ while [ $# -gt 0 ]; do
933
+ case "$1" in
934
+ --state) _st="${2:?}"; shift 2 ;;
935
+ --json) shift ;;
936
+ *) _args+=("$1"); shift ;;
937
+ esac
938
+ done
939
+ [ -n "$_st" ] || die "bb_window_listing: no --state"
940
+ [ -n "$PR_LIST_SINCE" ] || die "bb_window_listing: no window"
941
+ _q="state=$(url_encode "\"$(bb_query_state "$_st")\"") AND updated_on>=$(url_encode "\"$PR_LIST_SINCE\"")"
942
+ _q="${_q// /%20}"
943
+ _path="/repositories/{ws}/{repo}/pullrequests?q=${_q}&pagelen=50"
944
+ plot_tmpfile _spool host-window || return 3
945
+ while [ -n "$_path" ]; do
946
+ _page="$(bb ${_args[@]+"${_args[@]}"} api "$_path")" \
947
+ || { _rc=$?; rm -f "$_spool"; return $_rc; }
948
+ _pages=$((_pages + 1))
949
+ printf '%s\n' "$_page" | jq -c '.values // []' >> "$_spool" \
950
+ || { rm -f "$_spool"; return 3; }
951
+ _size="$(printf '%s' "$_page" | jq -r '.size // 0')" || { rm -f "$_spool"; return 3; }
952
+ _next="$(printf '%s' "$_page" | jq -r '.next // empty')" || { rm -f "$_spool"; return 3; }
953
+ _path=""
954
+ if [ -n "$_next" ]; then
955
+ case "$_next" in
956
+ "$BB_API_BASE"/*) _path="${_next#"$BB_API_BASE"}" ;;
957
+ *)
958
+ rm -f "$_spool"
959
+ echo "bitbucket window state=$_st: next page '$_next' is outside $BB_API_BASE; refusing a window it cannot read to the end" >&2
960
+ return 3
961
+ ;;
962
+ esac
963
+ fi
964
+ done
965
+ # Distinct by id: a pull request updated while the walk runs can move between
966
+ # pages and arrive twice.
967
+ _rows="$(jq -c -s 'add // [] | unique_by(.id)' < "$_spool")" || { rm -f "$_spool"; return 3; }
968
+ rm -f "$_spool"
969
+ local _got
970
+ _got="$(jq 'length' <<<"$_rows")" || return 3
971
+ if [ "$_got" -lt "$_size" ] 2>/dev/null; then
972
+ echo "bitbucket window state=$_st is short ($_got of $_size matches after $_pages pages); refusing it rather than printing it as whole" >&2
973
+ return 3
974
+ fi
975
+ printf '%s' "$_rows"
976
+ }
977
+
978
+ # List the pull requests in ONE state, one page of 50, as one array.
979
+ #
980
+ # THE PLAIN LISTING, and the fourth command in `pr_list_states`' slot. It takes
981
+ # the same trailing `--state <s> --json` as `bb_branch_sweep` and
982
+ # `bb_window_listing` and prints the same JSON array, so the states loop, the
983
+ # truncation report and the partial-answer rule stay in that helper.
984
+ #
985
+ # /repositories/{ws}/{repo}/pullrequests?state=MERGED&pagelen=50
986
+ #
987
+ # ONE REQUEST PER STATE, WHERE `bb pr list` SPENDS UP TO FIVE. `bb pr list`
988
+ # sends no `pagelen`, Bitbucket answers 10 rows a page, and `bb_paginate` walks
989
+ # pages until it holds 50. Asking `pagelen=50` returns the same 50 rows in one
990
+ # request. Measured 2026-09-30 on `quatico/quaweb-website` with bb 1.9.0: the
991
+ # three states cost 8 requests through `bb pr list` (open 1, merged 5,
992
+ # declined 2) and 3 through this path, and both return the same 67 rows
993
+ # (3 open, 50 merged, 14 declined).
994
+ #
995
+ # THE STATES STAY SEPARATE CALLS. One `state=` union over the three returns the
996
+ # 50 most recently updated rows across all of them: measured on the same
997
+ # repository, 50 of 67 rows, 12 of 14 declined PRs lost, and an open PR survives
998
+ # only if it was updated recently. That is the 2026-08-18 failure
999
+ # `bb_states_for` records, reached through the page cap instead of through a
1000
+ # flag.
1001
+ #
1002
+ # ONE PAGE, AS BEFORE. 50 is the endpoint's `pagelen` ceiling and the limit
1003
+ # `bb_paginate` applied, so a state holding more than 50 rows answers with its
1004
+ # 50 most recent here too, and `pr_list_report_truncation` still says so.
1005
+ #
1006
+ # THE HOST'S FAILURE TEXT AND EXIT CODE LEAVE UNTOUCHED, as in
1007
+ # `bb_branch_query`: `pr_list_call` classifies once, so a `429` still reads as
1008
+ # a rate limit.
1009
+ bb_state_listing() { # global bb args… --state <s> --json → one JSON array
1010
+ local _st="" _args=() _out
1011
+ while [ $# -gt 0 ]; do
1012
+ case "$1" in
1013
+ --state) _st="${2:?}"; shift 2 ;;
1014
+ --json) shift ;;
1015
+ *) _args+=("$1"); shift ;;
1016
+ esac
1017
+ done
1018
+ [ -n "$_st" ] || die "bb_state_listing: no --state"
1019
+ _out="$(bb ${_args[@]+"${_args[@]}"} api "/repositories/{ws}/{repo}/pullrequests?state=$(bb_query_state "$_st")&pagelen=50")" || return $?
1020
+ printf '%s' "$_out" | jq -c '.values // []'
1021
+ }
1022
+
872
1023
  # The branches a sweep asks about, newline-or-space separated. Empty means the
873
1024
  # caller named none, and the arm keeps the bulk listing it has always used.
874
1025
  #
@@ -883,13 +1034,13 @@ bb_branch_sweep() { # global bb args… --state <s> --json → one JSON array
883
1034
  # separator is git's guarantee rather than a hopeful convention.
884
1035
  PR_LIST_BRANCHES=""
885
1036
 
886
- # The window a sweep's query carries, the host's own stamp. Empty means ask
1037
+ # The window a Bitbucket query carries, the host's own stamp. Empty means ask
887
1038
  # about everything, which is what every caller predating `--since` asks.
888
1039
  #
889
1040
  # A GLOBAL FOR `PR_LIST_BRANCHES`' REASON, and it travels the same route: the
890
1041
  # `pr-list` arm sets it immediately before the call, `bb_branch_sweep` passes
891
- # through without reading it, and `bb_branch_query` composes it into the one
892
- # `q=` Bitbucket takes. Threading it through the sweep as an argument would mean
1042
+ # through without reading it, and `bb_branch_query` and `bb_window_listing`
1043
+ # compose it into the one `q=` Bitbucket takes. Threading it through the sweep as an argument would mean
893
1044
  # teaching that function a parameter it only forwards, and its header already
894
1045
  # refuses the mirror of that — "teaching `pr_list_states` which of its commands
895
1046
  # is a sweep would put a backend's shape inside the one piece of this file that
@@ -953,7 +1104,7 @@ pr_list_states() { # $1=backend $2=limit $3=states $4=jq-program; rest=the host
953
1104
  local backend="$1" limit="$2" states="$3" jq_prog="$4"; shift 4
954
1105
  local _s _raw _rc _err _tmp _ok=0 _failed=0 _first_rc=0 _failed_states=""
955
1106
  for _s in $states; do
956
- _tmp="/tmp/plot-host-prlist-state-err.$$.$_s"
1107
+ plot_tmpfile _tmp "host-prlist-state-err-$_s" || return 3
957
1108
  # THE SUBSHELL'S CODE IS THE ONLY CHANNEL OUT, so it is captured rather
958
1109
  # than propagated. `pr_list_failed` runs INSIDE the substitution and has
959
1110
  # already composed its report and its repair line; that text is spooled
@@ -989,7 +1140,13 @@ pr_list_states() { # $1=backend $2=limit $3=states $4=jq-program; rest=the host
989
1140
  # THE DETECTOR ITSELF IS UNTOUCHED and still fires exactly as it did on
990
1141
  # every listing call — `host.test.mjs:3060` passes unedited. What changed is
991
1142
  # that a path exists whose premise it was never written about.
992
- [ -n "$PR_LIST_BRANCHES" ] || pr_list_report_truncation "$backend" "$limit" "$_s" \
1143
+ #
1144
+ # A WINDOWED LISTING MAKES NO PAGE CLAIM EITHER. `bb_window_listing` reads
1145
+ # every page and refuses a window whose rows fall short of the server's
1146
+ # `size`, so an answer that reaches this line is whole. Only Bitbucket sets
1147
+ # `PR_LIST_SINCE`; the GitHub arm carries its window as `--search`.
1148
+ [ -n "$PR_LIST_BRANCHES" ] || [ -n "$PR_LIST_SINCE" ] \
1149
+ || pr_list_report_truncation "$backend" "$limit" "$_s" \
993
1150
  "$(jq 'length' <<<"$_raw" 2>/dev/null || echo 0)"
994
1151
  printf '%s' "$_raw" | jq -c ${PR_LIST_JQ_ARGS[@]+"${PR_LIST_JQ_ARGS[@]}"} "$jq_prog"
995
1152
  done
@@ -1793,6 +1950,13 @@ rest_pr_to_state() {
1793
1950
  # `bb` fix, and the three states partition the set (74 PRs, 74 unique ids,
1794
1951
  # 0 duplicates on the repo measured).
1795
1952
  #
1953
+ # bb 1.9.0 honours repeated `--state` and sends one union request, and the
1954
+ # states still stay separate. The union returns the 50 most recently updated
1955
+ # rows across all three states: measured 2026-09-29 on
1956
+ # `quatico/quaweb-website`, 50 of 67 rows, 12 of 14 declined PRs lost. The cost
1957
+ # is cut per state instead, by `bb_state_listing`'s `pagelen=50`: 8 requests
1958
+ # become 3 there, with all 67 rows kept.
1959
+ #
1796
1960
  # `superseded` is deliberately NOT part of `all`: such a PR is replaced by a
1797
1961
  # newer one for the same branch, and a board with one row per branch would
1798
1962
  # show that branch twice. `gh`'s `all` has no equivalent, so nothing is lost.
@@ -2547,11 +2711,11 @@ backend() {
2547
2711
  # `budget_rate`. A directory keyed on a pid must be removed by the process that
2548
2712
  # made it, or a long-lived machine accumulates one per `plot-host.sh` call.
2549
2713
  #
2550
- # `EXIT` ALONE, deliberately. It runs on a normal return and on an uncaught
2551
- # signal's default termination path is irrelevant here: the sweep is an
2552
- # optimisation's housekeeping, and a cache that outlives one run costs a stale
2553
- # reading at worst, which is the same staleness the memo grants by design.
2554
- trap 'budget_memo_clear' EXIT
2714
+ # Registered with `plot-tmp.sh`, which owns this process's EXIT, INT and TERM
2715
+ # traps: a second `trap … EXIT` here would replace the helper's and leak every
2716
+ # temp path above. A memo that outlives a SIGKILL costs a stale reading at
2717
+ # worst, and `plot-reap.sh --sweep-temp` removes a dead pid's directory.
2718
+ plot_on_exit budget_memo_clear
2555
2719
 
2556
2720
  # WHO IS SPENDING — read from the CLI's own config, never from an API call.
2557
2721
  #
@@ -2774,13 +2938,13 @@ plot_harvest_headers() {
2774
2938
  # read may not charge.
2775
2939
  gh_api_harvest() {
2776
2940
  local raw rc hdr_tmp err_tmp
2777
- hdr_tmp="$(mktemp "${TMPDIR:-/tmp}/plot-host-hdr.XXXXXX")" || {
2941
+ plot_tmpfile hdr_tmp host-hdr || {
2778
2942
  # No temp file, no harvest — and the call still happens, recorded by the
2779
2943
  # wrapper from its argv alone. Bookkeeping never fails its caller.
2780
2944
  gh api "$@"
2781
2945
  return $?
2782
2946
  }
2783
- err_tmp="$(mktemp "${TMPDIR:-/tmp}/plot-host-herr.XXXXXX")" || {
2947
+ plot_tmpfile err_tmp host-herr || {
2784
2948
  rm -f "$hdr_tmp"
2785
2949
  gh api "$@"
2786
2950
  return $?
@@ -2996,14 +3160,77 @@ case "$op" in
2996
3160
  ;;
2997
3161
 
2998
3162
  default-branch)
3163
+ # THE HOST IS ASKED FIRST, ON BOTH BACKENDS, and `origin/HEAD` is the
3164
+ # fallback rather than the answer. `origin/HEAD` is a LOCAL CACHE WRITTEN AT
3165
+ # CLONE TIME: a default branch changed afterwards never updates it and
3166
+ # nothing in git notices. Reported 2026-09-24 — a clone whose GitHub default
3167
+ # had moved to `develop` kept `origin/HEAD → main`, the board read plans from
3168
+ # `origin/main`, and two of three plans went missing.
3169
+ #
3170
+ # THE BITBUCKET ARM ASKED THE CACHE FIRST UNTIL 2026-09-25, so on Bitbucket
3171
+ # this op answered from the very cache it exists to bypass, the two readings
3172
+ # could never disagree, and the defect was invisible there. Every caller —
3173
+ # `plot-open-pr.sh:73`, `plot-approve.sh:205`, `plot-deliver.sh:218`,
3174
+ # `plot-reap.sh:176`, `plot-release-refs.sh:162`, `/plot-idea:252` — wants
3175
+ # the branch the host calls default, which is what this now answers.
3176
+ #
3177
+ # AND THE FALLBACK WAS DEAD CODE. `git symbolic-ref … | sed … || bb …` takes
3178
+ # the PIPELINE's exit status, which is `sed`'s, and `sed` exits 0 on empty
3179
+ # input — measured 2026-09-25 against a ref that does not exist. So `bb repo
3180
+ # view` had never once been reached, and a Bitbucket clone with no
3181
+ # `origin/HEAD` printed an empty line and exited 0. Each reading is now
3182
+ # tested for a NON-EMPTY value of its own, never for a pipeline's status.
3183
+ _db=""
2999
3184
  if [ "$be" = "github" ]; then
3000
- gh repo view --json defaultBranchRef --jq '.defaultBranchRef.name'
3185
+ _db=$(gh repo view --json defaultBranchRef --jq '.defaultBranchRef.name' 2>/dev/null || true)
3001
3186
  else
3002
- # Symbolic ref of origin/HEAD is host-neutral and offline; fall back to
3003
- # the bb API only when the local clone has no origin/HEAD.
3004
- git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|^origin/||' \
3005
- || bb repo view --json 2>/dev/null | jq -r '.mainbranch.name'
3187
+ # `--json` IS A CAPABILITY, NOT A GIVEN. Two products share the name `bb`
3188
+ # and craftamap's rejects the flag (see `bb_require_json`) — against it
3189
+ # this call emits `unknown flag: --json`, `jq` reads non-JSON, and the arm
3190
+ # would answer nothing while exiting 0. This call was unguarded while it
3191
+ # was unreachable; asking the host makes it live.
3192
+ bb_require_json
3193
+ _db=$(bb repo view --json 2>/dev/null | jq -r '.mainbranch.name // empty' 2>/dev/null || true)
3194
+ fi
3195
+ # `null` is what `jq` prints for an absent field and it is not a branch name.
3196
+ [ "$_db" = "null" ] && _db=""
3197
+ # THE CACHE IS THE FALLBACK: a host that cannot be asked is not a reason to
3198
+ # answer nothing, since every caller above needs a usable branch name and
3199
+ # worked from this reading alone before today.
3200
+ [ -n "$_db" ] || _db=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|^origin/||')
3201
+ printf '%s\n' "$_db"
3202
+ ;;
3203
+
3204
+ account)
3205
+ # WHO IS READING, from the same free reading `budget_account` makes. It
3206
+ # costs a file read and no request, which matters because the board asks on
3207
+ # its timer. `PLOT_BUDGET_ACCOUNT` overrides it on every backend.
3208
+ #
3209
+ # THE BITBUCKET ARM IS NOT EXPOSED. `budget_account bitbucket` answers the
3210
+ # remote's owner segment, which is the WORKSPACE: on a team workspace every
3211
+ # contributor gets the same word, and it matches no PR author. Measured
3212
+ # 2026-09-25, neither `bb` stores a username for free. Quatico's keeps only
3213
+ # the Atlassian email, in the Keychain. craftamap's keeps an email in
3214
+ # `configuration.toml`. A Bitbucket PR author carries a nickname, an
3215
+ # account id and a uuid, and never an email. So this backend has no answer,
3216
+ # and exit 4 says so.
3217
+ #
3218
+ # `unknown` IS NEVER PRINTED. It groups budget lines correctly and names
3219
+ # nobody, so an unreadable `hosts.yml` exits 3 with the reason on stderr.
3220
+ _acct=''
3221
+ if [ -n "${PLOT_BUDGET_ACCOUNT:-}" ]; then
3222
+ _acct="$PLOT_BUDGET_ACCOUNT"
3223
+ elif [ "$be" = "github" ]; then
3224
+ _acct="$(budget_account github)"
3225
+ else
3226
+ echo "plot-host.sh: account: $be names no signed-in user without an API request" >&2
3227
+ exit 4
3006
3228
  fi
3229
+ if [ -z "$_acct" ] || [ "$_acct" = "unknown" ]; then
3230
+ echo "plot-host.sh: account: no user in ${GH_CONFIG_DIR:-$HOME/.config/gh}/hosts.yml" >&2
3231
+ exit 3
3232
+ fi
3233
+ printf '%s\n' "$_acct"
3007
3234
  ;;
3008
3235
 
3009
3236
  pr-state)
@@ -3030,11 +3257,11 @@ case "$op" in
3030
3257
  # place that knows how to read either.
3031
3258
  rest_repo="$(gh_rest_repo)" || exit $?
3032
3259
  if [[ "$ref" =~ ^[0-9]+$ ]]; then
3033
- if out="$(gh_api_harvest "repos/$rest_repo/pulls/$ref" 2>/tmp/plot-host-err.$$)"; then
3034
- rm -f "/tmp/plot-host-err.$$"
3260
+ if out="$(gh_api_harvest "repos/$rest_repo/pulls/$ref" 2>"$HOST_ERR")"; then
3261
+ rm -f "$HOST_ERR"
3035
3262
  rest_pr_to_state <<<"$out"
3036
3263
  else
3037
- err="$(cat "/tmp/plot-host-err.$$" 2>/dev/null)"; rm -f "/tmp/plot-host-err.$$"
3264
+ err="$(cat "$HOST_ERR" 2>/dev/null)"; rm -f "$HOST_ERR"
3038
3265
  host_miss_or_fail "$err" \
3039
3266
  '{"number":0,"state":"NONE","draft":false,"url":"","mergeCommit":""}' || exit $?
3040
3267
  fi
@@ -3047,15 +3274,15 @@ case "$op" in
3047
3274
  # `state=all`, because the default is `open` and a merged PR would
3048
3275
  # otherwise read as NONE — wrong in the reassuring direction.
3049
3276
  rest_owner="${rest_repo%%/*}"
3050
- if out="$(gh_api_harvest "repos/$rest_repo/pulls?head=$rest_owner:$ref&state=all&per_page=1" 2>/tmp/plot-host-err.$$)"; then
3051
- rm -f "/tmp/plot-host-err.$$"
3277
+ if out="$(gh_api_harvest "repos/$rest_repo/pulls?head=$rest_owner:$ref&state=all&per_page=1" 2>"$HOST_ERR")"; then
3278
+ rm -f "$HOST_ERR"
3052
3279
  if [ "$(jq -r 'length' <<<"$out" 2>/dev/null)" = "0" ]; then
3053
3280
  echo '{"number":0,"state":"NONE","draft":false,"url":"","mergeCommit":""}'
3054
3281
  else
3055
3282
  jq -c '.[0]' <<<"$out" | rest_pr_to_state
3056
3283
  fi
3057
3284
  else
3058
- err="$(cat "/tmp/plot-host-err.$$" 2>/dev/null)"; rm -f "/tmp/plot-host-err.$$"
3285
+ err="$(cat "$HOST_ERR" 2>/dev/null)"; rm -f "$HOST_ERR"
3059
3286
  host_miss_or_fail "$err" \
3060
3287
  '{"number":0,"state":"NONE","draft":false,"url":"","mergeCommit":""}' || exit $?
3061
3288
  fi
@@ -3063,11 +3290,11 @@ case "$op" in
3063
3290
  # mergeCommit is what lets a caller ask "which release contains this?" —
3064
3291
  # `git tag --contains <sha>` answers exactly, where dates cannot. It is ""
3065
3292
  # for anything unmerged, which is the honest answer rather than a guess.
3066
- elif out="$(gh ${repo_args[@]+"${repo_args[@]}"} pr view "$ref" --json number,state,isDraft,url,mergeCommit 2>/tmp/plot-host-err.$$)"; then
3067
- rm -f "/tmp/plot-host-err.$$"
3293
+ elif out="$(gh ${repo_args[@]+"${repo_args[@]}"} pr view "$ref" --json number,state,isDraft,url,mergeCommit 2>"$HOST_ERR")"; then
3294
+ rm -f "$HOST_ERR"
3068
3295
  jq -c '{number:.number,state:.state,draft:.isDraft,url:.url,mergeCommit:(.mergeCommit.oid // "")}' <<<"$out"
3069
3296
  else
3070
- err="$(cat "/tmp/plot-host-err.$$" 2>/dev/null)"; rm -f "/tmp/plot-host-err.$$"
3297
+ err="$(cat "$HOST_ERR" 2>/dev/null)"; rm -f "$HOST_ERR"
3071
3298
  # REFUSED FOR RATE IS NOT ANSWERED. The budget gate above could not see
3072
3299
  # this coming — `rate_limit` does not report the secondary limit — so the
3073
3300
  # cheap path was chosen and then declined. The second path is the one
@@ -3100,11 +3327,11 @@ case "$op" in
3100
3327
  # the GitHub arm gives for anything unmerged, so a caller cannot tell the
3101
3328
  # backends apart.
3102
3329
  if [[ "$ref" =~ ^[0-9]+$ ]]; then
3103
- if out="$(bb ${repo_args[@]+"${repo_args[@]}"} pr view "$ref" --json 2>/tmp/plot-host-err.$$)"; then
3104
- rm -f "/tmp/plot-host-err.$$"
3330
+ if out="$(bb ${repo_args[@]+"${repo_args[@]}"} pr view "$ref" --json 2>"$HOST_ERR")"; then
3331
+ rm -f "$HOST_ERR"
3105
3332
  jq -c '{number:.id,state:(if .state=="DECLINED" then "CLOSED" else .state end),draft:(.draft // false),url:.links.html.href,mergeCommit:(.merge_commit.hash // "")}' <<<"$out"
3106
3333
  else
3107
- err="$(cat "/tmp/plot-host-err.$$" 2>/dev/null)"; rm -f "/tmp/plot-host-err.$$"
3334
+ err="$(cat "$HOST_ERR" 2>/dev/null)"; rm -f "$HOST_ERR"
3108
3335
  host_miss_or_fail "$err" '{"number":0,"state":"NONE","draft":false,"url":"","mergeCommit":""}' || exit $?
3109
3336
  fi
3110
3337
  else
@@ -3129,7 +3356,7 @@ case "$op" in
3129
3356
  out=""; bb_rc=0
3130
3357
  bb_all_states="$(bb_states_for all)" || exit 1
3131
3358
  for _s in $bb_all_states; do
3132
- if _part="$(bb ${repo_args[@]+"${repo_args[@]}"} pr list --state "$_s" --json 2>/tmp/plot-host-err.$$)"; then
3359
+ if _part="$(bb ${repo_args[@]+"${repo_args[@]}"} pr list --state "$_s" --json 2>"$HOST_ERR")"; then
3133
3360
  out="$out$_part"
3134
3361
  # `jq -e` exits non-zero on null/false, so this asks "did this state
3135
3362
  # contain the branch?" without a second parse of the whole page.
@@ -3141,7 +3368,7 @@ case "$op" in
3141
3368
  fi
3142
3369
  done
3143
3370
  if [ "$bb_rc" = 0 ]; then
3144
- rm -f "/tmp/plot-host-err.$$"
3371
+ rm -f "$HOST_ERR"
3145
3372
  out="$(jq -c -s 'add // []' <<<"$out")"
3146
3373
  # THE BRANCH ARM CARRIES `mergeCommit` TOO, and it is the path that
3147
3374
  # matters most: `plot-pr-state.sh:33` asks `pr-state "idea/${SLUG}"` —
@@ -3155,7 +3382,7 @@ case "$op" in
3155
3382
  | if .==null then {number:0,state:"NONE",draft:false,url:"",mergeCommit:""}
3156
3383
  else {number:.id,state:(if .state=="DECLINED" then "CLOSED" else .state end),draft:(.draft // false),url:.links.html.href,mergeCommit:(.merge_commit.hash // "")} end' <<<"$out"
3157
3384
  else
3158
- err="$(cat "/tmp/plot-host-err.$$" 2>/dev/null)"; rm -f "/tmp/plot-host-err.$$"
3385
+ err="$(cat "$HOST_ERR" 2>/dev/null)"; rm -f "$HOST_ERR"
3159
3386
  host_miss_or_fail "$err" '{"number":0,"state":"NONE","draft":false,"url":"","mergeCommit":""}' || exit $?
3160
3387
  fi
3161
3388
  fi
@@ -3187,15 +3414,15 @@ case "$op" in
3187
3414
  # --state all, because a merged PR reports CLOSED and the default `open`
3188
3415
  # would hide every one of them. --limit 100 rather than 1: the newest PR
3189
3416
  # is not the merge, exactly as the state is not the merge.
3190
- if out="$(gh ${repo_args[@]+"${repo_args[@]}"} pr list --head "$ref" --state all --limit 100 --json mergedAt 2>/tmp/plot-host-err.$$)"; then
3191
- rm -f "/tmp/plot-host-err.$$"
3417
+ if out="$(gh ${repo_args[@]+"${repo_args[@]}"} pr list --head "$ref" --state all --limit 100 --json mergedAt 2>"$HOST_ERR")"; then
3418
+ rm -f "$HOST_ERR"
3192
3419
  if jq -e 'any(.[]; .mergedAt != null)' >/dev/null 2>&1 <<<"$out"; then
3193
3420
  echo "merged"
3194
3421
  else
3195
3422
  echo "not-merged"
3196
3423
  fi
3197
3424
  else
3198
- err="$(cat "/tmp/plot-host-err.$$" 2>/dev/null)"; rm -f "/tmp/plot-host-err.$$"
3425
+ err="$(cat "$HOST_ERR" 2>/dev/null)"; rm -f "$HOST_ERR"
3199
3426
  # A LOOKUP MISS IS AN ANSWER — the branch has no PR, so nothing merged.
3200
3427
  # Anything else is the host failing to be asked, and that is `unknown`
3201
3428
  # rather than exit 3: the caller asked a question with a third value
@@ -3214,15 +3441,15 @@ case "$op" in
3214
3441
  # exposes no `mergedAt`, so this is the closest fact the backend holds,
3215
3442
  # and it is a positive statement about the merge rather than an inference
3216
3443
  # from CLOSED — `DECLINED` is bb's closed-unmerged word and is distinct.
3217
- if out="$(bb ${repo_args[@]+"${repo_args[@]}"} pr list --state merged --json 2>/tmp/plot-host-err.$$)"; then
3218
- rm -f "/tmp/plot-host-err.$$"
3444
+ if out="$(bb ${repo_args[@]+"${repo_args[@]}"} pr list --state merged --json 2>"$HOST_ERR")"; then
3445
+ rm -f "$HOST_ERR"
3219
3446
  if jq -e --arg b "$ref" 'any(.[]; .source.branch.name==$b)' >/dev/null 2>&1 <<<"$out"; then
3220
3447
  echo "merged"
3221
3448
  else
3222
3449
  echo "not-merged"
3223
3450
  fi
3224
3451
  else
3225
- err="$(cat "/tmp/plot-host-err.$$" 2>/dev/null)"; rm -f "/tmp/plot-host-err.$$"
3452
+ err="$(cat "$HOST_ERR" 2>/dev/null)"; rm -f "$HOST_ERR"
3226
3453
  if [ -z "$err" ] || is_lookup_miss "$err"; then
3227
3454
  echo "not-merged"
3228
3455
  else
@@ -3262,15 +3489,15 @@ case "$op" in
3262
3489
  esac
3263
3490
  done
3264
3491
  if [ "$be" = "github" ]; then
3265
- if out="$(gh ${repo_args[@]+"${repo_args[@]}"} pr list --head "$ref" --state all --limit 100 --json mergedAt,mergeCommit 2>/tmp/plot-host-err.$$)"; then
3266
- rm -f "/tmp/plot-host-err.$$"
3492
+ if out="$(gh ${repo_args[@]+"${repo_args[@]}"} pr list --head "$ref" --state all --limit 100 --json mergedAt,mergeCommit 2>"$HOST_ERR")"; then
3493
+ rm -f "$HOST_ERR"
3267
3494
  # The FIRST merged PR carrying a sha. A branch may hold several merged
3268
3495
  # PRs; each names its own merge commit, and any of them is a commit that
3269
3496
  # landed this branch's work.
3270
3497
  jq -r 'map(select(.mergedAt != null and .mergeCommit != null))
3271
3498
  | map(.mergeCommit.oid) | first // empty' <<<"$out"
3272
3499
  else
3273
- err="$(cat "/tmp/plot-host-err.$$" 2>/dev/null)"; rm -f "/tmp/plot-host-err.$$"
3500
+ err="$(cat "$HOST_ERR" 2>/dev/null)"; rm -f "$HOST_ERR"
3274
3501
  # A lookup miss is an answer: no PR, so no merge commit. Anything else
3275
3502
  # is the question failing, and silence must not read as "carried
3276
3503
  # nothing" — the caller distinguishes the two by the exit code.
@@ -3284,12 +3511,12 @@ case "$op" in
3284
3511
  else
3285
3512
  bb_require_json
3286
3513
  # Bitbucket names the merge commit `merge_commit.hash` on a merged PR.
3287
- if out="$(bb ${repo_args[@]+"${repo_args[@]}"} pr list --state merged --json 2>/tmp/plot-host-err.$$)"; then
3288
- rm -f "/tmp/plot-host-err.$$"
3514
+ if out="$(bb ${repo_args[@]+"${repo_args[@]}"} pr list --state merged --json 2>"$HOST_ERR")"; then
3515
+ rm -f "$HOST_ERR"
3289
3516
  jq -r --arg b "$ref" 'map(select(.source.branch.name==$b))
3290
3517
  | map(.merge_commit.hash // empty) | first // empty' <<<"$out"
3291
3518
  else
3292
- err="$(cat "/tmp/plot-host-err.$$" 2>/dev/null)"; rm -f "/tmp/plot-host-err.$$"
3519
+ err="$(cat "$HOST_ERR" 2>/dev/null)"; rm -f "$HOST_ERR"
3293
3520
  if [ -z "$err" ] || is_lookup_miss "$err"; then
3294
3521
  :
3295
3522
  else
@@ -3550,7 +3777,7 @@ case "$op" in
3550
3777
  # $jentry == null → the branch has no Jenkins job; `none`.
3551
3778
  # otherwise → the joined colour's `checks`, job named on fail.
3552
3779
  _gh_raw="$(pr_list_call gh ${repo_args[@]+"${repo_args[@]}"} pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} ${search_args[@]+"${search_args[@]}"} \
3553
- --json number,title,state,headRefName,isDraft,mergeable,mergeStateStatus,reviewDecision,url,updatedAt)" || exit $?
3780
+ --json number,title,state,headRefName,isDraft,mergeable,mergeStateStatus,reviewDecision,url,updatedAt,author)" || exit $?
3554
3781
  pr_list_report_truncation github "$limit" "$state" \
3555
3782
  "$(jq 'length' <<<"$_gh_raw" 2>/dev/null || echo 0)"
3556
3783
  printf '%s' "$_gh_raw" \
@@ -3571,6 +3798,7 @@ case "$op" in
3571
3798
  review:(.reviewDecision // ""),
3572
3799
  url:.url,
3573
3800
  updatedAt:(.updatedAt // ""),
3801
+ author:(.author.login // ""),
3574
3802
  failing_checks:(
3575
3803
  if $jentry != null and $jentry.checks == "failing"
3576
3804
  then [$jentry.job]
@@ -3580,7 +3808,7 @@ case "$op" in
3580
3808
  else
3581
3809
  # GitHub without Jenkins (or Jenkins not configured): use GitHub rollup
3582
3810
  _gh_raw="$(pr_list_call gh ${repo_args[@]+"${repo_args[@]}"} pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} ${search_args[@]+"${search_args[@]}"} \
3583
- --json number,title,state,headRefName,isDraft,statusCheckRollup,mergeable,mergeStateStatus,reviewDecision,url,updatedAt)" || exit $?
3811
+ --json number,title,state,headRefName,isDraft,statusCheckRollup,mergeable,mergeStateStatus,reviewDecision,url,updatedAt,author)" || exit $?
3584
3812
  pr_list_report_truncation github "$limit" "$state" \
3585
3813
  "$(jq 'length' <<<"$_gh_raw" 2>/dev/null || echo 0)"
3586
3814
  printf '%s' "$_gh_raw" \
@@ -3603,6 +3831,7 @@ case "$op" in
3603
3831
  review:(.reviewDecision // ""),
3604
3832
  url:.url,
3605
3833
  updatedAt:(.updatedAt // ""),
3834
+ author:(.author.login // ""),
3606
3835
  failing_checks:[
3607
3836
  .statusCheckRollup[]? | select((if (.conclusion // "") != "" then .conclusion else (.status // .state) end) as $c
3608
3837
  | $c=="FAILURE" or $c=="ERROR" or $c=="CANCELLED"
@@ -3612,13 +3841,19 @@ case "$op" in
3612
3841
  fi
3613
3842
  else
3614
3843
  _gh_raw="$(pr_list_call gh ${repo_args[@]+"${repo_args[@]}"} pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} ${search_args[@]+"${search_args[@]}"} \
3615
- --json number,title,state,headRefName)" || exit $?
3844
+ --json number,title,state,headRefName,author)" || exit $?
3616
3845
  pr_list_report_truncation github "$limit" "$state" \
3617
3846
  "$(jq 'length' <<<"$_gh_raw" 2>/dev/null || echo 0)"
3618
3847
  printf '%s' "$_gh_raw" \
3619
- | jq -c '.[] | {number:.number,title:.title,state:.state,head:.headRefName}'
3848
+ | jq -c '.[] | {number:.number,title:.title,state:.state,head:.headRefName,author:(.author.login // "")}'
3620
3849
  fi
3621
3850
  else
3851
+ # `author` IS THE `nickname`, the one handle field a Bitbucket user
3852
+ # object carries. Measured 2026-09-25 against bb 1.9.0: `author` holds
3853
+ # `display_name`, `nickname`, `account_id` and `uuid` and no username,
3854
+ # and on a Quatico workspace `nickname` equals the display name. GitHub's
3855
+ # arm reads `.author.login`. Both answer `""` where the host omits it.
3856
+ #
3622
3857
  # Bitbucket carries no check rollup through `bb pr list`, and no
3623
3858
  # mergeability verdict either. Rather than guess, --rich reports
3624
3859
  # checks:"unknown" and mergeable:"unknown" — a consumer must render those
@@ -3630,16 +3865,17 @@ case "$op" in
3630
3865
  # integration GitHub uses, which is why it is ABOVE the backend branch.
3631
3866
  # Bitbucket's `unknown` becomes a real value.
3632
3867
  #
3633
- # `bb pr list` has no --limit: it returns a fixed page (50 at 1.0.0).
3634
- # Forwarding it errors with `unknown flag`, and dropping it silently
3868
+ # The listing has no --limit: it returns one page of 50 per state
3869
+ # (`bb_state_listing`). `bb pr list` refuses the flag with `unknown flag`,
3870
+ # and the page size is the endpoint's ceiling. Dropping it silently
3635
3871
  # would serve a short page as if it were the whole set — the quiet wrong
3636
3872
  # answer this adapter refuses elsewhere. So it is dropped AND said.
3637
3873
  # A SWEEP IS NOT A PAGE AND OWES NO SUCH WARNING. `--limit` bounds a
3638
3874
  # listing; a per-branch query returns that branch's pull requests and
3639
3875
  # nothing was capped, so the notice would describe a truncation that did
3640
3876
  # not happen. Said only for the listing it is about.
3641
- if [ -n "$limit" ] && [ -z "$branches" ]; then
3642
- echo "plot-host: bitbucket ignores --limit $limit; bb returns a fixed page (50 at 1.0.0)" >&2
3877
+ if [ -n "$limit" ] && [ -z "$branches" ] && [ -z "$since" ]; then
3878
+ echo "plot-host: bitbucket ignores --limit $limit; the listing returns one page of 50 per state" >&2
3643
3879
  fi
3644
3880
  # Establish that bb supports --json BEFORE calling it — Done-when 5.
3645
3881
  bb_require_json
@@ -3656,25 +3892,23 @@ case "$op" in
3656
3892
  # ways, which is how the six hand-applied fixes `pr_list_call` warns about
3657
3893
  # began. One assignment here; the sites are untouched but for this word.
3658
3894
  PR_LIST_BRANCHES="$branches"
3659
- # THE WINDOW REACHES THE SWEEP AND NOT THE LISTING, and the asymmetry is
3660
- # the CLI's rather than a choice. `bb pr list` takes `--state`, `--author`,
3661
- # `--json` and `--jq` and no query flag at all — verified against bb 1.9.0,
3662
- # which answers `unknown flag: --query` — so the only Bitbucket path that
3663
- # can carry `updated_on` is `bb_branch_query`'s own REST `q=`.
3895
+ # THE WINDOW REACHES EVERY BITBUCKET PATH THROUGH `q=`. `bb pr list` takes
3896
+ # no query flag (bb 1.9.0 answers `unknown flag: --query`), so a windowed
3897
+ # LISTING goes through `bb_window_listing`, and a windowed SWEEP through
3898
+ # `bb_branch_query`. Both put `updated_on>=` in the one `q=` Bitbucket
3899
+ # takes, beside the state clause.
3664
3900
  #
3665
- # SAID RATHER THAN SWALLOWED, for the reason `--limit` two blocks up is
3666
- # said: a caller that asked for a window and got a full listing must not
3667
- # read the answer as a delta. It would advance its watermark over a window
3668
- # it never applied — harmless this pass, since a full listing holds every
3669
- # row a narrow one would, and wrong the moment the caller uses the flag to
3670
- # decide whether its answer was complete.
3901
+ # THE REPORT STAYS FOR A PATH THAT CANNOT NARROW. Every path above
3902
+ # narrows today, so `ignores --since` prints nowhere. A path added later
3903
+ # that cannot carry the window clears `PR_LIST_SINCE` and says so: a
3904
+ # caller that asked for a window and got a full listing must not read the
3905
+ # answer as a delta.
3671
3906
  PR_LIST_SINCE="$since"
3672
- if [ -n "$since" ] && [ -z "$branches" ]; then
3673
- echo "plot-host: bitbucket ignores --since $since on a listing; bb pr list has no query flag (bb 1.9.0) — answering in full" >&2
3674
- PR_LIST_SINCE=""
3675
- fi
3676
3907
  PR_SWEEP_ASKED=0
3677
- bb_cmd=(bb ${repo_args[@]+"${repo_args[@]}"} pr list)
3908
+ # THE PLAIN LISTING IS `bb_state_listing`, one request per state at
3909
+ # `pagelen=50`, where `bb pr list` walks 10-row pages. Its header has the
3910
+ # measurement.
3911
+ bb_cmd=(bb_state_listing ${repo_args[@]+"${repo_args[@]}"})
3678
3912
  if [ -n "$branches" ]; then
3679
3913
  # A SWEEP'S COST IS THE CALLER'S WORKING SET, and it is reported so the
3680
3914
  # caller can check the claim it is about to be handed. Branches × states
@@ -3682,6 +3916,8 @@ case "$op" in
3682
3916
  # that answer for 50 of 902 rows.
3683
3917
  for _b in $branches; do PR_SWEEP_ASKED=$((PR_SWEEP_ASKED + 1)); done
3684
3918
  bb_cmd=(bb_branch_sweep ${repo_args[@]+"${repo_args[@]}"})
3919
+ elif [ -n "$PR_LIST_SINCE" ]; then
3920
+ bb_cmd=(bb_window_listing ${repo_args[@]+"${repo_args[@]}"})
3685
3921
  fi
3686
3922
  if [ "$rich" = 1 ]; then
3687
3923
  if [ "$ci" = "jenkins" ]; then
@@ -3705,6 +3941,7 @@ case "$op" in
3705
3941
  review:"",
3706
3942
  url:(.links.html.href // ""),
3707
3943
  updatedAt:(.updated_on // ""),
3944
+ author:(.author.nickname // ""),
3708
3945
  failing_checks:(
3709
3946
  if $jentry != null and $jentry.checks == "failing"
3710
3947
  then [$jentry.job]
@@ -3715,13 +3952,13 @@ case "$op" in
3715
3952
  # Bitbucket without Jenkins: checks remain unknown
3716
3953
  PR_LIST_JQ_ARGS=()
3717
3954
  pr_list_states bitbucket "$limit" "$bb_states" \
3718
- '.[] | {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 // ""),updatedAt:(.updated_on // ""),failing_checks:[]}' \
3955
+ '.[] | {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 // ""),updatedAt:(.updated_on // ""),author:(.author.nickname // ""),failing_checks:[]}' \
3719
3956
  "${bb_cmd[@]}" || exit $?
3720
3957
  fi
3721
3958
  else
3722
3959
  PR_LIST_JQ_ARGS=()
3723
3960
  pr_list_states bitbucket "$limit" "$bb_states" \
3724
- '.[] | {number:.id,title:.title,state:(if .state=="DECLINED" then "CLOSED" else .state end),head:.source.branch.name}' \
3961
+ '.[] | {number:.id,title:.title,state:(if .state=="DECLINED" then "CLOSED" else .state end),head:.source.branch.name,author:(.author.nickname // "")}' \
3725
3962
  "${bb_cmd[@]}" || exit $?
3726
3963
  fi
3727
3964
  fi
@@ -4111,13 +4348,13 @@ case "$op" in
4111
4348
  # unstarted by the only vocabulary GitHub has.
4112
4349
  gh_state="open"
4113
4350
  if out="$(gh issue list --state "$gh_state" ${limit_args[@]+"${limit_args[@]}"} \
4114
- --json number,title,url,createdAt 2>/tmp/plot-host-err.$$)"; then
4115
- rm -f "/tmp/plot-host-err.$$"
4351
+ --json number,title,url,createdAt 2>"$HOST_ERR")"; then
4352
+ rm -f "$HOST_ERR"
4116
4353
  jq -c --arg status "$gh_state" \
4117
4354
  '.[] | {number:.number,title:.title,url:(.url // ""),createdAt:(.createdAt // ""),
4118
4355
  status:$status,statusCategory:(if $status == "open" then "To Do" else "Done" end)}' <<<"$out"
4119
4356
  else
4120
- err="$(cat "/tmp/plot-host-err.$$" 2>/dev/null)"; rm -f "/tmp/plot-host-err.$$"
4357
+ err="$(cat "$HOST_ERR" 2>/dev/null)"; rm -f "$HOST_ERR"
4121
4358
  # NO empty-list fallback. `host_miss_or_fail` exists for a lookup whose
4122
4359
  # subject is absent — one PR that does not exist. A LIST has no absent
4123
4360
  # subject: if the call failed, the answer is unknown, and printing
@@ -4231,11 +4468,11 @@ case "$op" in
4231
4468
  url: ($base + "/browse/" + .key)
4232
4469
  }'
4233
4470
  elif [ "$be" = "github" ]; then
4234
- if out="$(gh issue view "$num" --json number,title,body,url 2>/tmp/plot-host-err.$$)"; then
4235
- rm -f "/tmp/plot-host-err.$$"
4471
+ if out="$(gh issue view "$num" --json number,title,body,url 2>"$HOST_ERR")"; then
4472
+ rm -f "$HOST_ERR"
4236
4473
  jq -c '{number:.number,title:(.title // ""),body:(.body // ""),url:(.url // "")}' <<<"$out"
4237
4474
  else
4238
- err="$(cat "/tmp/plot-host-err.$$" 2>/dev/null)"; rm -f "/tmp/plot-host-err.$$"
4475
+ err="$(cat "$HOST_ERR" 2>/dev/null)"; rm -f "$HOST_ERR"
4239
4476
  # NO miss/fail split here, and that is deliberate. `host_miss_or_fail`
4240
4477
  # exists where an absent subject is a NORMAL answer — a branch with no
4241
4478
  # PR. An issue number reaching this op was read off `issue-list`
@@ -4383,8 +4620,8 @@ case "$op" in
4383
4620
  # This op REPORTS; it does not decide. A caller that wants to fall back when
4384
4621
  # one budget is spent reads this, compares remaining to zero, and acts.
4385
4622
  if [ "$be" = "github" ]; then
4386
- if out="$(gh api rate_limit 2>/tmp/plot-host-err.$$)"; then
4387
- rm -f "/tmp/plot-host-err.$$"
4623
+ if out="$(gh api rate_limit 2>"$HOST_ERR")"; then
4624
+ rm -f "$HOST_ERR"
4388
4625
  # The payload has `resources.graphql` and `resources.core`, each with
4389
4626
  # `remaining`, `limit`, and `reset`. Extract the two we care about.
4390
4627
  jq -c '{
@@ -4400,7 +4637,7 @@ case "$op" in
4400
4637
  }
4401
4638
  }' <<<"$out"
4402
4639
  else
4403
- err="$(cat "/tmp/plot-host-err.$$" 2>/dev/null)"; rm -f "/tmp/plot-host-err.$$"
4640
+ err="$(cat "$HOST_ERR" 2>/dev/null)"; rm -f "$HOST_ERR"
4404
4641
  # The host could not be asked. Report unknown rather than failing outright,
4405
4642
  # because this is informational — a caller that cannot read the budget
4406
4643
  # should proceed with the default path, not error out.
@@ -4463,7 +4700,8 @@ case "$op" in
4463
4700
  # inventing a reading nobody took — the mistake `rate_limit` makes by
4464
4701
  # answering for both at once.
4465
4702
  if [ "$be" = "github" ]; then
4466
- _hdr_tmp="/tmp/plot-host-limit.$$"
4703
+ _hdr_tmp=""
4704
+ plot_tmpfile _hdr_tmp host-limit || die3 "limit: no temp file could be made under ${TMPDIR:-/tmp}"
4467
4705
  # `command gh`, NOT THE WRAPPER, and for the ordering reason
4468
4706
  # `gh_api_harvest` gives: the wrapper records the moment the call returns,
4469
4707
  # before any header has been read, so a wrapped call here would file an