@plot-pm/board 0.15.0 → 0.16.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/plot-host.sh CHANGED
@@ -391,6 +391,15 @@ set -uo pipefail
391
391
 
392
392
  here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
393
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
+
394
403
  die() { echo "plot-host: $*" >&2; exit 1; }
395
404
 
396
405
  # Exit 3 — reserved for "the op itself cannot proceed", distinct from `die`'s
@@ -575,7 +584,9 @@ pr_list_failed() { # $1=stderr text
575
584
  # hundred lines below, where a `die` in a subshell turned an unknown state into
576
585
  # "no PRs matched".
577
586
  pr_list_call() { # "$@"=the host command → payload on stdout, or dies
578
- 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; }
579
590
  out="$("$@" 2>"$tmp")"; rc=$?
580
591
  err="$(cat "$tmp" 2>/dev/null)"; rm -f "$tmp"
581
592
  [ "$rc" -eq 0 ] || pr_list_failed "$err"
@@ -858,7 +869,7 @@ bb_branch_sweep() { # global bb args… --state <s> --json → one JSON array
858
869
  # already seen once per branch, so eleven branches parsed the first branch's
859
870
  # payload eleven times.
860
871
  local _spool
861
- _spool="$(mktemp "/tmp/plot-host-sweep.$$.XXXXXX")" || return 3
872
+ plot_tmpfile _spool host-sweep || return 3
862
873
  for _br in $PR_LIST_BRANCHES; do
863
874
  # RETURN, NOT EXIT. This runs inside the command substitution
864
875
  # `pr_list_call` wraps the sweep in, so the code must travel back as this
@@ -867,7 +878,7 @@ bb_branch_sweep() { # global bb args… --state <s> --json → one JSON array
867
878
  # as the sweep's own — the silent empty list `pr_list_call`'s header names.
868
879
  #
869
880
  # The spool is removed on EVERY exit path, including the failing one: a
870
- # 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.
871
882
  bb_branch_query "$_br" "$_st" ${_args[@]+"${_args[@]}"} >> "$_spool" \
872
883
  || { _rc=$?; rm -f "$_spool"; return $_rc; }
873
884
  done
@@ -880,6 +891,135 @@ bb_branch_sweep() { # global bb args… --state <s> --json → one JSON array
880
891
  printf '%s' "$_acc"
881
892
  }
882
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
+
883
1023
  # The branches a sweep asks about, newline-or-space separated. Empty means the
884
1024
  # caller named none, and the arm keeps the bulk listing it has always used.
885
1025
  #
@@ -894,13 +1034,13 @@ bb_branch_sweep() { # global bb args… --state <s> --json → one JSON array
894
1034
  # separator is git's guarantee rather than a hopeful convention.
895
1035
  PR_LIST_BRANCHES=""
896
1036
 
897
- # 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
898
1038
  # about everything, which is what every caller predating `--since` asks.
899
1039
  #
900
1040
  # A GLOBAL FOR `PR_LIST_BRANCHES`' REASON, and it travels the same route: the
901
1041
  # `pr-list` arm sets it immediately before the call, `bb_branch_sweep` passes
902
- # through without reading it, and `bb_branch_query` composes it into the one
903
- # `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
904
1044
  # teaching that function a parameter it only forwards, and its header already
905
1045
  # refuses the mirror of that — "teaching `pr_list_states` which of its commands
906
1046
  # is a sweep would put a backend's shape inside the one piece of this file that
@@ -964,7 +1104,7 @@ pr_list_states() { # $1=backend $2=limit $3=states $4=jq-program; rest=the host
964
1104
  local backend="$1" limit="$2" states="$3" jq_prog="$4"; shift 4
965
1105
  local _s _raw _rc _err _tmp _ok=0 _failed=0 _first_rc=0 _failed_states=""
966
1106
  for _s in $states; do
967
- _tmp="/tmp/plot-host-prlist-state-err.$$.$_s"
1107
+ plot_tmpfile _tmp "host-prlist-state-err-$_s" || return 3
968
1108
  # THE SUBSHELL'S CODE IS THE ONLY CHANNEL OUT, so it is captured rather
969
1109
  # than propagated. `pr_list_failed` runs INSIDE the substitution and has
970
1110
  # already composed its report and its repair line; that text is spooled
@@ -1000,7 +1140,13 @@ pr_list_states() { # $1=backend $2=limit $3=states $4=jq-program; rest=the host
1000
1140
  # THE DETECTOR ITSELF IS UNTOUCHED and still fires exactly as it did on
1001
1141
  # every listing call — `host.test.mjs:3060` passes unedited. What changed is
1002
1142
  # that a path exists whose premise it was never written about.
1003
- [ -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" \
1004
1150
  "$(jq 'length' <<<"$_raw" 2>/dev/null || echo 0)"
1005
1151
  printf '%s' "$_raw" | jq -c ${PR_LIST_JQ_ARGS[@]+"${PR_LIST_JQ_ARGS[@]}"} "$jq_prog"
1006
1152
  done
@@ -1804,6 +1950,13 @@ rest_pr_to_state() {
1804
1950
  # `bb` fix, and the three states partition the set (74 PRs, 74 unique ids,
1805
1951
  # 0 duplicates on the repo measured).
1806
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
+ #
1807
1960
  # `superseded` is deliberately NOT part of `all`: such a PR is replaced by a
1808
1961
  # newer one for the same branch, and a board with one row per branch would
1809
1962
  # show that branch twice. `gh`'s `all` has no equivalent, so nothing is lost.
@@ -2558,11 +2711,11 @@ backend() {
2558
2711
  # `budget_rate`. A directory keyed on a pid must be removed by the process that
2559
2712
  # made it, or a long-lived machine accumulates one per `plot-host.sh` call.
2560
2713
  #
2561
- # `EXIT` ALONE, deliberately. It runs on a normal return and on an uncaught
2562
- # signal's default termination path is irrelevant here: the sweep is an
2563
- # optimisation's housekeeping, and a cache that outlives one run costs a stale
2564
- # reading at worst, which is the same staleness the memo grants by design.
2565
- 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
2566
2719
 
2567
2720
  # WHO IS SPENDING — read from the CLI's own config, never from an API call.
2568
2721
  #
@@ -2785,13 +2938,13 @@ plot_harvest_headers() {
2785
2938
  # read may not charge.
2786
2939
  gh_api_harvest() {
2787
2940
  local raw rc hdr_tmp err_tmp
2788
- hdr_tmp="$(mktemp "${TMPDIR:-/tmp}/plot-host-hdr.XXXXXX")" || {
2941
+ plot_tmpfile hdr_tmp host-hdr || {
2789
2942
  # No temp file, no harvest — and the call still happens, recorded by the
2790
2943
  # wrapper from its argv alone. Bookkeeping never fails its caller.
2791
2944
  gh api "$@"
2792
2945
  return $?
2793
2946
  }
2794
- err_tmp="$(mktemp "${TMPDIR:-/tmp}/plot-host-herr.XXXXXX")" || {
2947
+ plot_tmpfile err_tmp host-herr || {
2795
2948
  rm -f "$hdr_tmp"
2796
2949
  gh api "$@"
2797
2950
  return $?
@@ -3104,11 +3257,11 @@ case "$op" in
3104
3257
  # place that knows how to read either.
3105
3258
  rest_repo="$(gh_rest_repo)" || exit $?
3106
3259
  if [[ "$ref" =~ ^[0-9]+$ ]]; then
3107
- if out="$(gh_api_harvest "repos/$rest_repo/pulls/$ref" 2>/tmp/plot-host-err.$$)"; then
3108
- 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"
3109
3262
  rest_pr_to_state <<<"$out"
3110
3263
  else
3111
- 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"
3112
3265
  host_miss_or_fail "$err" \
3113
3266
  '{"number":0,"state":"NONE","draft":false,"url":"","mergeCommit":""}' || exit $?
3114
3267
  fi
@@ -3121,15 +3274,15 @@ case "$op" in
3121
3274
  # `state=all`, because the default is `open` and a merged PR would
3122
3275
  # otherwise read as NONE — wrong in the reassuring direction.
3123
3276
  rest_owner="${rest_repo%%/*}"
3124
- if out="$(gh_api_harvest "repos/$rest_repo/pulls?head=$rest_owner:$ref&state=all&per_page=1" 2>/tmp/plot-host-err.$$)"; then
3125
- 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"
3126
3279
  if [ "$(jq -r 'length' <<<"$out" 2>/dev/null)" = "0" ]; then
3127
3280
  echo '{"number":0,"state":"NONE","draft":false,"url":"","mergeCommit":""}'
3128
3281
  else
3129
3282
  jq -c '.[0]' <<<"$out" | rest_pr_to_state
3130
3283
  fi
3131
3284
  else
3132
- 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"
3133
3286
  host_miss_or_fail "$err" \
3134
3287
  '{"number":0,"state":"NONE","draft":false,"url":"","mergeCommit":""}' || exit $?
3135
3288
  fi
@@ -3137,11 +3290,11 @@ case "$op" in
3137
3290
  # mergeCommit is what lets a caller ask "which release contains this?" —
3138
3291
  # `git tag --contains <sha>` answers exactly, where dates cannot. It is ""
3139
3292
  # for anything unmerged, which is the honest answer rather than a guess.
3140
- elif out="$(gh ${repo_args[@]+"${repo_args[@]}"} pr view "$ref" --json number,state,isDraft,url,mergeCommit 2>/tmp/plot-host-err.$$)"; then
3141
- 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"
3142
3295
  jq -c '{number:.number,state:.state,draft:.isDraft,url:.url,mergeCommit:(.mergeCommit.oid // "")}' <<<"$out"
3143
3296
  else
3144
- 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"
3145
3298
  # REFUSED FOR RATE IS NOT ANSWERED. The budget gate above could not see
3146
3299
  # this coming — `rate_limit` does not report the secondary limit — so the
3147
3300
  # cheap path was chosen and then declined. The second path is the one
@@ -3174,11 +3327,11 @@ case "$op" in
3174
3327
  # the GitHub arm gives for anything unmerged, so a caller cannot tell the
3175
3328
  # backends apart.
3176
3329
  if [[ "$ref" =~ ^[0-9]+$ ]]; then
3177
- if out="$(bb ${repo_args[@]+"${repo_args[@]}"} pr view "$ref" --json 2>/tmp/plot-host-err.$$)"; then
3178
- 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"
3179
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"
3180
3333
  else
3181
- 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"
3182
3335
  host_miss_or_fail "$err" '{"number":0,"state":"NONE","draft":false,"url":"","mergeCommit":""}' || exit $?
3183
3336
  fi
3184
3337
  else
@@ -3203,7 +3356,7 @@ case "$op" in
3203
3356
  out=""; bb_rc=0
3204
3357
  bb_all_states="$(bb_states_for all)" || exit 1
3205
3358
  for _s in $bb_all_states; do
3206
- 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
3207
3360
  out="$out$_part"
3208
3361
  # `jq -e` exits non-zero on null/false, so this asks "did this state
3209
3362
  # contain the branch?" without a second parse of the whole page.
@@ -3215,7 +3368,7 @@ case "$op" in
3215
3368
  fi
3216
3369
  done
3217
3370
  if [ "$bb_rc" = 0 ]; then
3218
- rm -f "/tmp/plot-host-err.$$"
3371
+ rm -f "$HOST_ERR"
3219
3372
  out="$(jq -c -s 'add // []' <<<"$out")"
3220
3373
  # THE BRANCH ARM CARRIES `mergeCommit` TOO, and it is the path that
3221
3374
  # matters most: `plot-pr-state.sh:33` asks `pr-state "idea/${SLUG}"` —
@@ -3229,7 +3382,7 @@ case "$op" in
3229
3382
  | if .==null then {number:0,state:"NONE",draft:false,url:"",mergeCommit:""}
3230
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"
3231
3384
  else
3232
- 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"
3233
3386
  host_miss_or_fail "$err" '{"number":0,"state":"NONE","draft":false,"url":"","mergeCommit":""}' || exit $?
3234
3387
  fi
3235
3388
  fi
@@ -3261,15 +3414,15 @@ case "$op" in
3261
3414
  # --state all, because a merged PR reports CLOSED and the default `open`
3262
3415
  # would hide every one of them. --limit 100 rather than 1: the newest PR
3263
3416
  # is not the merge, exactly as the state is not the merge.
3264
- if out="$(gh ${repo_args[@]+"${repo_args[@]}"} pr list --head "$ref" --state all --limit 100 --json mergedAt 2>/tmp/plot-host-err.$$)"; then
3265
- 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"
3266
3419
  if jq -e 'any(.[]; .mergedAt != null)' >/dev/null 2>&1 <<<"$out"; then
3267
3420
  echo "merged"
3268
3421
  else
3269
3422
  echo "not-merged"
3270
3423
  fi
3271
3424
  else
3272
- 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"
3273
3426
  # A LOOKUP MISS IS AN ANSWER — the branch has no PR, so nothing merged.
3274
3427
  # Anything else is the host failing to be asked, and that is `unknown`
3275
3428
  # rather than exit 3: the caller asked a question with a third value
@@ -3288,15 +3441,15 @@ case "$op" in
3288
3441
  # exposes no `mergedAt`, so this is the closest fact the backend holds,
3289
3442
  # and it is a positive statement about the merge rather than an inference
3290
3443
  # from CLOSED — `DECLINED` is bb's closed-unmerged word and is distinct.
3291
- if out="$(bb ${repo_args[@]+"${repo_args[@]}"} pr list --state merged --json 2>/tmp/plot-host-err.$$)"; then
3292
- 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"
3293
3446
  if jq -e --arg b "$ref" 'any(.[]; .source.branch.name==$b)' >/dev/null 2>&1 <<<"$out"; then
3294
3447
  echo "merged"
3295
3448
  else
3296
3449
  echo "not-merged"
3297
3450
  fi
3298
3451
  else
3299
- 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"
3300
3453
  if [ -z "$err" ] || is_lookup_miss "$err"; then
3301
3454
  echo "not-merged"
3302
3455
  else
@@ -3336,15 +3489,15 @@ case "$op" in
3336
3489
  esac
3337
3490
  done
3338
3491
  if [ "$be" = "github" ]; then
3339
- if out="$(gh ${repo_args[@]+"${repo_args[@]}"} pr list --head "$ref" --state all --limit 100 --json mergedAt,mergeCommit 2>/tmp/plot-host-err.$$)"; then
3340
- 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"
3341
3494
  # The FIRST merged PR carrying a sha. A branch may hold several merged
3342
3495
  # PRs; each names its own merge commit, and any of them is a commit that
3343
3496
  # landed this branch's work.
3344
3497
  jq -r 'map(select(.mergedAt != null and .mergeCommit != null))
3345
3498
  | map(.mergeCommit.oid) | first // empty' <<<"$out"
3346
3499
  else
3347
- 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"
3348
3501
  # A lookup miss is an answer: no PR, so no merge commit. Anything else
3349
3502
  # is the question failing, and silence must not read as "carried
3350
3503
  # nothing" — the caller distinguishes the two by the exit code.
@@ -3358,12 +3511,12 @@ case "$op" in
3358
3511
  else
3359
3512
  bb_require_json
3360
3513
  # Bitbucket names the merge commit `merge_commit.hash` on a merged PR.
3361
- if out="$(bb ${repo_args[@]+"${repo_args[@]}"} pr list --state merged --json 2>/tmp/plot-host-err.$$)"; then
3362
- 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"
3363
3516
  jq -r --arg b "$ref" 'map(select(.source.branch.name==$b))
3364
3517
  | map(.merge_commit.hash // empty) | first // empty' <<<"$out"
3365
3518
  else
3366
- 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"
3367
3520
  if [ -z "$err" ] || is_lookup_miss "$err"; then
3368
3521
  :
3369
3522
  else
@@ -3712,16 +3865,17 @@ case "$op" in
3712
3865
  # integration GitHub uses, which is why it is ABOVE the backend branch.
3713
3866
  # Bitbucket's `unknown` becomes a real value.
3714
3867
  #
3715
- # `bb pr list` has no --limit: it returns a fixed page (50 at 1.0.0).
3716
- # 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
3717
3871
  # would serve a short page as if it were the whole set — the quiet wrong
3718
3872
  # answer this adapter refuses elsewhere. So it is dropped AND said.
3719
3873
  # A SWEEP IS NOT A PAGE AND OWES NO SUCH WARNING. `--limit` bounds a
3720
3874
  # listing; a per-branch query returns that branch's pull requests and
3721
3875
  # nothing was capped, so the notice would describe a truncation that did
3722
3876
  # not happen. Said only for the listing it is about.
3723
- if [ -n "$limit" ] && [ -z "$branches" ]; then
3724
- 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
3725
3879
  fi
3726
3880
  # Establish that bb supports --json BEFORE calling it — Done-when 5.
3727
3881
  bb_require_json
@@ -3738,25 +3892,23 @@ case "$op" in
3738
3892
  # ways, which is how the six hand-applied fixes `pr_list_call` warns about
3739
3893
  # began. One assignment here; the sites are untouched but for this word.
3740
3894
  PR_LIST_BRANCHES="$branches"
3741
- # THE WINDOW REACHES THE SWEEP AND NOT THE LISTING, and the asymmetry is
3742
- # the CLI's rather than a choice. `bb pr list` takes `--state`, `--author`,
3743
- # `--json` and `--jq` and no query flag at all — verified against bb 1.9.0,
3744
- # which answers `unknown flag: --query` — so the only Bitbucket path that
3745
- # 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.
3746
3900
  #
3747
- # SAID RATHER THAN SWALLOWED, for the reason `--limit` two blocks up is
3748
- # said: a caller that asked for a window and got a full listing must not
3749
- # read the answer as a delta. It would advance its watermark over a window
3750
- # it never applied — harmless this pass, since a full listing holds every
3751
- # row a narrow one would, and wrong the moment the caller uses the flag to
3752
- # 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.
3753
3906
  PR_LIST_SINCE="$since"
3754
- if [ -n "$since" ] && [ -z "$branches" ]; then
3755
- 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
3756
- PR_LIST_SINCE=""
3757
- fi
3758
3907
  PR_SWEEP_ASKED=0
3759
- 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[@]}"})
3760
3912
  if [ -n "$branches" ]; then
3761
3913
  # A SWEEP'S COST IS THE CALLER'S WORKING SET, and it is reported so the
3762
3914
  # caller can check the claim it is about to be handed. Branches × states
@@ -3764,6 +3916,8 @@ case "$op" in
3764
3916
  # that answer for 50 of 902 rows.
3765
3917
  for _b in $branches; do PR_SWEEP_ASKED=$((PR_SWEEP_ASKED + 1)); done
3766
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[@]}"})
3767
3921
  fi
3768
3922
  if [ "$rich" = 1 ]; then
3769
3923
  if [ "$ci" = "jenkins" ]; then
@@ -4194,13 +4348,13 @@ case "$op" in
4194
4348
  # unstarted by the only vocabulary GitHub has.
4195
4349
  gh_state="open"
4196
4350
  if out="$(gh issue list --state "$gh_state" ${limit_args[@]+"${limit_args[@]}"} \
4197
- --json number,title,url,createdAt 2>/tmp/plot-host-err.$$)"; then
4198
- rm -f "/tmp/plot-host-err.$$"
4351
+ --json number,title,url,createdAt 2>"$HOST_ERR")"; then
4352
+ rm -f "$HOST_ERR"
4199
4353
  jq -c --arg status "$gh_state" \
4200
4354
  '.[] | {number:.number,title:.title,url:(.url // ""),createdAt:(.createdAt // ""),
4201
4355
  status:$status,statusCategory:(if $status == "open" then "To Do" else "Done" end)}' <<<"$out"
4202
4356
  else
4203
- 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"
4204
4358
  # NO empty-list fallback. `host_miss_or_fail` exists for a lookup whose
4205
4359
  # subject is absent — one PR that does not exist. A LIST has no absent
4206
4360
  # subject: if the call failed, the answer is unknown, and printing
@@ -4314,11 +4468,11 @@ case "$op" in
4314
4468
  url: ($base + "/browse/" + .key)
4315
4469
  }'
4316
4470
  elif [ "$be" = "github" ]; then
4317
- if out="$(gh issue view "$num" --json number,title,body,url 2>/tmp/plot-host-err.$$)"; then
4318
- 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"
4319
4473
  jq -c '{number:.number,title:(.title // ""),body:(.body // ""),url:(.url // "")}' <<<"$out"
4320
4474
  else
4321
- 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"
4322
4476
  # NO miss/fail split here, and that is deliberate. `host_miss_or_fail`
4323
4477
  # exists where an absent subject is a NORMAL answer — a branch with no
4324
4478
  # PR. An issue number reaching this op was read off `issue-list`
@@ -4466,8 +4620,8 @@ case "$op" in
4466
4620
  # This op REPORTS; it does not decide. A caller that wants to fall back when
4467
4621
  # one budget is spent reads this, compares remaining to zero, and acts.
4468
4622
  if [ "$be" = "github" ]; then
4469
- if out="$(gh api rate_limit 2>/tmp/plot-host-err.$$)"; then
4470
- rm -f "/tmp/plot-host-err.$$"
4623
+ if out="$(gh api rate_limit 2>"$HOST_ERR")"; then
4624
+ rm -f "$HOST_ERR"
4471
4625
  # The payload has `resources.graphql` and `resources.core`, each with
4472
4626
  # `remaining`, `limit`, and `reset`. Extract the two we care about.
4473
4627
  jq -c '{
@@ -4483,7 +4637,7 @@ case "$op" in
4483
4637
  }
4484
4638
  }' <<<"$out"
4485
4639
  else
4486
- 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"
4487
4641
  # The host could not be asked. Report unknown rather than failing outright,
4488
4642
  # because this is informational — a caller that cannot read the budget
4489
4643
  # should proceed with the default path, not error out.
@@ -4546,7 +4700,8 @@ case "$op" in
4546
4700
  # inventing a reading nobody took — the mistake `rate_limit` makes by
4547
4701
  # answering for both at once.
4548
4702
  if [ "$be" = "github" ]; then
4549
- _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}"
4550
4705
  # `command gh`, NOT THE WRAPPER, and for the ordering reason
4551
4706
  # `gh_api_harvest` gives: the wrapper records the moment the call returns,
4552
4707
  # before any header has been read, so a wrapped call here would file an