feat(labels): needs-ruling sweep invariants — staleness skip, bare-flag check, 7-day nudge #64
9 changed files with 693 additions and 7 deletions
|
|
@ -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).
|
||||
- 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).
|
||||
- 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
|
||||
|
||||
|
|
|
|||
10
LABELS.md
10
LABELS.md
|
|
@ -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`
|
||||
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
|
||||
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@ STALE_AFTER=$((ISSUEFLOW_STALE_HOURS * 3600))
|
|||
QUEUE_LABELS=(ready claimed blocked)
|
||||
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' "$*"; }
|
||||
run() { if [ -n "${DRY_RUN:-}" ]; then log "DRY_RUN: $*"; else "$@"; fi; }
|
||||
|
||||
|
|
@ -225,7 +229,16 @@ reconcile_issue() {
|
|||
assignees="$(jq '.assignees | length' <<<"$ISSUE_JSON")"
|
||||
grep -qxF "$n" <<<"${OPEN_PR_ISSUES:-}" && open_pr=true
|
||||
age="$(last_issue_activity "$n" "$(jq -r '.created_at' <<<"$ISSUE_JSON")")"
|
||||
decision="$(claim_decision_at "$assignees" "$open_pr" "$age")"
|
||||
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")"
|
||||
fi
|
||||
case "$decision" in
|
||||
FLAG_UNASSIGNED)
|
||||
ensure_comment "$n" claimed-unassigned \
|
||||
|
|
@ -272,6 +285,23 @@ reconcile_issue() {
|
|||
log "#$n: completed epic nudged"
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -50,6 +50,10 @@ LABELS=""
|
|||
RETIRED=(state:needs-rebase)
|
||||
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' "$*"; }
|
||||
|
||||
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"; }
|
||||
|
||||
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)"
|
||||
|
||||
|
|
@ -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'
|
||||
} | 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
|
||||
# (#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
|
||||
|
|
@ -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
|
||||
log "#$n: stale ($((age / 3600))h quiet)"
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -226,9 +226,12 @@ build (#15) and incubator's GHCR image push (#16).
|
|||
## Labels automation
|
||||
|
||||
The reusable labels workflow owns two independent jobs: additive path-based
|
||||
`scope:*` labels and reconciliation of PR state, blockers, handoff, and stale
|
||||
status. The consumer keeps its path mapping in `.github/labeler.yml` and its
|
||||
review panel plus scope taxonomy in `.github/labels.conf`.
|
||||
`scope:*` labels and reconciliation of PR state, blockers, handoff, stale
|
||||
status, and the `needs-ruling` invariants on both surfaces — the bare-flag
|
||||
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:
|
||||
|
||||
|
|
@ -253,6 +256,8 @@ jobs:
|
|||
`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
|
||||
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
|
||||
`triage-actors` setting, and then zero or more scope rows:
|
||||
|
|
|
|||
210
lib/ruling.sh
Normal file
210
lib/ruling.sh
Normal 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
|
||||
}
|
||||
|
|
@ -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 "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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
#!/usr/bin/env bash
|
||||
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
|
||||
# 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" \
|
||||
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"
|
||||
[ "$fail" -eq 0 ]
|
||||
|
|
|
|||
196
test/ruling.test.sh
Normal file
196
test/ruling.test.sh
Normal 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
|
||||
Loading…
Reference in a new issue