@plot-pm/board 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/plot-host.sh CHANGED
@@ -8,6 +8,17 @@
8
8
  #
9
9
  # Ops (the ~6 operations plot actually needs):
10
10
  # backend print the resolved backend: github|bitbucket
11
+ # REPORTS WHAT THE REPO DECLARED. An
12
+ # unrecognised `Git host` word is NOT silently
13
+ # defaulted to github — that drove a repo
14
+ # declaring `gitlab` through `gh` until
15
+ # 2026-09-08. A backend this script has no arm
16
+ # for exits 4 and names the word on stderr:
17
+ # exit 4 is "this backend has no answer at
18
+ # all", a configuration a person fixes rather
19
+ # than a transient to retry. Adding a host is
20
+ # an edit to `HOST_DRIVES` and the arms below,
21
+ # and to nothing in `packages/domain`.
11
22
  # default-branch print the repo's default branch name
12
23
  # pr-state <number|branch> [--repo <owner/repo>] one JSON object:
13
24
  # {"number":N,"state":"OPEN|MERGED|CLOSED|NONE",
@@ -35,6 +46,15 @@
35
46
  # deciding whether to REMOVE something and
36
47
  # silence is never permission. A call that
37
48
  # failed outright still exits 3.
49
+ # pr-merge-commit <branch> the merge commit sha of a merged PR for this
50
+ # branch, or EMPTY (exit 0) where none merged.
51
+ # The same query `pr-merged` runs, reading a
52
+ # second field of the one response. A separate
53
+ # subcommand because `pr-merged` prints one word
54
+ # and its callers read one word.
55
+ # EXIT 3 is the question failing, and it is not
56
+ # the same as an empty answer: a diff that could
57
+ # not be read is not a diff that was empty.
38
58
  # pr-create --title T [--body B] [--base BR] [--head BR] [--draft]
39
59
  # create a PR, print its URL
40
60
  # pr-merge <number> [--squash] [--delete-branch]
@@ -85,13 +105,25 @@
85
105
  # and a real failure presents identically.
86
106
  # Nothing here compares runs or concludes.
87
107
  # METERED: ask only for a branch already known
88
- # to be failing. Empty on bitbucket (bb has no
89
- # run listing) unavailable, never "never
90
- # failed".
108
+ # to be failing.
109
+ # DISPATCHED ON THE `CI` KEY, never on the git
110
+ # host: a build is the CI system's fact, and the
111
+ # two keys are independent. A repository that
112
+ # declared no CI, or one Plot has no connector
113
+ # for, EXITS 4 — not being able to ask is not an
114
+ # empty history.
115
+ # On jenkins it reports ONE current state as a
116
+ # history of one, with `startedAt` and `url`
117
+ # empty: `jen job list` carries no history and
118
+ # no timestamps, and inventing them would be a
119
+ # collector reaching a verdict.
91
120
  # run-for-sha <branch> <sha> the run for ONE sha — else the branch's newest
92
121
  # run, with `sha` saying which it is — as a
93
122
  # single JSON object, or nothing when the branch
94
- # has no runs at all:
123
+ # has no runs at all. Dispatched on the `CI` key
124
+ # like `runs`, and EXITS 4 on jenkins: `jen job
125
+ # list` names no commit, so there is nothing to
126
+ # match a sha against. Output:
95
127
  # {"sha":"…","status":"queued|in_progress|
96
128
  # completed|waiting|requested",
97
129
  # "conclusion":"success|failure|…|null",
@@ -127,7 +159,26 @@
127
159
  # unavailable, never "no run".
128
160
  # issue-list [--limit N] open tracker issues as JSON lines:
129
161
  # {"number":N,"title":"…","url":"…",
130
- # "createdAt":"…"}
162
+ # "createdAt":"…","status":"…",
163
+ # "statusCategory":"…"}
164
+ # `status` is the tracker's OWN word for the
165
+ # stage — per-workflow, possibly localised
166
+ # (*Internal Approving*). `statusCategory` is
167
+ # the stable three-value vocabulary (To Do /
168
+ # In Progress / Done) a board may group on.
169
+ # BOTH, because neither substitutes for the
170
+ # other: the name is what a person reads, the
171
+ # category is what a board decides on. EVERY
172
+ # backend answers both — GitHub derives them
173
+ # from the `--state` this op asks for, jira
174
+ # reads .fields.status, and bitbucket maps the
175
+ # state badge it already parses. A Jira-only
176
+ # field is what this adapter exists to prevent.
177
+ # `statusCategory` is "" where the tracker's
178
+ # vocabulary has no word for it — bitbucket's
179
+ # ON HOLD/INVALID/DUPLICATE/WONTFIX. Empty is
180
+ # an honest answer; an invented category files
181
+ # abandoned work beside finished work.
131
182
  # READ-ONLY. Plot writes no label, no assignee
132
183
  # and no close-on-merge, because a copy of
133
184
  # tracker state ages into a lie. The one write
@@ -366,6 +417,52 @@ host_failure_kind() { # $1=stderr text → throttled|secondary|failed
366
417
  fi
367
418
  }
368
419
 
420
+ # --- the connector names its own repair -------------------------------------
421
+ #
422
+ # THE TEXT COMES FROM THE CONNECTOR THAT FAILED, never from a caller branching
423
+ # on the stack. A refusal saying `gh auth login` is wrong advice on a Bitbucket
424
+ # team, and it is wrong in the direction that costs most: a teammate who has not
425
+ # read this repository runs the command they were given, it does nothing, and
426
+ # the next step is to find a person. This connector already resolved its vendor
427
+ # — `backend` above — so it is the one place that can name a CLI that exists.
428
+ #
429
+ # ONE FUNCTION RATHER THAN A TABLE AT EACH CALL SITE, and it is the connector
430
+ # contract CLAUDE.md already states applied to words instead of budgets: only a
431
+ # connector knows its account, its transport and its login command, so only a
432
+ # connector may name them.
433
+ #
434
+ # A BACKEND THIS DOES NOT KNOW GETS NO ADVICE. Inventing a login command for an
435
+ # unrecognised vendor is the same failure as naming the wrong one, so it says
436
+ # what it does not know rather than guessing — the direction `host_failure_kind`
437
+ # refuses in too.
438
+ #
439
+ # $1 = the backend word, as `backend` reported it.
440
+ host_cli() { # → the CLI this backend drives, or '' where none is known
441
+ case "$1" in
442
+ github) echo "gh" ;;
443
+ bitbucket) echo "bb" ;;
444
+ *) echo "" ;;
445
+ esac
446
+ }
447
+
448
+ # The repair line for a host call that failed, on stderr, in this connector's
449
+ # own words. Prints nothing where the vendor is unknown — see above.
450
+ host_repair() { # $1=backend
451
+ local cli; cli="$(host_cli "$1")"
452
+ if [ -z "$cli" ]; then
453
+ echo " Plot does not know which CLI drives '$1', so it cannot name the fix." >&2
454
+ echo " Set 'Git host' in CLAUDE.md to a host Plot drives (github, bitbucket)." >&2
455
+ return
456
+ fi
457
+ if ! command -v "$cli" >/dev/null 2>&1; then
458
+ echo " '$cli' is not on PATH, and this backend is driven through it." >&2
459
+ echo " Install it, then: $cli auth login" >&2
460
+ return
461
+ fi
462
+ echo " Check the CLI can answer: $cli auth status" >&2
463
+ echo " If it is not logged in: $cli auth login" >&2
464
+ }
465
+
369
466
  # A failed `pr-list`, reported and never swallowed.
370
467
  #
371
468
  # THREE OUTCOMES, KEPT APART — the rule `issue-list` states in full and this
@@ -380,13 +477,28 @@ pr_list_failed() { # $1=stderr text
380
477
  local err="$1"
381
478
  case "$(host_failure_kind "$err")" in
382
479
  secondary)
383
- die6 "pr-list: host refused a burst ${err:-the host refused the request and said nothing}"
480
+ # NO REPAIR NAMED, AND THAT IS THE ANSWER. A burst refusal clears in
481
+ # seconds and nothing is broken, so `$cli auth login` here would send a
482
+ # reader to fix a login that is already fine. The decision is the wait.
483
+ echo "plot-host: pr-list: host refused a burst — ${err:-the host refused the request and said nothing}" >&2
484
+ echo " Nothing is wrong and nothing needs fixing: this limit bounds calls at" >&2
485
+ echo " once, not per hour. Retry shortly, with fewer at a time." >&2
486
+ exit 6
384
487
  ;;
385
488
  throttled)
386
- die5 "pr-list: host throttled — ${err:-the host refused the request and said nothing}"
489
+ echo "plot-host: pr-list: host throttled — ${err:-the host refused the request and said nothing}" >&2
490
+ echo " The window's quota is spent. Wait for the reset the message names," >&2
491
+ echo " or run against an account with quota left. No login will help." >&2
492
+ exit 5
387
493
  ;;
388
494
  esac
389
- die3 "pr-list: ${err:-the host failed the request and said nothing}"
495
+ # THE ONE KIND A COMMAND FIXES. An auth gap and a DNS blip both land here, and
496
+ # the repair for the first is a login this connector can name — see
497
+ # `host_repair`, which asks the CLI this backend drives rather than assuming
498
+ # one. A failure that is neither costs the reader one `auth status`.
499
+ echo "plot-host: pr-list: ${err:-the host failed the request and said nothing}" >&2
500
+ host_repair "$(backend)"
501
+ exit 3
390
502
  }
391
503
 
392
504
  # Run one `pr-list` host call, or die reporting which failure it was.
@@ -447,17 +559,141 @@ pr_list_call() { # "$@"=the host command → payload on stdout, or dies
447
559
  # per row rather than blanking the entire list, and exit 3 is reserved for
448
560
  # when the operation itself cannot proceed.
449
561
 
450
- # Resolve CI backend: $PLOT_CI (for tests), then `CI` key, default "".
451
- # Non-jenkins values (github-actions, none, "") mean "no separate CI fetch".
452
- # The CI system this repo runs its builds on: `jenkins`, `github-actions`, or
453
- # `none`. Independent of the Git host a Bitbucket repo can build on Jenkins,
454
- # and this key is what pairs the two. `PLOT_CI` overrides for tests.
455
- ci_backend() {
562
+ # The raw `CI` declaration: `$PLOT_CI`, then the `CI` key, else empty.
563
+ #
564
+ # `PLOT_CI` IS A PRODUCTION CONTRACT, not a test override. The function this
565
+ # replaced called it *"overrides for tests"*, and that had been false since
566
+ # the build connectors landed: `build-actions.ts:39` passes
567
+ # `buildReads({ context, system: SYSTEM }, { PLOT_CI: SYSTEM })` as the dispatch
568
+ # contract the arm reads. It keeps its precedence and is split the same way, so
569
+ # a connector's bare `SYSTEM` word and a person's prose reach one comparison.
570
+ ci_raw() {
456
571
  if [ -n "${PLOT_CI:-}" ]; then
457
- printf '%s\n' "$PLOT_CI" | tr '[:upper:]' '[:lower:]'
572
+ printf '%s\n' "$PLOT_CI"
458
573
  return
459
574
  fi
460
- bash "$here/plot-config.sh" get "CI" "" | tr '[:upper:]' '[:lower:]'
575
+ bash "$here/plot-config.sh" get "CI" ""
576
+ }
577
+
578
+ # The CI system this repository declares — the FIRST token, lowercased.
579
+ #
580
+ # THE ONE READER OF THE `CI` KEY'S SCHEME, and every arm that dispatches on the
581
+ # CI system compares this. The function it replaced returned the WHOLE value
582
+ # while its four callers each matched a bare word (`jenkins`,
583
+ # `github-actions`), so a repository declaring
584
+ # `` CI: Jenkins at `jenkins-ci-ewz…` `` missed every arm and read
585
+ # `basis: unknown` where the Jenkins connector had `predicted: 60` ready.
586
+ # Measured 2026-09-09 against a reachable instance: only the bare word matched,
587
+ # and no repository on the estate writes it.
588
+ #
589
+ # THE PAYLOAD WAS THE SECOND HALF OF THAT COST. `ci-limit` reported the prose
590
+ # value as its `connector`, and `build-shell.ts` filters readings by equality
591
+ # against the connector's bare system word — so the reading was discarded before
592
+ # any caller saw it, and a metered Jenkins reported as metering nothing.
593
+ #
594
+ # Shaped after `tracker_scheme()` (`:1340`) — the same key packing a scheme
595
+ # ahead of its detail, split the same way. Empty when nothing is declared, and
596
+ # ABSENT IS NOT FALSE: `ci_unaskable()` reads an empty scheme as *this
597
+ # repository named no CI system*, which is a different answer from *this system
598
+ # has no connector*.
599
+ ci_scheme() {
600
+ ci_raw | awk '{print tolower($1)}'
601
+ }
602
+
603
+ # Whether a candidate token is a host `jen -I` could be given.
604
+ #
605
+ # A URL passes whole. Otherwise it must be dot-separated labels ending in an
606
+ # alphabetic TLD, carrying no path separator and no `@`.
607
+ #
608
+ # THE FINAL FILTER IS THE FILE EXTENSION, and that is a measurement rather than
609
+ # a preference. `ci.yml` and `package.json` both match the dotted-label pattern,
610
+ # while `.ch` and `.io` are real two-letter TLDs — so TLD LENGTH cannot separate
611
+ # a host from a filename in either direction, and a `CI:` value naming a
612
+ # workflow file (`github-actions (see .github/workflows/ci.yml)`) is the
613
+ # measured case that would otherwise be read as a server.
614
+ ci_looks_like_host() {
615
+ case "$1" in
616
+ https://*|http://*) return 0 ;;
617
+ */*|*@*) return 1 ;;
618
+ esac
619
+ printf '%s\n' "$1" \
620
+ | grep -qE '^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*\.[a-z]{2,}$' || return 1
621
+ case "$1" in
622
+ *.yml|*.yaml|*.json|*.md|*.sh|*.js|*.ts|*.mjs|*.cjs|*.toml|*.xml|*.txt|*.lock|*.gradle) return 1 ;;
623
+ esac
624
+ return 0
625
+ }
626
+
627
+ # Where this repository's CI lives: the `Jenkins instance` key, then
628
+ # `$JENKINS_INSTANCE`, then a host read out of the `CI` value. Empty when none
629
+ # of the three names one.
630
+ #
631
+ # THE ORDER IS NOT THE TRACKER'S, and the reason is information content.
632
+ # `tracker_base_url()` lets `$PLOT_JIRA_BASE_URL` win over its config value
633
+ # because both carry the same shape — a URL. Here the key carries MORE than the
634
+ # prose can express: `jenkins_instance()` (`:665`) returns `<slug>/<job/path>`,
635
+ # and the container path is a fact no file in a repository states
636
+ # (`stack.ts:98` calls the host *"the SLUG half"*). A prose-derived slug alone
637
+ # degrades `jenkins_build_map()` to root-scope listing, where its own comment
638
+ # says *"every branch reads `none`"* — and `quaweb` is nested
639
+ # (`job/quaweb/job/release`), so that renders an empty board for the exact
640
+ # repository this serves. So the explicit key is the primary and the prose is
641
+ # the fallback, never the other way round.
642
+ #
643
+ # IT EXTRACTS A HOST, NOT A REMAINDER. "Everything after the first token" was
644
+ # measured against all three real values and fails on every one:
645
+ # `at jenkins-ci-ewz… (Bitbucket PRs…`, `pipelines in .build/pipelines/ …`,
646
+ # `(e.g. continuous-build…)`. None is a host, and handing `jen -I` a sentence
647
+ # is worse than handing it nothing. `Tracker:` gets away with the naive split
648
+ # because its value is a scheme and a URL; `CI:` is prose in every repository
649
+ # that declares it.
650
+ #
651
+ # A BACKTICKED SPAN IS PREFERRED because the backticks are a deliberate act —
652
+ # the writer pointed at the value. It must still look like a host: one real
653
+ # repository marks up a PATH (`` `.build/pipelines/` ``), which is not a server.
654
+ #
655
+ # BACKTICKS ARE STRIPPED AND NO SCHEME IS NORMALISED ON. `ewz` writes a bare
656
+ # host in backticks and the probe used a full `https://` URL; `jen -I` accepts
657
+ # either, so inventing a canonical form would break whichever caller passes the
658
+ # other one.
659
+ #
660
+ # EMPTY IS AN ANSWER, not a failure: `Jenkins (e.g. continuous-build, …)` names
661
+ # no instance. Read the exit code, never the emptiness.
662
+ ci_instance() {
663
+ local instance candidate
664
+ instance=$(bash "$here/plot-config.sh" get "Jenkins instance" "" 2>/dev/null || echo "")
665
+ [ -n "$instance" ] || instance="${JENKINS_INSTANCE:-}"
666
+ if [ -n "$instance" ]; then
667
+ printf '%s' "$instance"
668
+ return
669
+ fi
670
+
671
+ local value
672
+ value="$(ci_raw)"
673
+
674
+ # The backticked spans, in order: `tr` puts each on its own line, so the
675
+ # even-numbered lines are what sat between a pair of backticks.
676
+ while IFS= read -r candidate; do
677
+ [ -n "$candidate" ] || continue
678
+ if ci_looks_like_host "$candidate"; then
679
+ printf '%s' "$candidate"
680
+ return
681
+ fi
682
+ done <<EOF
683
+ $(printf '%s\n' "$value" | tr '\`' '\n' | awk 'NR % 2 == 0')
684
+ EOF
685
+
686
+ # Otherwise the first bare token that looks like a host. Punctuation that
687
+ # ends a clause is split off so `(jenkins.acme.dev)` reads as the host.
688
+ while IFS= read -r candidate; do
689
+ [ -n "$candidate" ] || continue
690
+ if ci_looks_like_host "$candidate"; then
691
+ printf '%s' "$candidate"
692
+ return
693
+ fi
694
+ done <<EOF
695
+ $(printf '%s\n' "$value" | tr -d '\`' | tr ' \t,()[]<>"'"'" '\n')
696
+ EOF
461
697
  }
462
698
 
463
699
  # Fetch Jenkins build statuses for every branch in a multibranch job, in ONE
@@ -562,6 +798,56 @@ jenkins_build_map() {
562
798
  '
563
799
  }
564
800
 
801
+ # Where this repository's Jenkins lives — the `Jenkins instance` key, else
802
+ # `JENKINS_INSTANCE`. Prints nothing when neither is set.
803
+ #
804
+ # Read through ONE function because three ops now ask it. `pr-list --rich` read
805
+ # it inline first; `runs` and `run-for-sha` ask the same question and must get
806
+ # the same answer and the same refusal, or a repository would be configured for
807
+ # one op and not the others.
808
+ jenkins_instance() {
809
+ local instance
810
+ instance=$(bash "$here/plot-config.sh" get "Jenkins instance" "" 2>/dev/null || echo "")
811
+ [ -n "$instance" ] || instance="${JENKINS_INSTANCE:-}"
812
+ printf '%s' "$instance"
813
+ }
814
+
815
+ # The refusal a Jenkins op prints when it has no instance to ask, naming the
816
+ # three repairs. Exit 3 — the op itself cannot proceed, which is a config error
817
+ # only a person can fix, and deliberately NOT exit 4: a `CI: jenkins` repository
818
+ # CAN be asked about builds, it just has not said where.
819
+ jenkins_no_instance() {
820
+ echo "plot-host: CI is jenkins but no Jenkins instance is configured" >&2
821
+ echo " Add a 'Jenkins instance' key to the ## Plot Config section of CLAUDE.md," >&2
822
+ echo " naming the instance \`jen\` knows, or set JENKINS_INSTANCE." >&2
823
+ echo " Or drop the 'CI: jenkins' key to read build status from the git host." >&2
824
+ exit 3
825
+ }
826
+
827
+ # NOT BEING ABLE TO ASK IS NOT AN EMPTY ANSWER, and exit 4 is where the two are
828
+ # kept apart. An empty run list means *this branch has no runs*; a repository
829
+ # that declared no CI system has nothing that could hold one, and a caller
830
+ # reading `[]` for that would report *nothing has ever failed here* about a
831
+ # question nobody asked.
832
+ #
833
+ # `ci-limit`'s `*)` arm already draws this line for a connector nobody wrote an
834
+ # estimate for. This draws it one step earlier, for a connector that does not
835
+ # exist at all — and `resultOf` (`adapters/run-script.ts:211`) maps exit 4 onto
836
+ # `unaskable`, which is the word the build port already answers with.
837
+ ci_unaskable() { # $1 = the op's name, $2 = the CI word (may be empty)
838
+ local ci_word="${2:-}"
839
+ if [ -z "$ci_word" ] || [ "$ci_word" = none ]; then
840
+ echo "plot-host: $1 — this repository declares no CI system, so there is nothing to ask" >&2
841
+ echo " Add a 'CI' key to the ## Plot Config section of CLAUDE.md naming the" >&2
842
+ echo " system that builds this repository — \`github-actions\` or \`jenkins\`." >&2
843
+ else
844
+ echo "plot-host: $1 — no connector for CI system '$ci_word'" >&2
845
+ echo " Plot answers this op for \`github-actions\` and \`jenkins\`. An unknown" >&2
846
+ echo " system cannot be asked, which is not the same as having no runs." >&2
847
+ fi
848
+ exit 4
849
+ }
850
+
565
851
  # A LOOKUP MISS AND A TRANSPORT FAILURE ARE TWO ANSWERS, AND THE CLI GIVES ONE
566
852
  # EXIT CODE FOR BOTH.
567
853
  #
@@ -1208,6 +1494,60 @@ tracker_base_url() {
1208
1494
  tracker_raw | awk '{print $2}' | sed 's:/*$::'
1209
1495
  }
1210
1496
 
1497
+ # The Jira projects this repository's work lives in, one per line, or nothing.
1498
+ #
1499
+ # WHY A KEY AND NOT A QUERY. The inbox's default JQL scopes by PERSON
1500
+ # (`assignee = currentUser()`) and by STATE (`resolution = EMPTY`), and by
1501
+ # nothing else. On a shared Jira instance that is instance-wide: one reporter's
1502
+ # board showed twelve issues, of which one belonged to a different customer
1503
+ # entirely. Jira has no notion of the repository a board serves — no
1504
+ # `currentProject()` function exists — and that mapping lives only here, in this
1505
+ # repository's own config. Asking Jira which projects the user can see answers
1506
+ # the wrong question: that list is instance-wide too, and is what produced the
1507
+ # bug.
1508
+ #
1509
+ # IT HOLDS A LIST, and the single prefix adoption seeds only STARTS it.
1510
+ # `plot-detect-repo.sh` takes `head -1`, so a freshly adopted repo declares one
1511
+ # project. Filtering on that alone would be a second defect wearing the fix's
1512
+ # clothes — on the reported repository it shows 3 of 12 issues and hides the
1513
+ # other two projects' work under a heading claiming nobody had planned it. A
1514
+ # repository mapping to several Jira projects is the normal case.
1515
+ #
1516
+ # NAMED `Ticket prefixes`, not `Jira projects` (which puts a vendor in a config
1517
+ # key and severs the word the probe already uses) and not `Tracker projects`
1518
+ # (which generalises over a set of one). It sits next to `Branch prefixes` in
1519
+ # every adopting repo's config — an unrelated structural key holding `idea/`,
1520
+ # `feature/`, `bug/` — and only this docstring keeps the two apart.
1521
+ #
1522
+ # EMPTY IS THE ANSWER FOR AN UNDECLARED KEY, and callers must keep it meaning
1523
+ # *do not scope*: the absent case has to leave today's query untouched, or an
1524
+ # upgrade empties every existing board's inbox — a worse failure than the one
1525
+ # being fixed, because it looks like *no tickets* rather than the wrong ones.
1526
+ #
1527
+ # PARSING IS STATED HERE, not inherited: this is the estate's first list-valued
1528
+ # key with a consumer (`Implementation home` is documented as taking a list and
1529
+ # nothing splits one). Commas separate, surrounding whitespace is not part of a
1530
+ # value, and an empty element is dropped rather than emitted — a join over a
1531
+ # stray comma would otherwise produce `IN (PROJ-A,)`, which Jira rejects. A
1532
+ # value that is punctuation alone therefore reads as *undeclared*: it restores
1533
+ # the unscoped query rather than sending a query no instance would accept.
1534
+ #
1535
+ # `PLOT_TICKET_PREFIXES` overrides, the shape `PLOT_TRACKER` already sets. It
1536
+ # cannot express ABSENCE — an empty override falls through to the config — so
1537
+ # the absent case is tested with a repository that declares nothing.
1538
+ tracker_projects() {
1539
+ local raw
1540
+ if [ -n "${PLOT_TICKET_PREFIXES:-}" ]; then
1541
+ raw="$PLOT_TICKET_PREFIXES"
1542
+ else
1543
+ raw="$(bash "$here/plot-config.sh" get "Ticket prefixes" "")"
1544
+ fi
1545
+ # One per line, trimmed, empties dropped. `plot-config.sh` already normalises
1546
+ # `A,B` to `A, B`, but an override arrives unnormalised and a hand-edited
1547
+ # value can carry a stray comma — so the split is done here regardless.
1548
+ printf '%s' "$raw" | tr ',' '\n' | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//' | grep -v '^$' || true
1549
+ }
1550
+
1211
1551
  # The env var scheme for Jira auth. The plan left the EXACT names open, to be
1212
1552
  # confirmed against a real instance; these follow Jira Cloud's documented Basic
1213
1553
  # scheme (email + API token, base64'd into an Authorization header):
@@ -1225,7 +1565,10 @@ jira_require_config() {
1225
1565
  die3 "Tracker is jira but no base URL is configured (write 'Tracker: jira https://your.atlassian.net' or set PLOT_JIRA_BASE_URL)"
1226
1566
  fi
1227
1567
  if [ -z "${JIRA_EMAIL:-}" ] || [ -z "${JIRA_API_TOKEN:-}" ]; then
1228
- die3 "Jira needs JIRA_EMAIL and JIRA_API_TOKEN in the environment — an unauthenticated Jira must not read as an empty inbox"
1568
+ 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
1569
+ echo " Create a token at https://id.atlassian.com/manage-profile/security/api-tokens" >&2
1570
+ echo " then export JIRA_EMAIL=<your account email> and JIRA_API_TOKEN=<the token>." >&2
1571
+ exit 3
1229
1572
  fi
1230
1573
  }
1231
1574
 
@@ -1348,21 +1691,64 @@ pr_list_report_truncation() {
1348
1691
  echo "plot-host: $be pr-list state=$state possibly truncated ($count rows, requested limit $limit unprovable) — a join against this page may read older branches as 'no PR' (#333)" >&2
1349
1692
  }
1350
1693
 
1351
- backend() {
1694
+ # The backends this script has an arm for, which is what "drivable" means here.
1695
+ #
1696
+ # THE LIST LIVES IN THE SCRIPT BECAUSE THE SCRIPT IS WHAT WOULD CHANGE. Adding a
1697
+ # host means teaching the ops below its CLI; this array is the record of which
1698
+ # have been taught, and it sits beside the arms it describes. `packages/domain`
1699
+ # holds no copy — `HostBackend` is any string, and `host-shell.ts` passes
1700
+ # through whatever this prints — so a third host is an edit here and nowhere in
1701
+ # the domain.
1702
+ HOST_DRIVES="github bitbucket"
1703
+
1704
+ # Is this a backend the ops below can actually drive?
1705
+ host_drivable() { # $1=backend word
1706
+ case " $HOST_DRIVES " in *" $1 "*) return 0 ;; *) return 1 ;; esac
1707
+ }
1708
+
1709
+ # The backend this repository declared, in the word it used.
1710
+ #
1711
+ # REPORTS WHAT IT WAS TOLD, AND DEFAULTS ONLY WHERE NOTHING WAS SAID. A repo
1712
+ # declaring `Git host: gitlab` was answered `github` until 2026-09-08 — the
1713
+ # unrecognised word was discarded and the default returned in its place, so a
1714
+ # GitLab team was driven through `gh` and every refusal named a host they had
1715
+ # not configured. An absent key still defaults to github, which is the same
1716
+ # answer for the same reason: nothing was said, so nothing was discarded.
1717
+ #
1718
+ # `bb` is an ALIAS rather than an unknown word, and it normalises to bitbucket.
1719
+ backend_declared() {
1352
1720
  if [ -n "${PLOT_HOST:-}" ]; then
1353
- case "$PLOT_HOST" in
1354
- github|bitbucket) echo "$PLOT_HOST"; return ;;
1355
- *) die "unknown PLOT_HOST '$PLOT_HOST' (github|bitbucket)" ;;
1356
- esac
1721
+ printf '%s\n' "$PLOT_HOST" | tr '[:upper:]' '[:lower:]'
1722
+ return
1357
1723
  fi
1358
1724
  local v
1359
1725
  v="$(bash "$here/plot-config.sh" get "Git host" "github" | tr '[:upper:]' '[:lower:]')"
1360
1726
  case "$v" in
1361
- bitbucket|bb) echo "bitbucket" ;;
1362
- *) echo "github" ;;
1727
+ bb) echo "bitbucket" ;;
1728
+ "") echo "github" ;;
1729
+ *) printf '%s\n' "$v" ;;
1363
1730
  esac
1364
1731
  }
1365
1732
 
1733
+ # The resolved backend, refused where this script has no arm for it.
1734
+ #
1735
+ # THE REFUSAL IS THIS LAYER'S, AND IT NAMES THE WORD. Every op below dispatches
1736
+ # on this answer, so a word with no arm must stop here rather than fall into
1737
+ # whichever branch happens to be last — that is how `gitlab` became a Bitbucket
1738
+ # call. Exit 4 says the question cannot be asked of this backend AT ALL, which
1739
+ # is what an unknown host is: not a broken call to retry, but a configuration a
1740
+ # person must fix. `host-shell.ts` reads that code as `unaskable` and reads the
1741
+ # sentence below for the name.
1742
+ backend() {
1743
+ local v
1744
+ v="$(backend_declared)" || return 1
1745
+ if ! host_drivable "$v"; then
1746
+ echo "plot-host: cannot drive '$v' — this script drives ${HOST_DRIVES// /, }; set the 'Git host' key in CLAUDE.md (or \$PLOT_HOST) to one of them" >&2
1747
+ return 4
1748
+ fi
1749
+ printf '%s\n' "$v"
1750
+ }
1751
+
1366
1752
 
1367
1753
  # --- the connector counts what it spends -----------------------------------
1368
1754
  #
@@ -1802,7 +2188,11 @@ jen() {
1802
2188
 
1803
2189
  op="${1:-}"; [ -n "$op" ] || die "usage: plot-host.sh <op> [args...] (see header)"
1804
2190
  shift
1805
- be="$(backend)" || exit 1
2191
+ # THE BACKEND'S REFUSAL IS PASSED THROUGH, NOT FLATTENED. `backend` exits 4 for
2192
+ # a host it has no arm for, and exit 4 is the contract's "this backend has no
2193
+ # answer at all" — the one code every caller reads as permanent rather than
2194
+ # transient. Collapsing it to 1 here would tell a GitLab repo to retry forever.
2195
+ be="$(backend)" || exit $?
1806
2196
 
1807
2197
  # EVERY GITHUB OP CONSULTS THE ROUTER, ONCE, HERE. `gh_route` is asked before
1808
2198
  # the op runs and its answer is read from `$route` by whichever arm needs it —
@@ -2041,6 +2431,73 @@ case "$op" in
2041
2431
  fi
2042
2432
  ;;
2043
2433
 
2434
+ pr-merge-commit)
2435
+ # THE MERGE COMMIT OF A BRANCH'S MERGED PR, and nothing else.
2436
+ #
2437
+ # A SECOND SUBCOMMAND RATHER THAN A FIELD ON `pr-merged`, because that one
2438
+ # prints ONE WORD and eleven callers read it as one — `plot-reap.sh`,
2439
+ # `plot-release-refs.sh` and `plot-dispatch.sh` among them, each deciding
2440
+ # whether to remove something. Widening its output to carry a sha would
2441
+ # rewrite a contract those callers depend on, to serve a question none of
2442
+ # them asks.
2443
+ #
2444
+ # THE QUERY IS `pr-merged`'s, so a caller asking both spends TWO calls where
2445
+ # one would do — and that is a cost this path does not pay: delivery asks
2446
+ # this only for a branch it has already been told merged, and the plan it
2447
+ # serves budgets one host call per branch either way.
2448
+ #
2449
+ # `--state all` and `--limit 100` for `pr-merged`'s own two reasons: a
2450
+ # merged PR reports CLOSED, and the newest PR is not the merge.
2451
+ #
2452
+ # PRINTS NOTHING AND EXITS 0 where no PR merged — an ANSWER, the same shape
2453
+ # `pr-state` gives for a missing PR. Exit 3 is the question failing.
2454
+ ref="${1:?pr-merge-commit needs a branch}"; shift || true
2455
+ repo_args=()
2456
+ while [ $# -gt 0 ]; do
2457
+ case "$1" in
2458
+ --repo) repo_args=(-R "${2:?}"); shift 2 ;;
2459
+ *) die "pr-merge-commit: unknown arg $1" ;;
2460
+ esac
2461
+ done
2462
+ if [ "$be" = "github" ]; then
2463
+ if out="$(gh ${repo_args[@]+"${repo_args[@]}"} pr list --head "$ref" --state all --limit 100 --json mergedAt,mergeCommit 2>/tmp/plot-host-err.$$)"; then
2464
+ rm -f "/tmp/plot-host-err.$$"
2465
+ # The FIRST merged PR carrying a sha. A branch may hold several merged
2466
+ # PRs; each names its own merge commit, and any of them is a commit that
2467
+ # landed this branch's work.
2468
+ jq -r 'map(select(.mergedAt != null and .mergeCommit != null))
2469
+ | map(.mergeCommit.oid) | first // empty' <<<"$out"
2470
+ else
2471
+ err="$(cat "/tmp/plot-host-err.$$" 2>/dev/null)"; rm -f "/tmp/plot-host-err.$$"
2472
+ # A lookup miss is an answer: no PR, so no merge commit. Anything else
2473
+ # is the question failing, and silence must not read as "carried
2474
+ # nothing" — the caller distinguishes the two by the exit code.
2475
+ if [ -z "$err" ] || is_lookup_miss "$err"; then
2476
+ :
2477
+ else
2478
+ echo "plot-host: $err" >&2
2479
+ exit 3
2480
+ fi
2481
+ fi
2482
+ else
2483
+ bb_require_json
2484
+ # Bitbucket names the merge commit `merge_commit.hash` on a merged PR.
2485
+ if out="$(bb ${repo_args[@]+"${repo_args[@]}"} pr list --state merged --json 2>/tmp/plot-host-err.$$)"; then
2486
+ rm -f "/tmp/plot-host-err.$$"
2487
+ jq -r --arg b "$ref" 'map(select(.source.branch.name==$b))
2488
+ | map(.merge_commit.hash // empty) | first // empty' <<<"$out"
2489
+ else
2490
+ err="$(cat "/tmp/plot-host-err.$$" 2>/dev/null)"; rm -f "/tmp/plot-host-err.$$"
2491
+ if [ -z "$err" ] || is_lookup_miss "$err"; then
2492
+ :
2493
+ else
2494
+ echo "plot-host: $err" >&2
2495
+ exit 3
2496
+ fi
2497
+ fi
2498
+ fi
2499
+ ;;
2500
+
2044
2501
  pr-create)
2045
2502
  title=""; body=""; base=""; head=""; draft=0
2046
2503
  while [ $# -gt 0 ]; do
@@ -2154,15 +2611,12 @@ case "$op" in
2154
2611
  # the board rejects a non-zero pr-list and keeps its last good map; one
2155
2612
  # dead Jenkins must not darken every row. This reconciles Done-when 4's
2156
2613
  # "exits 3" with the brief's "prefer unknown on the affected rows".
2157
- ci="$(ci_backend)"
2614
+ ci="$(ci_scheme)"
2158
2615
  jen_map=""
2159
2616
  jen_status=""
2160
2617
  if [ "$ci" = "jenkins" ] && [ "$rich" = 1 ]; then
2161
- jen_instance=$(bash "$here/plot-config.sh" get "Jenkins instance" "" 2>/dev/null || echo "")
2162
- [ -n "$jen_instance" ] || jen_instance="${JENKINS_INSTANCE:-}"
2163
- if [ -z "$jen_instance" ]; then
2164
- die3 "CI is jenkins but no Jenkins instance is configured (set a 'Jenkins instance' key)"
2165
- fi
2618
+ jen_instance="$(jenkins_instance)"
2619
+ [ -n "$jen_instance" ] || jenkins_no_instance
2166
2620
  jen_payload=$(jenkins_build_map "$jen_instance")
2167
2621
  jen_status=$(printf '%s' "$jen_payload" | jq -r '.status // "failed"' 2>/dev/null || echo "failed")
2168
2622
  jen_map=$(printf '%s' "$jen_payload" | jq -c '.map // {}' 2>/dev/null || echo "{}")
@@ -2417,9 +2871,13 @@ case "$op" in
2417
2871
  # and failing branches are rare by construction; a caller that asked for
2418
2872
  # every branch would spend a budget the board has already exhausted once.
2419
2873
  #
2420
- # Bitbucket reports nothing here rather than something invented. `bb` has no
2421
- # run listing, and an empty history renders as "unavailable" never as
2422
- # "this branch has never failed before".
2874
+ # IT DISPATCHES ON THE CI SYSTEM, NEVER ON THE GIT HOST. This arm gated on
2875
+ # `be` until 2026-09-08, which made this repository's accidentGitHub is
2876
+ # both the shape of the op: a Bitbucket team building on Jenkins got
2877
+ # silence, and a GitHub team building on Jenkins got GitHub Actions runs
2878
+ # for a repository whose CI is not GitHub Actions. `CI` and `Git host` are
2879
+ # independent `## Plot Config` keys, and this is the op that reads the
2880
+ # first of them.
2423
2881
  branch="${1:?runs needs a branch}"; shift
2424
2882
  limit=10
2425
2883
  while [ $# -gt 0 ]; do
@@ -2428,13 +2886,61 @@ case "$op" in
2428
2886
  *) die "runs: unknown arg $1" ;;
2429
2887
  esac
2430
2888
  done
2431
- if [ "$be" = "github" ]; then
2432
- gh run list --branch "$branch" --limit "$limit" \
2433
- --json workflowName,conclusion,status,startedAt,url 2>/dev/null \
2434
- | jq -c '.[] | {workflow:.workflowName,
2435
- conclusion:(if (.conclusion // "") == "" then .status else .conclusion end),
2436
- startedAt:.startedAt, url:.url}' 2>/dev/null || true
2437
- fi
2889
+ _ci="$(ci_scheme)"
2890
+ case "$_ci" in
2891
+ github-actions)
2892
+ # THE GIT HOST STILL HAS TO BE GITHUB, and that is a second condition
2893
+ # rather than the same one. `gh run list` reads the runs of the
2894
+ # repository the CWD's remote names, so a repository declaring
2895
+ # `CI: github-actions` on a Bitbucket remote has declared something
2896
+ # `gh` cannot reach — an unaskable configuration, not an empty history.
2897
+ if [ "$be" != "github" ]; then
2898
+ echo "plot-host: runs — CI is github-actions but the git host is '$be'" >&2
2899
+ echo " \`gh run list\` reads the runs of the repository its remote names," >&2
2900
+ echo " so there is no GitHub repository here to ask about." >&2
2901
+ exit 4
2902
+ fi
2903
+ gh run list --branch "$branch" --limit "$limit" \
2904
+ --json workflowName,conclusion,status,startedAt,url 2>/dev/null \
2905
+ | jq -c '.[] | {workflow:.workflowName,
2906
+ conclusion:(if (.conclusion // "") == "" then .status else .conclusion end),
2907
+ startedAt:.startedAt, url:.url}' 2>/dev/null || true
2908
+ ;;
2909
+ jenkins)
2910
+ # ONE STATE PER BRANCH, REPORTED AS A HISTORY OF ONE — and the PR says
2911
+ # so, because it is the honest limit of the transport this arm has.
2912
+ # `jenkins_build_map` answers `{color, checks, job}` for every branch in
2913
+ # one call: a CURRENT state, with no timestamps, no URL and no earlier
2914
+ # runs. A history is what Jenkins holds and what its REST API answers;
2915
+ # reading it is `the-ci-connector-is-jenkins`'s own slice, and inventing
2916
+ # entries here to fill the shape would be the collector reaching a
2917
+ # verdict (Principle 3).
2918
+ #
2919
+ # SO THE FIELDS THIS CANNOT MEASURE ARE EMPTY, never guessed. `workflow`
2920
+ # names the Jenkins job, which is the one thing the map does carry and
2921
+ # the detail a reader opens; `conclusion` is the `checks` word the
2922
+ # colour table already settled; `startedAt` and `url` are empty,
2923
+ # because a timestamp this arm invented would read as measurement.
2924
+ _jen_instance="$(jenkins_instance)"
2925
+ [ -n "$_jen_instance" ] || jenkins_no_instance
2926
+ _jen_payload=$(jenkins_build_map "$_jen_instance")
2927
+ _jen_status=$(printf '%s' "$_jen_payload" | jq -r '.status // "failed"' 2>/dev/null || echo "failed")
2928
+ if [ "$_jen_status" != "ok" ]; then
2929
+ # UNREACHABLE IS NOT EMPTY EITHER. `pr-list --rich` can mark its rows
2930
+ # `unknown` and keep them; this op has no row to carry the word, so
2931
+ # the only way to say *cannot verify* is the exit code. Printing
2932
+ # nothing at exit 0 would read as *this branch has never run*.
2933
+ echo "plot-host: runs — jenkins unreachable ($_jen_status)" >&2
2934
+ exit 4
2935
+ fi
2936
+ printf '%s' "$_jen_payload" | jq -c --arg branch "$branch" \
2937
+ '.map[$branch] // empty
2938
+ | {workflow:(.job // ""), conclusion:(.checks // ""), startedAt:"", url:""}' 2>/dev/null || true
2939
+ ;;
2940
+ *)
2941
+ ci_unaskable runs "$_ci"
2942
+ ;;
2943
+ esac
2438
2944
  ;;
2439
2945
 
2440
2946
  run-for-sha)
@@ -2480,39 +2986,80 @@ case "$op" in
2480
2986
  *) die "run-for-sha: unknown arg $1" ;;
2481
2987
  esac
2482
2988
  done
2483
- if [ "$be" = "github" ]; then
2484
- # `headSha` is the field that makes this answerable at all; `runs` omits
2485
- # it, which is why that op cannot be reused here.
2486
- #
2487
- # NEWEST FIRST, then the FIRST match is taken: `gh run list` returns runs
2488
- # newest-first, and a sha can carry several (a rerun, or several
2489
- # workflows). The newest is the live answer; older ones for the same sha
2490
- # are superseded by the same argument that superseded runs for older shas.
2491
- # THE SHA ASKED ABOUT IF THERE IS ONE, ELSE THE NEWEST RUN ON THE BRANCH —
2492
- # and `sha` in the output says WHICH, because a caller that could not tell
2493
- # the two apart would be back to the branch-scoped guessing this op exists
2494
- # to end.
2495
- #
2496
- # WHY IT FALLS BACK AT ALL, rather than reporting nothing. Filtering to
2497
- # the asked-for sha and stopping makes the most important case invisible:
2498
- # a run IN FLIGHT for a commit the branch has already moved past reports
2499
- # identically to no run at all, so a caller cannot distinguish *CI has not
2500
- # started yet* from *CI is busy answering about the past*. The second is
2501
- # the state that had two merge waiters reporting on superseded runs on
2502
- # 2026-08-30, and it is exactly what a caller needs to see.
2503
- #
2504
- # IT STILL DECIDES NOTHING (Principle 3). It reports the run it found and
2505
- # the sha that run is for; whether that sha being different from the one
2506
- # asked about means "superseded" is the caller's rule. This collects.
2507
- gh run list --branch "$branch" --limit "$limit" \
2508
- --json headSha,conclusion,status,startedAt,url 2>/dev/null \
2509
- | jq -c --arg sha "$sha" \
2510
- '(map(select(.headSha == $sha)) | .[0]) // .[0]
2511
- | select(. != null)
2512
- | {sha:.headSha, status:.status,
2513
- conclusion:(if (.conclusion // "") == "" then null else .conclusion end),
2514
- url:.url, startedAt:.startedAt}' 2>/dev/null || true
2989
+ # DISPATCHED ON THE CI SYSTEM, for the reason `runs` above states: the git
2990
+ # host is a separate key, and a build is the CI system's fact.
2991
+ _ci="$(ci_scheme)"
2992
+ case "$_ci" in
2993
+ jenkins)
2994
+ # JENKINS CANNOT ANSWER THIS THROUGH `jen`, AND THAT IS A MEASUREMENT
2995
+ # RATHER THAN A GAP LEFT OPEN. `jenkins_build_map` the only Jenkins
2996
+ # reader this script has answers `{color, checks, job}` per BRANCH and
2997
+ # carries no commit at all, so there is nothing here to match a sha
2998
+ # against.
2999
+ #
3000
+ # THE ANSWER EXISTS AND THIS TRANSPORT DOES NOT REACH IT. Measured
3001
+ # 2026-09-08 against a live instance, a build names its commit at
3002
+ # `actions[].BuildData.lastBuiltRevision.SHA1`, paired with its branch
3003
+ # over the REST API, which `the-ci-connector-is-jenkins` reads. Until
3004
+ # that lands, `unaskable` is the true word: this connector cannot be
3005
+ # asked, which is not the same as the branch having no run for the sha.
3006
+ #
3007
+ # FALLING BACK TO THE BRANCH'S CURRENT STATE WOULD BE THE ONE ANSWER
3008
+ # THAT COSTS A MERGE. This op exists because a run for a superseded
3009
+ # commit reads identically to a run for the current one, and reporting
3010
+ # a branch-scoped state with no sha in it is exactly the guessing it was
3011
+ # written to end two merge waiters were stopped for it on 2026-08-30.
3012
+ echo "plot-host: run-for-sha jenkins has no sha-scoped answer through \`jen\`" >&2
3013
+ echo " \`jen job list\` reports one state per branch and names no commit." >&2
3014
+ echo " A build's sha is in Jenkins and reached over its REST API, which" >&2
3015
+ echo " the Jenkins build connector reads. Until then this cannot be asked." >&2
3016
+ exit 4
3017
+ ;;
3018
+ github-actions) : ;;
3019
+ *) ci_unaskable run-for-sha "$_ci" ;;
3020
+ esac
3021
+ if [ "$be" != "github" ]; then
3022
+ # SAME SECOND CONDITION AS `runs`: `gh run list` reads the repository its
3023
+ # remote names, so `CI: github-actions` on a non-GitHub remote names runs
3024
+ # that cannot be reached from here.
3025
+ echo "plot-host: run-for-sha — CI is github-actions but the git host is '$be'" >&2
3026
+ echo " \`gh run list\` reads the runs of the repository its remote names," >&2
3027
+ echo " so there is no GitHub repository here to ask about." >&2
3028
+ exit 4
2515
3029
  fi
3030
+ # From here the CI system is `github-actions` and the git host is GitHub;
3031
+ # both were established above, so nothing guards the call.
3032
+ # `headSha` is the field that makes this answerable at all; `runs` omits
3033
+ # it, which is why that op cannot be reused here.
3034
+ #
3035
+ # NEWEST FIRST, then the FIRST match is taken: `gh run list` returns runs
3036
+ # newest-first, and a sha can carry several (a rerun, or several
3037
+ # workflows). The newest is the live answer; older ones for the same sha
3038
+ # are superseded by the same argument that superseded runs for older shas.
3039
+ # THE SHA ASKED ABOUT IF THERE IS ONE, ELSE THE NEWEST RUN ON THE BRANCH —
3040
+ # and `sha` in the output says WHICH, because a caller that could not tell
3041
+ # the two apart would be back to the branch-scoped guessing this op exists
3042
+ # to end.
3043
+ #
3044
+ # WHY IT FALLS BACK AT ALL, rather than reporting nothing. Filtering to
3045
+ # the asked-for sha and stopping makes the most important case invisible:
3046
+ # a run IN FLIGHT for a commit the branch has already moved past reports
3047
+ # identically to no run at all, so a caller cannot distinguish *CI has not
3048
+ # started yet* from *CI is busy answering about the past*. The second is
3049
+ # the state that had two merge waiters reporting on superseded runs on
3050
+ # 2026-08-30, and it is exactly what a caller needs to see.
3051
+ #
3052
+ # IT STILL DECIDES NOTHING (Principle 3). It reports the run it found and
3053
+ # the sha that run is for; whether that sha being different from the one
3054
+ # asked about means "superseded" is the caller's rule. This collects.
3055
+ gh run list --branch "$branch" --limit "$limit" \
3056
+ --json headSha,conclusion,status,startedAt,url 2>/dev/null \
3057
+ | jq -c --arg sha "$sha" \
3058
+ '(map(select(.headSha == $sha)) | .[0]) // .[0]
3059
+ | select(. != null)
3060
+ | {sha:.headSha, status:.status,
3061
+ conclusion:(if (.conclusion // "") == "" then null else .conclusion end),
3062
+ url:.url, startedAt:.startedAt}' 2>/dev/null || true
2516
3063
  ;;
2517
3064
 
2518
3065
  issue-list)
@@ -2548,7 +3095,29 @@ case "$op" in
2548
3095
  # narrower inbox. ORDER BY created DESC so the newest ticket is first, the
2549
3096
  # same order `createdAt` gives the GitHub arm.
2550
3097
  jira_require_config
2551
- jql="${PLOT_JIRA_JQL:-assignee = currentUser() AND resolution = EMPTY ORDER BY created DESC}"
3098
+ # `Ticket prefixes` SCOPES THE DEFAULT to this repository's projects. The
3099
+ # default alone scopes by person and by state, so on a shared instance it
3100
+ # is instance-wide: it returned another customer's ticket to a reporter's
3101
+ # board (see `tracker_projects` for the measurement and the naming).
3102
+ #
3103
+ # THE CLAUSE IS SPLICED, NOT APPENDED, and the split below is why: JQL
3104
+ # requires ORDER BY to close the query, so a clause added to the end of
3105
+ # the default string makes a query Jira rejects.
3106
+ #
3107
+ # AN UNDECLARED KEY LEAVES THE QUERY BYTE-IDENTICAL. `$scope` is empty,
3108
+ # the two halves rejoin exactly as they were written, and an upgrade
3109
+ # changes nothing for a repository that never set the key.
3110
+ scope=""
3111
+ if projects="$(tracker_projects)" && [ -n "$projects" ]; then
3112
+ # `IN (A, B)`, never a trailing comma: paste joins N-1 separators over N
3113
+ # elements, so a one-element list — what adoption's seed writes — yields
3114
+ # `IN (A)` rather than `IN (A,)`.
3115
+ scope=" AND project IN ($(printf '%s' "$projects" | paste -sd, - | sed 's/,/, /g'))"
3116
+ fi
3117
+ # PLOT_JIRA_JQL STILL WINS OVER BOTH. Teams worked around this bug with
3118
+ # their own JQL; an override that stopped overriding would break exactly
3119
+ # the people who noticed the problem first.
3120
+ jql="${PLOT_JIRA_JQL:-assignee = currentUser() AND resolution = EMPTY${scope} ORDER BY created DESC}"
2552
3121
  # maxResults bounds ONE page. The inbox is small by construction (a
2553
3122
  # person's open tickets), so no nextPageToken loop is needed; the caller's
2554
3123
  # --limit caps it, else Jira's default page. v2 `search/jql` takes the same
@@ -2560,7 +3129,7 @@ case "$op" in
2560
3129
  raw="$(jira_curl "/rest/api/2/search/jql" \
2561
3130
  -G \
2562
3131
  --data-urlencode "jql=$jql" \
2563
- --data-urlencode "fields=summary,created" \
3132
+ --data-urlencode "fields=summary,created,status" \
2564
3133
  --data-urlencode "maxResults=$max")"; curl_rc=$?
2565
3134
  body="$(jira_check "$raw" "$curl_rc")" || exit $?
2566
3135
  # `number` is the Jira KEY (PROJ-123), a string — #447 taught the parser to
@@ -2568,21 +3137,39 @@ case "$op" in
2568
3137
  # search payload carries no browse URL, and the base is ours to know
2569
3138
  # (Principle 3: this script is the one place that knows a host URL's shape).
2570
3139
  base="$(tracker_base_url)"
3140
+ # `status` is the workflow's own word (*Internal Approving*) and may be
3141
+ # localised; `statusCategory` is Jira's stable three-value vocabulary
3142
+ # (To Do / In Progress / Done). BOTH are carried because neither
3143
+ # substitutes for the other: the name is what a person reads, the
3144
+ # category is what a board groups on. A board grouping on the name
3145
+ # fragments across projects that spell the same stage differently.
2571
3146
  printf '%s' "$body" | jq -c --arg base "$base" '.issues[]? | {
2572
3147
  number: .key,
2573
3148
  title: (.fields.summary // ""),
2574
3149
  url: ($base + "/browse/" + .key),
2575
- createdAt: (.fields.created // "")
3150
+ createdAt: (.fields.created // ""),
3151
+ status: (.fields.status.name // ""),
3152
+ statusCategory: (.fields.status.statusCategory.name // "")
2576
3153
  }'
2577
3154
  elif [ "$be" = "github" ]; then
2578
3155
  # `gh issue list` — not `gh api /issues`. On GitHub every PR IS an issue,
2579
3156
  # so the REST endpoint returns both, and every open PR would arrive here
2580
3157
  # as a signal nobody had planned. The `gh` subcommand filters PRs out;
2581
3158
  # this note exists because that trap is invisible while it works.
2582
- if out="$(gh issue list --state open ${limit_args[@]+"${limit_args[@]}"} \
3159
+ # `status` is DERIVED from the state this call asks for, never written
3160
+ # twice. GitHub has no workflow states: an issue is open or closed, and
3161
+ # this op asks for open ones only. So the state passed to `gh` is the
3162
+ # answer, and `$gh_state` is read by both the flag and the projection —
3163
+ # a second literal would be a copy that a widened filter silently
3164
+ # outdates. `To Do` is the category because an open GitHub issue is
3165
+ # unstarted by the only vocabulary GitHub has.
3166
+ gh_state="open"
3167
+ if out="$(gh issue list --state "$gh_state" ${limit_args[@]+"${limit_args[@]}"} \
2583
3168
  --json number,title,url,createdAt 2>/tmp/plot-host-err.$$)"; then
2584
3169
  rm -f "/tmp/plot-host-err.$$"
2585
- jq -c '.[] | {number:.number,title:.title,url:(.url // ""),createdAt:(.createdAt // "")}' <<<"$out"
3170
+ jq -c --arg status "$gh_state" \
3171
+ '.[] | {number:.number,title:.title,url:(.url // ""),createdAt:(.createdAt // ""),
3172
+ status:$status,statusCategory:(if $status == "open" then "To Do" else "Done" end)}' <<<"$out"
2586
3173
  else
2587
3174
  err="$(cat "/tmp/plot-host-err.$$" 2>/dev/null)"; rm -f "/tmp/plot-host-err.$$"
2588
3175
  # NO empty-list fallback. `host_miss_or_fail` exists for a lookup whose
@@ -2622,15 +3209,37 @@ case "$op" in
2622
3209
  [[ "$line" =~ ^#0*([0-9]+)[[:space:]]+(.*)$ ]] || continue
2623
3210
  num="${BASH_REMATCH[1]}"
2624
3211
  rest="${BASH_REMATCH[2]}"
2625
- # Strip the leading state badge word (NEW/OPEN/RESOLVED/…) and its
2626
- # padding; the title starts after it.
2627
- rest="$(sed -E 's/^(NEW|OPEN|ON HOLD|INVALID|RESOLVED|DUPLICATE|WONTFIX|CLOSED)[[:space:]]+//' <<<"$rest")"
3212
+ # Lift the leading state badge word (NEW/OPEN/RESOLVED/…), then strip
3213
+ # it and its padding; the title starts after it. The badge was parsed
3214
+ # here before and THROWN AWAY to recover the title — it is captured
3215
+ # now rather than fetched, so this costs no extra call. ONE
3216
+ # vocabulary, matched once: a second copy in a mapping table is a list
3217
+ # that drifts from the one that strips.
3218
+ state=""
3219
+ if [[ "$rest" =~ ^(NEW|OPEN|ON\ HOLD|INVALID|RESOLVED|DUPLICATE|WONTFIX|CLOSED)[[:space:]]+(.*)$ ]]; then
3220
+ state="${BASH_REMATCH[1]}"
3221
+ rest="${BASH_REMATCH[2]}"
3222
+ fi
3223
+ # Map the badge onto the three-value category the board groups on.
3224
+ # NEW/OPEN are unstarted; RESOLVED/CLOSED are finished. The rest —
3225
+ # ON HOLD, INVALID, DUPLICATE, WONTFIX — get "" DELIBERATELY: they are
3226
+ # terminal-without-being-done or started-without-progressing, and
3227
+ # neither is a Jira category. Empty says *this vocabulary has no word
3228
+ # for it*, which a board can render; inventing `Done` for WONTFIX
3229
+ # would file abandoned work beside finished work.
3230
+ case "$state" in
3231
+ NEW|OPEN) category="To Do" ;;
3232
+ RESOLVED|CLOSED) category="Done" ;;
3233
+ *) category="" ;;
3234
+ esac
2628
3235
  # Drop the trailing reporter: bb prints three spaces then `by <name>`.
2629
3236
  title="$(sed -E 's/[[:space:]]{2,}by [^[:space:]].*$//' <<<"$rest")"
2630
3237
  # `url` is "" — bb issue list prints none; a consumer renders the number
2631
3238
  # as plain text, the rule the header states.
2632
3239
  jq -cn --argjson number "$num" --arg title "$title" \
2633
- '{number:$number,title:$title,url:"",createdAt:""}'
3240
+ --arg status "$state" --arg category "$category" \
3241
+ '{number:$number,title:$title,url:"",createdAt:"",
3242
+ status:$status,statusCategory:$category}'
2634
3243
  count=$((count + 1))
2635
3244
  # Honour the caller's --limit HERE: bb has no --limit, so a bound the
2636
3245
  # caller asked for is enforced by the adapter after parsing (Done-when 7).
@@ -2976,14 +3585,14 @@ case "$op" in
2976
3585
  ci-limit)
2977
3586
  # The CI connector's limit, which is a THIRD axis and does not follow the
2978
3587
  # git host. This repo runs GitHub Actions on a GitHub remote; `ekzweb` runs
2979
- # Jenkins against Bitbucket. `ci_backend()` already resolves it separately.
3588
+ # Jenkins against Bitbucket. `ci_scheme()` already resolves it separately.
2980
3589
  #
2981
3590
  # JENKINS IS THE `predicted` CASE THE DESIGN NAMES. A Jenkins instance
2982
3591
  # reports no rate limit — there is no header and no endpoint to ask — so the
2983
3592
  # ceiling is this adapter's estimate of what a shared controller tolerates,
2984
3593
  # tagged for what it is. It is NOT unlimited: a Jenkins that is hammered
2985
3594
  # refuses, and the refusal is what corrects the estimate.
2986
- _ci="$(ci_backend)"
3595
+ _ci="$(ci_scheme)"
2987
3596
  case "$_ci" in
2988
3597
  jenkins)
2989
3598
  echo '{"connector":"jenkins","bucket":"","limit":60,"remaining":null,"reset":null,"basis":"predicted"}'
@@ -2993,9 +3602,15 @@ case "$op" in
2993
3602
  # answer, not a limit of zero.
2994
3603
  ;;
2995
3604
  *)
2996
- # `ci_backend()` validates nothing, and neither does this — the list is
3605
+ # `ci_scheme()` validates nothing, and neither does this — the list is
2997
3606
  # open, and GitLab and Trello are named as next. A connector nobody has
2998
3607
  # written an estimate for answers `unknown`, which is the honest word.
3608
+ #
3609
+ # IT REPORTS THE SCHEME, never the whole `CI:` value. `build-shell.ts`
3610
+ # filters readings by `reading.connector === shell.system`, and the
3611
+ # system is a bare word — so a prose connector here is discarded before
3612
+ # any caller sees it, and the port defines that empty answer as *a
3613
+ # connector that meters nothing*.
2999
3614
  echo "{\"connector\":\"$_ci\",\"bucket\":\"\",\"limit\":null,\"remaining\":null,\"reset\":null,\"basis\":\"unknown\"}"
3000
3615
  ;;
3001
3616
  esac