@plot-pm/board 0.14.2 → 0.14.3

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
@@ -534,6 +534,405 @@ pr_list_call() { # "$@"=the host command → payload on stdout, or dies
534
534
  printf '%s' "$out"
535
535
  }
536
536
 
537
+ # The exit code for an answer that is INCOMPLETE rather than absent.
538
+ #
539
+ # SEVEN, BECAUSE THE VOCABULARY IS FULL BELOW IT. `pr_list_failed` spends 6
540
+ # (burst refusal), 5 (throttled) and 3 (everything else); 4 is the backend
541
+ # having no such capability; 1 is a refusal before any call; 0 is a whole
542
+ # answer. Two is left alone deliberately — it is bash's own conventional code
543
+ # for a misused builtin, and a partial answer sharing it could not be told from
544
+ # a shell-level fault the script never intended.
545
+ #
546
+ # NON-ZERO IS THE LOAD-BEARING PART. `plot-fleet-scan.sh:675` reads this code
547
+ # DIRECTLY, not through the transport, and branches on `rc -ne 0`. Exiting 0 on
548
+ # a partial answer would set `HOST_VERDICT=ok` over a page missing a whole
549
+ # state — a complete reading reported over an incomplete one, which is the
550
+ # quiet wrong answer this adapter refuses everywhere else. It would also
551
+ # re-create precisely the state fixed on 2026-08-30, when `pr-list` swallowed
552
+ # its own failure and exited 0 with empty stdout.
553
+ PR_LIST_PARTIAL_RC=7
554
+
555
+ # Run ONE `pr-list` over several host states, printing what answered.
556
+ #
557
+ # THE BITBUCKET ASYMMETRY THIS EXISTS FOR. `bb pr list` has no `all` state, so
558
+ # `bb_states_for all` expands to three and the arm must call `bb` once per
559
+ # state. GitHub takes `--state all` in a single call and can never reach this
560
+ # shape: there, a failure means nothing was printed.
561
+ #
562
+ # ONE MECHANISM FOR THREE CALL SITES, and that is the point rather than a
563
+ # tidy-up — `pr_list_call`'s own header makes the argument: *"a fix applied by
564
+ # hand six times is a fix that drifts, and the arm that drifts is the one
565
+ # nobody's repo exercises."* The three sites (rich+Jenkins, rich, plain) differ
566
+ # ONLY in the jq program they pipe the payload through, so that program and its
567
+ # arguments are what this takes.
568
+ #
569
+ # WHY THE LOOP CANNOT KEEP `|| exit $?`. That propagation is not a style tic:
570
+ # `pr_list_call` is invoked in a command substitution — a subshell — so the
571
+ # `exit` inside `pr_list_failed` leaves only that subshell, and without the
572
+ # propagation the outer script carries on with an empty payload and jq emits
573
+ # nothing. Collecting across states means the first failure can no longer end
574
+ # the run, so the subshell's exit code is captured and classified instead: a
575
+ # non-zero rc is THIS STATE FAILED, which is a different fact from this state
576
+ # returning an empty list.
577
+ #
578
+ # WHAT IT EXITS WITH:
579
+ # 0 every state answered.
580
+ # PR_LIST_PARTIAL_RC some answered and some did not — the rows of those
581
+ # that answered are on stdout, and the failures are
582
+ # named on stderr.
583
+ # the first failure's rc NO state answered. A total outage keeps the code it
584
+ # has always had (3, 5 or 6 by kind), so a genuine
585
+ # outage can never read as a partial page.
586
+ #
587
+ # A SINGLE-STATE CALL HAS NO PARTIAL ANSWER TO REPORT. With one state asked,
588
+ # "some answered and some did not" is unreachable by construction: either the
589
+ # one state answered (0) or none did (its own code). The counting below gives
590
+ # that for free rather than by a special case.
591
+ # TWO VARIADIC LISTS, ONE `"$@"`. The host command and the jq arguments are
592
+ # both open-ended, and bash has one positional array — so the jq side travels
593
+ # in this global, set by the caller immediately before the call. It is read
594
+ # once per state and never written here.
595
+ PR_LIST_JQ_ARGS=()
596
+
597
+ # --- the per-branch sweep (#333) --------------------------------------------
598
+ #
599
+ # THE JOIN ASKS ABOUT BRANCHES AND THE LIST ANSWERS ABOUT A REPOSITORY, and the
600
+ # gap between those two questions is #333. `plot-fleet-scan.sh` holds a dozen
601
+ # branch names and asks `pr-list` for every pull request the repository has
602
+ # ever had, then indexes what came back by `head` and discards the rest.
603
+ # Measured 2026-09-20 on `quatico/quaweb-website`: 12 board rows against 902
604
+ # pull requests, of which the join uses about 1%.
605
+ #
606
+ # THAT WOULD ONLY BE WASTEFUL IF THE LIST WERE WHOLE. It is not. `bb pr list`
607
+ # returns a fixed page of 50 per state, and the repository holds 886 MERGED
608
+ # pull requests — so 836 of them are invisible to the join, and every branch
609
+ # whose pull request is among them reads as having none. That is the fabricated
610
+ # verdict `plot-fleet-scan.sh:876` rules against by name, reached from the one
611
+ # direction the truncation detector below can report but not repair.
612
+ #
613
+ # SO THE SWEEP INVERTS THE QUESTION. Given the branches the caller actually
614
+ # tracks, it asks the REST endpoint about each one by name, through the same
615
+ # `q=` filter `bb` already builds for `--author`:
616
+ #
617
+ # /repositories/{ws}/{repo}/pullrequests
618
+ # ?q=state="MERGED" AND source.branch.name="feature/x"&pagelen=50
619
+ #
620
+ # Measured on that repository: `size: 1 | values: 1 | ids: 902` — PR 902 is one
621
+ # of the 836 a listing cannot reach — and a branch with no pull request answers
622
+ # `size: 0`, which is an EXACT ABSENCE rather than a short page.
623
+ #
624
+ # WHY NOT PAGE THE LIST INSTEAD, since the endpoint carries a `next` cursor.
625
+ # Two measurements refuse it. `fleet.ts:184` declares the per-refresh cost and
626
+ # `prRefreshMsFor` stretches the interval by it so hourly spend stays 60;
627
+ # walking all 18 merged pages makes the cost ~21 and the interval follows
628
+ # mechanically, 240 s → 1260 s, with `MAX_CADENCE_STRETCH = 8` taking the worst
629
+ # case to 2.8 hours. And the growth curve runs the wrong way: paging costs grow
630
+ # with the repository's pull-request history — the very quantity whose growth
631
+ # makes #333 worse — while a sweep costs what the CALLER TRACKS and is constant
632
+ # in pull-request count. `pagelen=100` is refused by Bitbucket (HTTP 400), so
633
+ # 50 is the ceiling and 18 pages was a floor rather than a safe estimate.
634
+ #
635
+ # THE LEADING SLASH IS LOAD-BEARING. `bb api` concatenates "${BB_API}${path}",
636
+ # so a path without it yields `…/2.0repositories/…` and an HTTP 403 that reads
637
+ # exactly like a missing scope. A previous plan was rejected for inferring a
638
+ # scope problem from this symptom. It is written once, here, and pinned.
639
+ #
640
+ # `bb`'s OWN PAGINATOR IS NOT REACHED FOR, and that is deliberate rather than
641
+ # incidental: `bb:203` caps at 10 pages — 500 rows at `pagelen=50`, 386 short
642
+ # of 886 — and exits with no error and no marker. A helper that silently
643
+ # returns a prefix is worse here than one that refuses.
644
+
645
+ # Bitbucket's own state word for one of this adapter's states.
646
+ #
647
+ # The `q=` filter matches Bitbucket's vocabulary, which is upper-case and calls
648
+ # a rejected pull request DECLINED. The adapter's own words are `bb pr list`'s,
649
+ # which are lower-case. One mapping, so a caller never spells a state twice.
650
+ bb_query_state() { # $1=adapter state word → Bitbucket's
651
+ case "$1" in
652
+ open) printf 'OPEN' ;;
653
+ merged) printf 'MERGED' ;;
654
+ declined) printf 'DECLINED' ;;
655
+ superseded) printf 'SUPERSEDED' ;;
656
+ *) die "bb_query_state: unknown state '$1'" ;;
657
+ esac
658
+ }
659
+
660
+ # Percent-encode one value for a URL query string.
661
+ #
662
+ # BRANCH NAMES ARE NOT URL-SAFE and this repository proves it: `feature/x` is
663
+ # the ordinary shape, and `/` inside an unencoded `q=` value ends the filter
664
+ # early, so the query would ask about `feature` and answer about the wrong
665
+ # branch — or about none. Encoding is done here rather than by the caller so
666
+ # every query on this path is encoded the same way.
667
+ #
668
+ # `LC_ALL=C` makes the loop byte-wise, so a multi-byte character is encoded as
669
+ # its bytes rather than mangled into one `?`. Branch names carrying non-ASCII
670
+ # are rare and entirely legal.
671
+ url_encode() { # $1=raw → percent-encoded on stdout
672
+ local _s="$1" _i _c _out=""
673
+ local LC_ALL=C
674
+ for (( _i = 0; _i < ${#_s}; _i++ )); do
675
+ _c="${_s:_i:1}"
676
+ case "$_c" in
677
+ [a-zA-Z0-9.~_-]) _out="$_out$_c" ;;
678
+ *) _out="$_out$(printf '%%%02X' "'$_c")" ;;
679
+ esac
680
+ done
681
+ printf '%s' "$_out"
682
+ }
683
+
684
+ # Ask Bitbucket about ONE branch in ONE state, and print the `values` array.
685
+ #
686
+ # WHAT IT PRINTS is the endpoint's `values` — a JSON array of 0 or 1 pull
687
+ # request objects, in the SAME shape `bb pr list --json` emits, which is what
688
+ # lets the existing jq programs consume it unchanged. The `--rich` field set
689
+ # (`plot-host.sh:3247`) reads `.id`, `.title`, `.state`, `.source.branch.name`,
690
+ # `.draft` and `.links.html.href`; the REST object carries all six.
691
+ #
692
+ # AN ABSENT ANSWER IS NOT A FAILED ONE, and the exit code is what says which.
693
+ # `size: 0` is an honest absence — the branch has no pull request in this state
694
+ # — and exits 0 with `[]` on stdout. A refused call exits non-zero and prints
695
+ # nothing, so a caller reading the CODE can never mistake an outage for an
696
+ # empty repository. `plot-fleet-scan.sh:891` records that exact confusion
697
+ # happening from the other side: a host exiting 0 while printing nothing once
698
+ # read as "this repo has no PRs".
699
+ #
700
+ # `pagelen=50` rather than 1. A branch may legitimately carry several pull
701
+ # requests in one state — a merged attempt and a merged successor — and the
702
+ # consumers rank them (`fleet.ts`'s `prOutranks`, the scan's OPEN-before-MERGED
703
+ # sort). Asking for one would silently hand them whichever the host listed
704
+ # first, which no adapter promises. Fifty is the endpoint's ceiling and costs
705
+ # the same as one.
706
+ # THE HOST'S OWN FAILURE TEXT LEAVES HERE UNCLASSIFIED, and that is the whole
707
+ # reason this does not call `pr_list_call`. That wrapper classifies a failure
708
+ # ONCE — throttled, burst, or everything else — and composes the sentence a
709
+ # reader acts on. Calling it per branch and again around the sweep classifies
710
+ # twice, and the second pass reads the FIRST pass's prose rather than the host's
711
+ # message: measured here, a `429` became *"the host failed the request and said
712
+ # nothing"* because `Rate limit … exceeded` was no longer in the text being
713
+ # matched. So the raw stderr and the raw exit code travel out of this function
714
+ # untouched, and the single `pr_list_call` that `pr_list_states` already wraps
715
+ # the whole sweep in does the one classification — exactly the layering
716
+ # `bb pr list` has always had.
717
+ bb_branch_query() { # $1=branch $2=adapter state; rest=global bb args → values[]
718
+ local _br="$1" _st="$2"; shift 2
719
+ local _q _path _out _rc
720
+ _q="state=$(url_encode "\"$(bb_query_state "$_st")\"") AND source.branch.name=$(url_encode "\"$_br\"")"
721
+ # The space between the two terms is encoded too; `bb api` passes the path to
722
+ # curl verbatim and an unencoded space would truncate the request line.
723
+ _q="${_q// /%20}"
724
+ # THE LEADING SLASH. See the block header — without it this is a 403 that
725
+ # reads as a scope error.
726
+ _path="/repositories/{ws}/{repo}/pullrequests?q=${_q}&pagelen=50"
727
+ # `jq` is applied only to a SUCCESSFUL payload. Piping a failed call into it
728
+ # would turn the host's exit code into jq's, and a parse error and a spent
729
+ # quota are not the same fact.
730
+ _out="$(bb "$@" api "$_path")" || return $?
731
+ printf '%s' "$_out" | jq -c '.values // []'
732
+ }
733
+
734
+ # Ask about EVERY tracked branch in one state, printing one combined array.
735
+ #
736
+ # THE SHAPE `pr_list_states` ALREADY EXPECTS. It calls its host command once
737
+ # per state and pipes the result through a jq program that starts `.[]`, so the
738
+ # sweep's job is to produce the same thing a single `bb pr list --state X`
739
+ # would have: one JSON array of pull request objects. The states loop, the row
740
+ # counting, the truncation report and the partial-answer rule above all stay in
741
+ # exactly one place — `pr_list_call`'s own header makes that argument, and a
742
+ # sweep with its own copy of the loop is the drift it names.
743
+ #
744
+ # A BRANCH THAT FAILS ENDS THE STATE, and that is the conservative direction.
745
+ # The combined array is only an answer if every branch in it was asked; one
746
+ # refused query means absence is no longer derivable for that branch, and a
747
+ # short array reported as whole is what #333 IS. So a failure propagates —
748
+ # `pr_list_call` exits — and `pr_list_states` classifies the state as failed,
749
+ # which reaches the caller as a partial answer (exit 7) when other states
750
+ # answered, or as the failure's own code when none did. One vocabulary.
751
+ # THE CALLING CONVENTION IS `pr_list_states`', NOT THIS FUNCTION'S OWN. That
752
+ # helper appends `--state <s> --json` to whatever command it was given, so a
753
+ # sweep that wants to sit in the same slot must accept those two trailing
754
+ # arguments and read the state out of them. Doing it the other way — teaching
755
+ # `pr_list_states` which of its commands is a sweep — would put a backend's
756
+ # shape inside the one piece of this file that has none.
757
+ #
758
+ # `--json` is accepted and ignored. The REST payload is JSON whether or not it
759
+ # is asked for, and refusing a flag the caller must pass would make the slot
760
+ # incompatible for the sake of a distinction with no consequence.
761
+ bb_branch_sweep() { # global bb args… --state <s> --json → one JSON array
762
+ local _st="" _args=() _acc="[]" _br _rc
763
+ while [ $# -gt 0 ]; do
764
+ case "$1" in
765
+ --state) _st="${2:?}"; shift 2 ;;
766
+ --json) shift ;;
767
+ *) _args+=("$1"); shift ;;
768
+ esac
769
+ done
770
+ [ -n "$_st" ] || die "bb_branch_sweep: no --state"
771
+ # THE ANSWERS ARE SPOOLED TO A FILE AND JOINED ONCE, NEVER PASSED THROUGH
772
+ # ARGV. The first version accumulated with
773
+ # `jq -c --argjson add "$_one" '. + $add'`, which hands a whole branch's
774
+ # payload to `jq` as a command-line argument — and Linux caps one argument at
775
+ # `MAX_ARG_STRLEN` (128 KB) where macOS has no such ceiling.
776
+ #
777
+ # WHAT THAT COST, measured 2026-09-20 against a Debian container: a branch
778
+ # carrying 886 merged pull requests is a 147 KB payload, `jq` died with
779
+ # *"Argument list too long"*, `_acc` came back EMPTY, and the sweep exited 0
780
+ # while printing no rows AND stating its completeness — a confident claim of
781
+ # "no pull requests" over a branch that had 886. That is precisely the
782
+ # fabricated verdict this whole slice exists to remove, rebuilt one layer in.
783
+ # It passed on macOS and failed only on Linux, which is where CI and every
784
+ # board run.
785
+ #
786
+ # A FILE HAS NO SUCH CEILING, and one `jq -s add` over the spool replaces N
787
+ # re-parses of a growing accumulator: the old shape re-read every row it had
788
+ # already seen once per branch, so eleven branches parsed the first branch's
789
+ # payload eleven times.
790
+ local _spool
791
+ _spool="$(mktemp "/tmp/plot-host-sweep.$$.XXXXXX")" || return 3
792
+ for _br in $PR_LIST_BRANCHES; do
793
+ # RETURN, NOT EXIT. This runs inside the command substitution
794
+ # `pr_list_call` wraps the sweep in, so the code must travel back as this
795
+ # function's status for that wrapper to classify it. An `exit` here would
796
+ # leave the substitution with an empty payload and a code the wrapper reads
797
+ # as the sweep's own — the silent empty list `pr_list_call`'s header names.
798
+ #
799
+ # The spool is removed on EVERY exit path, including the failing one: a
800
+ # sweep that gives up mid-way must not leave a payload behind in /tmp.
801
+ bb_branch_query "$_br" "$_st" ${_args[@]+"${_args[@]}"} >> "$_spool" \
802
+ || { _rc=$?; rm -f "$_spool"; return $_rc; }
803
+ done
804
+ # `-s` reads the whole stream as one array of arrays; `add` flattens it.
805
+ # An EMPTY spool — every branch answered `[]` — makes `add` yield `null`, so
806
+ # the fallback keeps the contract that this prints a JSON ARRAY, which is
807
+ # what the caller's `.[]` needs.
808
+ _acc="$(jq -c -s 'add // []' < "$_spool")" || { rm -f "$_spool"; return 3; }
809
+ rm -f "$_spool"
810
+ printf '%s' "$_acc"
811
+ }
812
+
813
+ # The branches a sweep asks about, newline-or-space separated. Empty means the
814
+ # caller named none, and the arm keeps the bulk listing it has always used.
815
+ #
816
+ # A GLOBAL FOR `PR_LIST_JQ_ARGS`' REASON, stated two hundred lines above: the
817
+ # host command is already variadic and bash has one positional array. It is set
818
+ # by the `pr-list` arm immediately before the call and read nowhere else.
819
+ #
820
+ # SPACE-SEPARATED, AND GIT IS WHAT MAKES THAT SAFE. `bb_branch_sweep` reads this
821
+ # with an unquoted `for`, so a name carrying whitespace would split into two
822
+ # branches that do not exist. `git check-ref-format` REFUSES a ref name
823
+ # containing a space or a tab — verified 2026-09-20, both exit non-zero — so the
824
+ # separator is git's guarantee rather than a hopeful convention.
825
+ PR_LIST_BRANCHES=""
826
+
827
+ # How many branches the last sweep asked about, and how many answered.
828
+ #
829
+ # THE COMPLETENESS SIGNAL, AND WHY THE ARM STATES IT RATHER THAN THE SCAN
830
+ # INFERRING IT. `plot-fleet-scan.sh:901` writes `.list-complete` when
831
+ # `0 < rows < PR_LIST_LIMIT` — completeness read off a single page's size,
832
+ # which is the only evidence a bulk listing offers. A sweep has no page: each
833
+ # query returns 0 or 1, and `size: 0` is already an exact answer for that
834
+ # branch. So completeness stops being a property of a row count and becomes a
835
+ # property of the SWEEP — every tracked branch was asked and each one answered
836
+ # — which is a stronger claim than the page heuristic could ever make, and one
837
+ # this side can state as a fact rather than leave to be guessed from a number.
838
+ #
839
+ # A PARTIAL SWEEP MUST NOT MAKE THE CLAIM. If any branch's query failed, the
840
+ # survivors are still valid answers and are still printed, but absence is no
841
+ # longer derivable for the branches that went unasked. The line is emitted only
842
+ # when every state answered.
843
+ #
844
+ # ONE COUNTER, NOT TWO. How many branches ANSWERED is not tracked beside this,
845
+ # because a failed branch query ends its whole state (see `bb_branch_sweep`) and
846
+ # the states tally `pr_list_states` already keeps is therefore the same fact. A
847
+ # second counter would be a second answer to one question, and the two would
848
+ # drift the first time either side changed.
849
+ PR_SWEEP_ASKED=0
850
+
851
+ # State on stderr that the sweep was WHOLE — the licence `.list-complete` needs.
852
+ #
853
+ # THE LINE IS A CONTRACT, not a log. `plot-fleet-scan.sh` reads it to decide
854
+ # whether a cache miss means "no pull request" or "never asked", so its wording
855
+ # is pinned by a test the same way the truncation report's is. It names both
856
+ # counts, because a reader who sees the claim should be able to check it.
857
+ #
858
+ # WHAT IT LICENSES IS A SHORTCUT, NOT THE ANSWER. Without it, a `--ask` caller
859
+ # whose branch missed the join falls through to a `pr-state` call per branch and
860
+ # still gets a correct answer — the per-branch N+1 that #216 removed, which is a
861
+ # COST regression rather than a wrong one. So withholding the line is always
862
+ # safe and is what a partial sweep does.
863
+ #
864
+ # EVERY STATE MUST HAVE ANSWERED. A sweep asks each branch once per state, and a
865
+ # branch's absence is only established when every state was asked about it: a
866
+ # merged pull request missed because the `merged` state failed reads exactly
867
+ # like a branch that never had one. So the claim is made on the STATES' tally,
868
+ # which `pr_list_states` already keeps, rather than on a per-branch count that
869
+ # would have to be reconciled with it.
870
+ #
871
+ # SILENT WHEN NO SWEEP RAN. The bulk path makes no per-branch claim and keeps
872
+ # the row-count heuristic it has always used, so nothing is printed and no
873
+ # existing caller's behaviour changes.
874
+ pr_sweep_report() { # $1=states answered $2=states asked
875
+ [ -n "$PR_LIST_BRANCHES" ] || return 0
876
+ [ "$1" -eq "$2" ] 2>/dev/null || return 0
877
+ echo "plot-host: pr-list sweep complete ($PR_SWEEP_ASKED branches asked, $1 of $2 states answered) — every tracked branch was asked and each answered" >&2
878
+ }
879
+
880
+ pr_list_states() { # $1=backend $2=limit $3=states $4=jq-program; rest=the host command
881
+ local backend="$1" limit="$2" states="$3" jq_prog="$4"; shift 4
882
+ local _s _raw _rc _err _tmp _ok=0 _failed=0 _first_rc=0 _failed_states=""
883
+ for _s in $states; do
884
+ _tmp="/tmp/plot-host-prlist-state-err.$$.$_s"
885
+ # THE SUBSHELL'S CODE IS THE ONLY CHANNEL OUT, so it is captured rather
886
+ # than propagated. `pr_list_failed` runs INSIDE the substitution and has
887
+ # already composed its report and its repair line; that text is spooled
888
+ # here only so the state's name can be added to it before it is passed on.
889
+ _raw="$(pr_list_call "$@" --state "$_s" --json 2>"$_tmp")"; _rc=$?
890
+ _err="$(cat "$_tmp" 2>/dev/null)"; rm -f "$_tmp"
891
+ if [ "$_rc" -ne 0 ]; then
892
+ # THE HOST'S OWN REPORT GOES FIRST AND IS NEVER PREFIXED. `pr_list_failed`
893
+ # already composed the sentence that says WHY — a spent quota, a burst
894
+ # refusal, a DNS blip — and that sentence is what a reader acts on. A line
895
+ # of this helper's own naming WHICH state, emitted ahead of it, buries the
896
+ # reason under the bookkeeping: the scan reads the first stderr line into
897
+ # its error field, and a reader chasing `HTTP 429` would be shown
898
+ # `state 'open' failed` instead. That is #912's own failure mode — a
899
+ # message describing the wrong thing — reproduced one layer up, and the
900
+ # contract suite caught it.
901
+ [ -n "$_err" ] && printf '%s\n' "$_err" >&2
902
+ _failed=$((_failed + 1))
903
+ [ "$_first_rc" -eq 0 ] && _first_rc=$_rc
904
+ _failed_states="${_failed_states:+$_failed_states, }$_s"
905
+ continue
906
+ fi
907
+ [ -n "$_err" ] && printf '%s\n' "$_err" >&2
908
+ _ok=$((_ok + 1))
909
+ # A SWEEP MAKES NO PAGE CLAIM, so the page detector is not asked. Its rule
910
+ # is about a LISTING that cannot report a total — see its header — and a
911
+ # sweep's row count has no page semantics at all: the count is how many of
912
+ # the asked branches have a pull request in this state, and two of eleven is
913
+ # a complete answer rather than a short one. Running it here would print
914
+ # "possibly truncated" immediately before `pr_sweep_report` states the
915
+ # answer was whole, which is the adapter contradicting itself on one stream.
916
+ #
917
+ # THE DETECTOR ITSELF IS UNTOUCHED and still fires exactly as it did on
918
+ # every listing call — `host.test.mjs:3060` passes unedited. What changed is
919
+ # that a path exists whose premise it was never written about.
920
+ [ -n "$PR_LIST_BRANCHES" ] || pr_list_report_truncation "$backend" "$limit" "$_s" \
921
+ "$(jq 'length' <<<"$_raw" 2>/dev/null || echo 0)"
922
+ printf '%s' "$_raw" | jq -c ${PR_LIST_JQ_ARGS[@]+"${PR_LIST_JQ_ARGS[@]}"} "$jq_prog"
923
+ done
924
+ pr_sweep_report "$_ok" "$((_ok + _failed))"
925
+ [ -z "$_failed_states" ] && return 0
926
+ if [ "$_ok" -eq 0 ]; then
927
+ # NO STATE ANSWERED — a total outage, and it keeps the code it has always
928
+ # had so a real outage can never be read as a partial page.
929
+ echo "plot-host: pr-list: no state answered; this is not a partial answer" >&2
930
+ return "$_first_rc"
931
+ fi
932
+ echo "plot-host: pr-list: answered $_ok of $((_ok + _failed)) states; missing: $_failed_states" >&2
933
+ return "$PR_LIST_PARTIAL_RC"
934
+ }
935
+
537
936
  # --- Jenkins CI integration ------------------------------------------------
538
937
  # A repo may declare `CI: jenkins` independently of `Git host`. When it does,
539
938
  # build status (`checks`) is resolved through `jen` — a multibranch job's
@@ -1683,6 +2082,33 @@ tracker_projects() {
1683
2082
  printf '%s' "$raw" | tr ',' '\n' | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//' | grep -v '^$' || true
1684
2083
  }
1685
2084
 
2085
+ # Reads ONE variable from a `.env`-shaped file. Evaluates nothing.
2086
+ #
2087
+ # NOT `set -a; . ./.env; set +a`, which is the usual one-liner. It was measured
2088
+ # aborting in zsh on a file whose third line holds an unquoted JSON object, and
2089
+ # it imports every unrelated variable in the file — which on a credentials path
2090
+ # is a reason of its own.
2091
+ #
2092
+ # NOT `grep '^NAME=' | cut -d= -f2-` either: measured 2026-09-17, that returns
2093
+ # EMPTY for an `export `-prefixed line and KEEPS THE QUOTES on a quoted one, and
2094
+ # a quoted token reaching `curl -u` produces the 401 this read exists to remove.
2095
+ #
2096
+ # THE STRIP ORDER IS THE DEFECT, and it has been got wrong twice. Whitespace is
2097
+ # stripped BEFORE the quotes and again after. On `T="tok" ` the `"$` anchor
2098
+ # misses if the quotes go first, and the value keeps them:
2099
+ #
2100
+ # quotes first: ["tok"] whitespace first: [tok]
2101
+ #
2102
+ # `head -1` takes the first assignment, so a duplicated name resolves the way a
2103
+ # shell reading top-to-bottom would.
2104
+ read_env_var() { # $1=name $2=file → the value, or nothing
2105
+ sed -n "s/^[[:space:]]*\(export[[:space:]]\+\)\{0,1\}$1=//p" "$2" \
2106
+ | head -1 \
2107
+ | sed -e 's/[[:space:]]*$//' \
2108
+ -e 's/^"\(.*\)"$/\1/' -e "s/^'\(.*\)'\$/\1/" \
2109
+ -e 's/[[:space:]]*$//'
2110
+ }
2111
+
1686
2112
  # The env var scheme for Jira auth. The plan left the EXACT names open, to be
1687
2113
  # confirmed against a real instance; these follow Jira Cloud's documented Basic
1688
2114
  # scheme (email + API token, base64'd into an Authorization header):
@@ -1695,14 +2121,55 @@ tracker_projects() {
1695
2121
  # This guard is called in the MAIN shell, BEFORE the `$(jira_curl …)` capture —
1696
2122
  # `die3` exits the whole script only from there, not from inside a command
1697
2123
  # substitution where it would end only the subshell and leak a second error.
2124
+ #
2125
+ # WHERE BOTH ARE UNSET, THE REPOSITORY'S `.env` IS READ. The refusal below named
2126
+ # two variables without ever looking where a repository puts them, so an
2127
+ # operator holding working credentials — measured 2026-09-17, a 200 from
2128
+ # `/rest/api/3/myself` with the same pair — was sent to create a second token.
2129
+ # "Export it in your shell" does not reach the board either: it is a long-lived
2130
+ # process, which is why `plot-fleetctl.sh` bakes an environment into its unit.
2131
+ #
2132
+ # THE ENVIRONMENT WINS AND NOTHING IS READ WHERE IT ANSWERS. The file is opened
2133
+ # only when BOTH are unset, so a deliberate export is never second-guessed and
2134
+ # the common path touches no disk.
2135
+ #
2136
+ # AND THE SOURCE IS NAMED, which is a requirement rather than a nicety: two
2137
+ # tokens may exist, and an operator debugging a 401 must be able to tell which
2138
+ # one was used. `plot-board-probe.sh` reports `auth` as three words rather than
2139
+ # a boolean for the same reason. THE VALUE IS NEVER PRINTED — only the source.
2140
+ jira_load_env_file() {
2141
+ local root env_file email token
2142
+ [ -z "${JIRA_EMAIL:-}" ] && [ -z "${JIRA_API_TOKEN:-}" ] || return 0
2143
+
2144
+ # The repository root, `plot-config.sh:145`'s idiom. NO UPWARD WALK past it:
2145
+ # a search toward $HOME would read a file the operator did not mean for this
2146
+ # repository.
2147
+ root="$(git rev-parse --show-toplevel 2>/dev/null)" || root="."
2148
+ env_file="$root/.env"
2149
+ [ -r "$env_file" ] || return 0
2150
+
2151
+ email="$(read_env_var JIRA_EMAIL "$env_file")"
2152
+ token="$(read_env_var JIRA_API_TOKEN "$env_file")"
2153
+ # BOTH OR NEITHER. Half a Basic credential authenticates nothing, and a
2154
+ # partial pickup would turn today's honest refusal into a 401 further in.
2155
+ [ -n "$email" ] && [ -n "$token" ] || return 0
2156
+
2157
+ JIRA_EMAIL="$email"
2158
+ JIRA_API_TOKEN="$token"
2159
+ export JIRA_EMAIL JIRA_API_TOKEN
2160
+ echo "plot-host: JIRA_EMAIL and JIRA_API_TOKEN read from .env" >&2
2161
+ }
2162
+
1698
2163
  jira_require_config() {
1699
2164
  if [ -z "$(tracker_base_url)" ]; then
1700
2165
  die3 "Tracker is jira but no base URL is configured (write 'Tracker: jira https://your.atlassian.net' or set PLOT_JIRA_BASE_URL)"
1701
2166
  fi
2167
+ jira_load_env_file
1702
2168
  if [ -z "${JIRA_EMAIL:-}" ] || [ -z "${JIRA_API_TOKEN:-}" ]; then
1703
2169
  echo "plot-host: Jira needs JIRA_EMAIL and JIRA_API_TOKEN in the environment — an unauthenticated Jira must not read as an empty inbox" >&2
1704
2170
  echo " Create a token at https://id.atlassian.com/manage-profile/security/api-tokens" >&2
1705
- echo " then export JIRA_EMAIL=<your account email> and JIRA_API_TOKEN=<the token>." >&2
2171
+ echo " then export JIRA_EMAIL=<your account email> and JIRA_API_TOKEN=<the token>," >&2
2172
+ echo " or put both in this repository's .env (which .gitignore already excludes)." >&2
1706
2173
  exit 3
1707
2174
  fi
1708
2175
  }
@@ -1734,12 +2201,45 @@ jira_curl() {
1734
2201
  return $rc
1735
2202
  }
1736
2203
 
2204
+ # The account a Jira record is keyed on — DERIVED, never the email itself.
2205
+ #
2206
+ # THE EMAIL IS HALF A BASIC CREDENTIAL and this ledger is written on every call:
2207
+ # measured 2026-09-17, `$HOME/.plot/state/budget.tsv` held 2448 jira lines on one
2208
+ # machine. Until `.env` was read that happened only where somebody exported the
2209
+ # variable deliberately; it now happens wherever a `.env` exists — a population
2210
+ # that never consented to a machine-local record of it. A change that widens who
2211
+ # gets written down owns the writing down.
2212
+ #
2213
+ # AND IT STAYS PER-ACCOUNT DISTINGUISHABLE, because the field is a MATCH KEY and
2214
+ # not a label: `plot-budget.sh:250` is `if ($2 != want_c || $3 != want_a) next`,
2215
+ # `spend-rate` publishes it, and `decodeEntry`/`sameKey` read it. One machine's
2216
+ # ledger holds three distinct Jira accounts, so a CONSTANT redaction would merge
2217
+ # their rate windows and the rate a connector reads becomes the sum of several
2218
+ # people's. A hash keeps the key one-to-one while carrying no address.
2219
+ #
2220
+ # AT THE SOURCE rather than at `budget.tsv`, because fixing the one known writer
2221
+ # leaves the next to inherit the defect — `slots-file.ts:185` turns an account
2222
+ # into a DIRECTORY NAME and is one `slots.acquire` call away from being live.
2223
+ #
2224
+ # `jira:` prefixed and truncated to 12 hex: long enough that two accounts on one
2225
+ # machine will not collide, short enough to read in a ledger line.
2226
+ jira_budget_account() {
2227
+ local raw="${JIRA_EMAIL:-}"
2228
+ [ -n "$raw" ] || { printf 'unknown\n'; return 0; }
2229
+ local h
2230
+ h="$(printf '%s' "$raw" | shasum -a 256 2>/dev/null | awk '{print $1}')"
2231
+ # No hasher, no guess: a raw email must never be the fallback, so an
2232
+ # unhashable account degrades to the same word an absent one uses.
2233
+ [ -n "$h" ] || { printf 'unknown\n'; return 0; }
2234
+ printf 'jira:%s\n' "${h:0:12}"
2235
+ }
2236
+
1737
2237
  # Records one Jira call. Jira meters, publishes no header this adapter reads,
1738
2238
  # and this slice does not add header parsing — so the reading is `unknown`,
1739
2239
  # which is never read as free.
1740
2240
  budget_record_jira() {
1741
2241
  [ -z "${PLOT_BUDGET_OFF:-}" ] || return 0
1742
- budget_append jira "${JIRA_EMAIL:-unknown}" api 1 - - - unknown
2242
+ budget_append jira "$(jira_budget_account)" api 1 - - - unknown
1743
2243
  }
1744
2244
 
1745
2245
  # Split a jira_curl response into (body, status) and enforce the three outcomes.
@@ -1788,12 +2288,20 @@ jira_check() {
1788
2288
  # AT LEAST the requested limit — the host may have
1789
2289
  # had more that the limit hid. Fewer rows than the
1790
2290
  # limit PROVES completeness.
1791
- # bitbucket (IGNORES --limit): `bb pr list` has no --limit and cannot report a
1792
- # total or a cursor, so it can NEVER prove
2291
+ # bitbucket (IGNORES --limit): `bb pr list` has no --limit and reports neither
2292
+ # a total nor a cursor, so it can NEVER prove
1793
2293
  # completeness for a --limit call. Any non-empty
1794
2294
  # page is therefore possibly truncated. An empty
1795
2295
  # page had nothing to truncate.
1796
2296
  #
2297
+ # THE PREMISE ABOVE IS ABOUT `bb pr list`, AND IT WAS ONCE WRITTEN ABOUT
2298
+ # BITBUCKET. It said the host "cannot report a total or a cursor" — true of the
2299
+ # CLI's listing and false of the REST endpoint behind it, which carries both a
2300
+ # `size` and a `next`. That mattered the moment a path existed that could ask:
2301
+ # the per-branch sweep (#333) proves completeness exactly, per branch, and this
2302
+ # detector is deliberately not asked about it (`pr_list_states`). The rule below
2303
+ # is unchanged and still governs every listing call.
2304
+ #
1797
2305
  # No --limit was requested → the caller accepted the host's default page and is
1798
2306
  # owed no report, so no existing no-limit caller's behaviour changes.
1799
2307
  #
@@ -2490,13 +2998,30 @@ case "$op" in
2490
2998
  else
2491
2999
  # Establish that bb supports --json BEFORE calling it — Done-when 5.
2492
3000
  bb_require_json
3001
+ # THE SAME KEY SET AS THE GITHUB ARM, INCLUDING `mergeCommit`. This arm
3002
+ # dropped that key on all four of its paths until 2026-09-18, and the
3003
+ # consumer reads it as `.mergeCommit // empty` — where `jq` cannot tell an
3004
+ # absent key from an empty one. So `plot-reconcile-scan.sh` reported
3005
+ # `no merge commit → cannot resolve` for every delivered plan on a
3006
+ # Bitbucket repository, which reads as a host that answered rather than an
3007
+ # arm that never asked. `pr-list`'s arm is the precedent: it emits every
3008
+ # key its GitHub arm does, because absent is not false.
3009
+ #
3010
+ # `merge_commit.hash` IS TAKEN FROM THE PAYLOAD ALREADY FETCHED — the same
3011
+ # field `pr-merge-commit` reads from the same shape. A second `bb` call to
3012
+ # re-ask for it would double a cost measured at ~10s per call.
3013
+ #
3014
+ # `// ""` COLLAPSES THREE SHAPES INTO ONE HONEST VALUE: `merge_commit`
3015
+ # absent on an open PR, the object null, or the hash null. `""` is what
3016
+ # the GitHub arm gives for anything unmerged, so a caller cannot tell the
3017
+ # backends apart.
2493
3018
  if [[ "$ref" =~ ^[0-9]+$ ]]; then
2494
3019
  if out="$(bb ${repo_args[@]+"${repo_args[@]}"} pr view "$ref" --json 2>/tmp/plot-host-err.$$)"; then
2495
3020
  rm -f "/tmp/plot-host-err.$$"
2496
- jq -c '{number:.id,state:(if .state=="DECLINED" then "CLOSED" else .state end),draft:(.draft // false),url:.links.html.href}' <<<"$out"
3021
+ 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"
2497
3022
  else
2498
3023
  err="$(cat "/tmp/plot-host-err.$$" 2>/dev/null)"; rm -f "/tmp/plot-host-err.$$"
2499
- host_miss_or_fail "$err" '{"number":0,"state":"NONE","draft":false,"url":""}' || exit $?
3024
+ host_miss_or_fail "$err" '{"number":0,"state":"NONE","draft":false,"url":"","mergeCommit":""}' || exit $?
2500
3025
  fi
2501
3026
  else
2502
3027
  # The list CALL succeeding and the branch being absent FROM the list are
@@ -2534,12 +3059,20 @@ case "$op" in
2534
3059
  if [ "$bb_rc" = 0 ]; then
2535
3060
  rm -f "/tmp/plot-host-err.$$"
2536
3061
  out="$(jq -c -s 'add // []' <<<"$out")"
3062
+ # THE BRANCH ARM CARRIES `mergeCommit` TOO, and it is the path that
3063
+ # matters most: `plot-pr-state.sh:33` asks `pr-state "idea/${SLUG}"` —
3064
+ # a branch, not a number — and `:47` reads `.mergeCommit // empty`
3065
+ # from the answer. A fix touching only the numeric pair above would
3066
+ # leave that caller reading an absent key on every Bitbucket repo.
3067
+ #
3068
+ # The hash rides on the page the state walk already fetched, so this
3069
+ # costs no extra call.
2537
3070
  jq -c --arg b "$ref" '[.[] | select(.source.branch.name==$b)][0] // null
2538
- | if .==null then {number:0,state:"NONE",draft:false,url:""}
2539
- else {number:.id,state:(if .state=="DECLINED" then "CLOSED" else .state end),draft:(.draft // false),url:.links.html.href} end' <<<"$out"
3071
+ | if .==null then {number:0,state:"NONE",draft:false,url:"",mergeCommit:""}
3072
+ 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"
2540
3073
  else
2541
3074
  err="$(cat "/tmp/plot-host-err.$$" 2>/dev/null)"; rm -f "/tmp/plot-host-err.$$"
2542
- host_miss_or_fail "$err" '{"number":0,"state":"NONE","draft":false,"url":""}' || exit $?
3075
+ host_miss_or_fail "$err" '{"number":0,"state":"NONE","draft":false,"url":"","mergeCommit":""}' || exit $?
2543
3076
  fi
2544
3077
  fi
2545
3078
  fi
@@ -2768,12 +3301,28 @@ case "$op" in
2768
3301
  # that wants history says how much; the default stays the host's, so no
2769
3302
  # existing caller's result changes.
2770
3303
  limit=""
3304
+ branches=""
2771
3305
  while [ $# -gt 0 ]; do
2772
3306
  case "$1" in
2773
3307
  --state) state="${2:?}"; shift 2 ;;
2774
3308
  --limit) limit="${2:?}"; shift 2 ;;
2775
3309
  --rich) rich=1; shift ;;
2776
3310
  --repo) repo_args=(-R "${2:?}"); shift 2 ;;
3311
+ # THE BRANCHES THE CALLER TRACKS, repeatable, and OPT-IN. Given any,
3312
+ # the Bitbucket arm sweeps the REST endpoint once per branch per state
3313
+ # instead of listing the repository; given none, every existing caller
3314
+ # gets exactly the listing it always got. Four callers pass none today
3315
+ # (`plot-fleet-scan.sh`, `plot-open-pr.sh`, `plot-impl-status.sh` and
3316
+ # `fleet.ts`), so the bulk path stays the default rather than the
3317
+ # legacy one.
3318
+ #
3319
+ # WHY THE CALLER NAMES THEM AND THIS OP DOES NOT GUESS. The same rule
3320
+ # `--repo` states a few lines up: the caller knows which branches its
3321
+ # refs came from and this op cannot. Deriving them here — from remote
3322
+ # refs, say — would make the adapter invent a working set, and a sweep
3323
+ # over the wrong set answers confidently about branches nobody asked
3324
+ # about while missing the ones they did.
3325
+ --branch) branches="${branches:+$branches }${2:?}"; shift 2 ;;
2777
3326
  *) die "pr-list: unknown arg $1" ;;
2778
3327
  esac
2779
3328
  done
@@ -2972,7 +3521,11 @@ case "$op" in
2972
3521
  # Forwarding it errors with `unknown flag`, and dropping it silently
2973
3522
  # would serve a short page as if it were the whole set — the quiet wrong
2974
3523
  # answer this adapter refuses elsewhere. So it is dropped AND said.
2975
- if [ -n "$limit" ]; then
3524
+ # A SWEEP IS NOT A PAGE AND OWES NO SUCH WARNING. `--limit` bounds a
3525
+ # listing; a per-branch query returns that branch's pull requests and
3526
+ # nothing was capped, so the notice would describe a truncation that did
3527
+ # not happen. Said only for the listing it is about.
3528
+ if [ -n "$limit" ] && [ -z "$branches" ]; then
2976
3529
  echo "plot-host: bitbucket ignores --limit $limit; bb returns a fixed page (50 at 1.0.0)" >&2
2977
3530
  fi
2978
3531
  # Establish that bb supports --json BEFORE calling it — Done-when 5.
@@ -2983,17 +3536,30 @@ case "$op" in
2983
3536
  # with no output — an unknown state reading as "no PRs matched", which
2984
3537
  # is the exact failure this translation exists to remove.
2985
3538
  bb_states="$(bb_states_for "$state")" || exit 1
3539
+ # THE ONE PLACE THE SWEEP IS CHOSEN, and it is chosen as a COMMAND rather
3540
+ # than as a flag the three sites below each test. They differ only in the
3541
+ # jq program they pipe the payload through — `pr_list_states`' header says
3542
+ # so — and a sweep that added an `if` to each would make them differ in two
3543
+ # ways, which is how the six hand-applied fixes `pr_list_call` warns about
3544
+ # began. One assignment here; the sites are untouched but for this word.
3545
+ PR_LIST_BRANCHES="$branches"
3546
+ PR_SWEEP_ASKED=0
3547
+ bb_cmd=(bb ${repo_args[@]+"${repo_args[@]}"} pr list)
3548
+ if [ -n "$branches" ]; then
3549
+ # A SWEEP'S COST IS THE CALLER'S WORKING SET, and it is reported so the
3550
+ # caller can check the claim it is about to be handed. Branches × states
3551
+ # — 11 branches over 3 states is 33 exact queries, against 3 listings
3552
+ # that answer for 50 of 902 rows.
3553
+ for _b in $branches; do PR_SWEEP_ASKED=$((PR_SWEEP_ASKED + 1)); done
3554
+ bb_cmd=(bb_branch_sweep ${repo_args[@]+"${repo_args[@]}"})
3555
+ fi
2986
3556
  if [ "$rich" = 1 ]; then
2987
3557
  if [ "$ci" = "jenkins" ]; then
2988
3558
  # Bitbucket PR list, `checks` filled from Jenkins — the SAME overlay
2989
3559
  # the GitHub arm uses, which is why it lives above the backend branch.
2990
3560
  # `bb`'s standing `unknown` becomes a real value where Jenkins answers.
2991
- for _s in $bb_states; do
2992
- _bb_raw="$(pr_list_call bb ${repo_args[@]+"${repo_args[@]}"} pr list --state "$_s" --json)" || exit $?
2993
- pr_list_report_truncation bitbucket "$limit" "$_s" \
2994
- "$(jq 'length' <<<"$_bb_raw" 2>/dev/null || echo 0)"
2995
- printf '%s' "$_bb_raw" \
2996
- | jq -c --argjson jmap "$jen_map" --arg jstatus "$jen_status" '.[] |
3561
+ PR_LIST_JQ_ARGS=(--argjson jmap "$jen_map" --arg jstatus "$jen_status")
3562
+ pr_list_states bitbucket "$limit" "$bb_states" '.[] |
2997
3563
  ($jmap[.source.branch.name] // null) as $jentry |
2998
3564
  {
2999
3565
  number:.id, title:.title,
@@ -3013,26 +3579,19 @@ case "$op" in
3013
3579
  then [$jentry.job]
3014
3580
  else []
3015
3581
  end)
3016
- }'
3017
- done
3582
+ }' "${bb_cmd[@]}" || exit $?
3018
3583
  else
3019
3584
  # Bitbucket without Jenkins: checks remain unknown
3020
- for _s in $bb_states; do
3021
- _bb_raw="$(pr_list_call bb ${repo_args[@]+"${repo_args[@]}"} pr list --state "$_s" --json)" || exit $?
3022
- pr_list_report_truncation bitbucket "$limit" "$_s" \
3023
- "$(jq 'length' <<<"$_bb_raw" 2>/dev/null || echo 0)"
3024
- printf '%s' "$_bb_raw" \
3025
- | jq -c '.[] | {number:.id,title:.title,state:(if .state=="DECLINED" then "CLOSED" else .state end),head:.source.branch.name,draft:(.draft // false),checks:"unknown",mergeable:"unknown",review:"",url:(.links.html.href // ""),failing_checks:[]}'
3026
- done
3585
+ PR_LIST_JQ_ARGS=()
3586
+ pr_list_states bitbucket "$limit" "$bb_states" \
3587
+ '.[] | {number:.id,title:.title,state:(if .state=="DECLINED" then "CLOSED" else .state end),head:.source.branch.name,draft:(.draft // false),checks:"unknown",mergeable:"unknown",review:"",url:(.links.html.href // ""),failing_checks:[]}' \
3588
+ "${bb_cmd[@]}" || exit $?
3027
3589
  fi
3028
3590
  else
3029
- for _s in $bb_states; do
3030
- _bb_raw="$(pr_list_call bb ${repo_args[@]+"${repo_args[@]}"} pr list --state "$_s" --json)" || exit $?
3031
- pr_list_report_truncation bitbucket "$limit" "$_s" \
3032
- "$(jq 'length' <<<"$_bb_raw" 2>/dev/null || echo 0)"
3033
- printf '%s' "$_bb_raw" \
3034
- | jq -c '.[] | {number:.id,title:.title,state:(if .state=="DECLINED" then "CLOSED" else .state end),head:.source.branch.name}'
3035
- done
3591
+ PR_LIST_JQ_ARGS=()
3592
+ pr_list_states bitbucket "$limit" "$bb_states" \
3593
+ '.[] | {number:.id,title:.title,state:(if .state=="DECLINED" then "CLOSED" else .state end),head:.source.branch.name}' \
3594
+ "${bb_cmd[@]}" || exit $?
3036
3595
  fi
3037
3596
  fi
3038
3597
  ;;