Merge pull request #64 from claude-bot-andresmgsl/build/52-needs-ruling-sweep

feat(labels): needs-ruling sweep invariants — staleness skip, bare-flag check, 7-day nudge
This commit is contained in:
Daniel Marin 2026-07-23 13:41:18 +01:00 committed by GitHub
commit dfcfd45563
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 693 additions and 7 deletions

View file

@ -10,6 +10,7 @@ so entries say what changed, cite the issue, and stop.
- `needs-ruling` — the cross-cutting flag for a pending human decision, excluded from `state:needs-human` and from the staleness sweep (#51). - `needs-ruling` — the cross-cutting flag for a pending human decision, excluded from `state:needs-human` and from the staleness sweep (#51).
- Cross-repo doctrine: the panel is the PR's repo's roster, a review request is authorization but not panel membership, and `Part of <repo>#N` replaces the `Closes #N` that cannot cross repos (#57). - Cross-repo doctrine: the panel is the PR's repo's roster, a review request is authorization but not panel membership, and `Part of <repo>#N` replaces the `Closes #N` that cannot cross repos (#57).
- `actions/runner-isolated` — a `pull_request`-triggered job may never run on a self-hosted runner (#58). - `actions/runner-isolated` — a `pull_request`-triggered job may never run on a self-hosted runner (#58).
- The sweep's `needs-ruling` invariants, one implementation for both surfaces: the issue-side staleness exemption, the bare-flag check (comment-only, the label is never removed), and the 7-day nudge to the decider (#52).
## 0.1.0 — 2026-07-22 ## 0.1.0 — 2026-07-22

View file

@ -89,7 +89,15 @@ disagrees that agreement was reached, the label goes back on. The machine
reads it and never writes it: the reconciler refuses `state:needs-human` reads it and never writes it: the reconciler refuses `state:needs-human`
while it stands (the PR falls to `state:addressing` — the ball on the PR is while it stands (the PR falls to `state:addressing` — the ball on the PR is
the builder's, who carries the ruling in), and the staleness sweep skips the builder's, who carries the ruling in), and the staleness sweep skips
it, because waiting on a human is legitimately quiet. it, because waiting on a human is legitimately quiet. Quiet, but not
unwatched (#52, both surfaces): a flag set with no escalation comment from
its setter is called out by the sweep — comment-only, scoped to the labeled
event, the label never removed — and a ruling with no real activity for 7
days draws a comment-only nudge addressed to the decider, linking the
escalation. The nudge carries no marker on purpose: the comment is itself
activity, so it resets its own window and never repeats within a quiet
week. Label churn is not activity — the clock reads comments, reviews and
commits, or the sweep would reset itself.
## Scope — which surface? (PRs and issues, any number) ## Scope — which surface? (PRs and issues, any number)

View file

@ -24,6 +24,10 @@ STALE_AFTER=$((ISSUEFLOW_STALE_HOURS * 3600))
QUEUE_LABELS=(ready claimed blocked) QUEUE_LABELS=(ready claimed blocked)
TRIAGE_ACTORS=() TRIAGE_ACTORS=()
# The needs-ruling invariants (#52) — one implementation for both surfaces.
# shellcheck source=lib/ruling.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/ruling.sh"
log() { printf 'issueflow: %s\n' "$*"; } log() { printf 'issueflow: %s\n' "$*"; }
run() { if [ -n "${DRY_RUN:-}" ]; then log "DRY_RUN: $*"; else "$@"; fi; } run() { if [ -n "${DRY_RUN:-}" ]; then log "DRY_RUN: $*"; else "$@"; fi; }
@ -225,7 +229,16 @@ reconcile_issue() {
assignees="$(jq '.assignees | length' <<<"$ISSUE_JSON")" assignees="$(jq '.assignees | length' <<<"$ISSUE_JSON")"
grep -qxF "$n" <<<"${OPEN_PR_ISSUES:-}" && open_pr=true grep -qxF "$n" <<<"${OPEN_PR_ISSUES:-}" && open_pr=true
age="$(last_issue_activity "$n" "$(jq -r '.created_at' <<<"$ISSUE_JSON")")" age="$(last_issue_activity "$n" "$(jq -r '.created_at' <<<"$ISSUE_JSON")")"
if [ "$(ruling_stale_exempt <<<"$ISSUE_LABELS")" = EXEMPT ]; then
# Waiting on a human is legitimately quiet (#50 D10): the reclaim
# clock does not run under a pending ruling — the same treatment
# `blocked` gets by never reaching this branch at all. Only the clock
# stops: an unassigned claim is still a repair the decision must see,
# so it runs on a zero age rather than being skipped.
decision="$(claim_decision "$assignees" "$open_pr" 0)"
else
decision="$(claim_decision_at "$assignees" "$open_pr" "$age")" decision="$(claim_decision_at "$assignees" "$open_pr" "$age")"
fi
case "$decision" in case "$decision" in
FLAG_UNASSIGNED) FLAG_UNASSIGNED)
ensure_comment "$n" claimed-unassigned \ ensure_comment "$n" claimed-unassigned \
@ -272,6 +285,23 @@ reconcile_issue() {
log "#$n: completed epic nudged" log "#$n: completed epic nudged"
fi fi
fi fi
# ---- the ruling invariants (#52), on any queue state ----
# The flag composes with the queue labels (#50 D8), so this runs after the
# queue branches rather than inside one of them. The FLAG_CONFLICT return
# above still short-circuits it on purpose: a board lying about its queue
# state is repaired by triage before anything else is derived from it.
if has_issue_label needs-ruling; then
# An already-applied stale comes off: waiting on a human is legitimately
# quiet (#50 D10), and nothing on the issue side ever puts stale back.
if has_issue_label stale; then
run gh issue edit "$n" -R "$REPO" --remove-label stale >/dev/null
log "#$n: unstale (a ruling is pending)"
fi
[ -n "${age:-}" ] \
|| age="$(last_issue_activity "$n" "$(jq -r '.created_at' <<<"$ISSUE_JSON")")"
reconcile_ruling "$n" "$age" "$NOW"
fi
} }
reconcile_opened_issue() { reconcile_opened_issue() {

View file

@ -50,6 +50,10 @@ LABELS=""
RETIRED=(state:needs-rebase) RETIRED=(state:needs-rebase)
STALE_AFTER=$((48 * 3600)) STALE_AFTER=$((48 * 3600))
# The needs-ruling invariants (#52) — one implementation for both surfaces.
# shellcheck source=lib/ruling.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/ruling.sh"
log() { printf 'labels: %s\n' "$*"; } log() { printf 'labels: %s\n' "$*"; }
run() { # every mutation goes through here — DRY_RUN=1 logs instead of doing run() { # every mutation goes through here — DRY_RUN=1 logs instead of doing
@ -412,7 +416,7 @@ $(configured_label_rows "$LABELS_CONF")"
has_label() { grep -qxF "$1" <<<"$LABELS"; } has_label() { grep -qxF "$1" <<<"$LABELS"; }
reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
local n="$1" desired remove s args last_activity age local n="$1" desired remove s args last_activity last_activity_epoch age
desired="$(decide_state)" desired="$(decide_state)"
@ -517,7 +521,8 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
gh api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date' gh api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date'
} | sort | tail -n1 } | sort | tail -n1
)" )"
age=$((NOW - $(date -d "$last_activity" +%s))) last_activity_epoch="$(date -d "$last_activity" +%s)"
age=$((NOW - last_activity_epoch))
# needs-ruling joins blocked here: waiting on a human is legitimately quiet # needs-ruling joins blocked here: waiting on a human is legitimately quiet
# (#50 D10). The 7-day nudge is #52's, once for both surfaces. # (#50 D10). The 7-day nudge is #52's, once for both surfaces.
if has_label blocked || has_label needs-ruling || [ "$age" -le "$STALE_AFTER" ]; then if has_label blocked || has_label needs-ruling || [ "$age" -le "$STALE_AFTER" ]; then
@ -529,6 +534,15 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
run gh issue edit "$n" -R "$REPO" --add-label stale >/dev/null run gh issue edit "$n" -R "$REPO" --add-label stale >/dev/null
log "#$n: stale ($((age / 3600))h quiet)" log "#$n: stale ($((age / 3600))h quiet)"
fi fi
# ---- the ruling invariants (#52): the bare-flag check + the 7-day nudge --
# The stale EXEMPTION above is #51's; these are the sweep halves that ride
# the same real-activity computation (lib/ruling.sh, shared with the issue
# side). Behind the flag check so flag-free PRs — all of them, almost
# always — cost no extra API reads.
if has_label needs-ruling; then
reconcile_ruling "$n" "$last_activity_epoch" "$NOW"
fi
} }
main() { main() {

View file

@ -226,9 +226,12 @@ build (#15) and incubator's GHCR image push (#16).
## Labels automation ## Labels automation
The reusable labels workflow owns two independent jobs: additive path-based The reusable labels workflow owns two independent jobs: additive path-based
`scope:*` labels and reconciliation of PR state, blockers, handoff, and stale `scope:*` labels and reconciliation of PR state, blockers, handoff, stale
status. The consumer keeps its path mapping in `.github/labeler.yml` and its status, and the `needs-ruling` invariants on both surfaces — the bare-flag
review panel plus scope taxonomy in `.github/labels.conf`. check and the 7-day comment-only nudge (#52; the sweep reads that flag and
never writes it). The consumer keeps its path mapping in
`.github/labeler.yml` and its review panel plus scope taxonomy in
`.github/labels.conf`.
The complete caller is: The complete caller is:
@ -253,6 +256,8 @@ jobs:
`pull_request_target` is intentional: fork PRs need the base repository's `pull_request_target` is intentional: fork PRs need the base repository's
token to write labels. The reusable workflow executes no PR code. It checks token to write labels. The reusable workflow executes no PR code. It checks
out only the consumer's base branch and the pinned ceremony implementation. out only the consumer's base branch and the pinned ceremony implementation.
The #52 ruling invariants ride exactly these triggers — the caller above is
unchanged since #18, so adopting them is a pin bump, not a stub edit.
`.github/labels.conf` has one mandatory panel setting, one mandatory `.github/labels.conf` has one mandatory panel setting, one mandatory
`triage-actors` setting, and then zero or more scope rows: `triage-actors` setting, and then zero or more scope rows:

210
lib/ruling.sh Normal file
View file

@ -0,0 +1,210 @@
#!/usr/bin/env bash
# lib/ruling.sh — the `needs-ruling` sweep invariants (issue #52, epic #50).
#
# Both reconcilers source this file: the bare-flag check and the 7-day nudge
# are ONE implementation serving both surfaces — two copies of a 7-day rule
# is how the family got here in the first place (#50). Pure decisions sit
# above the divider (facts in, verdict out); the one impure orchestrator
# below talks to gh and posts through the sourcing script's run()/log().
#
# Standing rules this file lives under:
# - The machine never sets or clears `needs-ruling` (#50 D9). Nothing here
# mutates a label; the only writes are comments. Pinned by a sweep-probe
# test and a grep-level check over the mutation calls.
# - The machine never judges prose (#50 D4). "Did the escalation contract
# accompany the flag" is decided by a mechanical proxy: the actor who
# applied the label has a comment timestamped no earlier than 15 minutes
# before the `labeled` event. The back-window exists because the natural
# ordering is post-the-escalation-then-set-the-label, seconds apart; a
# strictly-after rule would flag every correctly-formed escalation.
# - The failure direction is always *flag*, never *act*: a bare flag is
# commented on, never removed (removing would delete somebody's
# escalation on the strength of a timestamp heuristic), and an
# unreadable timeline does nothing at all — an unreadable fact must
# never invent a verdict (the reconciler's standing rule).
# One constant, both surfaces — the whole reason this file exists. 7 days
# (#50 D10): long enough that an active back-and-forth is never nudged,
# short enough that a forgotten ruling surfaces within the week.
RULING_NUDGE_AFTER=$((7 * 24 * 3600))
# The escalation back-window: a comment this many seconds before the
# `labeled` event still accompanies it.
RULING_BARE_WINDOW=$((15 * 60))
# The idempotency marker for the bare-flag comment. The nudge deliberately
# has NO marker — see ruling_nudge_decision.
RULING_BARE_MARKER='<!-- ceremony:needs-ruling-bare -->'
# ---------------------------------------------------------------------------
# Pure decisions. Facts in (args/stdin), verdict out. No gh, no clock.
# ---------------------------------------------------------------------------
ruling_stale_exempt() { # labels on stdin → EXEMPT | SWEEP
# Waiting on a human is legitimately quiet (#50 D10): under a pending
# ruling the staleness clock does not run, the same treatment `blocked`
# gets. The PR-side skip is #51's and lives in labels-reconcile.sh; this
# verdict drives the ISSUE side (the claim-reclaim clock), so the rule has
# one spelling even though the two surfaces consult it in different places.
if grep -qxF needs-ruling; then echo EXEMPT; else echo SWEEP; fi
}
ruling_accompanies() { # $1 comment epoch, $2 labeled epoch → 0 iff in-window
# One spelling of the window rule, shared by the bare verdict and the
# escalation-comment lookup the nudge links — two comparisons drifting
# apart would let the nudge link a comment the bare check rejected.
[ "$1" -ge "$(($2 - RULING_BARE_WINDOW))" ]
}
ruling_bare_decision() { # $1 setter, $2 labeled epoch; "login epoch" lines on stdin
# → ACCOMPANIED | BARE. Only the flag-setter's own comments count: the
# escalation contract (question, options, recommendation — #50 D4) is the
# flag-setter's to post, and somebody else's chatter must not satisfy it.
local setter="$1" labeled="$2" login epoch
while read -r login epoch; do
[ -n "$login" ] || continue
[ "$login" = "$setter" ] || continue
if ruling_accompanies "$epoch" "$labeled"; then
echo ACCOMPANIED
return 0
fi
done
echo BARE
}
ruling_bare_comment_needed() { # $1 labeled epoch, $2 newest marked-comment epoch ("" = none)
# → POST | SKIP. Scoped to the CURRENT labeled event: a marked comment
# older than the event belongs to an earlier flag episode, so a genuine
# re-flag is re-checked while a 15-minute cron never repeats itself.
local labeled="$1" marked="${2:-}"
if [ -n "$marked" ] && [ "$marked" -gt "$labeled" ]; then
echo SKIP
else
echo POST
fi
}
ruling_nudge_decision() { # $1 now, $2 last real-activity epoch → NUDGE | KEEP
# Real activity only — comments, reviews, commits, never label churn, or
# the sweep would reset its own clock. The nudge needs NO marker: the
# nudge comment is itself activity, so posting it resets this window and
# the rule self-rate-limits to at most one nudge per 7 quiet days. That is
# deliberate — a later refactor that "fixes" it by adding a marker breaks
# exactly the property that makes it safe on a 15-minute cron.
if [ "$(($1 - $2))" -gt "$RULING_NUDGE_AFTER" ]; then echo NUDGE; else echo KEEP; fi
}
ruling_newest_flag() { # "login<TAB>iso8601" lines on stdin → the newest line
# A re-flag after a removal is judged on its own escalation, never on the
# last one's — so every fact anchors to the MOST RECENT labeled event.
# ISO-8601 UTC sorts lexically, so no date parsing is needed here.
sort -t $'\t' -k2 | tail -n1
}
ruling_escalation_url() { # $1 setter, $2 labeled epoch; "login epoch url" lines on stdin
# → the url of the EARLIEST in-window comment by the setter, or nothing.
# Earliest, because the natural shape is escalation-then-flag: the first
# qualifying comment is the escalation itself, later ones are follow-ups.
local setter="$1" labeled="$2" login epoch url best_epoch="" best_url=""
while read -r login epoch url; do
[ -n "$login" ] || continue
[ "$login" = "$setter" ] || continue
ruling_accompanies "$epoch" "$labeled" || continue
if [ -z "$best_epoch" ] || [ "$epoch" -lt "$best_epoch" ]; then
best_epoch="$epoch"
best_url="$url"
fi
done
[ -z "$best_url" ] || printf '%s\n' "$best_url"
}
# ---------------------------------------------------------------------------
# The impure orchestrator: fetch the facts, call the decisions, post through
# the caller's run(). Called by both reconcilers for every open item that
# carries the flag. Needs REPO; uses the caller's run() and log().
# ---------------------------------------------------------------------------
reconcile_ruling() { # $1 item number, $2 last real-activity epoch, $3 now
local n="$1" last_activity="$2" now="$3"
: "${REPO:?reconcile_ruling: REPO is required}"
# The newest `labeled` event for the flag: actor + timestamp. A failed read
# skips BOTH checks — the nudge's specified content links the escalation
# comment, which only these facts identify, and half-verdicts on half-read
# facts is the exact shape the reconciler's standing rule forbids.
local flags newest setter labeled_at labeled_epoch
if ! flags="$(gh api --paginate "repos/$REPO/issues/$n/timeline" \
--jq '.[] | select(.event == "labeled" and .label.name == "needs-ruling")
| [.actor.login, .created_at] | @tsv' 2>/dev/null)"; then
log "#$n: ruling timeline unreadable — no verdict invented this pass"
return 0
fi
if [ -z "$flags" ]; then
# The label is on the item but no labeled event is visible (a timeline
# hiccup, or an import). Same treatment as unreadable: do nothing.
log "#$n: ruling flag has no visible labeled event — no verdict invented this pass"
return 0
fi
newest="$(ruling_newest_flag <<<"$flags")"
setter="${newest%%$'\t'*}"
labeled_at="${newest##*$'\t'}"
labeled_epoch="$(date -d "$labeled_at" +%s)"
local comments
if ! comments="$(gh api --paginate "repos/$REPO/issues/$n/comments" \
--jq '.[] | [.user.login, .created_at, .html_url,
(if ((.body // "") | contains("<!-- ceremony:needs-ruling-bare -->"))
then "marked" else "plain" end)] | @tsv' 2>/dev/null)"; then
log "#$n: ruling comments unreadable — no verdict invented this pass"
return 0
fi
# One pass over the comments builds every fact the decisions consume:
# who commented when (for the bare verdict), the newest marked comment
# (for idempotency), and the "login epoch url" rows the link lookup reads.
local login at url kind epoch authored="" rows="" marked=""
while IFS=$'\t' read -r login at url kind; do
[ -n "$login" ] || continue
epoch="$(date -d "$at" +%s)"
authored="$authored$login $epoch"$'\n'
rows="$rows$login $epoch $url"$'\n'
if [ "$kind" = marked ]; then
if [ -z "$marked" ] || [ "$epoch" -gt "$marked" ]; then marked="$epoch"; fi
fi
done <<<"$comments"
# ---- the bare-flag check (#50 D4, mechanical proxy) ----
if [ "$(ruling_bare_decision "$setter" "$labeled_epoch" <<<"$authored")" = BARE ] \
&& [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked")" = POST ]; then
run gh issue comment "$n" -R "$REPO" --body "$RULING_BARE_MARKER
The ruling flag on this item was set by @$setter with no accompanying
escalation comment. Setting it requires the escalation contract — the
**question**, the **options**, and a **recommendation** — posted by the
flag-setter no more than 15 minutes before applying the label, or any time
after ([LABELS.md](https://github.com/heavy-duty/ceremony/blob/main/LABELS.md)
carries the flag-setter's obligations; heavy-duty/ceremony#50 D4). The label stays — this machine never removes an
escalation on the strength of a timestamp heuristic — but the contract is
still owed." >/dev/null
log "#$n: ruling flag is bare — commented (the label is never removed)"
fi
# ---- the 7-day nudge (#50 D10) ----
if [ "$(ruling_nudge_decision "$now" "$last_activity")" = NUDGE ]; then
# The decider is the repo's human reviewer — the same knob the PR
# reconciler trusts for the merge gate, defaulted the same way. The
# flag-setter is named but deliberately not tagged: address the decider,
# never the whole thread's cast (#50 D10).
local decider="${HUMAN_REVIEWER:-danmt}" days esc_url esc_line
days=$(((now - last_activity) / 86400))
esc_url="$(ruling_escalation_url "$setter" "$labeled_epoch" <<<"$rows")"
if [ -n "$esc_url" ]; then
esc_line="The escalation is here: $esc_url"
else
esc_line="No escalation comment accompanies the flag — the contract (question, options, recommendation) is still owed by the flag-setter."
fi
run gh issue comment "$n" -R "$REPO" --body "@$decider — a ruling on this item has been pending with no activity for ${days} days. $esc_line
Per heavy-duty/ceremony#50 D6/D7 the flag-setter ($setter) owns closing this out: judge when agreement is reached, record the ruling as a decision in one comment, remove the label, and return the item to its flow in that same comment.
*This nudge is comment-only and carries no idempotency marker on purpose: the comment itself is activity, so posting it resets the 7-day window and the rule self-rate-limits. Do not add a marker.*" >/dev/null
log "#$n: ruling nudge (${days}d quiet — the decider owes an answer)"
fi
}

View file

@ -149,4 +149,133 @@ check "completed epic is nudged" 0 "NUDGE" epic_decision "$epic_refs" $'CLOSED\n
check "open epic child suppresses nudge" 0 "KEEP" epic_decision "$epic_refs" $'CLOSED\nOPEN' check "open epic child suppresses nudge" 0 "KEEP" epic_decision "$epic_refs" $'CLOSED\nOPEN'
check "epic without parseable children is stable" 0 "KEEP" epic_decision "" "" check "epic without parseable children is stable" 0 "KEEP" epic_decision "" ""
# Invariant 1 keeps ignoring the ruling flag (#50 D8): it composes with the
# queue labels and is not one of them.
check "claimed plus a pending ruling is a healthy issue" 0 "KEEP" \
queue_decision <<< $'claimed\nneeds-ruling'
check "a ruling flag alone is still invariant 1's violation" 0 "ADD_NEEDS_TRIAGE" \
queue_decision <<< $'needs-ruling'
# ---------------------------------------------------------------------------
# The ruling pass on the issue surface (#52), against a recording stub: the
# reclaim clock stops under a pending ruling, an applied stale heals off,
# label churn is not activity, the nudge resets on its own comment, and no
# edit anywhere names the flag (#50 D9). The stub serves fixture JSON per
# endpoint with the caller's --jq applied by real jq, appends posted comments
# back into the fixture (a second sweep sees the first one's writes), and
# records every label edit.
# ---------------------------------------------------------------------------
INOW=2000000000
iso_at() { date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ; }
issue_stub_gh() {
if [ "$1" = api ]; then
shift
local jqexpr="" endpoint="" file
while [ $# -gt 0 ]; do
case "$1" in
--jq) jqexpr="$2"; shift ;;
-*) ;;
*) [ -n "$endpoint" ] || endpoint="$1" ;;
esac
shift
done
file="$TMP/$(printf '%s' "$endpoint" | tr '/' '_').json"
[ -f "$file" ] || { printf '[]\n'; return 0; }
if [ -n "$jqexpr" ]; then jq -r "$jqexpr" "$file"; else cat "$file"; fi
elif [ "$1" = issue ] && [ "$2" = comment ]; then
local n="$3" body="" file
shift 3
while [ $# -gt 0 ]; do
case "$1" in --body) body="$2"; shift ;; esac
shift
done
printf '%s\n----\n' "$body" >>"$TMP/posted-$n"
file="$TMP/repos_owner_repo_issues_${n}_comments.json"
[ -f "$file" ] || printf '[]\n' >"$file"
jq --arg b "$body" --arg at "$(iso_at "$INOW")" \
'. + [{"user":{"login":"sweep-bot"},"created_at":$at,"html_url":"https://x/posted","body":$b}]' \
"$file" >"$file.tmp" && mv "$file.tmp" "$file"
elif [ "$1" = issue ] && [ "$2" = edit ]; then
printf '%s\n' "$*" >>"$TMP/issue-edits"
fi
}
issue_probe() { # $1 = issue number, $2 = labels → reconcile_issue's log lines
(
REPO=owner/repo NOW="$INOW"
ISSUE_LABELS="$2"
ISSUE_JSON="$(jq -n --arg at "$(iso_at $((INOW - 10 * 86400)))" \
'{created_at: $at, assignees: [{login: "owner-bot"}], body: ""}')"
OPEN_PR_ISSUES=""
run() { "$@"; }
gh() { issue_stub_gh "$@"; }
reconcile_issue "$1" 2>&1
)
}
tfix() { printf '%s/repos_owner_repo_issues_%s_timeline.json' "$TMP" "$1"; }
cfix() { printf '%s/repos_owner_repo_issues_%s_comments.json' "$TMP" "$1"; }
# -- the reclaim clock stops under a pending ruling (48h quiet, no PR) -------
jq -n --arg l "$(iso_at $((INOW - 10 * 86400)))" \
'[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$l},
{"event":"assigned","created_at":$l}]' >"$(tfix 21)"
jq -n --arg at "$(iso_at $((INOW - 10 * 86400 - 60)))" \
'[{"user":{"login":"setter"},"created_at":$at,"html_url":"https://x/esc21","body":"question, options, recommendation"}]' \
>"$(cfix 21)"
exempt="$(issue_probe 21 $'claimed\nneeds-ruling')"
check "a 10-day-quiet claim under a ruling is not reclaimed" 1 "" \
grep -q 'reclaimed' <<<"$exempt"
check "...the same silence still nudges the pending ruling" 0 "" \
grep -q 'ruling nudge' <<<"$exempt"
# shellcheck disable=SC2016 # expansions belong to the isolated bash -c process
check "...and the nudge went to the decider with the escalation linked" 0 "" \
bash -c 'grep -qF "@danmt" "$1" && grep -qF "https://x/esc21" "$1"' _ "$TMP/posted-21"
again="$(issue_probe 21 $'claimed\nneeds-ruling')"
check "the sweep right after the nudge holds its silence" 1 "" \
grep -q 'ruling nudge' <<<"$again"
check "exactly one nudge across both sweeps" 0 "1" \
grep -c -- '^----$' "$TMP/posted-21"
# -- control: the same silence without the flag is reclaimed -----------------
jq -n --arg l "$(iso_at $((INOW - 10 * 86400)))" \
'[{"event":"assigned","created_at":$l}]' >"$(tfix 22)"
printf '[]\n' >"$(cfix 22)"
control="$(issue_probe 22 claimed)"
check "the flag-free control is reclaimed (the clock still runs elsewhere)" 0 "" \
grep -q 'stale claim reclaimed -> ready' <<<"$control"
# -- an already-applied stale heals off, and no edit names the flag ----------
jq -n --arg l "$(iso_at $((INOW - 3600)))" \
'[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$l}]' >"$(tfix 23)"
jq -n --arg at "$(iso_at $((INOW - 3660)))" \
'[{"user":{"login":"setter"},"created_at":$at,"html_url":"https://x/esc23","body":"question, options, recommendation"}]' \
>"$(cfix 23)"
healed="$(issue_probe 23 $'claimed\nneeds-ruling\nstale')"
check "an applied stale comes off under a pending ruling" 0 "" \
grep -q 'unstale (a ruling is pending)' <<<"$healed"
check "...via an edit that removes exactly stale" 0 "" \
grep -q -- '--remove-label stale' "$TMP/issue-edits"
check "no issue edit across every probe names the ruling flag (#50 D9)" 1 "" \
grep -q 'needs-ruling' "$TMP/issue-edits"
# -- label churn is not activity: the nudge clock reads comments, not labels --
jq -n --arg flag "$(iso_at $((INOW - 8 * 86400)))" \
--arg churn "$(iso_at $((INOW - 2 * 86400)))" \
--arg assigned "$(iso_at $((INOW - 9 * 86400)))" \
'[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$flag},
{"event":"labeled","label":{"name":"priority"},"actor":{"login":"anyone"},"created_at":$churn},
{"event":"assigned","created_at":$assigned}]' >"$(tfix 24)"
jq -n --arg at "$(iso_at $((INOW - 8 * 86400 - 60)))" \
'[{"user":{"login":"setter"},"created_at":$at,"html_url":"https://x/esc24","body":"question, options, recommendation"}]' \
>"$(cfix 24)"
churn_last="$( (REPO=owner/repo; gh() { issue_stub_gh "$@"; }
last_issue_activity 24 "$(iso_at $((INOW - 10 * 86400)))") )"
check "last activity ignores the 2-day-old label churn" 0 "" \
test "$churn_last" = "$((INOW - 8 * 86400 - 60))"
churned="$(issue_probe 24 $'claimed\nneeds-ruling')"
check "8 real-quiet days nudge through a 2-day-old label churn" 0 "" \
grep -q 'ruling nudge' <<<"$churned"
summary summary

View file

@ -1,5 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# Byte-wise collation, matching CI: the probes sort ISO timestamps, and the
# verdict must not flip with the runner's ambient locale.
export LC_ALL=C
# Fixture tests for the labels-reconcile state machine: a comment is a # Fixture tests for the labels-reconcile state machine: a comment is a
# non-verdict whatever its body says (the AUTHOR escalates by requesting the # non-verdict whatever its body says (the AUTHOR escalates by requesting the
@ -489,5 +492,95 @@ unstale="$(ruling_probe "$(printf 'needs-ruling\nstale')")"
expect "...and an already-applied stale comes off" \ expect "...and an already-applied stale comes off" \
yes "$(grep -q 'unstale' <<<"$unstale" && echo yes || echo no)" yes "$(grep -q 'unstale' <<<"$unstale" && echo yes || echo no)"
# ---------------------------------------------------------------------------
# The ruling pass on the PR surface (#52): the bare-flag check and the 7-day
# nudge ride reconcile_pr behind the flag, on the same real-activity
# computation the stale sweep reads. A recording stub serves the API facts:
# fixture JSON per endpoint (with the caller's --jq applied by real jq),
# posted comments appended back into the fixture so a second sweep sees the
# first one's writes, and every label edit recorded.
# ---------------------------------------------------------------------------
RTMP="$(mktemp -d)"
trap 'rm -rf "$RTMP"' EXIT
iso_at() { date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ; }
RNOW=2000000000
ruling_sweep_probe() { # $1 = the PR's labels → reconcile_pr's log lines
(
REPO_LABELS="$(printf 'state:addressing\nstate:needs-human\nmerge-next\nstale\nneeds-ruling')"
REPO=owner/repo NOW="$RNOW"
LABELS="$1"
DRAFT=false HEAD_SHA=head1 REQUESTED=""
# Approvals submitted 8 days ago — the newest real activity anywhere.
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" APPROVED head1 "" "$(iso_at $((RNOW - 8 * 86400)))")" \
"$(rev "$BOT2" APPROVED head1 "" "$(iso_at $((RNOW - 8 * 86400)))")" \
"$(rev "$BOT3" APPROVED head1 "" "$(iso_at $((RNOW - 8 * 86400)))")")"
MERGEABLE=MERGEABLE CHECKS=SUCCESS
PR_JSON="$(jq -n --arg at "$(iso_at $((RNOW - 10 * 86400)))" '{created_at: $at}')"
run() { "$@"; } # mutations reach the stub and are recorded, not swallowed
gh() {
if [ "$1" = api ]; then
shift
local jqexpr="" endpoint="" file
while [ $# -gt 0 ]; do
case "$1" in
--jq) jqexpr="$2"; shift ;;
-*) ;;
*) [ -n "$endpoint" ] || endpoint="$1" ;;
esac
shift
done
file="$RTMP/$(printf '%s' "$endpoint" | tr '/' '_').json"
# A missing fixture is an empty collection — projected through the
# caller's --jq exactly like real gh, so '.[].foo' yields no lines.
[ -f "$file" ] || { printf '[]\n' | jq -r "${jqexpr:-.}"; return 0; }
if [ -n "$jqexpr" ]; then jq -r "$jqexpr" "$file"; else cat "$file"; fi
elif [ "$1" = issue ] && [ "$2" = comment ]; then
local n="$3" body="" file
shift 3
while [ $# -gt 0 ]; do
case "$1" in --body) body="$2"; shift ;; esac
shift
done
printf '%s\n----\n' "$body" >>"$RTMP/posted-$n"
file="$RTMP/repos_owner_repo_issues_${n}_comments.json"
[ -f "$file" ] || printf '[]\n' >"$file"
jq --arg b "$body" --arg at "$(iso_at "$RNOW")" \
'. + [{"user":{"login":"sweep-bot"},"created_at":$at,"html_url":"https://x/posted","body":$b}]' \
"$file" >"$file.tmp" && mv "$file.tmp" "$file"
elif [ "$1" = issue ] && [ "$2" = edit ]; then
printf '%s\n' "$*" >>"$RTMP/edits"
fi
}
reconcile_pr 77 2>&1
)
}
# The flag went up 8 days ago with its escalation posted seconds earlier.
jq -n --arg at "$(iso_at $((RNOW - 8 * 86400)))" \
'[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$at}]' \
>"$RTMP/repos_owner_repo_issues_77_timeline.json"
jq -n --arg at "$(iso_at $((RNOW - 8 * 86400 - 60)))" \
'[{"user":{"login":"setter"},"created_at":$at,"html_url":"https://x/esc77","body":"question, options, recommendation"}]' \
>"$RTMP/repos_owner_repo_issues_77_comments.json"
wired="$(ruling_sweep_probe "needs-ruling")"
expect "8 quiet days under a ruling nudges on the PR surface" \
yes "$(grep -q 'ruling nudge' <<<"$wired" && echo yes || echo no)"
expect "...while the quiet stays stale-free (#51's skip intact)" \
no "$(grep -q 'stale (' <<<"$wired" && echo yes || echo no)"
expect "...the accompanied flag is not called bare" \
no "$(grep -q 'ruling flag is bare' <<<"$wired" && echo yes || echo no)"
expect "the nudge addressed the decider and linked the escalation" \
yes "$(grep -qF '@danmt' "$RTMP/posted-77" && grep -qF 'https://x/esc77' "$RTMP/posted-77" && echo yes || echo no)"
again="$(ruling_sweep_probe "needs-ruling")"
expect "the sweep right after the nudge holds its silence — the comment reset the window" \
no "$(grep -q 'ruling nudge' <<<"$again" && echo yes || echo no)"
expect "exactly one nudge across both sweeps" \
1 "$(grep -c '^----$' "$RTMP/posted-77")"
expect "no label edit across both sweeps names the ruling flag" \
no "$(grep -q 'needs-ruling' "$RTMP/edits" 2>/dev/null && echo yes || echo no)"
printf 'labels-reconcile tests: %d passed, %d failed\n' "$pass" "$fail" printf 'labels-reconcile tests: %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ] [ "$fail" -eq 0 ]

196
test/ruling.test.sh Normal file
View file

@ -0,0 +1,196 @@
#!/usr/bin/env bash
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh
source "$ROOT/test/harness.sh"
# shellcheck source=lib/ruling.sh
source "$ROOT/lib/ruling.sh"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
# ---------------------------------------------------------------------------
# Pure decisions. Epochs are arbitrary fixed numbers — no wall clock anywhere.
# ---------------------------------------------------------------------------
check "needs-ruling is stale-exempt" 0 "EXEMPT" ruling_stale_exempt <<< $'claimed\nneeds-ruling'
check "a flag-free issue sweeps normally" 0 "SWEEP" ruling_stale_exempt <<< $'claimed'
L=100000 # the labeled event, in every window case below
check "escalation 14 minutes before the flag accompanies it" 0 "ACCOMPANIED" \
ruling_bare_decision setter "$L" <<<"setter $((L - 840))"
check "escalation exactly 15 minutes before still accompanies (no earlier than)" 0 "ACCOMPANIED" \
ruling_bare_decision setter "$L" <<<"setter $((L - 900))"
check "escalation after the flag accompanies it" 0 "ACCOMPANIED" \
ruling_bare_decision setter "$L" <<<"setter $((L + 60))"
check "escalation 16 minutes before is bare" 0 "BARE" \
ruling_bare_decision setter "$L" <<<"setter $((L - 960))"
check "somebody else's comment does not satisfy the contract" 0 "BARE" \
ruling_bare_decision setter "$L" <<<"bystander $((L - 60))"
check "no comment at all is bare" 0 "BARE" \
ruling_bare_decision setter "$L" </dev/null
# Re-flag: every fact anchors to the NEWEST labeled event, so an escalation
# that accompanied the first flag does not satisfy the second.
L2=$((L + 7200))
newest="$(ruling_newest_flag <<< "$(printf 'setter\t2020-01-01T00:00:00Z\nsetter\t2020-01-01T02:00:00Z\n')")"
check "the newest labeled event wins" 0 "" test "$newest" = "$(printf 'setter\t2020-01-01T02:00:00Z')"
check "first-flag escalation does not satisfy a re-flag" 0 "BARE" \
ruling_bare_decision setter "$L2" <<<"setter $((L - 60))"
check "no marked comment yet posts" 0 "POST" ruling_bare_comment_needed "$L" ""
check "a marked comment newer than the event skips" 0 "SKIP" ruling_bare_comment_needed "$L" $((L + 300))
check "a marked comment from an earlier flag episode re-posts" 0 "POST" ruling_bare_comment_needed "$L2" $((L + 300))
NOW=2000000000
check "8 days of silence nudges" 0 "NUDGE" ruling_nudge_decision "$NOW" $((NOW - 8 * 86400))
check "6 days of silence holds" 0 "KEEP" ruling_nudge_decision "$NOW" $((NOW - 6 * 86400))
check "exactly 7 days holds — strictly past the horizon, like the stale sweep" 0 "KEEP" \
ruling_nudge_decision "$NOW" $((NOW - 7 * 86400))
check "fresh activity holds" 0 "KEEP" ruling_nudge_decision "$NOW" $((NOW - 60))
rows="$(printf 'setter %s https://x/first\nsetter %s https://x/late\nbystander %s https://x/other\nsetter %s https://x/early-out\n' \
"$((L - 300))" "$((L + 600))" "$((L - 60))" "$((L - 5000))")"
check "the nudge links the earliest in-window escalation by the setter" 0 "https://x/first" \
ruling_escalation_url setter "$L" <<<"$rows"
check "no qualifying escalation yields no link" 0 "" \
ruling_escalation_url setter "$L" <<<"bystander $((L - 60)) https://x/other"
# ---------------------------------------------------------------------------
# The orchestrator, against a recording gh stub. The stub serves fixture JSON
# per endpoint (missing file = unreadable read), applies the caller's --jq
# with real jq, appends posted comments back into the fixture (so a second
# sweep sees the first sweep's writes, exactly like the live board), and
# records every `gh issue edit` — which must never happen from this code.
# ---------------------------------------------------------------------------
REPO=owner/repo
log() { printf 'test-sweep: %s\n' "$*"; }
run() { "$@"; }
iso() { date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ; }
gh() {
if [ "$1" = api ]; then
shift
local jqexpr="" endpoint="" file
while [ $# -gt 0 ]; do
case "$1" in
--jq) jqexpr="$2"; shift ;;
-*) ;;
*) [ -n "$endpoint" ] || endpoint="$1" ;;
esac
shift
done
file="$TMP/$(printf '%s' "$endpoint" | tr '/' '_').json"
[ -f "$file" ] || return 1
if [ -n "$jqexpr" ]; then jq -r "$jqexpr" "$file"; else cat "$file"; fi
elif [ "$1" = issue ] && [ "$2" = comment ]; then
local n="$3" body="" file
shift 3
while [ $# -gt 0 ]; do
case "$1" in --body) body="$2"; shift ;; esac
shift
done
printf '%s\n----\n' "$body" >>"$TMP/posted-$n"
file="$TMP/repos_${REPO%%/*}_${REPO#*/}_issues_${n}_comments.json"
[ -f "$file" ] || printf '[]\n' >"$file"
jq --arg b "$body" --arg at "$(iso "$NOW")" \
'. + [{"user":{"login":"sweep-bot"},"created_at":$at,"html_url":"https://x/posted","body":$b}]' \
"$file" >"$file.tmp" && mv "$file.tmp" "$file"
elif [ "$1" = issue ] && [ "$2" = edit ]; then
printf '%s\n' "$*" >>"$TMP/edits"
fi
}
posts() { [ -f "$TMP/posted-$1" ] && grep -c '^----$' "$TMP/posted-$1" || echo 0; }
timeline_file() { printf '%s/repos_owner_repo_issues_%s_timeline.json' "$TMP" "$1"; }
comments_file() { printf '%s/repos_owner_repo_issues_%s_comments.json' "$TMP" "$1"; }
# -- bare flag, swept twice: exactly one comment -----------------------------
T=$((NOW - 3600))
jq -n --arg at "$(iso "$T")" \
'[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$at}]' \
>"$(timeline_file 9)"
printf '[]\n' >"$(comments_file 9)"
check "a bare flag is commented on" 0 "ruling flag is bare" reconcile_ruling 9 "$T" "$NOW"
check "...and the sweep never posted twice" 0 "" reconcile_ruling 9 "$T" "$NOW"
check "one bare comment across two sweeps" 0 "1" posts 9
check "the bare comment carries its marker" 0 "" \
grep -qF '<!-- ceremony:needs-ruling-bare -->' "$TMP/posted-9"
check "the bare comment names the missing contract" 0 "" \
grep -q 'question' "$TMP/posted-9"
# -- accompanied flag: silence ----------------------------------------------
jq -n --arg at "$(iso "$T")" \
'[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$at}]' \
>"$(timeline_file 10)"
jq -n --arg at "$(iso "$((T - 840))")" \
'[{"user":{"login":"setter"},"created_at":$at,"html_url":"https://x/esc","body":"question, options, recommendation"}]' \
>"$(comments_file 10)"
reconcile_ruling 10 "$T" "$NOW" >/dev/null
check "an accompanied flag posts nothing" 0 "0" posts 10
# -- re-flag: judged on its own escalation, marker scoped per event ----------
T1=$((NOW - 86400)) T2=$((NOW - 7200))
jq -n --arg t1 "$(iso "$T1")" --arg t2 "$(iso "$T2")" \
'[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$t1},
{"event":"unlabeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$t2},
{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$t2}]' \
>"$(timeline_file 11)"
jq -n --arg esc "$(iso "$((T1 - 60))")" --arg marked "$(iso "$((T1 + 300))")" \
'[{"user":{"login":"setter"},"created_at":$esc,"html_url":"https://x/esc1","body":"the first escalation"},
{"user":{"login":"sweep-bot"},"created_at":$marked,"html_url":"https://x/bare1","body":"<!-- ceremony:needs-ruling-bare -->\nolder episode"}]' \
>"$(comments_file 11)"
reconcile_ruling 11 "$T2" "$NOW" >/dev/null
check "a re-flag is re-checked against its own escalation" 0 "1" posts 11
# -- nudge: fires past 7 quiet days, links the escalation, resets itself -----
T0=$((NOW - 8 * 86400))
jq -n --arg at "$(iso "$T0")" \
'[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$at}]' \
>"$(timeline_file 12)"
jq -n --arg at "$(iso "$((T0 - 60))")" \
'[{"user":{"login":"setter"},"created_at":$at,"html_url":"https://x/esc12","body":"question, options, recommendation"}]' \
>"$(comments_file 12)"
check "8 quiet days nudge" 0 "ruling nudge" reconcile_ruling 12 "$T0" "$NOW"
check "one nudge posted" 0 "1" posts 12
check "the nudge links the escalation comment" 0 "" grep -qF 'https://x/esc12' "$TMP/posted-12"
check "the nudge addresses the decider" 0 "" grep -qF '@danmt' "$TMP/posted-12"
check "the nudge does not tag the flag-setter" 1 "" grep -qF '@setter' "$TMP/posted-12"
check "the nudge carries no marker — the comment itself resets the window" 1 "" \
grep -qF "$RULING_BARE_MARKER" "$TMP/posted-12"
# The reset, through the surfaces' own activity computation: the nudge the
# stub appended is the newest comment, so the recomputed last-activity is NOW.
newest_at="$(jq -r 'map(.created_at) | max' "$(comments_file 12)")"
check "the posted nudge is now the newest activity" 0 "" \
test "$(date -d "$newest_at" +%s)" = "$NOW"
reconcile_ruling 12 "$(date -d "$newest_at" +%s)" "$NOW" >/dev/null
check "a sweep right after the nudge holds its silence" 0 "1" posts 12
# -- 6 quiet days: silence ---------------------------------------------------
jq -n --arg at "$(iso "$((NOW - 6 * 86400))")" \
'[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$at}]' \
>"$(timeline_file 13)"
jq -n --arg at "$(iso "$((NOW - 6 * 86400))")" \
'[{"user":{"login":"setter"},"created_at":$at,"html_url":"https://x/esc13","body":"question, options, recommendation"}]' \
>"$(comments_file 13)"
reconcile_ruling 13 $((NOW - 6 * 86400)) "$NOW" >/dev/null
check "6 quiet days do not nudge" 0 "0" posts 13
# -- unreadable timeline: nothing happens ------------------------------------
check "an unreadable timeline invents no verdict" 0 "timeline unreadable" \
reconcile_ruling 14 "$T" "$NOW"
check "...and posts nothing" 0 "0" posts 14
# -- across every scenario above: not one label write ------------------------
check "the ruling sweep never wrote a label" 1 "" test -f "$TMP/edits"
# Grep-level pin for #50 D9: no mutation call in the sweep code names the
# flag. The only writes reconcile_ruling makes are comments.
check "no add/remove-label mutation names the ruling flag" 1 "" \
grep -rEn -- '(add|remove)-label[^"]*needs-ruling' "$ROOT/actions" "$ROOT/lib"
summary