From c4079ea37c5e7fe27243ed556b4ceee0750a090f Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Thu, 23 Jul 2026 11:57:20 +0000 Subject: [PATCH] test(labels): surface-level ruling contracts on both reconcilers Issue side: invariant-1 composition, the reclaim clock stopping under a pending ruling (with a flag-free reclaim control), the stale heal, label churn invisible to the activity clock, the surface-level nudge reset, and no edit anywhere naming the flag. PR side: the wired nudge riding the stale sweep's activity computation, one nudge across two sweeps, #51's stale skip intact. Co-Authored-By: Claude Fable 5 --- test/issueflow-reconcile.test.sh | 129 +++++++++++++++++++++++++++++++ test/labels-reconcile.test.sh | 88 +++++++++++++++++++++ 2 files changed, 217 insertions(+) diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index f020f14..4f913a2 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -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 diff --git a/test/labels-reconcile.test.sh b/test/labels-reconcile.test.sh index a3cf17d..c112aec 100755 --- a/test/labels-reconcile.test.sh +++ b/test/labels-reconcile.test.sh @@ -489,5 +489,93 @@ 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" + [ -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" >>"$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 ]