@plot-pm/board 0.14.3 → 0.14.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/board-server.mjs +125 -124
- package/package.json +1 -1
- package/plot-budget.sh +166 -1
- package/plot-host.sh +138 -7
- package/plot-reap.sh +37 -10
package/package.json
CHANGED
package/plot-budget.sh
CHANGED
|
@@ -221,7 +221,7 @@ BUDGET_FALLBACK_WINDOW_MS=3600000
|
|
|
221
221
|
# which is a reading about whichever pool was spent last — so a caller deciding
|
|
222
222
|
# whether a bucket is spent must name that bucket. `graphql_budget_spent` does,
|
|
223
223
|
# and this is why.
|
|
224
|
-
|
|
224
|
+
budget_rate_read() {
|
|
225
225
|
local connector="${1:-}" account="${2:-}" bucket="${3:-}" now="${4:-}"
|
|
226
226
|
local path
|
|
227
227
|
[ -n "$now" ] || now="$(budget_now_ms)"
|
|
@@ -321,6 +321,171 @@ budget_rate() {
|
|
|
321
321
|
' "$path" 2>/dev/null || echo '{"spent":0,"spanMs":0,"perHour":null,"lines":0,"unreadable":0,"limit":null,"remaining":null,"resetAt":null,"basis":"unknown"}'
|
|
322
322
|
}
|
|
323
323
|
|
|
324
|
+
# THE SAME ANSWER IS SCANNED FOR ONCE PER PROCESS, and that is the whole of this
|
|
325
|
+
# memo. Measured on `quatico/quaweb-website` at Plot 2.19.0: `budget.tsv` holds
|
|
326
|
+
# 312589 lines across 17.6 MB, one read of it takes **516 ms** against 5 ms over
|
|
327
|
+
# fifty lines, and a single `pr-list` calls this three times for the same answer.
|
|
328
|
+
# The file is append-only and a process that asks twice is asking about the same
|
|
329
|
+
# window, so the second scan buys nothing a caller can observe.
|
|
330
|
+
#
|
|
331
|
+
# THE MEMO IS A FILE, AND A VARIABLE ALONE COULD NOT HAVE WORKED. Every caller
|
|
332
|
+
# in `plot-host.sh` writes `rate="$(budget_rate ...)"`, and a command
|
|
333
|
+
# substitution is a SUBSHELL: a variable the function sets inside one dies when
|
|
334
|
+
# the substitution closes. Measured on this branch with a counter — three
|
|
335
|
+
# substituted calls on one key scanned the ledger 3 times with a variable memo
|
|
336
|
+
# in place, against 1 for three direct calls. A variable memo is not slow at the
|
|
337
|
+
# call sites that exist, it is ABSENT from them, and every behavioural test
|
|
338
|
+
# still passes because the three answers are identical.
|
|
339
|
+
#
|
|
340
|
+
# `$$`, NOT `BASHPID`, IS THE SCOPE. `$$` is the invoking shell's pid and is
|
|
341
|
+
# deliberately NOT updated inside a subshell, so it names the process TREE —
|
|
342
|
+
# which is exactly "the life of the process" this memo is bounded by. `BASHPID`
|
|
343
|
+
# tracks the fork and would give every substitution its own empty cache, which
|
|
344
|
+
# is the variable memo's defect with an extra file write.
|
|
345
|
+
#
|
|
346
|
+
# THE COST IS PAID BACK ABOUT TWO THOUSAND TIMES. Measured here: a builtin
|
|
347
|
+
# `read` of one memo line is 82 us and a write 153 us, against 188 ms for one
|
|
348
|
+
# scan of a 40000-line ledger and the 516 ms reported above. The read is a
|
|
349
|
+
# builtin with no fork, which is what keeps it in that range.
|
|
350
|
+
#
|
|
351
|
+
# PER PROCESS IS THE WHOLE BOUND. There is no TTL, no `stat` of the record and
|
|
352
|
+
# no invalidation hook, because a `plot-host.sh` invocation is short-lived and a
|
|
353
|
+
# memo that tried to notice the file moving would re-`stat` on every call — the
|
|
354
|
+
# cost this exists to remove, re-introduced in a smaller form. `budget_append`
|
|
355
|
+
# writes between two reads and the second read is deliberately served the first
|
|
356
|
+
# one's answer.
|
|
357
|
+
#
|
|
358
|
+
# THE KEY IS THE TRIPLE, NEVER ONE FIELD OF IT. An EMPTY bucket means *every
|
|
359
|
+
# bucket* and is a different question from any named one — `budget_rate_read`
|
|
360
|
+
# says so in its own words above, and the two live in one process: measured with
|
|
361
|
+
# a probe, one `pr-list` asks `(github,jwloka,'')` for the concurrency bound and
|
|
362
|
+
# `(github,jwloka,graphql)` for the transport choice. A memo keyed on less than
|
|
363
|
+
# the triple answers the first with the second's reading.
|
|
364
|
+
#
|
|
365
|
+
# AN EXPLICIT `now` BYPASSES THE MEMO ENTIRELY, and a reader will ask why. It is
|
|
366
|
+
# a FOURTH question rather than a fourth key: a caller naming a moment is asking
|
|
367
|
+
# what the record looked like THEN, and every window boundary above is computed
|
|
368
|
+
# from it. Keying on it would make every lookup a miss, because the callers that
|
|
369
|
+
# omit it get `budget_now_ms()` and differ by milliseconds — a memo that is dead
|
|
370
|
+
# code and still passes every behavioural test. Serving a memo across different
|
|
371
|
+
# `now` values would answer a named moment with another one's window. Only the
|
|
372
|
+
# callers that omit it — every one in `plot-host.sh` — are memoised.
|
|
373
|
+
#
|
|
374
|
+
# NOT-YET-COMPUTED AND COMPUTED-TO-EMPTY ARE TWO STATES, and the FILE'S
|
|
375
|
+
# EXISTENCE is what separates them. The zero object is a well-formed answer for
|
|
376
|
+
# a missing record and for a `budget_path` that failed, so it is CACHED like any
|
|
377
|
+
# other — a memo that treated it as no-answer-worth-keeping would restore the
|
|
378
|
+
# scan on exactly the machines with nothing to scan. The entry is never empty:
|
|
379
|
+
# `budget_rate_read` prints a JSON object on every path, so a zero-byte entry
|
|
380
|
+
# means a torn write and is re-read rather than served.
|
|
381
|
+
#
|
|
382
|
+
# PUBLISHED BY `mv`, NEVER BY `>`, for `budget_slot_acquire`'s reason one
|
|
383
|
+
# paragraph down: a redirect creates the NAME before the CONTENT, so a sibling
|
|
384
|
+
# subshell can open the file and read half an answer. The rename is atomic
|
|
385
|
+
# within a directory, so the name and the answer arrive together.
|
|
386
|
+
budget_rate() {
|
|
387
|
+
local connector="${1:-}" account="${2:-}" bucket="${3:-}" now="${4:-}"
|
|
388
|
+
|
|
389
|
+
# A caller that named a moment is asking a different question. Straight
|
|
390
|
+
# through, neither read nor written.
|
|
391
|
+
if [ -n "$now" ]; then
|
|
392
|
+
budget_rate_read "$connector" "$account" "$bucket" "$now"
|
|
393
|
+
return $?
|
|
394
|
+
fi
|
|
395
|
+
|
|
396
|
+
local key slot
|
|
397
|
+
key="${connector}|${account}|${bucket}"
|
|
398
|
+
# Every character a variable name may not hold becomes `_`. Two distinct
|
|
399
|
+
# triples could collide only by differing in punctuation alone, which no
|
|
400
|
+
# connector, account or bucket name does.
|
|
401
|
+
slot="_budget_rate_memo_$(printf '%s' "$key" | LC_ALL=C tr -c '[:alnum:]_' '_')"
|
|
402
|
+
|
|
403
|
+
# TIER ONE, FREE: the same shell asking twice. SET, NOT NON-EMPTY — the zero
|
|
404
|
+
# object is a real answer and an empty one is a state this memo never stores.
|
|
405
|
+
if eval "[ -n \"\${${slot}+set}\" ]"; then
|
|
406
|
+
eval "printf '%s\\n' \"\${${slot}}\""
|
|
407
|
+
return 0
|
|
408
|
+
fi
|
|
409
|
+
|
|
410
|
+
# TIER TWO, 82 us: a subshell asking what its parent already asked. This is
|
|
411
|
+
# the tier that fires at every call site `plot-host.sh` actually has.
|
|
412
|
+
local dir file answer
|
|
413
|
+
dir="$(budget_memo_dir)" || dir=''
|
|
414
|
+
if [ -n "$dir" ]; then
|
|
415
|
+
file="$dir/$slot"
|
|
416
|
+
# `-s`, NOT `-f`. A zero-byte entry is a torn write, never an answer.
|
|
417
|
+
if [ -s "$file" ]; then
|
|
418
|
+
IFS= read -r answer < "$file" 2>/dev/null || answer=''
|
|
419
|
+
if [ -n "$answer" ]; then
|
|
420
|
+
eval "${slot}=\$answer"
|
|
421
|
+
printf '%s\n' "$answer"
|
|
422
|
+
return 0
|
|
423
|
+
fi
|
|
424
|
+
fi
|
|
425
|
+
fi
|
|
426
|
+
|
|
427
|
+
local rc
|
|
428
|
+
# THE EXIT CODE IS THE READ'S, never this wrapper's. `budget_rate_read`
|
|
429
|
+
# returns 0 on every path today and its callers test the CONTENT, so a memo
|
|
430
|
+
# that invented an exit status would be the one place the two disagree.
|
|
431
|
+
answer="$(budget_rate_read "$connector" "$account" "$bucket")"; rc=$?
|
|
432
|
+
if [ "$rc" -ne 0 ]; then
|
|
433
|
+
# A read that failed is not an answer, so nothing is remembered: the next
|
|
434
|
+
# caller asks again rather than inheriting a failure for the process life.
|
|
435
|
+
printf '%s\n' "$answer"
|
|
436
|
+
return "$rc"
|
|
437
|
+
fi
|
|
438
|
+
eval "${slot}=\$answer"
|
|
439
|
+
# NEVER FAILS ITS CALLER, `budget_append`'s rule for the same reason: the memo
|
|
440
|
+
# is an optimisation beside an answer that is already correct, so a cache that
|
|
441
|
+
# cannot be written must not turn a good reading into a failed one.
|
|
442
|
+
if [ -n "$dir" ] && mkdir -p "$dir" 2>/dev/null; then
|
|
443
|
+
if printf '%s\n' "$answer" >"$file.$BASHPID.tmp" 2>/dev/null; then
|
|
444
|
+
mv -f "$file.$BASHPID.tmp" "$file" 2>/dev/null || rm -f "$file.$BASHPID.tmp" 2>/dev/null || true
|
|
445
|
+
fi
|
|
446
|
+
fi
|
|
447
|
+
printf '%s\n' "$answer"
|
|
448
|
+
return 0
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
# Where THIS PROCESS's memoised rates live, and it is deliberately not beside
|
|
452
|
+
# the record. `$PLOT_BUDGET_HOME/memo/<pid>` — same override as the record and
|
|
453
|
+
# the slots, so a test pointing `PLOT_BUDGET_HOME` at a sandbox gets a sandboxed
|
|
454
|
+
# cache too, and a `$PLOT_BUDGET_HOME` that cannot be resolved means no cache
|
|
455
|
+
# rather than a cache in the wrong place.
|
|
456
|
+
#
|
|
457
|
+
# `$$` IS THE DIRECTORY NAME because it is the one identifier that is stable
|
|
458
|
+
# across a command substitution — see `budget_rate` above, where that property
|
|
459
|
+
# is the whole reason this file exists. A pid is reused by the kernel after the
|
|
460
|
+
# process ends, so an entry could in principle be inherited by a later,
|
|
461
|
+
# unrelated process holding the same pid. That is bounded by `budget_memo_clear`
|
|
462
|
+
# below, which the adapter calls on exit, and it is why the cache holds a
|
|
463
|
+
# DERIVED reading rather than anything a caller could act on irreversibly: the
|
|
464
|
+
# worst case is one stale rate, which is the same staleness the memo grants
|
|
465
|
+
# within a process by design.
|
|
466
|
+
budget_memo_dir() {
|
|
467
|
+
local home="${PLOT_BUDGET_HOME:-}"
|
|
468
|
+
if [ -z "$home" ]; then
|
|
469
|
+
[ -n "${HOME:-}" ] || return 1
|
|
470
|
+
home="$HOME/.plot/state"
|
|
471
|
+
fi
|
|
472
|
+
printf '%s\n' "$home/memo/$$"
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
# Removes this process's memo directory. Called on exit by the adapter, so a
|
|
476
|
+
# long-lived machine does not accumulate one directory per `plot-host.sh` call.
|
|
477
|
+
#
|
|
478
|
+
# NEVER FAILS ITS CALLER. It runs in a trap beside work that has already
|
|
479
|
+
# happened, and a cache that cannot be cleared must not change an exit status.
|
|
480
|
+
budget_memo_clear() {
|
|
481
|
+
local dir
|
|
482
|
+
dir="$(budget_memo_dir)" || return 0
|
|
483
|
+
case "$dir" in
|
|
484
|
+
*/memo/[0-9]*) rm -rf "$dir" 2>/dev/null || true ;;
|
|
485
|
+
esac
|
|
486
|
+
return 0
|
|
487
|
+
}
|
|
488
|
+
|
|
324
489
|
# ── The concurrency bound ────────────────────────────────────────────────────
|
|
325
490
|
#
|
|
326
491
|
# HOW MANY CALLS THIS ACCOUNT HAS OPEN AT ONCE, bounded across PROCESSES. The
|
package/plot-host.sh
CHANGED
|
@@ -61,6 +61,30 @@
|
|
|
61
61
|
# pr-ready <number> take a PR out of draft
|
|
62
62
|
# merge the PR
|
|
63
63
|
# pr-list [--state open|merged|closed|all] [--limit N] [--rich]
|
|
64
|
+
# [--since <iso>] narrows the listing to pull
|
|
65
|
+
# requests the host has seen change since that
|
|
66
|
+
# stamp. Measured 2026-09-21 on this repository:
|
|
67
|
+
# one `--state all` over 933 pull requests takes
|
|
68
|
+
# 29 811 ms with the fields the board needs, and
|
|
69
|
+
# the same call over one day takes 943 ms for 3
|
|
70
|
+
# rows — factor 32, with every expensive field
|
|
71
|
+
# still included.
|
|
72
|
+
# THE STAMP IS THE HOST'S OWN, passed through
|
|
73
|
+
# byte-for-byte: a caller that re-renders it
|
|
74
|
+
# sends its own clock's spelling, and a client
|
|
75
|
+
# two seconds fast excludes the PRs updated in
|
|
76
|
+
# that gap from every later window — forever,
|
|
77
|
+
# because the window never reopens.
|
|
78
|
+
# GITHUB NARROWS AND BITBUCKET'S BULK LISTING
|
|
79
|
+
# CANNOT. `gh pr list` takes `--search`; `bb pr
|
|
80
|
+
# list` has no query flag at all (verified
|
|
81
|
+
# against bb 1.9.0: `unknown flag: --query`), so
|
|
82
|
+
# only the per-branch sweep's REST `q=` can
|
|
83
|
+
# carry it. The bulk Bitbucket listing SAYS it
|
|
84
|
+
# could not narrow and answers in full, because
|
|
85
|
+
# a full answer reported as a delta is what
|
|
86
|
+
# would let a caller advance a watermark over a
|
|
87
|
+
# window it never applied.
|
|
64
88
|
# [--repo <owner/repo>] pins the list to ONE
|
|
65
89
|
# repository, exactly as pr-state and pr-merged
|
|
66
90
|
# do. A checkout with remotes on two hosts lets
|
|
@@ -70,7 +94,24 @@
|
|
|
70
94
|
# JSON lines: {"number":N,"title":"...",
|
|
71
95
|
# "state":"...","head":"..."}
|
|
72
96
|
# --rich adds: draft, checks, mergeable, review,
|
|
73
|
-
# url, failing_checks —
|
|
97
|
+
# url, updatedAt, failing_checks —
|
|
98
|
+
# `updatedAt` is WHEN THE HOST LAST SAW THE PR
|
|
99
|
+
# CHANGE, in the host's own words and on the
|
|
100
|
+
# host's own clock (`updatedAt` on GitHub,
|
|
101
|
+
# `updated_on` on Bitbucket), "" where the CLI
|
|
102
|
+
# omits it. It is the field a durable store
|
|
103
|
+
# advances its watermark by, and it is the
|
|
104
|
+
# host's rather than this machine's because a
|
|
105
|
+
# client clock two seconds fast would exclude
|
|
106
|
+
# every PR updated in that gap from every later
|
|
107
|
+
# window — permanently, and silently.
|
|
108
|
+
# Measured 2026-09-21: over 937 PRs,
|
|
109
|
+
# `number,updatedAt` is 4715 ms against 5417 ms
|
|
110
|
+
# for the base fields and 18842 ms for
|
|
111
|
+
# `statusCheckRollup`. Asked on --rich ONLY —
|
|
112
|
+
# the plain arm is the cheap one and no shell
|
|
113
|
+
# caller reads a watermark.
|
|
114
|
+
# `failing_checks` names
|
|
74
115
|
# WHICH checks failed, the detail `checks`
|
|
75
116
|
# collapses to one word, from the same response
|
|
76
117
|
# at no extra call; [] on bitbucket and wherever
|
|
@@ -718,6 +759,24 @@ bb_branch_query() { # $1=branch $2=adapter state; rest=global bb args → values
|
|
|
718
759
|
local _br="$1" _st="$2"; shift 2
|
|
719
760
|
local _q _path _out _rc
|
|
720
761
|
_q="state=$(url_encode "\"$(bb_query_state "$_st")\"") AND source.branch.name=$(url_encode "\"$_br\"")"
|
|
762
|
+
# THE WINDOW COMPOSES WITH THE STATE FILTER AND NEVER REPLACES IT. Bitbucket
|
|
763
|
+
# takes ONE `q=` parameter, so a second one would silently win or lose
|
|
764
|
+
# depending on the host's parsing — and either way the state clause this query
|
|
765
|
+
# is built around would be the term at risk. `AND` is the same conjunction the
|
|
766
|
+
# two terms above already use.
|
|
767
|
+
#
|
|
768
|
+
# ENCODED LIKE EVERY OTHER TERM ON THIS PATH, and an ISO stamp needs it: `:`
|
|
769
|
+
# and `-` are not in `url_encode`'s safe set, and the block header records
|
|
770
|
+
# what an unencoded value costs — `/` ends the filter early, so the query asks
|
|
771
|
+
# about one thing and answers about another.
|
|
772
|
+
#
|
|
773
|
+
# `>=` RATHER THAN `>`, and the asymmetry with the GitHub arm is deliberate.
|
|
774
|
+
# Bitbucket's `updated_on` carries microseconds and GitHub's stamp does not,
|
|
775
|
+
# so an exclusive comparison against a truncated stamp would drop a PR updated
|
|
776
|
+
# inside the same second. Re-reading one row the caller already holds costs a
|
|
777
|
+
# row; missing one costs a change nobody ever sees again.
|
|
778
|
+
[ -n "$PR_LIST_SINCE" ] \
|
|
779
|
+
&& _q="$_q AND updated_on>=$(url_encode "\"$PR_LIST_SINCE\"")"
|
|
721
780
|
# The space between the two terms is encoded too; `bb api` passes the path to
|
|
722
781
|
# curl verbatim and an unencoded space would truncate the request line.
|
|
723
782
|
_q="${_q// /%20}"
|
|
@@ -824,6 +883,19 @@ bb_branch_sweep() { # global bb args… --state <s> --json → one JSON array
|
|
|
824
883
|
# separator is git's guarantee rather than a hopeful convention.
|
|
825
884
|
PR_LIST_BRANCHES=""
|
|
826
885
|
|
|
886
|
+
# The window a sweep's query carries, the host's own stamp. Empty means ask
|
|
887
|
+
# about everything, which is what every caller predating `--since` asks.
|
|
888
|
+
#
|
|
889
|
+
# A GLOBAL FOR `PR_LIST_BRANCHES`' REASON, and it travels the same route: the
|
|
890
|
+
# `pr-list` arm sets it immediately before the call, `bb_branch_sweep` passes
|
|
891
|
+
# through without reading it, and `bb_branch_query` composes it into the one
|
|
892
|
+
# `q=` Bitbucket takes. Threading it through the sweep as an argument would mean
|
|
893
|
+
# teaching that function a parameter it only forwards, and its header already
|
|
894
|
+
# refuses the mirror of that — "teaching `pr_list_states` which of its commands
|
|
895
|
+
# is a sweep would put a backend's shape inside the one piece of this file that
|
|
896
|
+
# has none."
|
|
897
|
+
PR_LIST_SINCE=""
|
|
898
|
+
|
|
827
899
|
# How many branches the last sweep asked about, and how many answered.
|
|
828
900
|
#
|
|
829
901
|
# THE COMPLETENESS SIGNAL, AND WHY THE ARM STATES IT RATHER THAN THE SCAN
|
|
@@ -2469,6 +2541,18 @@ backend() {
|
|
|
2469
2541
|
# written beside it.
|
|
2470
2542
|
. "$here/plot-budget.sh"
|
|
2471
2543
|
|
|
2544
|
+
# THE MEMO IS SWEPT ON THE WAY OUT. `budget_rate` caches its reading under
|
|
2545
|
+
# `$PLOT_BUDGET_HOME/memo/$$` because a command substitution is a subshell and a
|
|
2546
|
+
# variable set inside one does not survive it — see the block above
|
|
2547
|
+
# `budget_rate`. A directory keyed on a pid must be removed by the process that
|
|
2548
|
+
# made it, or a long-lived machine accumulates one per `plot-host.sh` call.
|
|
2549
|
+
#
|
|
2550
|
+
# `EXIT` ALONE, deliberately. It runs on a normal return and on an uncaught
|
|
2551
|
+
# signal's default termination path is irrelevant here: the sweep is an
|
|
2552
|
+
# optimisation's housekeeping, and a cache that outlives one run costs a stale
|
|
2553
|
+
# reading at worst, which is the same staleness the memo grants by design.
|
|
2554
|
+
trap 'budget_memo_clear' EXIT
|
|
2555
|
+
|
|
2472
2556
|
# WHO IS SPENDING — read from the CLI's own config, never from an API call.
|
|
2473
2557
|
#
|
|
2474
2558
|
# `gh api user` would answer authoritatively and cost one request against the
|
|
@@ -3302,12 +3386,22 @@ case "$op" in
|
|
|
3302
3386
|
# existing caller's result changes.
|
|
3303
3387
|
limit=""
|
|
3304
3388
|
branches=""
|
|
3389
|
+
# THE WINDOW, AND IT IS THE HOST'S OWN STAMP. Empty means ask about
|
|
3390
|
+
# everything, which is what every caller predating this asks and what a
|
|
3391
|
+
# caller with no stored watermark must ask. A caller that has one sends it
|
|
3392
|
+
# verbatim — see the header: re-rendering it is how a window closes forever.
|
|
3393
|
+
since=""
|
|
3305
3394
|
while [ $# -gt 0 ]; do
|
|
3306
3395
|
case "$1" in
|
|
3307
3396
|
--state) state="${2:?}"; shift 2 ;;
|
|
3308
3397
|
--limit) limit="${2:?}"; shift 2 ;;
|
|
3309
3398
|
--rich) rich=1; shift ;;
|
|
3310
3399
|
--repo) repo_args=(-R "${2:?}"); shift 2 ;;
|
|
3400
|
+
# `${2:?}` REFUSES AN EMPTY VALUE, and that is the point rather than
|
|
3401
|
+
# boilerplate. `--since ""` would reach GitHub as `--search "updated:>"`,
|
|
3402
|
+
# a syntax error the host may answer with everything or with nothing —
|
|
3403
|
+
# and a caller whose watermark was null would send exactly that.
|
|
3404
|
+
--since) since="${2:?}"; shift 2 ;;
|
|
3311
3405
|
# THE BRANCHES THE CALLER TRACKS, repeatable, and OPT-IN. Given any,
|
|
3312
3406
|
# the Bitbucket arm sweeps the REST endpoint once per branch per state
|
|
3313
3407
|
# instead of listing the repository; given none, every existing caller
|
|
@@ -3328,6 +3422,23 @@ case "$op" in
|
|
|
3328
3422
|
done
|
|
3329
3423
|
limit_args=()
|
|
3330
3424
|
[ -n "$limit" ] && limit_args=(--limit "$limit")
|
|
3425
|
+
# THE WINDOW AS GITHUB TAKES IT, built once and appended at all three call
|
|
3426
|
+
# sites — the same argument `pr_list_call`'s header makes about the six
|
|
3427
|
+
# hand-applied fixes: three sites each composing their own search string is
|
|
3428
|
+
# three places for the next window's shape to drift.
|
|
3429
|
+
#
|
|
3430
|
+
# `updated:>` is EXCLUSIVE, and that is the safe direction here. The
|
|
3431
|
+
# watermark is the stamp of a row the caller has ALREADY stored, so
|
|
3432
|
+
# excluding it re-asks nothing; including it would re-fetch that row every
|
|
3433
|
+
# pass for no new information. A PR updated in the same second as the
|
|
3434
|
+
# watermark is the one this can miss, and the periodic full read the caller
|
|
3435
|
+
# is required to keep making is what corrects it.
|
|
3436
|
+
#
|
|
3437
|
+
# THE STAMP IS NOT QUOTED INSIDE THE QUERY. `gh` sends `--search`'s value as
|
|
3438
|
+
# one API parameter and an ISO-8601 stamp carries no space, so a quote would
|
|
3439
|
+
# travel to GitHub as part of the term and match nothing.
|
|
3440
|
+
search_args=()
|
|
3441
|
+
[ -n "$since" ] && search_args=(--search "updated:>$since")
|
|
3331
3442
|
|
|
3332
3443
|
# --- Jenkins CI integration (orthogonal to Git host) ---
|
|
3333
3444
|
# When `CI: jenkins` is configured, build status comes from Jenkins rather
|
|
@@ -3438,8 +3549,8 @@ case "$op" in
|
|
|
3438
3549
|
# $jstatus != "ok" → Jenkins could not answer; every row `unknown`.
|
|
3439
3550
|
# $jentry == null → the branch has no Jenkins job; `none`.
|
|
3440
3551
|
# 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 $?
|
|
3552
|
+
_gh_raw="$(pr_list_call gh ${repo_args[@]+"${repo_args[@]}"} pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} ${search_args[@]+"${search_args[@]}"} \
|
|
3553
|
+
--json number,title,state,headRefName,isDraft,mergeable,mergeStateStatus,reviewDecision,url,updatedAt)" || exit $?
|
|
3443
3554
|
pr_list_report_truncation github "$limit" "$state" \
|
|
3444
3555
|
"$(jq 'length' <<<"$_gh_raw" 2>/dev/null || echo 0)"
|
|
3445
3556
|
printf '%s' "$_gh_raw" \
|
|
@@ -3459,6 +3570,7 @@ case "$op" in
|
|
|
3459
3570
|
else "unknown" end),
|
|
3460
3571
|
review:(.reviewDecision // ""),
|
|
3461
3572
|
url:.url,
|
|
3573
|
+
updatedAt:(.updatedAt // ""),
|
|
3462
3574
|
failing_checks:(
|
|
3463
3575
|
if $jentry != null and $jentry.checks == "failing"
|
|
3464
3576
|
then [$jentry.job]
|
|
@@ -3467,8 +3579,8 @@ case "$op" in
|
|
|
3467
3579
|
}'
|
|
3468
3580
|
else
|
|
3469
3581
|
# 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 $?
|
|
3582
|
+
_gh_raw="$(pr_list_call gh ${repo_args[@]+"${repo_args[@]}"} pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} ${search_args[@]+"${search_args[@]}"} \
|
|
3583
|
+
--json number,title,state,headRefName,isDraft,statusCheckRollup,mergeable,mergeStateStatus,reviewDecision,url,updatedAt)" || exit $?
|
|
3472
3584
|
pr_list_report_truncation github "$limit" "$state" \
|
|
3473
3585
|
"$(jq 'length' <<<"$_gh_raw" 2>/dev/null || echo 0)"
|
|
3474
3586
|
printf '%s' "$_gh_raw" \
|
|
@@ -3490,6 +3602,7 @@ case "$op" in
|
|
|
3490
3602
|
else "unknown" end),
|
|
3491
3603
|
review:(.reviewDecision // ""),
|
|
3492
3604
|
url:.url,
|
|
3605
|
+
updatedAt:(.updatedAt // ""),
|
|
3493
3606
|
failing_checks:[
|
|
3494
3607
|
.statusCheckRollup[]? | select((if (.conclusion // "") != "" then .conclusion else (.status // .state) end) as $c
|
|
3495
3608
|
| $c=="FAILURE" or $c=="ERROR" or $c=="CANCELLED"
|
|
@@ -3498,7 +3611,7 @@ case "$op" in
|
|
|
3498
3611
|
}'
|
|
3499
3612
|
fi
|
|
3500
3613
|
else
|
|
3501
|
-
_gh_raw="$(pr_list_call gh ${repo_args[@]+"${repo_args[@]}"} pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} \
|
|
3614
|
+
_gh_raw="$(pr_list_call gh ${repo_args[@]+"${repo_args[@]}"} pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} ${search_args[@]+"${search_args[@]}"} \
|
|
3502
3615
|
--json number,title,state,headRefName)" || exit $?
|
|
3503
3616
|
pr_list_report_truncation github "$limit" "$state" \
|
|
3504
3617
|
"$(jq 'length' <<<"$_gh_raw" 2>/dev/null || echo 0)"
|
|
@@ -3543,6 +3656,23 @@ case "$op" in
|
|
|
3543
3656
|
# ways, which is how the six hand-applied fixes `pr_list_call` warns about
|
|
3544
3657
|
# began. One assignment here; the sites are untouched but for this word.
|
|
3545
3658
|
PR_LIST_BRANCHES="$branches"
|
|
3659
|
+
# THE WINDOW REACHES THE SWEEP AND NOT THE LISTING, and the asymmetry is
|
|
3660
|
+
# the CLI's rather than a choice. `bb pr list` takes `--state`, `--author`,
|
|
3661
|
+
# `--json` and `--jq` and no query flag at all — verified against bb 1.9.0,
|
|
3662
|
+
# which answers `unknown flag: --query` — so the only Bitbucket path that
|
|
3663
|
+
# can carry `updated_on` is `bb_branch_query`'s own REST `q=`.
|
|
3664
|
+
#
|
|
3665
|
+
# SAID RATHER THAN SWALLOWED, for the reason `--limit` two blocks up is
|
|
3666
|
+
# said: a caller that asked for a window and got a full listing must not
|
|
3667
|
+
# read the answer as a delta. It would advance its watermark over a window
|
|
3668
|
+
# it never applied — harmless this pass, since a full listing holds every
|
|
3669
|
+
# row a narrow one would, and wrong the moment the caller uses the flag to
|
|
3670
|
+
# decide whether its answer was complete.
|
|
3671
|
+
PR_LIST_SINCE="$since"
|
|
3672
|
+
if [ -n "$since" ] && [ -z "$branches" ]; then
|
|
3673
|
+
echo "plot-host: bitbucket ignores --since $since on a listing; bb pr list has no query flag (bb 1.9.0) — answering in full" >&2
|
|
3674
|
+
PR_LIST_SINCE=""
|
|
3675
|
+
fi
|
|
3546
3676
|
PR_SWEEP_ASKED=0
|
|
3547
3677
|
bb_cmd=(bb ${repo_args[@]+"${repo_args[@]}"} pr list)
|
|
3548
3678
|
if [ -n "$branches" ]; then
|
|
@@ -3574,6 +3704,7 @@ case "$op" in
|
|
|
3574
3704
|
mergeable:"unknown",
|
|
3575
3705
|
review:"",
|
|
3576
3706
|
url:(.links.html.href // ""),
|
|
3707
|
+
updatedAt:(.updated_on // ""),
|
|
3577
3708
|
failing_checks:(
|
|
3578
3709
|
if $jentry != null and $jentry.checks == "failing"
|
|
3579
3710
|
then [$jentry.job]
|
|
@@ -3584,7 +3715,7 @@ case "$op" in
|
|
|
3584
3715
|
# Bitbucket without Jenkins: checks remain unknown
|
|
3585
3716
|
PR_LIST_JQ_ARGS=()
|
|
3586
3717
|
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:[]}' \
|
|
3718
|
+
'.[] | {number:.id,title:.title,state:(if .state=="DECLINED" then "CLOSED" else .state end),head:.source.branch.name,draft:(.draft // false),checks:"unknown",mergeable:"unknown",review:"",url:(.links.html.href // ""),updatedAt:(.updated_on // ""),failing_checks:[]}' \
|
|
3588
3719
|
"${bb_cmd[@]}" || exit $?
|
|
3589
3720
|
fi
|
|
3590
3721
|
else
|
package/plot-reap.sh
CHANGED
|
@@ -385,11 +385,19 @@ manifest_for() {
|
|
|
385
385
|
reap=0; kept=0; removed=0; cleared=0; vanished=0; unplaced=0
|
|
386
386
|
printf '%-8s %-52s %s\n' "verdict" "branch" "why"
|
|
387
387
|
|
|
388
|
-
while IFS=$'\
|
|
388
|
+
while IFS=$'\037' read -r wt br prunable; do
|
|
389
389
|
[ -n "$wt" ] || continue
|
|
390
390
|
short=${br#refs/heads/}
|
|
391
391
|
[ "$wt" = "$ROOT" ] && continue
|
|
392
392
|
|
|
393
|
+
# WHAT THE REPORT CALLS THIS TREE. `short` is the branch and stays empty for
|
|
394
|
+
# a detached desk, which is the correct reading and a useless label — an
|
|
395
|
+
# operator reading a blank column cannot tell which of thirteen it names.
|
|
396
|
+
# The directory is what identifies such a tree, so the report says so and
|
|
397
|
+
# every refusal still measures `short`.
|
|
398
|
+
label=$short
|
|
399
|
+
[ -z "$label" ] && label="(detached) $(basename "$wt")"
|
|
400
|
+
|
|
393
401
|
# 4a. GIT'S OWN ANSWER THAT THE DIRECTORY IS GONE, and it is a REPORT rather
|
|
394
402
|
# than a sixth refusal. The five below each say *do not remove this* and
|
|
395
403
|
# send an operator to look; this says *there is nothing to remove and the
|
|
@@ -414,7 +422,7 @@ while IFS=$'\t' read -r wt br prunable; do
|
|
|
414
422
|
# prune stays the operator's decision — the same discipline that makes
|
|
415
423
|
# every refusal a measurement rather than an act.
|
|
416
424
|
if [ "$prunable" = "yes" ]; then
|
|
417
|
-
printf '%-8s %-52s %s\n' "vanished" "$
|
|
425
|
+
printf '%-8s %-52s %s\n' "vanished" "$label" "directory gone — 'git worktree prune' clears the entry"
|
|
418
426
|
vanished=$((vanished+1)); continue
|
|
419
427
|
fi
|
|
420
428
|
|
|
@@ -482,7 +490,7 @@ while IFS=$'\t' read -r wt br prunable; do
|
|
|
482
490
|
# population the rule excludes. It counts, because a refusal that counts is
|
|
483
491
|
# the difference between *nothing to clean* and *nothing was looked at*.
|
|
484
492
|
if [ "$unclassified" = true ]; then
|
|
485
|
-
printf '%-8s %-52s %s\n' "unknown" "$
|
|
493
|
+
printf '%-8s %-52s %s\n' "unknown" "$label" \
|
|
486
494
|
"under $(basename "$WT_ROOT")/, no worker pid and no recognised name — needs a person"
|
|
487
495
|
unplaced=$((unplaced+1)); continue
|
|
488
496
|
fi
|
|
@@ -531,6 +539,25 @@ while IFS=$'\t' read -r wt br prunable; do
|
|
|
531
539
|
merge=merged; why="merged into $DEFAULT"
|
|
532
540
|
elif [ -n "$short" ] && pr_merged "$short"; then
|
|
533
541
|
merge=merged; why="PR merged (squash)"
|
|
542
|
+
elif [ -z "$short" ] \
|
|
543
|
+
&& [ "$(git -C "$wt" rev-list --count "origin/$DEFAULT..HEAD" 2>/dev/null || echo 1)" = "0" ]; then
|
|
544
|
+
# A DETACHED DESK HAS NOTHING TO LAND, and that is a measurement rather
|
|
545
|
+
# than a weakened refusal. `plot-dispatch.sh --start` cuts a free agent's
|
|
546
|
+
# desk detached at `origin/<main>` because a free agent holds no slice, so
|
|
547
|
+
# such a tree never had a branch and can never have a PR. Reading it
|
|
548
|
+
# through `no-merged-pr` would refuse every one of them forever — the same
|
|
549
|
+
# silence this slice removes, in the opposite direction.
|
|
550
|
+
#
|
|
551
|
+
# THE READING IS `HEAD` AGAINST THE DEFAULT BRANCH, never the desk's name.
|
|
552
|
+
# Measured 2026-09-22: three `free-*` desks hold a branch and two carry
|
|
553
|
+
# live workers (pids 27820, 6542), so a prefix-keyed test reaps a running
|
|
554
|
+
# agent. Detachment is the condition; the name is not.
|
|
555
|
+
#
|
|
556
|
+
# A detached desk carrying commits falls through to `not-merged` and is
|
|
557
|
+
# kept, which is correct: somebody committed there and nothing says the
|
|
558
|
+
# work landed. The live-pid, marker and dirty refusals are all asked
|
|
559
|
+
# before this and are unaffected.
|
|
560
|
+
merge=merged; why="detached, nothing to land"
|
|
534
561
|
fi
|
|
535
562
|
|
|
536
563
|
# THE DECISION. One call, and the script holds no `if` about whether a
|
|
@@ -599,11 +626,11 @@ NODE_EOF
|
|
|
599
626
|
no-merged-pr) reason="unlanded work — no merged PR" ;;
|
|
600
627
|
*) reason="rule could not be asked — keeping" ;;
|
|
601
628
|
esac
|
|
602
|
-
printf '%-8s %-52s %s\n' "keep" "$
|
|
629
|
+
printf '%-8s %-52s %s\n' "keep" "$label" "$reason"; kept=$((kept+1)); continue
|
|
603
630
|
fi
|
|
604
631
|
|
|
605
632
|
if [ "$MAX" -gt 0 ] && [ "$reap" -ge "$MAX" ]; then
|
|
606
|
-
printf '%-8s %-52s %s\n' "keep" "$
|
|
633
|
+
printf '%-8s %-52s %s\n' "keep" "$label" "--max $MAX reached"; kept=$((kept+1)); continue
|
|
607
634
|
fi
|
|
608
635
|
|
|
609
636
|
# Resolved BEFORE the removal, because `canonical` needs the directory to
|
|
@@ -619,7 +646,7 @@ NODE_EOF
|
|
|
619
646
|
|
|
620
647
|
reap=$((reap+1))
|
|
621
648
|
if [ "$DRY" -eq 1 ]; then
|
|
622
|
-
printf '%-8s %-52s %s\n' "would" "$
|
|
649
|
+
printf '%-8s %-52s %s\n' "would" "$label" "$why${logs:+, log $logs}"
|
|
623
650
|
else
|
|
624
651
|
if git worktree remove --force "$wt" 2>/dev/null; then
|
|
625
652
|
# The worktree is gone; NOW the manifest may go. Inside the success arm
|
|
@@ -647,16 +674,16 @@ NODE_EOF
|
|
|
647
674
|
while IFS= read -r f; do rm -f "$f" 2>/dev/null; done < <(branch_log_files "$short")
|
|
648
675
|
why="$why, log removed"
|
|
649
676
|
fi
|
|
650
|
-
printf '%-8s %-52s %s\n' "reaped" "$
|
|
677
|
+
printf '%-8s %-52s %s\n' "reaped" "$label" "$why"; removed=$((removed+1))
|
|
651
678
|
else
|
|
652
|
-
printf '%-8s %-52s %s\n' "FAILED" "$
|
|
679
|
+
printf '%-8s %-52s %s\n' "FAILED" "$label" "git worktree remove refused"; kept=$((kept+1))
|
|
653
680
|
fi
|
|
654
681
|
fi
|
|
655
682
|
done < <(git worktree list --porcelain \
|
|
656
|
-
| awk '/^worktree /{ if (
|
|
683
|
+
| awk -v OFS="\037" '/^worktree /{ if (p != "") print p, br, pr; p=$2; br=""; pr="no"; next }
|
|
657
684
|
/^branch / { br=$2; next }
|
|
658
685
|
/^prunable/ { pr="yes"; next }
|
|
659
|
-
END { if (
|
|
686
|
+
END { if (p != "") print p, br, pr }')
|
|
660
687
|
|
|
661
688
|
[ "$DRY" -eq 0 ] && git worktree prune 2>/dev/null
|
|
662
689
|
|