@plot-pm/board 0.14.3 → 0.15.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
@@ -19,7 +19,18 @@
19
19
  # than a transient to retry. Adding a host is
20
20
  # an edit to `HOST_DRIVES` and the arms below,
21
21
  # and to nothing in `packages/domain`.
22
- # default-branch print the repo's default branch name
22
+ # default-branch print the repo's default branch name. THE HOST
23
+ # IS ASKED, on both backends, and the local
24
+ # `origin/HEAD` is the fallback where it cannot
25
+ # answer — that ref is a clone-time cache and a
26
+ # default branch moved since then never updates
27
+ # it. Prints "" where neither can answer.
28
+ # account print the signed-in host user, read from the
29
+ # CLI's config file (no API request).
30
+ # `PLOT_BUDGET_ACCOUNT` overrides it. Exit 3
31
+ # where GitHub's `hosts.yml` names no user;
32
+ # exit 4 on Bitbucket, which stores no username
33
+ # for free. Never prints `unknown`.
23
34
  # pr-state <number|branch> [--repo <owner/repo>] one JSON object:
24
35
  # {"number":N,"state":"OPEN|MERGED|CLOSED|NONE",
25
36
  # "draft":true|false,"url":"..."}
@@ -61,6 +72,30 @@
61
72
  # pr-ready <number> take a PR out of draft
62
73
  # merge the PR
63
74
  # pr-list [--state open|merged|closed|all] [--limit N] [--rich]
75
+ # [--since <iso>] narrows the listing to pull
76
+ # requests the host has seen change since that
77
+ # stamp. Measured 2026-09-21 on this repository:
78
+ # one `--state all` over 933 pull requests takes
79
+ # 29 811 ms with the fields the board needs, and
80
+ # the same call over one day takes 943 ms for 3
81
+ # rows — factor 32, with every expensive field
82
+ # still included.
83
+ # THE STAMP IS THE HOST'S OWN, passed through
84
+ # byte-for-byte: a caller that re-renders it
85
+ # sends its own clock's spelling, and a client
86
+ # two seconds fast excludes the PRs updated in
87
+ # that gap from every later window — forever,
88
+ # because the window never reopens.
89
+ # GITHUB NARROWS AND BITBUCKET'S BULK LISTING
90
+ # CANNOT. `gh pr list` takes `--search`; `bb pr
91
+ # list` has no query flag at all (verified
92
+ # against bb 1.9.0: `unknown flag: --query`), so
93
+ # only the per-branch sweep's REST `q=` can
94
+ # carry it. The bulk Bitbucket listing SAYS it
95
+ # could not narrow and answers in full, because
96
+ # a full answer reported as a delta is what
97
+ # would let a caller advance a watermark over a
98
+ # window it never applied.
64
99
  # [--repo <owner/repo>] pins the list to ONE
65
100
  # repository, exactly as pr-state and pr-merged
66
101
  # do. A checkout with remotes on two hosts lets
@@ -70,7 +105,24 @@
70
105
  # JSON lines: {"number":N,"title":"...",
71
106
  # "state":"...","head":"..."}
72
107
  # --rich adds: draft, checks, mergeable, review,
73
- # url, failing_checks — `failing_checks` names
108
+ # url, updatedAt, failing_checks —
109
+ # `updatedAt` is WHEN THE HOST LAST SAW THE PR
110
+ # CHANGE, in the host's own words and on the
111
+ # host's own clock (`updatedAt` on GitHub,
112
+ # `updated_on` on Bitbucket), "" where the CLI
113
+ # omits it. It is the field a durable store
114
+ # advances its watermark by, and it is the
115
+ # host's rather than this machine's because a
116
+ # client clock two seconds fast would exclude
117
+ # every PR updated in that gap from every later
118
+ # window — permanently, and silently.
119
+ # Measured 2026-09-21: over 937 PRs,
120
+ # `number,updatedAt` is 4715 ms against 5417 ms
121
+ # for the base fields and 18842 ms for
122
+ # `statusCheckRollup`. Asked on --rich ONLY —
123
+ # the plain arm is the cheap one and no shell
124
+ # caller reads a watermark.
125
+ # `failing_checks` names
74
126
  # WHICH checks failed, the detail `checks`
75
127
  # collapses to one word, from the same response
76
128
  # at no extra call; [] on bitbucket and wherever
@@ -718,6 +770,24 @@ bb_branch_query() { # $1=branch $2=adapter state; rest=global bb args → values
718
770
  local _br="$1" _st="$2"; shift 2
719
771
  local _q _path _out _rc
720
772
  _q="state=$(url_encode "\"$(bb_query_state "$_st")\"") AND source.branch.name=$(url_encode "\"$_br\"")"
773
+ # THE WINDOW COMPOSES WITH THE STATE FILTER AND NEVER REPLACES IT. Bitbucket
774
+ # takes ONE `q=` parameter, so a second one would silently win or lose
775
+ # depending on the host's parsing — and either way the state clause this query
776
+ # is built around would be the term at risk. `AND` is the same conjunction the
777
+ # two terms above already use.
778
+ #
779
+ # ENCODED LIKE EVERY OTHER TERM ON THIS PATH, and an ISO stamp needs it: `:`
780
+ # and `-` are not in `url_encode`'s safe set, and the block header records
781
+ # what an unencoded value costs — `/` ends the filter early, so the query asks
782
+ # about one thing and answers about another.
783
+ #
784
+ # `>=` RATHER THAN `>`, and the asymmetry with the GitHub arm is deliberate.
785
+ # Bitbucket's `updated_on` carries microseconds and GitHub's stamp does not,
786
+ # so an exclusive comparison against a truncated stamp would drop a PR updated
787
+ # inside the same second. Re-reading one row the caller already holds costs a
788
+ # row; missing one costs a change nobody ever sees again.
789
+ [ -n "$PR_LIST_SINCE" ] \
790
+ && _q="$_q AND updated_on>=$(url_encode "\"$PR_LIST_SINCE\"")"
721
791
  # The space between the two terms is encoded too; `bb api` passes the path to
722
792
  # curl verbatim and an unencoded space would truncate the request line.
723
793
  _q="${_q// /%20}"
@@ -824,6 +894,19 @@ bb_branch_sweep() { # global bb args… --state <s> --json → one JSON array
824
894
  # separator is git's guarantee rather than a hopeful convention.
825
895
  PR_LIST_BRANCHES=""
826
896
 
897
+ # The window a sweep's query carries, the host's own stamp. Empty means ask
898
+ # about everything, which is what every caller predating `--since` asks.
899
+ #
900
+ # A GLOBAL FOR `PR_LIST_BRANCHES`' REASON, and it travels the same route: the
901
+ # `pr-list` arm sets it immediately before the call, `bb_branch_sweep` passes
902
+ # through without reading it, and `bb_branch_query` composes it into the one
903
+ # `q=` Bitbucket takes. Threading it through the sweep as an argument would mean
904
+ # teaching that function a parameter it only forwards, and its header already
905
+ # refuses the mirror of that — "teaching `pr_list_states` which of its commands
906
+ # is a sweep would put a backend's shape inside the one piece of this file that
907
+ # has none."
908
+ PR_LIST_SINCE=""
909
+
827
910
  # How many branches the last sweep asked about, and how many answered.
828
911
  #
829
912
  # THE COMPLETENESS SIGNAL, AND WHY THE ARM STATES IT RATHER THAN THE SCAN
@@ -2469,6 +2552,18 @@ backend() {
2469
2552
  # written beside it.
2470
2553
  . "$here/plot-budget.sh"
2471
2554
 
2555
+ # THE MEMO IS SWEPT ON THE WAY OUT. `budget_rate` caches its reading under
2556
+ # `$PLOT_BUDGET_HOME/memo/$$` because a command substitution is a subshell and a
2557
+ # variable set inside one does not survive it — see the block above
2558
+ # `budget_rate`. A directory keyed on a pid must be removed by the process that
2559
+ # made it, or a long-lived machine accumulates one per `plot-host.sh` call.
2560
+ #
2561
+ # `EXIT` ALONE, deliberately. It runs on a normal return and on an uncaught
2562
+ # signal's default termination path is irrelevant here: the sweep is an
2563
+ # optimisation's housekeeping, and a cache that outlives one run costs a stale
2564
+ # reading at worst, which is the same staleness the memo grants by design.
2565
+ trap 'budget_memo_clear' EXIT
2566
+
2472
2567
  # WHO IS SPENDING — read from the CLI's own config, never from an API call.
2473
2568
  #
2474
2569
  # `gh api user` would answer authoritatively and cost one request against the
@@ -2912,14 +3007,77 @@ case "$op" in
2912
3007
  ;;
2913
3008
 
2914
3009
  default-branch)
3010
+ # THE HOST IS ASKED FIRST, ON BOTH BACKENDS, and `origin/HEAD` is the
3011
+ # fallback rather than the answer. `origin/HEAD` is a LOCAL CACHE WRITTEN AT
3012
+ # CLONE TIME: a default branch changed afterwards never updates it and
3013
+ # nothing in git notices. Reported 2026-09-24 — a clone whose GitHub default
3014
+ # had moved to `develop` kept `origin/HEAD → main`, the board read plans from
3015
+ # `origin/main`, and two of three plans went missing.
3016
+ #
3017
+ # THE BITBUCKET ARM ASKED THE CACHE FIRST UNTIL 2026-09-25, so on Bitbucket
3018
+ # this op answered from the very cache it exists to bypass, the two readings
3019
+ # could never disagree, and the defect was invisible there. Every caller —
3020
+ # `plot-open-pr.sh:73`, `plot-approve.sh:205`, `plot-deliver.sh:218`,
3021
+ # `plot-reap.sh:176`, `plot-release-refs.sh:162`, `/plot-idea:252` — wants
3022
+ # the branch the host calls default, which is what this now answers.
3023
+ #
3024
+ # AND THE FALLBACK WAS DEAD CODE. `git symbolic-ref … | sed … || bb …` takes
3025
+ # the PIPELINE's exit status, which is `sed`'s, and `sed` exits 0 on empty
3026
+ # input — measured 2026-09-25 against a ref that does not exist. So `bb repo
3027
+ # view` had never once been reached, and a Bitbucket clone with no
3028
+ # `origin/HEAD` printed an empty line and exited 0. Each reading is now
3029
+ # tested for a NON-EMPTY value of its own, never for a pipeline's status.
3030
+ _db=""
2915
3031
  if [ "$be" = "github" ]; then
2916
- gh repo view --json defaultBranchRef --jq '.defaultBranchRef.name'
3032
+ _db=$(gh repo view --json defaultBranchRef --jq '.defaultBranchRef.name' 2>/dev/null || true)
2917
3033
  else
2918
- # Symbolic ref of origin/HEAD is host-neutral and offline; fall back to
2919
- # the bb API only when the local clone has no origin/HEAD.
2920
- git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|^origin/||' \
2921
- || bb repo view --json 2>/dev/null | jq -r '.mainbranch.name'
3034
+ # `--json` IS A CAPABILITY, NOT A GIVEN. Two products share the name `bb`
3035
+ # and craftamap's rejects the flag (see `bb_require_json`) — against it
3036
+ # this call emits `unknown flag: --json`, `jq` reads non-JSON, and the arm
3037
+ # would answer nothing while exiting 0. This call was unguarded while it
3038
+ # was unreachable; asking the host makes it live.
3039
+ bb_require_json
3040
+ _db=$(bb repo view --json 2>/dev/null | jq -r '.mainbranch.name // empty' 2>/dev/null || true)
2922
3041
  fi
3042
+ # `null` is what `jq` prints for an absent field and it is not a branch name.
3043
+ [ "$_db" = "null" ] && _db=""
3044
+ # THE CACHE IS THE FALLBACK: a host that cannot be asked is not a reason to
3045
+ # answer nothing, since every caller above needs a usable branch name and
3046
+ # worked from this reading alone before today.
3047
+ [ -n "$_db" ] || _db=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|^origin/||')
3048
+ printf '%s\n' "$_db"
3049
+ ;;
3050
+
3051
+ account)
3052
+ # WHO IS READING, from the same free reading `budget_account` makes. It
3053
+ # costs a file read and no request, which matters because the board asks on
3054
+ # its timer. `PLOT_BUDGET_ACCOUNT` overrides it on every backend.
3055
+ #
3056
+ # THE BITBUCKET ARM IS NOT EXPOSED. `budget_account bitbucket` answers the
3057
+ # remote's owner segment, which is the WORKSPACE: on a team workspace every
3058
+ # contributor gets the same word, and it matches no PR author. Measured
3059
+ # 2026-09-25, neither `bb` stores a username for free. Quatico's keeps only
3060
+ # the Atlassian email, in the Keychain. craftamap's keeps an email in
3061
+ # `configuration.toml`. A Bitbucket PR author carries a nickname, an
3062
+ # account id and a uuid, and never an email. So this backend has no answer,
3063
+ # and exit 4 says so.
3064
+ #
3065
+ # `unknown` IS NEVER PRINTED. It groups budget lines correctly and names
3066
+ # nobody, so an unreadable `hosts.yml` exits 3 with the reason on stderr.
3067
+ _acct=''
3068
+ if [ -n "${PLOT_BUDGET_ACCOUNT:-}" ]; then
3069
+ _acct="$PLOT_BUDGET_ACCOUNT"
3070
+ elif [ "$be" = "github" ]; then
3071
+ _acct="$(budget_account github)"
3072
+ else
3073
+ echo "plot-host.sh: account: $be names no signed-in user without an API request" >&2
3074
+ exit 4
3075
+ fi
3076
+ if [ -z "$_acct" ] || [ "$_acct" = "unknown" ]; then
3077
+ echo "plot-host.sh: account: no user in ${GH_CONFIG_DIR:-$HOME/.config/gh}/hosts.yml" >&2
3078
+ exit 3
3079
+ fi
3080
+ printf '%s\n' "$_acct"
2923
3081
  ;;
2924
3082
 
2925
3083
  pr-state)
@@ -3302,12 +3460,22 @@ case "$op" in
3302
3460
  # existing caller's result changes.
3303
3461
  limit=""
3304
3462
  branches=""
3463
+ # THE WINDOW, AND IT IS THE HOST'S OWN STAMP. Empty means ask about
3464
+ # everything, which is what every caller predating this asks and what a
3465
+ # caller with no stored watermark must ask. A caller that has one sends it
3466
+ # verbatim — see the header: re-rendering it is how a window closes forever.
3467
+ since=""
3305
3468
  while [ $# -gt 0 ]; do
3306
3469
  case "$1" in
3307
3470
  --state) state="${2:?}"; shift 2 ;;
3308
3471
  --limit) limit="${2:?}"; shift 2 ;;
3309
3472
  --rich) rich=1; shift ;;
3310
3473
  --repo) repo_args=(-R "${2:?}"); shift 2 ;;
3474
+ # `${2:?}` REFUSES AN EMPTY VALUE, and that is the point rather than
3475
+ # boilerplate. `--since ""` would reach GitHub as `--search "updated:>"`,
3476
+ # a syntax error the host may answer with everything or with nothing —
3477
+ # and a caller whose watermark was null would send exactly that.
3478
+ --since) since="${2:?}"; shift 2 ;;
3311
3479
  # THE BRANCHES THE CALLER TRACKS, repeatable, and OPT-IN. Given any,
3312
3480
  # the Bitbucket arm sweeps the REST endpoint once per branch per state
3313
3481
  # instead of listing the repository; given none, every existing caller
@@ -3328,6 +3496,23 @@ case "$op" in
3328
3496
  done
3329
3497
  limit_args=()
3330
3498
  [ -n "$limit" ] && limit_args=(--limit "$limit")
3499
+ # THE WINDOW AS GITHUB TAKES IT, built once and appended at all three call
3500
+ # sites — the same argument `pr_list_call`'s header makes about the six
3501
+ # hand-applied fixes: three sites each composing their own search string is
3502
+ # three places for the next window's shape to drift.
3503
+ #
3504
+ # `updated:>` is EXCLUSIVE, and that is the safe direction here. The
3505
+ # watermark is the stamp of a row the caller has ALREADY stored, so
3506
+ # excluding it re-asks nothing; including it would re-fetch that row every
3507
+ # pass for no new information. A PR updated in the same second as the
3508
+ # watermark is the one this can miss, and the periodic full read the caller
3509
+ # is required to keep making is what corrects it.
3510
+ #
3511
+ # THE STAMP IS NOT QUOTED INSIDE THE QUERY. `gh` sends `--search`'s value as
3512
+ # one API parameter and an ISO-8601 stamp carries no space, so a quote would
3513
+ # travel to GitHub as part of the term and match nothing.
3514
+ search_args=()
3515
+ [ -n "$since" ] && search_args=(--search "updated:>$since")
3331
3516
 
3332
3517
  # --- Jenkins CI integration (orthogonal to Git host) ---
3333
3518
  # When `CI: jenkins` is configured, build status comes from Jenkins rather
@@ -3438,8 +3623,8 @@ case "$op" in
3438
3623
  # $jstatus != "ok" → Jenkins could not answer; every row `unknown`.
3439
3624
  # $jentry == null → the branch has no Jenkins job; `none`.
3440
3625
  # otherwise → the joined colour's `checks`, job named on fail.
3441
- _gh_raw="$(pr_list_call gh ${repo_args[@]+"${repo_args[@]}"} pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} \
3442
- --json number,title,state,headRefName,isDraft,mergeable,mergeStateStatus,reviewDecision,url)" || exit $?
3626
+ _gh_raw="$(pr_list_call gh ${repo_args[@]+"${repo_args[@]}"} pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} ${search_args[@]+"${search_args[@]}"} \
3627
+ --json number,title,state,headRefName,isDraft,mergeable,mergeStateStatus,reviewDecision,url,updatedAt,author)" || exit $?
3443
3628
  pr_list_report_truncation github "$limit" "$state" \
3444
3629
  "$(jq 'length' <<<"$_gh_raw" 2>/dev/null || echo 0)"
3445
3630
  printf '%s' "$_gh_raw" \
@@ -3459,6 +3644,8 @@ case "$op" in
3459
3644
  else "unknown" end),
3460
3645
  review:(.reviewDecision // ""),
3461
3646
  url:.url,
3647
+ updatedAt:(.updatedAt // ""),
3648
+ author:(.author.login // ""),
3462
3649
  failing_checks:(
3463
3650
  if $jentry != null and $jentry.checks == "failing"
3464
3651
  then [$jentry.job]
@@ -3467,8 +3654,8 @@ case "$op" in
3467
3654
  }'
3468
3655
  else
3469
3656
  # GitHub without Jenkins (or Jenkins not configured): use GitHub rollup
3470
- _gh_raw="$(pr_list_call gh ${repo_args[@]+"${repo_args[@]}"} pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} \
3471
- --json number,title,state,headRefName,isDraft,statusCheckRollup,mergeable,mergeStateStatus,reviewDecision,url)" || exit $?
3657
+ _gh_raw="$(pr_list_call gh ${repo_args[@]+"${repo_args[@]}"} pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} ${search_args[@]+"${search_args[@]}"} \
3658
+ --json number,title,state,headRefName,isDraft,statusCheckRollup,mergeable,mergeStateStatus,reviewDecision,url,updatedAt,author)" || exit $?
3472
3659
  pr_list_report_truncation github "$limit" "$state" \
3473
3660
  "$(jq 'length' <<<"$_gh_raw" 2>/dev/null || echo 0)"
3474
3661
  printf '%s' "$_gh_raw" \
@@ -3490,6 +3677,8 @@ case "$op" in
3490
3677
  else "unknown" end),
3491
3678
  review:(.reviewDecision // ""),
3492
3679
  url:.url,
3680
+ updatedAt:(.updatedAt // ""),
3681
+ author:(.author.login // ""),
3493
3682
  failing_checks:[
3494
3683
  .statusCheckRollup[]? | select((if (.conclusion // "") != "" then .conclusion else (.status // .state) end) as $c
3495
3684
  | $c=="FAILURE" or $c=="ERROR" or $c=="CANCELLED"
@@ -3498,14 +3687,20 @@ case "$op" in
3498
3687
  }'
3499
3688
  fi
3500
3689
  else
3501
- _gh_raw="$(pr_list_call gh ${repo_args[@]+"${repo_args[@]}"} pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} \
3502
- --json number,title,state,headRefName)" || exit $?
3690
+ _gh_raw="$(pr_list_call gh ${repo_args[@]+"${repo_args[@]}"} pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} ${search_args[@]+"${search_args[@]}"} \
3691
+ --json number,title,state,headRefName,author)" || exit $?
3503
3692
  pr_list_report_truncation github "$limit" "$state" \
3504
3693
  "$(jq 'length' <<<"$_gh_raw" 2>/dev/null || echo 0)"
3505
3694
  printf '%s' "$_gh_raw" \
3506
- | jq -c '.[] | {number:.number,title:.title,state:.state,head:.headRefName}'
3695
+ | jq -c '.[] | {number:.number,title:.title,state:.state,head:.headRefName,author:(.author.login // "")}'
3507
3696
  fi
3508
3697
  else
3698
+ # `author` IS THE `nickname`, the one handle field a Bitbucket user
3699
+ # object carries. Measured 2026-09-25 against bb 1.9.0: `author` holds
3700
+ # `display_name`, `nickname`, `account_id` and `uuid` and no username,
3701
+ # and on a Quatico workspace `nickname` equals the display name. GitHub's
3702
+ # arm reads `.author.login`. Both answer `""` where the host omits it.
3703
+ #
3509
3704
  # Bitbucket carries no check rollup through `bb pr list`, and no
3510
3705
  # mergeability verdict either. Rather than guess, --rich reports
3511
3706
  # checks:"unknown" and mergeable:"unknown" — a consumer must render those
@@ -3543,6 +3738,23 @@ case "$op" in
3543
3738
  # ways, which is how the six hand-applied fixes `pr_list_call` warns about
3544
3739
  # began. One assignment here; the sites are untouched but for this word.
3545
3740
  PR_LIST_BRANCHES="$branches"
3741
+ # THE WINDOW REACHES THE SWEEP AND NOT THE LISTING, and the asymmetry is
3742
+ # the CLI's rather than a choice. `bb pr list` takes `--state`, `--author`,
3743
+ # `--json` and `--jq` and no query flag at all — verified against bb 1.9.0,
3744
+ # which answers `unknown flag: --query` — so the only Bitbucket path that
3745
+ # can carry `updated_on` is `bb_branch_query`'s own REST `q=`.
3746
+ #
3747
+ # SAID RATHER THAN SWALLOWED, for the reason `--limit` two blocks up is
3748
+ # said: a caller that asked for a window and got a full listing must not
3749
+ # read the answer as a delta. It would advance its watermark over a window
3750
+ # it never applied — harmless this pass, since a full listing holds every
3751
+ # row a narrow one would, and wrong the moment the caller uses the flag to
3752
+ # decide whether its answer was complete.
3753
+ PR_LIST_SINCE="$since"
3754
+ if [ -n "$since" ] && [ -z "$branches" ]; then
3755
+ echo "plot-host: bitbucket ignores --since $since on a listing; bb pr list has no query flag (bb 1.9.0) — answering in full" >&2
3756
+ PR_LIST_SINCE=""
3757
+ fi
3546
3758
  PR_SWEEP_ASKED=0
3547
3759
  bb_cmd=(bb ${repo_args[@]+"${repo_args[@]}"} pr list)
3548
3760
  if [ -n "$branches" ]; then
@@ -3574,6 +3786,8 @@ case "$op" in
3574
3786
  mergeable:"unknown",
3575
3787
  review:"",
3576
3788
  url:(.links.html.href // ""),
3789
+ updatedAt:(.updated_on // ""),
3790
+ author:(.author.nickname // ""),
3577
3791
  failing_checks:(
3578
3792
  if $jentry != null and $jentry.checks == "failing"
3579
3793
  then [$jentry.job]
@@ -3584,13 +3798,13 @@ case "$op" in
3584
3798
  # Bitbucket without Jenkins: checks remain unknown
3585
3799
  PR_LIST_JQ_ARGS=()
3586
3800
  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:[]}' \
3801
+ '.[] | {number:.id,title:.title,state:(if .state=="DECLINED" then "CLOSED" else .state end),head:.source.branch.name,draft:(.draft // false),checks:"unknown",mergeable:"unknown",review:"",url:(.links.html.href // ""),updatedAt:(.updated_on // ""),author:(.author.nickname // ""),failing_checks:[]}' \
3588
3802
  "${bb_cmd[@]}" || exit $?
3589
3803
  fi
3590
3804
  else
3591
3805
  PR_LIST_JQ_ARGS=()
3592
3806
  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}' \
3807
+ '.[] | {number:.id,title:.title,state:(if .state=="DECLINED" then "CLOSED" else .state end),head:.source.branch.name,author:(.author.nickname // "")}' \
3594
3808
  "${bb_cmd[@]}" || exit $?
3595
3809
  fi
3596
3810
  fi
package/plot-plan-meta.sh CHANGED
@@ -107,8 +107,9 @@
107
107
  # placeholder
108
108
  # story story slug the plan belongs to (`## Status` `Story:` or
109
109
  # front matter `story:`); "" if absent or a placeholder
110
- # assignee github handle from the `## Approval` `Assignee:` line or
111
- # front matter `assignee:`; "" if absent
110
+ # assignee github handle from front matter `assignee:`, else the
111
+ # `## Approval` `Assignee:` line, else the `## Status`
112
+ # `Assignee:` line; "" if absent or a placeholder
112
113
  # branches branch names, sorted and unique, read from EITHER spelling:
113
114
  # the old `## Branches` section (a LIST ITEM whose first token
114
115
  # is the backtick-quoted name, matching the known prefixes) OR
@@ -415,7 +416,7 @@ function reset_state() {
415
416
  fm_delivered = ""; fm_design = ""
416
417
  fm_rounds = ""
417
418
  canon_state = ""; canon_phase = ""; canon_type = ""
418
- canon_sprint = ""; canon_story = ""; canon_assignee = ""
419
+ canon_sprint = ""; canon_story = ""; canon_assignee = ""; status_assignee = ""
419
420
  canon_review = ""; canon_impl = ""; canon_approved = ""; canon_released = ""
420
421
  canon_delivered = ""; canon_design = ""
421
422
  canon_rounds = ""
@@ -478,6 +479,9 @@ function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assigne
478
479
  sprint = strip_placeholder((fm_sprint != "") ? fm_sprint : canon_sprint)
479
480
  story = strip_placeholder((fm_story != "") ? fm_story : canon_story)
480
481
  assignee = strip_placeholder((fm_assignee != "") ? fm_assignee : canon_assignee)
482
+ # `## Status` is the section both templates offer; `## Approval` is older and
483
+ # outranks it, so a plan writing both keeps the answer it parsed to before.
484
+ if (fm_assignee == "" && assignee == "") assignee = status_assignee
481
485
  review = strip_placeholder((fm_review != "") ? fm_review : canon_review)
482
486
  impl = strip_placeholder((fm_impl != "") ? fm_impl : canon_impl)
483
487
  design = strip_placeholder((fm_design != "") ? fm_design : canon_design)
@@ -823,6 +827,9 @@ section == "status" {
823
827
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**issue[:*]/ && canon_issue == "") canon_issue = val_after_colon($0)
824
828
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**review[:*]/ && canon_review == "") canon_review = val_after_colon($0)
825
829
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**impl[:*]/ && canon_impl == "") canon_impl = val_after_colon($0)
830
+ # Its own slot, not `canon_assignee`: the `## Approval` line outranks this one
831
+ # in `emit_record`, whichever section comes first.
832
+ else if (lower ~ /^[ \t]*[-*]?[ \t]*\**assignee[:*]/ && status_assignee == "") status_assignee = strip_placeholder(val_after_colon($0))
826
833
  # EACH TAKES THE FIRST LINE THAT CARRIES A VALUE, NOT THE FIRST LINE.
827
834
  #
828
835
  # A plan may hold both a record and an unfilled placeholder for the same
package/plot-pr-merged.sh CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env bash
2
2
  # Plot helper: the ONE answer to "did the host merge ANY PR for this branch?"
3
3
  #
4
- # SOURCED, NOT RUN. `. "$script_dir/plot-pr-merged.sh"` defines `pr_merged` and
5
- # `pr_open`; the file does nothing else on load. That is what makes sourcing it
4
+ # SOURCED, NOT RUN. `. "$script_dir/plot-pr-merged.sh"` defines `pr_merged`,
5
+ # `pr_open` and `pr_merged_heads`; the file does nothing else on load. That is what makes sourcing it
6
6
  # safe, and it is the same shape — and the same reason — as
7
7
  # `plot-worker-state.sh`: the logic could not simply stay in `plot-reap.sh`,
8
8
  # because that file parses `$@` and `exit 2`s on an unknown argument at load
@@ -178,3 +178,30 @@ pr_open() {
178
178
  answer=$(_plot_landed none "$(_plot_open_lookup "$1")") || return 1
179
179
  case "$answer" in *" open-pr") return 0 ;; *) return 1 ;; esac
180
180
  }
181
+
182
+ # Which commits did the host merge for this branch? One `headRefOid` per merged
183
+ # PR, one per line.
184
+ #
185
+ # A THIRD QUESTION, asked by `plot-reap.sh` only. After a squash merge the
186
+ # host deletes the branch (`delete_branch_on_merge`), `git fetch --prune`
187
+ # drops the remote-tracking ref, and the branch's own commits are then on no
188
+ # remote ref at all. So "which commits did no remote ever hold?" needs the
189
+ # head the host merged: a commit reachable from it was pushed; a commit beyond
190
+ # it exists only on the desk.
191
+ #
192
+ # Returns 1 when the host cannot be asked. The caller reads that as
193
+ # `unknown`, which keeps the desk: here a failure to observe is the case that
194
+ # loses work. It prints nothing and returns 0 when the host answered and no PR
195
+ # merged.
196
+ pr_merged_heads() {
197
+ local br="$1" out
198
+ command -v gh >/dev/null 2>&1 || return 1
199
+ out=$(gh pr list --head "$br" --state all --limit 100 --json mergedAt,headRefOid 2>/dev/null) \
200
+ || return 1
201
+ printf '%s' "$out" | node -e '
202
+ let s = "";
203
+ process.stdin.on("data", (d) => { s += d; }).on("end", () => {
204
+ const rows = JSON.parse(s);
205
+ for (const r of rows) if (r.mergedAt && r.headRefOid) console.log(r.headRefOid);
206
+ });' 2>/dev/null || return 1
207
+ }