@plot-pm/board 0.14.2 → 0.14.4

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
@@ -61,6 +61,30 @@
61
61
  # pr-ready <number> take a PR out of draft
62
62
  # merge the PR
63
63
  # pr-list [--state open|merged|closed|all] [--limit N] [--rich]
64
+ # [--since <iso>] narrows the listing to pull
65
+ # requests the host has seen change since that
66
+ # stamp. Measured 2026-09-21 on this repository:
67
+ # one `--state all` over 933 pull requests takes
68
+ # 29 811 ms with the fields the board needs, and
69
+ # the same call over one day takes 943 ms for 3
70
+ # rows — factor 32, with every expensive field
71
+ # still included.
72
+ # THE STAMP IS THE HOST'S OWN, passed through
73
+ # byte-for-byte: a caller that re-renders it
74
+ # sends its own clock's spelling, and a client
75
+ # two seconds fast excludes the PRs updated in
76
+ # that gap from every later window — forever,
77
+ # because the window never reopens.
78
+ # GITHUB NARROWS AND BITBUCKET'S BULK LISTING
79
+ # CANNOT. `gh pr list` takes `--search`; `bb pr
80
+ # list` has no query flag at all (verified
81
+ # against bb 1.9.0: `unknown flag: --query`), so
82
+ # only the per-branch sweep's REST `q=` can
83
+ # carry it. The bulk Bitbucket listing SAYS it
84
+ # could not narrow and answers in full, because
85
+ # a full answer reported as a delta is what
86
+ # would let a caller advance a watermark over a
87
+ # window it never applied.
64
88
  # [--repo <owner/repo>] pins the list to ONE
65
89
  # repository, exactly as pr-state and pr-merged
66
90
  # do. A checkout with remotes on two hosts lets
@@ -70,7 +94,24 @@
70
94
  # JSON lines: {"number":N,"title":"...",
71
95
  # "state":"...","head":"..."}
72
96
  # --rich adds: draft, checks, mergeable, review,
73
- # url, failing_checks — `failing_checks` names
97
+ # url, updatedAt, failing_checks —
98
+ # `updatedAt` is WHEN THE HOST LAST SAW THE PR
99
+ # CHANGE, in the host's own words and on the
100
+ # host's own clock (`updatedAt` on GitHub,
101
+ # `updated_on` on Bitbucket), "" where the CLI
102
+ # omits it. It is the field a durable store
103
+ # advances its watermark by, and it is the
104
+ # host's rather than this machine's because a
105
+ # client clock two seconds fast would exclude
106
+ # every PR updated in that gap from every later
107
+ # window — permanently, and silently.
108
+ # Measured 2026-09-21: over 937 PRs,
109
+ # `number,updatedAt` is 4715 ms against 5417 ms
110
+ # for the base fields and 18842 ms for
111
+ # `statusCheckRollup`. Asked on --rich ONLY —
112
+ # the plain arm is the cheap one and no shell
113
+ # caller reads a watermark.
114
+ # `failing_checks` names
74
115
  # WHICH checks failed, the detail `checks`
75
116
  # collapses to one word, from the same response
76
117
  # at no extra call; [] on bitbucket and wherever
@@ -534,6 +575,436 @@ pr_list_call() { # "$@"=the host command → payload on stdout, or dies
534
575
  printf '%s' "$out"
535
576
  }
536
577
 
578
+ # The exit code for an answer that is INCOMPLETE rather than absent.
579
+ #
580
+ # SEVEN, BECAUSE THE VOCABULARY IS FULL BELOW IT. `pr_list_failed` spends 6
581
+ # (burst refusal), 5 (throttled) and 3 (everything else); 4 is the backend
582
+ # having no such capability; 1 is a refusal before any call; 0 is a whole
583
+ # answer. Two is left alone deliberately — it is bash's own conventional code
584
+ # for a misused builtin, and a partial answer sharing it could not be told from
585
+ # a shell-level fault the script never intended.
586
+ #
587
+ # NON-ZERO IS THE LOAD-BEARING PART. `plot-fleet-scan.sh:675` reads this code
588
+ # DIRECTLY, not through the transport, and branches on `rc -ne 0`. Exiting 0 on
589
+ # a partial answer would set `HOST_VERDICT=ok` over a page missing a whole
590
+ # state — a complete reading reported over an incomplete one, which is the
591
+ # quiet wrong answer this adapter refuses everywhere else. It would also
592
+ # re-create precisely the state fixed on 2026-08-30, when `pr-list` swallowed
593
+ # its own failure and exited 0 with empty stdout.
594
+ PR_LIST_PARTIAL_RC=7
595
+
596
+ # Run ONE `pr-list` over several host states, printing what answered.
597
+ #
598
+ # THE BITBUCKET ASYMMETRY THIS EXISTS FOR. `bb pr list` has no `all` state, so
599
+ # `bb_states_for all` expands to three and the arm must call `bb` once per
600
+ # state. GitHub takes `--state all` in a single call and can never reach this
601
+ # shape: there, a failure means nothing was printed.
602
+ #
603
+ # ONE MECHANISM FOR THREE CALL SITES, and that is the point rather than a
604
+ # tidy-up — `pr_list_call`'s own header makes the argument: *"a fix applied by
605
+ # hand six times is a fix that drifts, and the arm that drifts is the one
606
+ # nobody's repo exercises."* The three sites (rich+Jenkins, rich, plain) differ
607
+ # ONLY in the jq program they pipe the payload through, so that program and its
608
+ # arguments are what this takes.
609
+ #
610
+ # WHY THE LOOP CANNOT KEEP `|| exit $?`. That propagation is not a style tic:
611
+ # `pr_list_call` is invoked in a command substitution — a subshell — so the
612
+ # `exit` inside `pr_list_failed` leaves only that subshell, and without the
613
+ # propagation the outer script carries on with an empty payload and jq emits
614
+ # nothing. Collecting across states means the first failure can no longer end
615
+ # the run, so the subshell's exit code is captured and classified instead: a
616
+ # non-zero rc is THIS STATE FAILED, which is a different fact from this state
617
+ # returning an empty list.
618
+ #
619
+ # WHAT IT EXITS WITH:
620
+ # 0 every state answered.
621
+ # PR_LIST_PARTIAL_RC some answered and some did not — the rows of those
622
+ # that answered are on stdout, and the failures are
623
+ # named on stderr.
624
+ # the first failure's rc NO state answered. A total outage keeps the code it
625
+ # has always had (3, 5 or 6 by kind), so a genuine
626
+ # outage can never read as a partial page.
627
+ #
628
+ # A SINGLE-STATE CALL HAS NO PARTIAL ANSWER TO REPORT. With one state asked,
629
+ # "some answered and some did not" is unreachable by construction: either the
630
+ # one state answered (0) or none did (its own code). The counting below gives
631
+ # that for free rather than by a special case.
632
+ # TWO VARIADIC LISTS, ONE `"$@"`. The host command and the jq arguments are
633
+ # both open-ended, and bash has one positional array — so the jq side travels
634
+ # in this global, set by the caller immediately before the call. It is read
635
+ # once per state and never written here.
636
+ PR_LIST_JQ_ARGS=()
637
+
638
+ # --- the per-branch sweep (#333) --------------------------------------------
639
+ #
640
+ # THE JOIN ASKS ABOUT BRANCHES AND THE LIST ANSWERS ABOUT A REPOSITORY, and the
641
+ # gap between those two questions is #333. `plot-fleet-scan.sh` holds a dozen
642
+ # branch names and asks `pr-list` for every pull request the repository has
643
+ # ever had, then indexes what came back by `head` and discards the rest.
644
+ # Measured 2026-09-20 on `quatico/quaweb-website`: 12 board rows against 902
645
+ # pull requests, of which the join uses about 1%.
646
+ #
647
+ # THAT WOULD ONLY BE WASTEFUL IF THE LIST WERE WHOLE. It is not. `bb pr list`
648
+ # returns a fixed page of 50 per state, and the repository holds 886 MERGED
649
+ # pull requests — so 836 of them are invisible to the join, and every branch
650
+ # whose pull request is among them reads as having none. That is the fabricated
651
+ # verdict `plot-fleet-scan.sh:876` rules against by name, reached from the one
652
+ # direction the truncation detector below can report but not repair.
653
+ #
654
+ # SO THE SWEEP INVERTS THE QUESTION. Given the branches the caller actually
655
+ # tracks, it asks the REST endpoint about each one by name, through the same
656
+ # `q=` filter `bb` already builds for `--author`:
657
+ #
658
+ # /repositories/{ws}/{repo}/pullrequests
659
+ # ?q=state="MERGED" AND source.branch.name="feature/x"&pagelen=50
660
+ #
661
+ # Measured on that repository: `size: 1 | values: 1 | ids: 902` — PR 902 is one
662
+ # of the 836 a listing cannot reach — and a branch with no pull request answers
663
+ # `size: 0`, which is an EXACT ABSENCE rather than a short page.
664
+ #
665
+ # WHY NOT PAGE THE LIST INSTEAD, since the endpoint carries a `next` cursor.
666
+ # Two measurements refuse it. `fleet.ts:184` declares the per-refresh cost and
667
+ # `prRefreshMsFor` stretches the interval by it so hourly spend stays 60;
668
+ # walking all 18 merged pages makes the cost ~21 and the interval follows
669
+ # mechanically, 240 s → 1260 s, with `MAX_CADENCE_STRETCH = 8` taking the worst
670
+ # case to 2.8 hours. And the growth curve runs the wrong way: paging costs grow
671
+ # with the repository's pull-request history — the very quantity whose growth
672
+ # makes #333 worse — while a sweep costs what the CALLER TRACKS and is constant
673
+ # in pull-request count. `pagelen=100` is refused by Bitbucket (HTTP 400), so
674
+ # 50 is the ceiling and 18 pages was a floor rather than a safe estimate.
675
+ #
676
+ # THE LEADING SLASH IS LOAD-BEARING. `bb api` concatenates "${BB_API}${path}",
677
+ # so a path without it yields `…/2.0repositories/…` and an HTTP 403 that reads
678
+ # exactly like a missing scope. A previous plan was rejected for inferring a
679
+ # scope problem from this symptom. It is written once, here, and pinned.
680
+ #
681
+ # `bb`'s OWN PAGINATOR IS NOT REACHED FOR, and that is deliberate rather than
682
+ # incidental: `bb:203` caps at 10 pages — 500 rows at `pagelen=50`, 386 short
683
+ # of 886 — and exits with no error and no marker. A helper that silently
684
+ # returns a prefix is worse here than one that refuses.
685
+
686
+ # Bitbucket's own state word for one of this adapter's states.
687
+ #
688
+ # The `q=` filter matches Bitbucket's vocabulary, which is upper-case and calls
689
+ # a rejected pull request DECLINED. The adapter's own words are `bb pr list`'s,
690
+ # which are lower-case. One mapping, so a caller never spells a state twice.
691
+ bb_query_state() { # $1=adapter state word → Bitbucket's
692
+ case "$1" in
693
+ open) printf 'OPEN' ;;
694
+ merged) printf 'MERGED' ;;
695
+ declined) printf 'DECLINED' ;;
696
+ superseded) printf 'SUPERSEDED' ;;
697
+ *) die "bb_query_state: unknown state '$1'" ;;
698
+ esac
699
+ }
700
+
701
+ # Percent-encode one value for a URL query string.
702
+ #
703
+ # BRANCH NAMES ARE NOT URL-SAFE and this repository proves it: `feature/x` is
704
+ # the ordinary shape, and `/` inside an unencoded `q=` value ends the filter
705
+ # early, so the query would ask about `feature` and answer about the wrong
706
+ # branch — or about none. Encoding is done here rather than by the caller so
707
+ # every query on this path is encoded the same way.
708
+ #
709
+ # `LC_ALL=C` makes the loop byte-wise, so a multi-byte character is encoded as
710
+ # its bytes rather than mangled into one `?`. Branch names carrying non-ASCII
711
+ # are rare and entirely legal.
712
+ url_encode() { # $1=raw → percent-encoded on stdout
713
+ local _s="$1" _i _c _out=""
714
+ local LC_ALL=C
715
+ for (( _i = 0; _i < ${#_s}; _i++ )); do
716
+ _c="${_s:_i:1}"
717
+ case "$_c" in
718
+ [a-zA-Z0-9.~_-]) _out="$_out$_c" ;;
719
+ *) _out="$_out$(printf '%%%02X' "'$_c")" ;;
720
+ esac
721
+ done
722
+ printf '%s' "$_out"
723
+ }
724
+
725
+ # Ask Bitbucket about ONE branch in ONE state, and print the `values` array.
726
+ #
727
+ # WHAT IT PRINTS is the endpoint's `values` — a JSON array of 0 or 1 pull
728
+ # request objects, in the SAME shape `bb pr list --json` emits, which is what
729
+ # lets the existing jq programs consume it unchanged. The `--rich` field set
730
+ # (`plot-host.sh:3247`) reads `.id`, `.title`, `.state`, `.source.branch.name`,
731
+ # `.draft` and `.links.html.href`; the REST object carries all six.
732
+ #
733
+ # AN ABSENT ANSWER IS NOT A FAILED ONE, and the exit code is what says which.
734
+ # `size: 0` is an honest absence — the branch has no pull request in this state
735
+ # — and exits 0 with `[]` on stdout. A refused call exits non-zero and prints
736
+ # nothing, so a caller reading the CODE can never mistake an outage for an
737
+ # empty repository. `plot-fleet-scan.sh:891` records that exact confusion
738
+ # happening from the other side: a host exiting 0 while printing nothing once
739
+ # read as "this repo has no PRs".
740
+ #
741
+ # `pagelen=50` rather than 1. A branch may legitimately carry several pull
742
+ # requests in one state — a merged attempt and a merged successor — and the
743
+ # consumers rank them (`fleet.ts`'s `prOutranks`, the scan's OPEN-before-MERGED
744
+ # sort). Asking for one would silently hand them whichever the host listed
745
+ # first, which no adapter promises. Fifty is the endpoint's ceiling and costs
746
+ # the same as one.
747
+ # THE HOST'S OWN FAILURE TEXT LEAVES HERE UNCLASSIFIED, and that is the whole
748
+ # reason this does not call `pr_list_call`. That wrapper classifies a failure
749
+ # ONCE — throttled, burst, or everything else — and composes the sentence a
750
+ # reader acts on. Calling it per branch and again around the sweep classifies
751
+ # twice, and the second pass reads the FIRST pass's prose rather than the host's
752
+ # message: measured here, a `429` became *"the host failed the request and said
753
+ # nothing"* because `Rate limit … exceeded` was no longer in the text being
754
+ # matched. So the raw stderr and the raw exit code travel out of this function
755
+ # untouched, and the single `pr_list_call` that `pr_list_states` already wraps
756
+ # the whole sweep in does the one classification — exactly the layering
757
+ # `bb pr list` has always had.
758
+ bb_branch_query() { # $1=branch $2=adapter state; rest=global bb args → values[]
759
+ local _br="$1" _st="$2"; shift 2
760
+ local _q _path _out _rc
761
+ _q="state=$(url_encode "\"$(bb_query_state "$_st")\"") AND source.branch.name=$(url_encode "\"$_br\"")"
762
+ # THE WINDOW COMPOSES WITH THE STATE FILTER AND NEVER REPLACES IT. Bitbucket
763
+ # takes ONE `q=` parameter, so a second one would silently win or lose
764
+ # depending on the host's parsing — and either way the state clause this query
765
+ # is built around would be the term at risk. `AND` is the same conjunction the
766
+ # two terms above already use.
767
+ #
768
+ # ENCODED LIKE EVERY OTHER TERM ON THIS PATH, and an ISO stamp needs it: `:`
769
+ # and `-` are not in `url_encode`'s safe set, and the block header records
770
+ # what an unencoded value costs — `/` ends the filter early, so the query asks
771
+ # about one thing and answers about another.
772
+ #
773
+ # `>=` RATHER THAN `>`, and the asymmetry with the GitHub arm is deliberate.
774
+ # Bitbucket's `updated_on` carries microseconds and GitHub's stamp does not,
775
+ # so an exclusive comparison against a truncated stamp would drop a PR updated
776
+ # inside the same second. Re-reading one row the caller already holds costs a
777
+ # row; missing one costs a change nobody ever sees again.
778
+ [ -n "$PR_LIST_SINCE" ] \
779
+ && _q="$_q AND updated_on>=$(url_encode "\"$PR_LIST_SINCE\"")"
780
+ # The space between the two terms is encoded too; `bb api` passes the path to
781
+ # curl verbatim and an unencoded space would truncate the request line.
782
+ _q="${_q// /%20}"
783
+ # THE LEADING SLASH. See the block header — without it this is a 403 that
784
+ # reads as a scope error.
785
+ _path="/repositories/{ws}/{repo}/pullrequests?q=${_q}&pagelen=50"
786
+ # `jq` is applied only to a SUCCESSFUL payload. Piping a failed call into it
787
+ # would turn the host's exit code into jq's, and a parse error and a spent
788
+ # quota are not the same fact.
789
+ _out="$(bb "$@" api "$_path")" || return $?
790
+ printf '%s' "$_out" | jq -c '.values // []'
791
+ }
792
+
793
+ # Ask about EVERY tracked branch in one state, printing one combined array.
794
+ #
795
+ # THE SHAPE `pr_list_states` ALREADY EXPECTS. It calls its host command once
796
+ # per state and pipes the result through a jq program that starts `.[]`, so the
797
+ # sweep's job is to produce the same thing a single `bb pr list --state X`
798
+ # would have: one JSON array of pull request objects. The states loop, the row
799
+ # counting, the truncation report and the partial-answer rule above all stay in
800
+ # exactly one place — `pr_list_call`'s own header makes that argument, and a
801
+ # sweep with its own copy of the loop is the drift it names.
802
+ #
803
+ # A BRANCH THAT FAILS ENDS THE STATE, and that is the conservative direction.
804
+ # The combined array is only an answer if every branch in it was asked; one
805
+ # refused query means absence is no longer derivable for that branch, and a
806
+ # short array reported as whole is what #333 IS. So a failure propagates —
807
+ # `pr_list_call` exits — and `pr_list_states` classifies the state as failed,
808
+ # which reaches the caller as a partial answer (exit 7) when other states
809
+ # answered, or as the failure's own code when none did. One vocabulary.
810
+ # THE CALLING CONVENTION IS `pr_list_states`', NOT THIS FUNCTION'S OWN. That
811
+ # helper appends `--state <s> --json` to whatever command it was given, so a
812
+ # sweep that wants to sit in the same slot must accept those two trailing
813
+ # arguments and read the state out of them. Doing it the other way — teaching
814
+ # `pr_list_states` which of its commands is a sweep — would put a backend's
815
+ # shape inside the one piece of this file that has none.
816
+ #
817
+ # `--json` is accepted and ignored. The REST payload is JSON whether or not it
818
+ # is asked for, and refusing a flag the caller must pass would make the slot
819
+ # incompatible for the sake of a distinction with no consequence.
820
+ bb_branch_sweep() { # global bb args… --state <s> --json → one JSON array
821
+ local _st="" _args=() _acc="[]" _br _rc
822
+ while [ $# -gt 0 ]; do
823
+ case "$1" in
824
+ --state) _st="${2:?}"; shift 2 ;;
825
+ --json) shift ;;
826
+ *) _args+=("$1"); shift ;;
827
+ esac
828
+ done
829
+ [ -n "$_st" ] || die "bb_branch_sweep: no --state"
830
+ # THE ANSWERS ARE SPOOLED TO A FILE AND JOINED ONCE, NEVER PASSED THROUGH
831
+ # ARGV. The first version accumulated with
832
+ # `jq -c --argjson add "$_one" '. + $add'`, which hands a whole branch's
833
+ # payload to `jq` as a command-line argument — and Linux caps one argument at
834
+ # `MAX_ARG_STRLEN` (128 KB) where macOS has no such ceiling.
835
+ #
836
+ # WHAT THAT COST, measured 2026-09-20 against a Debian container: a branch
837
+ # carrying 886 merged pull requests is a 147 KB payload, `jq` died with
838
+ # *"Argument list too long"*, `_acc` came back EMPTY, and the sweep exited 0
839
+ # while printing no rows AND stating its completeness — a confident claim of
840
+ # "no pull requests" over a branch that had 886. That is precisely the
841
+ # fabricated verdict this whole slice exists to remove, rebuilt one layer in.
842
+ # It passed on macOS and failed only on Linux, which is where CI and every
843
+ # board run.
844
+ #
845
+ # A FILE HAS NO SUCH CEILING, and one `jq -s add` over the spool replaces N
846
+ # re-parses of a growing accumulator: the old shape re-read every row it had
847
+ # already seen once per branch, so eleven branches parsed the first branch's
848
+ # payload eleven times.
849
+ local _spool
850
+ _spool="$(mktemp "/tmp/plot-host-sweep.$$.XXXXXX")" || return 3
851
+ for _br in $PR_LIST_BRANCHES; do
852
+ # RETURN, NOT EXIT. This runs inside the command substitution
853
+ # `pr_list_call` wraps the sweep in, so the code must travel back as this
854
+ # function's status for that wrapper to classify it. An `exit` here would
855
+ # leave the substitution with an empty payload and a code the wrapper reads
856
+ # as the sweep's own — the silent empty list `pr_list_call`'s header names.
857
+ #
858
+ # 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.
860
+ bb_branch_query "$_br" "$_st" ${_args[@]+"${_args[@]}"} >> "$_spool" \
861
+ || { _rc=$?; rm -f "$_spool"; return $_rc; }
862
+ done
863
+ # `-s` reads the whole stream as one array of arrays; `add` flattens it.
864
+ # An EMPTY spool — every branch answered `[]` — makes `add` yield `null`, so
865
+ # the fallback keeps the contract that this prints a JSON ARRAY, which is
866
+ # what the caller's `.[]` needs.
867
+ _acc="$(jq -c -s 'add // []' < "$_spool")" || { rm -f "$_spool"; return 3; }
868
+ rm -f "$_spool"
869
+ printf '%s' "$_acc"
870
+ }
871
+
872
+ # The branches a sweep asks about, newline-or-space separated. Empty means the
873
+ # caller named none, and the arm keeps the bulk listing it has always used.
874
+ #
875
+ # A GLOBAL FOR `PR_LIST_JQ_ARGS`' REASON, stated two hundred lines above: the
876
+ # host command is already variadic and bash has one positional array. It is set
877
+ # by the `pr-list` arm immediately before the call and read nowhere else.
878
+ #
879
+ # SPACE-SEPARATED, AND GIT IS WHAT MAKES THAT SAFE. `bb_branch_sweep` reads this
880
+ # with an unquoted `for`, so a name carrying whitespace would split into two
881
+ # branches that do not exist. `git check-ref-format` REFUSES a ref name
882
+ # containing a space or a tab — verified 2026-09-20, both exit non-zero — so the
883
+ # separator is git's guarantee rather than a hopeful convention.
884
+ PR_LIST_BRANCHES=""
885
+
886
+ # The window a sweep's query carries, the host's own stamp. Empty means ask
887
+ # about everything, which is what every caller predating `--since` asks.
888
+ #
889
+ # A GLOBAL FOR `PR_LIST_BRANCHES`' REASON, and it travels the same route: the
890
+ # `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
893
+ # teaching that function a parameter it only forwards, and its header already
894
+ # refuses the mirror of that — "teaching `pr_list_states` which of its commands
895
+ # is a sweep would put a backend's shape inside the one piece of this file that
896
+ # has none."
897
+ PR_LIST_SINCE=""
898
+
899
+ # How many branches the last sweep asked about, and how many answered.
900
+ #
901
+ # THE COMPLETENESS SIGNAL, AND WHY THE ARM STATES IT RATHER THAN THE SCAN
902
+ # INFERRING IT. `plot-fleet-scan.sh:901` writes `.list-complete` when
903
+ # `0 < rows < PR_LIST_LIMIT` — completeness read off a single page's size,
904
+ # which is the only evidence a bulk listing offers. A sweep has no page: each
905
+ # query returns 0 or 1, and `size: 0` is already an exact answer for that
906
+ # branch. So completeness stops being a property of a row count and becomes a
907
+ # property of the SWEEP — every tracked branch was asked and each one answered
908
+ # — which is a stronger claim than the page heuristic could ever make, and one
909
+ # this side can state as a fact rather than leave to be guessed from a number.
910
+ #
911
+ # A PARTIAL SWEEP MUST NOT MAKE THE CLAIM. If any branch's query failed, the
912
+ # survivors are still valid answers and are still printed, but absence is no
913
+ # longer derivable for the branches that went unasked. The line is emitted only
914
+ # when every state answered.
915
+ #
916
+ # ONE COUNTER, NOT TWO. How many branches ANSWERED is not tracked beside this,
917
+ # because a failed branch query ends its whole state (see `bb_branch_sweep`) and
918
+ # the states tally `pr_list_states` already keeps is therefore the same fact. A
919
+ # second counter would be a second answer to one question, and the two would
920
+ # drift the first time either side changed.
921
+ PR_SWEEP_ASKED=0
922
+
923
+ # State on stderr that the sweep was WHOLE — the licence `.list-complete` needs.
924
+ #
925
+ # THE LINE IS A CONTRACT, not a log. `plot-fleet-scan.sh` reads it to decide
926
+ # whether a cache miss means "no pull request" or "never asked", so its wording
927
+ # is pinned by a test the same way the truncation report's is. It names both
928
+ # counts, because a reader who sees the claim should be able to check it.
929
+ #
930
+ # WHAT IT LICENSES IS A SHORTCUT, NOT THE ANSWER. Without it, a `--ask` caller
931
+ # whose branch missed the join falls through to a `pr-state` call per branch and
932
+ # still gets a correct answer — the per-branch N+1 that #216 removed, which is a
933
+ # COST regression rather than a wrong one. So withholding the line is always
934
+ # safe and is what a partial sweep does.
935
+ #
936
+ # EVERY STATE MUST HAVE ANSWERED. A sweep asks each branch once per state, and a
937
+ # branch's absence is only established when every state was asked about it: a
938
+ # merged pull request missed because the `merged` state failed reads exactly
939
+ # like a branch that never had one. So the claim is made on the STATES' tally,
940
+ # which `pr_list_states` already keeps, rather than on a per-branch count that
941
+ # would have to be reconciled with it.
942
+ #
943
+ # SILENT WHEN NO SWEEP RAN. The bulk path makes no per-branch claim and keeps
944
+ # the row-count heuristic it has always used, so nothing is printed and no
945
+ # existing caller's behaviour changes.
946
+ pr_sweep_report() { # $1=states answered $2=states asked
947
+ [ -n "$PR_LIST_BRANCHES" ] || return 0
948
+ [ "$1" -eq "$2" ] 2>/dev/null || return 0
949
+ 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
950
+ }
951
+
952
+ pr_list_states() { # $1=backend $2=limit $3=states $4=jq-program; rest=the host command
953
+ local backend="$1" limit="$2" states="$3" jq_prog="$4"; shift 4
954
+ local _s _raw _rc _err _tmp _ok=0 _failed=0 _first_rc=0 _failed_states=""
955
+ for _s in $states; do
956
+ _tmp="/tmp/plot-host-prlist-state-err.$$.$_s"
957
+ # THE SUBSHELL'S CODE IS THE ONLY CHANNEL OUT, so it is captured rather
958
+ # than propagated. `pr_list_failed` runs INSIDE the substitution and has
959
+ # already composed its report and its repair line; that text is spooled
960
+ # here only so the state's name can be added to it before it is passed on.
961
+ _raw="$(pr_list_call "$@" --state "$_s" --json 2>"$_tmp")"; _rc=$?
962
+ _err="$(cat "$_tmp" 2>/dev/null)"; rm -f "$_tmp"
963
+ if [ "$_rc" -ne 0 ]; then
964
+ # THE HOST'S OWN REPORT GOES FIRST AND IS NEVER PREFIXED. `pr_list_failed`
965
+ # already composed the sentence that says WHY — a spent quota, a burst
966
+ # refusal, a DNS blip — and that sentence is what a reader acts on. A line
967
+ # of this helper's own naming WHICH state, emitted ahead of it, buries the
968
+ # reason under the bookkeeping: the scan reads the first stderr line into
969
+ # its error field, and a reader chasing `HTTP 429` would be shown
970
+ # `state 'open' failed` instead. That is #912's own failure mode — a
971
+ # message describing the wrong thing — reproduced one layer up, and the
972
+ # contract suite caught it.
973
+ [ -n "$_err" ] && printf '%s\n' "$_err" >&2
974
+ _failed=$((_failed + 1))
975
+ [ "$_first_rc" -eq 0 ] && _first_rc=$_rc
976
+ _failed_states="${_failed_states:+$_failed_states, }$_s"
977
+ continue
978
+ fi
979
+ [ -n "$_err" ] && printf '%s\n' "$_err" >&2
980
+ _ok=$((_ok + 1))
981
+ # A SWEEP MAKES NO PAGE CLAIM, so the page detector is not asked. Its rule
982
+ # is about a LISTING that cannot report a total — see its header — and a
983
+ # sweep's row count has no page semantics at all: the count is how many of
984
+ # the asked branches have a pull request in this state, and two of eleven is
985
+ # a complete answer rather than a short one. Running it here would print
986
+ # "possibly truncated" immediately before `pr_sweep_report` states the
987
+ # answer was whole, which is the adapter contradicting itself on one stream.
988
+ #
989
+ # THE DETECTOR ITSELF IS UNTOUCHED and still fires exactly as it did on
990
+ # every listing call — `host.test.mjs:3060` passes unedited. What changed is
991
+ # that a path exists whose premise it was never written about.
992
+ [ -n "$PR_LIST_BRANCHES" ] || pr_list_report_truncation "$backend" "$limit" "$_s" \
993
+ "$(jq 'length' <<<"$_raw" 2>/dev/null || echo 0)"
994
+ printf '%s' "$_raw" | jq -c ${PR_LIST_JQ_ARGS[@]+"${PR_LIST_JQ_ARGS[@]}"} "$jq_prog"
995
+ done
996
+ pr_sweep_report "$_ok" "$((_ok + _failed))"
997
+ [ -z "$_failed_states" ] && return 0
998
+ if [ "$_ok" -eq 0 ]; then
999
+ # NO STATE ANSWERED — a total outage, and it keeps the code it has always
1000
+ # had so a real outage can never be read as a partial page.
1001
+ echo "plot-host: pr-list: no state answered; this is not a partial answer" >&2
1002
+ return "$_first_rc"
1003
+ fi
1004
+ echo "plot-host: pr-list: answered $_ok of $((_ok + _failed)) states; missing: $_failed_states" >&2
1005
+ return "$PR_LIST_PARTIAL_RC"
1006
+ }
1007
+
537
1008
  # --- Jenkins CI integration ------------------------------------------------
538
1009
  # A repo may declare `CI: jenkins` independently of `Git host`. When it does,
539
1010
  # build status (`checks`) is resolved through `jen` — a multibranch job's
@@ -1683,6 +2154,33 @@ tracker_projects() {
1683
2154
  printf '%s' "$raw" | tr ',' '\n' | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//' | grep -v '^$' || true
1684
2155
  }
1685
2156
 
2157
+ # Reads ONE variable from a `.env`-shaped file. Evaluates nothing.
2158
+ #
2159
+ # NOT `set -a; . ./.env; set +a`, which is the usual one-liner. It was measured
2160
+ # aborting in zsh on a file whose third line holds an unquoted JSON object, and
2161
+ # it imports every unrelated variable in the file — which on a credentials path
2162
+ # is a reason of its own.
2163
+ #
2164
+ # NOT `grep '^NAME=' | cut -d= -f2-` either: measured 2026-09-17, that returns
2165
+ # EMPTY for an `export `-prefixed line and KEEPS THE QUOTES on a quoted one, and
2166
+ # a quoted token reaching `curl -u` produces the 401 this read exists to remove.
2167
+ #
2168
+ # THE STRIP ORDER IS THE DEFECT, and it has been got wrong twice. Whitespace is
2169
+ # stripped BEFORE the quotes and again after. On `T="tok" ` the `"$` anchor
2170
+ # misses if the quotes go first, and the value keeps them:
2171
+ #
2172
+ # quotes first: ["tok"] whitespace first: [tok]
2173
+ #
2174
+ # `head -1` takes the first assignment, so a duplicated name resolves the way a
2175
+ # shell reading top-to-bottom would.
2176
+ read_env_var() { # $1=name $2=file → the value, or nothing
2177
+ sed -n "s/^[[:space:]]*\(export[[:space:]]\+\)\{0,1\}$1=//p" "$2" \
2178
+ | head -1 \
2179
+ | sed -e 's/[[:space:]]*$//' \
2180
+ -e 's/^"\(.*\)"$/\1/' -e "s/^'\(.*\)'\$/\1/" \
2181
+ -e 's/[[:space:]]*$//'
2182
+ }
2183
+
1686
2184
  # The env var scheme for Jira auth. The plan left the EXACT names open, to be
1687
2185
  # confirmed against a real instance; these follow Jira Cloud's documented Basic
1688
2186
  # scheme (email + API token, base64'd into an Authorization header):
@@ -1695,14 +2193,55 @@ tracker_projects() {
1695
2193
  # This guard is called in the MAIN shell, BEFORE the `$(jira_curl …)` capture —
1696
2194
  # `die3` exits the whole script only from there, not from inside a command
1697
2195
  # substitution where it would end only the subshell and leak a second error.
2196
+ #
2197
+ # WHERE BOTH ARE UNSET, THE REPOSITORY'S `.env` IS READ. The refusal below named
2198
+ # two variables without ever looking where a repository puts them, so an
2199
+ # operator holding working credentials — measured 2026-09-17, a 200 from
2200
+ # `/rest/api/3/myself` with the same pair — was sent to create a second token.
2201
+ # "Export it in your shell" does not reach the board either: it is a long-lived
2202
+ # process, which is why `plot-fleetctl.sh` bakes an environment into its unit.
2203
+ #
2204
+ # THE ENVIRONMENT WINS AND NOTHING IS READ WHERE IT ANSWERS. The file is opened
2205
+ # only when BOTH are unset, so a deliberate export is never second-guessed and
2206
+ # the common path touches no disk.
2207
+ #
2208
+ # AND THE SOURCE IS NAMED, which is a requirement rather than a nicety: two
2209
+ # tokens may exist, and an operator debugging a 401 must be able to tell which
2210
+ # one was used. `plot-board-probe.sh` reports `auth` as three words rather than
2211
+ # a boolean for the same reason. THE VALUE IS NEVER PRINTED — only the source.
2212
+ jira_load_env_file() {
2213
+ local root env_file email token
2214
+ [ -z "${JIRA_EMAIL:-}" ] && [ -z "${JIRA_API_TOKEN:-}" ] || return 0
2215
+
2216
+ # The repository root, `plot-config.sh:145`'s idiom. NO UPWARD WALK past it:
2217
+ # a search toward $HOME would read a file the operator did not mean for this
2218
+ # repository.
2219
+ root="$(git rev-parse --show-toplevel 2>/dev/null)" || root="."
2220
+ env_file="$root/.env"
2221
+ [ -r "$env_file" ] || return 0
2222
+
2223
+ email="$(read_env_var JIRA_EMAIL "$env_file")"
2224
+ token="$(read_env_var JIRA_API_TOKEN "$env_file")"
2225
+ # BOTH OR NEITHER. Half a Basic credential authenticates nothing, and a
2226
+ # partial pickup would turn today's honest refusal into a 401 further in.
2227
+ [ -n "$email" ] && [ -n "$token" ] || return 0
2228
+
2229
+ JIRA_EMAIL="$email"
2230
+ JIRA_API_TOKEN="$token"
2231
+ export JIRA_EMAIL JIRA_API_TOKEN
2232
+ echo "plot-host: JIRA_EMAIL and JIRA_API_TOKEN read from .env" >&2
2233
+ }
2234
+
1698
2235
  jira_require_config() {
1699
2236
  if [ -z "$(tracker_base_url)" ]; then
1700
2237
  die3 "Tracker is jira but no base URL is configured (write 'Tracker: jira https://your.atlassian.net' or set PLOT_JIRA_BASE_URL)"
1701
2238
  fi
2239
+ jira_load_env_file
1702
2240
  if [ -z "${JIRA_EMAIL:-}" ] || [ -z "${JIRA_API_TOKEN:-}" ]; then
1703
2241
  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
2242
  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
2243
+ echo " then export JIRA_EMAIL=<your account email> and JIRA_API_TOKEN=<the token>," >&2
2244
+ echo " or put both in this repository's .env (which .gitignore already excludes)." >&2
1706
2245
  exit 3
1707
2246
  fi
1708
2247
  }
@@ -1734,12 +2273,45 @@ jira_curl() {
1734
2273
  return $rc
1735
2274
  }
1736
2275
 
2276
+ # The account a Jira record is keyed on — DERIVED, never the email itself.
2277
+ #
2278
+ # THE EMAIL IS HALF A BASIC CREDENTIAL and this ledger is written on every call:
2279
+ # measured 2026-09-17, `$HOME/.plot/state/budget.tsv` held 2448 jira lines on one
2280
+ # machine. Until `.env` was read that happened only where somebody exported the
2281
+ # variable deliberately; it now happens wherever a `.env` exists — a population
2282
+ # that never consented to a machine-local record of it. A change that widens who
2283
+ # gets written down owns the writing down.
2284
+ #
2285
+ # AND IT STAYS PER-ACCOUNT DISTINGUISHABLE, because the field is a MATCH KEY and
2286
+ # not a label: `plot-budget.sh:250` is `if ($2 != want_c || $3 != want_a) next`,
2287
+ # `spend-rate` publishes it, and `decodeEntry`/`sameKey` read it. One machine's
2288
+ # ledger holds three distinct Jira accounts, so a CONSTANT redaction would merge
2289
+ # their rate windows and the rate a connector reads becomes the sum of several
2290
+ # people's. A hash keeps the key one-to-one while carrying no address.
2291
+ #
2292
+ # AT THE SOURCE rather than at `budget.tsv`, because fixing the one known writer
2293
+ # leaves the next to inherit the defect — `slots-file.ts:185` turns an account
2294
+ # into a DIRECTORY NAME and is one `slots.acquire` call away from being live.
2295
+ #
2296
+ # `jira:` prefixed and truncated to 12 hex: long enough that two accounts on one
2297
+ # machine will not collide, short enough to read in a ledger line.
2298
+ jira_budget_account() {
2299
+ local raw="${JIRA_EMAIL:-}"
2300
+ [ -n "$raw" ] || { printf 'unknown\n'; return 0; }
2301
+ local h
2302
+ h="$(printf '%s' "$raw" | shasum -a 256 2>/dev/null | awk '{print $1}')"
2303
+ # No hasher, no guess: a raw email must never be the fallback, so an
2304
+ # unhashable account degrades to the same word an absent one uses.
2305
+ [ -n "$h" ] || { printf 'unknown\n'; return 0; }
2306
+ printf 'jira:%s\n' "${h:0:12}"
2307
+ }
2308
+
1737
2309
  # Records one Jira call. Jira meters, publishes no header this adapter reads,
1738
2310
  # and this slice does not add header parsing — so the reading is `unknown`,
1739
2311
  # which is never read as free.
1740
2312
  budget_record_jira() {
1741
2313
  [ -z "${PLOT_BUDGET_OFF:-}" ] || return 0
1742
- budget_append jira "${JIRA_EMAIL:-unknown}" api 1 - - - unknown
2314
+ budget_append jira "$(jira_budget_account)" api 1 - - - unknown
1743
2315
  }
1744
2316
 
1745
2317
  # Split a jira_curl response into (body, status) and enforce the three outcomes.
@@ -1788,12 +2360,20 @@ jira_check() {
1788
2360
  # AT LEAST the requested limit — the host may have
1789
2361
  # had more that the limit hid. Fewer rows than the
1790
2362
  # 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
2363
+ # bitbucket (IGNORES --limit): `bb pr list` has no --limit and reports neither
2364
+ # a total nor a cursor, so it can NEVER prove
1793
2365
  # completeness for a --limit call. Any non-empty
1794
2366
  # page is therefore possibly truncated. An empty
1795
2367
  # page had nothing to truncate.
1796
2368
  #
2369
+ # THE PREMISE ABOVE IS ABOUT `bb pr list`, AND IT WAS ONCE WRITTEN ABOUT
2370
+ # BITBUCKET. It said the host "cannot report a total or a cursor" — true of the
2371
+ # CLI's listing and false of the REST endpoint behind it, which carries both a
2372
+ # `size` and a `next`. That mattered the moment a path existed that could ask:
2373
+ # the per-branch sweep (#333) proves completeness exactly, per branch, and this
2374
+ # detector is deliberately not asked about it (`pr_list_states`). The rule below
2375
+ # is unchanged and still governs every listing call.
2376
+ #
1797
2377
  # No --limit was requested → the caller accepted the host's default page and is
1798
2378
  # owed no report, so no existing no-limit caller's behaviour changes.
1799
2379
  #
@@ -1961,6 +2541,18 @@ backend() {
1961
2541
  # written beside it.
1962
2542
  . "$here/plot-budget.sh"
1963
2543
 
2544
+ # THE MEMO IS SWEPT ON THE WAY OUT. `budget_rate` caches its reading under
2545
+ # `$PLOT_BUDGET_HOME/memo/$$` because a command substitution is a subshell and a
2546
+ # variable set inside one does not survive it — see the block above
2547
+ # `budget_rate`. A directory keyed on a pid must be removed by the process that
2548
+ # made it, or a long-lived machine accumulates one per `plot-host.sh` call.
2549
+ #
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
2555
+
1964
2556
  # WHO IS SPENDING — read from the CLI's own config, never from an API call.
1965
2557
  #
1966
2558
  # `gh api user` would answer authoritatively and cost one request against the
@@ -2490,13 +3082,30 @@ case "$op" in
2490
3082
  else
2491
3083
  # Establish that bb supports --json BEFORE calling it — Done-when 5.
2492
3084
  bb_require_json
3085
+ # THE SAME KEY SET AS THE GITHUB ARM, INCLUDING `mergeCommit`. This arm
3086
+ # dropped that key on all four of its paths until 2026-09-18, and the
3087
+ # consumer reads it as `.mergeCommit // empty` — where `jq` cannot tell an
3088
+ # absent key from an empty one. So `plot-reconcile-scan.sh` reported
3089
+ # `no merge commit → cannot resolve` for every delivered plan on a
3090
+ # Bitbucket repository, which reads as a host that answered rather than an
3091
+ # arm that never asked. `pr-list`'s arm is the precedent: it emits every
3092
+ # key its GitHub arm does, because absent is not false.
3093
+ #
3094
+ # `merge_commit.hash` IS TAKEN FROM THE PAYLOAD ALREADY FETCHED — the same
3095
+ # field `pr-merge-commit` reads from the same shape. A second `bb` call to
3096
+ # re-ask for it would double a cost measured at ~10s per call.
3097
+ #
3098
+ # `// ""` COLLAPSES THREE SHAPES INTO ONE HONEST VALUE: `merge_commit`
3099
+ # absent on an open PR, the object null, or the hash null. `""` is what
3100
+ # the GitHub arm gives for anything unmerged, so a caller cannot tell the
3101
+ # backends apart.
2493
3102
  if [[ "$ref" =~ ^[0-9]+$ ]]; then
2494
3103
  if out="$(bb ${repo_args[@]+"${repo_args[@]}"} pr view "$ref" --json 2>/tmp/plot-host-err.$$)"; then
2495
3104
  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"
3105
+ 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
3106
  else
2498
3107
  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 $?
3108
+ host_miss_or_fail "$err" '{"number":0,"state":"NONE","draft":false,"url":"","mergeCommit":""}' || exit $?
2500
3109
  fi
2501
3110
  else
2502
3111
  # The list CALL succeeding and the branch being absent FROM the list are
@@ -2534,12 +3143,20 @@ case "$op" in
2534
3143
  if [ "$bb_rc" = 0 ]; then
2535
3144
  rm -f "/tmp/plot-host-err.$$"
2536
3145
  out="$(jq -c -s 'add // []' <<<"$out")"
3146
+ # THE BRANCH ARM CARRIES `mergeCommit` TOO, and it is the path that
3147
+ # matters most: `plot-pr-state.sh:33` asks `pr-state "idea/${SLUG}"` —
3148
+ # a branch, not a number — and `:47` reads `.mergeCommit // empty`
3149
+ # from the answer. A fix touching only the numeric pair above would
3150
+ # leave that caller reading an absent key on every Bitbucket repo.
3151
+ #
3152
+ # The hash rides on the page the state walk already fetched, so this
3153
+ # costs no extra call.
2537
3154
  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"
3155
+ | if .==null then {number:0,state:"NONE",draft:false,url:"",mergeCommit:""}
3156
+ 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
3157
  else
2541
3158
  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 $?
3159
+ host_miss_or_fail "$err" '{"number":0,"state":"NONE","draft":false,"url":"","mergeCommit":""}' || exit $?
2543
3160
  fi
2544
3161
  fi
2545
3162
  fi
@@ -2768,17 +3385,60 @@ case "$op" in
2768
3385
  # that wants history says how much; the default stays the host's, so no
2769
3386
  # existing caller's result changes.
2770
3387
  limit=""
3388
+ branches=""
3389
+ # THE WINDOW, AND IT IS THE HOST'S OWN STAMP. Empty means ask about
3390
+ # everything, which is what every caller predating this asks and what a
3391
+ # caller with no stored watermark must ask. A caller that has one sends it
3392
+ # verbatim — see the header: re-rendering it is how a window closes forever.
3393
+ since=""
2771
3394
  while [ $# -gt 0 ]; do
2772
3395
  case "$1" in
2773
3396
  --state) state="${2:?}"; shift 2 ;;
2774
3397
  --limit) limit="${2:?}"; shift 2 ;;
2775
3398
  --rich) rich=1; shift ;;
2776
3399
  --repo) repo_args=(-R "${2:?}"); shift 2 ;;
3400
+ # `${2:?}` REFUSES AN EMPTY VALUE, and that is the point rather than
3401
+ # boilerplate. `--since ""` would reach GitHub as `--search "updated:>"`,
3402
+ # a syntax error the host may answer with everything or with nothing —
3403
+ # and a caller whose watermark was null would send exactly that.
3404
+ --since) since="${2:?}"; shift 2 ;;
3405
+ # THE BRANCHES THE CALLER TRACKS, repeatable, and OPT-IN. Given any,
3406
+ # the Bitbucket arm sweeps the REST endpoint once per branch per state
3407
+ # instead of listing the repository; given none, every existing caller
3408
+ # gets exactly the listing it always got. Four callers pass none today
3409
+ # (`plot-fleet-scan.sh`, `plot-open-pr.sh`, `plot-impl-status.sh` and
3410
+ # `fleet.ts`), so the bulk path stays the default rather than the
3411
+ # legacy one.
3412
+ #
3413
+ # WHY THE CALLER NAMES THEM AND THIS OP DOES NOT GUESS. The same rule
3414
+ # `--repo` states a few lines up: the caller knows which branches its
3415
+ # refs came from and this op cannot. Deriving them here — from remote
3416
+ # refs, say — would make the adapter invent a working set, and a sweep
3417
+ # over the wrong set answers confidently about branches nobody asked
3418
+ # about while missing the ones they did.
3419
+ --branch) branches="${branches:+$branches }${2:?}"; shift 2 ;;
2777
3420
  *) die "pr-list: unknown arg $1" ;;
2778
3421
  esac
2779
3422
  done
2780
3423
  limit_args=()
2781
3424
  [ -n "$limit" ] && limit_args=(--limit "$limit")
3425
+ # THE WINDOW AS GITHUB TAKES IT, built once and appended at all three call
3426
+ # sites — the same argument `pr_list_call`'s header makes about the six
3427
+ # hand-applied fixes: three sites each composing their own search string is
3428
+ # three places for the next window's shape to drift.
3429
+ #
3430
+ # `updated:>` is EXCLUSIVE, and that is the safe direction here. The
3431
+ # watermark is the stamp of a row the caller has ALREADY stored, so
3432
+ # excluding it re-asks nothing; including it would re-fetch that row every
3433
+ # pass for no new information. A PR updated in the same second as the
3434
+ # watermark is the one this can miss, and the periodic full read the caller
3435
+ # is required to keep making is what corrects it.
3436
+ #
3437
+ # THE STAMP IS NOT QUOTED INSIDE THE QUERY. `gh` sends `--search`'s value as
3438
+ # one API parameter and an ISO-8601 stamp carries no space, so a quote would
3439
+ # travel to GitHub as part of the term and match nothing.
3440
+ search_args=()
3441
+ [ -n "$since" ] && search_args=(--search "updated:>$since")
2782
3442
 
2783
3443
  # --- Jenkins CI integration (orthogonal to Git host) ---
2784
3444
  # When `CI: jenkins` is configured, build status comes from Jenkins rather
@@ -2889,8 +3549,8 @@ case "$op" in
2889
3549
  # $jstatus != "ok" → Jenkins could not answer; every row `unknown`.
2890
3550
  # $jentry == null → the branch has no Jenkins job; `none`.
2891
3551
  # otherwise → the joined colour's `checks`, job named on fail.
2892
- _gh_raw="$(pr_list_call gh ${repo_args[@]+"${repo_args[@]}"} pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} \
2893
- --json number,title,state,headRefName,isDraft,mergeable,mergeStateStatus,reviewDecision,url)" || exit $?
3552
+ _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 $?
2894
3554
  pr_list_report_truncation github "$limit" "$state" \
2895
3555
  "$(jq 'length' <<<"$_gh_raw" 2>/dev/null || echo 0)"
2896
3556
  printf '%s' "$_gh_raw" \
@@ -2910,6 +3570,7 @@ case "$op" in
2910
3570
  else "unknown" end),
2911
3571
  review:(.reviewDecision // ""),
2912
3572
  url:.url,
3573
+ updatedAt:(.updatedAt // ""),
2913
3574
  failing_checks:(
2914
3575
  if $jentry != null and $jentry.checks == "failing"
2915
3576
  then [$jentry.job]
@@ -2918,8 +3579,8 @@ case "$op" in
2918
3579
  }'
2919
3580
  else
2920
3581
  # GitHub without Jenkins (or Jenkins not configured): use GitHub rollup
2921
- _gh_raw="$(pr_list_call gh ${repo_args[@]+"${repo_args[@]}"} pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} \
2922
- --json number,title,state,headRefName,isDraft,statusCheckRollup,mergeable,mergeStateStatus,reviewDecision,url)" || exit $?
3582
+ _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 $?
2923
3584
  pr_list_report_truncation github "$limit" "$state" \
2924
3585
  "$(jq 'length' <<<"$_gh_raw" 2>/dev/null || echo 0)"
2925
3586
  printf '%s' "$_gh_raw" \
@@ -2941,6 +3602,7 @@ case "$op" in
2941
3602
  else "unknown" end),
2942
3603
  review:(.reviewDecision // ""),
2943
3604
  url:.url,
3605
+ updatedAt:(.updatedAt // ""),
2944
3606
  failing_checks:[
2945
3607
  .statusCheckRollup[]? | select((if (.conclusion // "") != "" then .conclusion else (.status // .state) end) as $c
2946
3608
  | $c=="FAILURE" or $c=="ERROR" or $c=="CANCELLED"
@@ -2949,7 +3611,7 @@ case "$op" in
2949
3611
  }'
2950
3612
  fi
2951
3613
  else
2952
- _gh_raw="$(pr_list_call gh ${repo_args[@]+"${repo_args[@]}"} pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} \
3614
+ _gh_raw="$(pr_list_call gh ${repo_args[@]+"${repo_args[@]}"} pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} ${search_args[@]+"${search_args[@]}"} \
2953
3615
  --json number,title,state,headRefName)" || exit $?
2954
3616
  pr_list_report_truncation github "$limit" "$state" \
2955
3617
  "$(jq 'length' <<<"$_gh_raw" 2>/dev/null || echo 0)"
@@ -2972,7 +3634,11 @@ case "$op" in
2972
3634
  # Forwarding it errors with `unknown flag`, and dropping it silently
2973
3635
  # would serve a short page as if it were the whole set — the quiet wrong
2974
3636
  # answer this adapter refuses elsewhere. So it is dropped AND said.
2975
- if [ -n "$limit" ]; then
3637
+ # A SWEEP IS NOT A PAGE AND OWES NO SUCH WARNING. `--limit` bounds a
3638
+ # listing; a per-branch query returns that branch's pull requests and
3639
+ # nothing was capped, so the notice would describe a truncation that did
3640
+ # not happen. Said only for the listing it is about.
3641
+ if [ -n "$limit" ] && [ -z "$branches" ]; then
2976
3642
  echo "plot-host: bitbucket ignores --limit $limit; bb returns a fixed page (50 at 1.0.0)" >&2
2977
3643
  fi
2978
3644
  # Establish that bb supports --json BEFORE calling it — Done-when 5.
@@ -2983,17 +3649,47 @@ case "$op" in
2983
3649
  # with no output — an unknown state reading as "no PRs matched", which
2984
3650
  # is the exact failure this translation exists to remove.
2985
3651
  bb_states="$(bb_states_for "$state")" || exit 1
3652
+ # THE ONE PLACE THE SWEEP IS CHOSEN, and it is chosen as a COMMAND rather
3653
+ # than as a flag the three sites below each test. They differ only in the
3654
+ # jq program they pipe the payload through — `pr_list_states`' header says
3655
+ # so — and a sweep that added an `if` to each would make them differ in two
3656
+ # ways, which is how the six hand-applied fixes `pr_list_call` warns about
3657
+ # began. One assignment here; the sites are untouched but for this word.
3658
+ 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=`.
3664
+ #
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.
3671
+ 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
+ PR_SWEEP_ASKED=0
3677
+ bb_cmd=(bb ${repo_args[@]+"${repo_args[@]}"} pr list)
3678
+ if [ -n "$branches" ]; then
3679
+ # A SWEEP'S COST IS THE CALLER'S WORKING SET, and it is reported so the
3680
+ # caller can check the claim it is about to be handed. Branches × states
3681
+ # — 11 branches over 3 states is 33 exact queries, against 3 listings
3682
+ # that answer for 50 of 902 rows.
3683
+ for _b in $branches; do PR_SWEEP_ASKED=$((PR_SWEEP_ASKED + 1)); done
3684
+ bb_cmd=(bb_branch_sweep ${repo_args[@]+"${repo_args[@]}"})
3685
+ fi
2986
3686
  if [ "$rich" = 1 ]; then
2987
3687
  if [ "$ci" = "jenkins" ]; then
2988
3688
  # Bitbucket PR list, `checks` filled from Jenkins — the SAME overlay
2989
3689
  # the GitHub arm uses, which is why it lives above the backend branch.
2990
3690
  # `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" '.[] |
3691
+ PR_LIST_JQ_ARGS=(--argjson jmap "$jen_map" --arg jstatus "$jen_status")
3692
+ pr_list_states bitbucket "$limit" "$bb_states" '.[] |
2997
3693
  ($jmap[.source.branch.name] // null) as $jentry |
2998
3694
  {
2999
3695
  number:.id, title:.title,
@@ -3008,31 +3704,25 @@ case "$op" in
3008
3704
  mergeable:"unknown",
3009
3705
  review:"",
3010
3706
  url:(.links.html.href // ""),
3707
+ updatedAt:(.updated_on // ""),
3011
3708
  failing_checks:(
3012
3709
  if $jentry != null and $jentry.checks == "failing"
3013
3710
  then [$jentry.job]
3014
3711
  else []
3015
3712
  end)
3016
- }'
3017
- done
3713
+ }' "${bb_cmd[@]}" || exit $?
3018
3714
  else
3019
3715
  # 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
3716
+ PR_LIST_JQ_ARGS=()
3717
+ 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:[]}' \
3719
+ "${bb_cmd[@]}" || exit $?
3027
3720
  fi
3028
3721
  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
3722
+ PR_LIST_JQ_ARGS=()
3723
+ 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}' \
3725
+ "${bb_cmd[@]}" || exit $?
3036
3726
  fi
3037
3727
  fi
3038
3728
  ;;