@plot-pm/board 0.14.1 → 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/dist/board-server.mjs +128 -124
- package/package.json +1 -1
- package/plot-deliver.sh +72 -0
- package/plot-dispatch.sh +221 -46
- package/plot-fleet-scan.sh +134 -7
- package/plot-host.sh +687 -34
- package/plot-plan-meta.sh +98 -7
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
|
|
@@ -716,8 +1115,41 @@ EOF
|
|
|
716
1115
|
# failed — Jenkins is unreachable (`jen auth status` says so, while EXITING
|
|
717
1116
|
# 0 — Done-when 4: the wording decides, never `$?`), or the listing
|
|
718
1117
|
# was empty/garbled. `map` is {}; the caller renders rows `unknown`.
|
|
719
|
-
# unknown — the auth wording was unrecognised
|
|
720
|
-
#
|
|
1118
|
+
# unknown — the auth wording was unrecognised, or the configured job is a
|
|
1119
|
+
# SHAPE NOBODY MEASURED; degrade to failure-shaped (cannot verify),
|
|
1120
|
+
# never to ok. `map` is {}.
|
|
1121
|
+
#
|
|
1122
|
+
# THE JOB'S SHAPE DECIDES THE VERB, and reading the shape off the wrong object
|
|
1123
|
+
# is the defect this function carried until 2026-09-15.
|
|
1124
|
+
#
|
|
1125
|
+
# `job list` enumerates a CONTAINER'S CHILDREN. A `WorkflowMultiBranchProject`
|
|
1126
|
+
# has one child per branch, so listing it yields exactly the branch→colour map
|
|
1127
|
+
# below. A plain `WorkflowJob` has no children, so the same call yields `null` —
|
|
1128
|
+
# not an error, not an empty array — and the `type=="array"` guard reported
|
|
1129
|
+
# `failed`, which is the word for an unreachable host. Measured live 2026-09-15
|
|
1130
|
+
# on `Quatico.Webseite/quaweb-website`: `job list quaweb/continuous-deploy`
|
|
1131
|
+
# answered `null` while `job view` on the same path answered `color blue`,
|
|
1132
|
+
# `lastBuild #938 SUCCESS`. A healthy, signed-in, correctly declared pipeline
|
|
1133
|
+
# read as *the connector cannot be asked*.
|
|
1134
|
+
#
|
|
1135
|
+
# THE DECIDING `_class` IS THE CONFIGURED JOB'S OWN, AND IT IS NOT IN THE
|
|
1136
|
+
# LISTING THIS FUNCTION ALREADY PERFORMS. A child's `_class` describes the
|
|
1137
|
+
# CHILD: measured live, every child of the multibranch `quaweb/continuous-build`
|
|
1138
|
+
# carries `...job.WorkflowJob`, and this repository's own fixture agrees. So
|
|
1139
|
+
# reading `.[0]._class` from the listing would read a healthy multibranch job as
|
|
1140
|
+
# plain, route it to `job view`, and break the half that works today — while
|
|
1141
|
+
# every gate still passed. That mistake sank an earlier draft of the plan.
|
|
1142
|
+
#
|
|
1143
|
+
# SO `job view` IS ASKED FIRST, and it answers BOTH questions in ONE call: the
|
|
1144
|
+
# job's own `_class`, and — for a plain job — the `color` and `lastBuild` that
|
|
1145
|
+
# are its state. The multibranch path then makes the single `job list` it has
|
|
1146
|
+
# always made, so a multibranch refresh costs two calls rather than one per
|
|
1147
|
+
# branch, and the branch→checks map it returns is byte-identical.
|
|
1148
|
+
#
|
|
1149
|
+
# EXACTLY TWO SHAPES ARE READ, and anything else is `unknown` rather than a
|
|
1150
|
+
# guess. A `FreeStyleProject` has a `color` and would be readable; it still
|
|
1151
|
+
# reports `unknown`, which is the honest word — *a shape nobody measured*. It is
|
|
1152
|
+
# deliberately NOT `failed`, which claims the host did not answer.
|
|
721
1153
|
jenkins_build_map() {
|
|
722
1154
|
local instance="$1"
|
|
723
1155
|
local slug job
|
|
@@ -751,6 +1183,67 @@ jenkins_build_map() {
|
|
|
751
1183
|
printf '{"status":"unknown","map":{}}\n'; return 0
|
|
752
1184
|
fi
|
|
753
1185
|
|
|
1186
|
+
# THE SHAPE, READ FROM THE CONFIGURED JOB ITSELF. `job view` returns that
|
|
1187
|
+
# job's own `_class` — never a child's — plus the `color` and `lastBuild` a
|
|
1188
|
+
# plain job's state is made of. One call, two answers.
|
|
1189
|
+
#
|
|
1190
|
+
# A BARE-HOST INSTANCE NAMES NO JOB, so there is nothing to view: `job` is
|
|
1191
|
+
# empty, the root scope has no `_class` of its own, and the multibranch path
|
|
1192
|
+
# below already handles it by listing at the root. Probing with an empty path
|
|
1193
|
+
# would ask about the instance rather than about a job.
|
|
1194
|
+
local shape="" view_out=""
|
|
1195
|
+
if [ -n "$job" ]; then
|
|
1196
|
+
view_out=$(jen -I "$slug" job view "$job" --json 2>&1) || true
|
|
1197
|
+
if [ -n "$view_out" ]; then
|
|
1198
|
+
shape=$(printf '%s' "$view_out" | jq -r 'if type=="object" then (._class // "") else "" end' 2>/dev/null || echo "")
|
|
1199
|
+
fi
|
|
1200
|
+
fi
|
|
1201
|
+
|
|
1202
|
+
case "$shape" in
|
|
1203
|
+
# A PLAIN PIPELINE — the case that reported `failed` until 2026-09-15. Its
|
|
1204
|
+
# state is already in hand: `job view` answered it, and no `job list`
|
|
1205
|
+
# follows, because listing a job with no children is what returned `null`.
|
|
1206
|
+
#
|
|
1207
|
+
# THE BRANCH KEY IS THE JOB PATH'S LAST SEGMENT. A plain job builds one
|
|
1208
|
+
# thing and Jenkins names no branch for it, so there is no branch→colour
|
|
1209
|
+
# map to build. Keying on the job's own name is what lets `.map[$branch]`
|
|
1210
|
+
# find it — `runs` reads that key, and the op's caller asks by the name the
|
|
1211
|
+
# instance declares.
|
|
1212
|
+
*'.WorkflowJob')
|
|
1213
|
+
printf '%s' "$view_out" | jq -c --arg job "$job" '
|
|
1214
|
+
def color_to_checks:
|
|
1215
|
+
if . == null or . == "" then "none"
|
|
1216
|
+
elif endswith("_anime") then "pending"
|
|
1217
|
+
elif . == "blue" then "green"
|
|
1218
|
+
elif . == "red" or . == "yellow" then "failing"
|
|
1219
|
+
else "none"
|
|
1220
|
+
end;
|
|
1221
|
+
($job | split("/") | last) as $name
|
|
1222
|
+
| { status: "ok",
|
|
1223
|
+
map: { ($name): { color: .color,
|
|
1224
|
+
checks: (.color | color_to_checks),
|
|
1225
|
+
job: $job } } }
|
|
1226
|
+
' 2>/dev/null || printf '{"status":"failed","map":{}}\n'
|
|
1227
|
+
return 0
|
|
1228
|
+
;;
|
|
1229
|
+
*'.WorkflowMultiBranchProject')
|
|
1230
|
+
: # fall through to the listing below — the path that has always worked
|
|
1231
|
+
;;
|
|
1232
|
+
'')
|
|
1233
|
+
# `job view` answered nothing usable. NOT a shape verdict: an instance
|
|
1234
|
+
# naming no job reaches here by design, and so does a `jen` too old to
|
|
1235
|
+
# know the verb. Fall through and let the listing decide, which is
|
|
1236
|
+
# exactly what this function did before the probe existed.
|
|
1237
|
+
:
|
|
1238
|
+
;;
|
|
1239
|
+
*)
|
|
1240
|
+
# A SHAPE NOBODY MEASURED. `unknown` says that; `failed` would claim
|
|
1241
|
+
# Jenkins did not answer, when it answered clearly and said something
|
|
1242
|
+
# this reader has never been taught to read.
|
|
1243
|
+
printf '{"status":"unknown","map":{}}\n'; return 0
|
|
1244
|
+
;;
|
|
1245
|
+
esac
|
|
1246
|
+
|
|
754
1247
|
# One call, every branch — the spike's whole point (Done-when 5).
|
|
755
1248
|
local out=""
|
|
756
1249
|
out=$(jen -I "$slug" job list ${job:+"$job"} --json 2>&1) || true
|
|
@@ -1589,6 +2082,33 @@ tracker_projects() {
|
|
|
1589
2082
|
printf '%s' "$raw" | tr ',' '\n' | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//' | grep -v '^$' || true
|
|
1590
2083
|
}
|
|
1591
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
|
+
|
|
1592
2112
|
# The env var scheme for Jira auth. The plan left the EXACT names open, to be
|
|
1593
2113
|
# confirmed against a real instance; these follow Jira Cloud's documented Basic
|
|
1594
2114
|
# scheme (email + API token, base64'd into an Authorization header):
|
|
@@ -1601,14 +2121,55 @@ tracker_projects() {
|
|
|
1601
2121
|
# This guard is called in the MAIN shell, BEFORE the `$(jira_curl …)` capture —
|
|
1602
2122
|
# `die3` exits the whole script only from there, not from inside a command
|
|
1603
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
|
+
|
|
1604
2163
|
jira_require_config() {
|
|
1605
2164
|
if [ -z "$(tracker_base_url)" ]; then
|
|
1606
2165
|
die3 "Tracker is jira but no base URL is configured (write 'Tracker: jira https://your.atlassian.net' or set PLOT_JIRA_BASE_URL)"
|
|
1607
2166
|
fi
|
|
2167
|
+
jira_load_env_file
|
|
1608
2168
|
if [ -z "${JIRA_EMAIL:-}" ] || [ -z "${JIRA_API_TOKEN:-}" ]; then
|
|
1609
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
|
|
1610
2170
|
echo " Create a token at https://id.atlassian.com/manage-profile/security/api-tokens" >&2
|
|
1611
|
-
echo " then export JIRA_EMAIL=<your account email> and JIRA_API_TOKEN=<the token
|
|
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
|
|
1612
2173
|
exit 3
|
|
1613
2174
|
fi
|
|
1614
2175
|
}
|
|
@@ -1640,12 +2201,45 @@ jira_curl() {
|
|
|
1640
2201
|
return $rc
|
|
1641
2202
|
}
|
|
1642
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
|
+
|
|
1643
2237
|
# Records one Jira call. Jira meters, publishes no header this adapter reads,
|
|
1644
2238
|
# and this slice does not add header parsing — so the reading is `unknown`,
|
|
1645
2239
|
# which is never read as free.
|
|
1646
2240
|
budget_record_jira() {
|
|
1647
2241
|
[ -z "${PLOT_BUDGET_OFF:-}" ] || return 0
|
|
1648
|
-
budget_append jira "$
|
|
2242
|
+
budget_append jira "$(jira_budget_account)" api 1 - - - unknown
|
|
1649
2243
|
}
|
|
1650
2244
|
|
|
1651
2245
|
# Split a jira_curl response into (body, status) and enforce the three outcomes.
|
|
@@ -1694,12 +2288,20 @@ jira_check() {
|
|
|
1694
2288
|
# AT LEAST the requested limit — the host may have
|
|
1695
2289
|
# had more that the limit hid. Fewer rows than the
|
|
1696
2290
|
# limit PROVES completeness.
|
|
1697
|
-
# bitbucket (IGNORES --limit): `bb pr list` has no --limit and
|
|
1698
|
-
# total
|
|
2291
|
+
# bitbucket (IGNORES --limit): `bb pr list` has no --limit and reports neither
|
|
2292
|
+
# a total nor a cursor, so it can NEVER prove
|
|
1699
2293
|
# completeness for a --limit call. Any non-empty
|
|
1700
2294
|
# page is therefore possibly truncated. An empty
|
|
1701
2295
|
# page had nothing to truncate.
|
|
1702
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
|
+
#
|
|
1703
2305
|
# No --limit was requested → the caller accepted the host's default page and is
|
|
1704
2306
|
# owed no report, so no existing no-limit caller's behaviour changes.
|
|
1705
2307
|
#
|
|
@@ -2396,13 +2998,30 @@ case "$op" in
|
|
|
2396
2998
|
else
|
|
2397
2999
|
# Establish that bb supports --json BEFORE calling it — Done-when 5.
|
|
2398
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.
|
|
2399
3018
|
if [[ "$ref" =~ ^[0-9]+$ ]]; then
|
|
2400
3019
|
if out="$(bb ${repo_args[@]+"${repo_args[@]}"} pr view "$ref" --json 2>/tmp/plot-host-err.$$)"; then
|
|
2401
3020
|
rm -f "/tmp/plot-host-err.$$"
|
|
2402
|
-
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"
|
|
2403
3022
|
else
|
|
2404
3023
|
err="$(cat "/tmp/plot-host-err.$$" 2>/dev/null)"; rm -f "/tmp/plot-host-err.$$"
|
|
2405
|
-
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 $?
|
|
2406
3025
|
fi
|
|
2407
3026
|
else
|
|
2408
3027
|
# The list CALL succeeding and the branch being absent FROM the list are
|
|
@@ -2440,12 +3059,20 @@ case "$op" in
|
|
|
2440
3059
|
if [ "$bb_rc" = 0 ]; then
|
|
2441
3060
|
rm -f "/tmp/plot-host-err.$$"
|
|
2442
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.
|
|
2443
3070
|
jq -c --arg b "$ref" '[.[] | select(.source.branch.name==$b)][0] // null
|
|
2444
|
-
| if .==null then {number:0,state:"NONE",draft:false,url:""}
|
|
2445
|
-
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"
|
|
2446
3073
|
else
|
|
2447
3074
|
err="$(cat "/tmp/plot-host-err.$$" 2>/dev/null)"; rm -f "/tmp/plot-host-err.$$"
|
|
2448
|
-
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 $?
|
|
2449
3076
|
fi
|
|
2450
3077
|
fi
|
|
2451
3078
|
fi
|
|
@@ -2674,12 +3301,28 @@ case "$op" in
|
|
|
2674
3301
|
# that wants history says how much; the default stays the host's, so no
|
|
2675
3302
|
# existing caller's result changes.
|
|
2676
3303
|
limit=""
|
|
3304
|
+
branches=""
|
|
2677
3305
|
while [ $# -gt 0 ]; do
|
|
2678
3306
|
case "$1" in
|
|
2679
3307
|
--state) state="${2:?}"; shift 2 ;;
|
|
2680
3308
|
--limit) limit="${2:?}"; shift 2 ;;
|
|
2681
3309
|
--rich) rich=1; shift ;;
|
|
2682
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 ;;
|
|
2683
3326
|
*) die "pr-list: unknown arg $1" ;;
|
|
2684
3327
|
esac
|
|
2685
3328
|
done
|
|
@@ -2878,7 +3521,11 @@ case "$op" in
|
|
|
2878
3521
|
# Forwarding it errors with `unknown flag`, and dropping it silently
|
|
2879
3522
|
# would serve a short page as if it were the whole set — the quiet wrong
|
|
2880
3523
|
# answer this adapter refuses elsewhere. So it is dropped AND said.
|
|
2881
|
-
|
|
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
|
|
2882
3529
|
echo "plot-host: bitbucket ignores --limit $limit; bb returns a fixed page (50 at 1.0.0)" >&2
|
|
2883
3530
|
fi
|
|
2884
3531
|
# Establish that bb supports --json BEFORE calling it — Done-when 5.
|
|
@@ -2889,17 +3536,30 @@ case "$op" in
|
|
|
2889
3536
|
# with no output — an unknown state reading as "no PRs matched", which
|
|
2890
3537
|
# is the exact failure this translation exists to remove.
|
|
2891
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
|
|
2892
3556
|
if [ "$rich" = 1 ]; then
|
|
2893
3557
|
if [ "$ci" = "jenkins" ]; then
|
|
2894
3558
|
# Bitbucket PR list, `checks` filled from Jenkins — the SAME overlay
|
|
2895
3559
|
# the GitHub arm uses, which is why it lives above the backend branch.
|
|
2896
3560
|
# `bb`'s standing `unknown` becomes a real value where Jenkins answers.
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
pr_list_report_truncation bitbucket "$limit" "$_s" \
|
|
2900
|
-
"$(jq 'length' <<<"$_bb_raw" 2>/dev/null || echo 0)"
|
|
2901
|
-
printf '%s' "$_bb_raw" \
|
|
2902
|
-
| 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" '.[] |
|
|
2903
3563
|
($jmap[.source.branch.name] // null) as $jentry |
|
|
2904
3564
|
{
|
|
2905
3565
|
number:.id, title:.title,
|
|
@@ -2919,26 +3579,19 @@ case "$op" in
|
|
|
2919
3579
|
then [$jentry.job]
|
|
2920
3580
|
else []
|
|
2921
3581
|
end)
|
|
2922
|
-
}'
|
|
2923
|
-
done
|
|
3582
|
+
}' "${bb_cmd[@]}" || exit $?
|
|
2924
3583
|
else
|
|
2925
3584
|
# Bitbucket without Jenkins: checks remain unknown
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
printf '%s' "$_bb_raw" \
|
|
2931
|
-
| 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:[]}'
|
|
2932
|
-
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 $?
|
|
2933
3589
|
fi
|
|
2934
3590
|
else
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
printf '%s' "$_bb_raw" \
|
|
2940
|
-
| jq -c '.[] | {number:.id,title:.title,state:(if .state=="DECLINED" then "CLOSED" else .state end),head:.source.branch.name}'
|
|
2941
|
-
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 $?
|
|
2942
3595
|
fi
|
|
2943
3596
|
fi
|
|
2944
3597
|
;;
|