forked from heavy-duty/ceremony
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 <noreply@anthropic.com>
581 lines
31 KiB
Bash
Executable file
581 lines
31 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
# Fixture tests for the labels-reconcile state machine: a comment is a
|
|
# non-verdict whatever its body says (the AUTHOR escalates by requesting the
|
|
# human), a stale approval does not promote unreviewed code, and an explicit
|
|
# human request outranks everything.
|
|
# Dependency-free beyond jq; no network, no daemon — pure decide_state.
|
|
|
|
cd "$(dirname "$0")/.."
|
|
# shellcheck source=actions/labels-reconcile/labels-reconcile.sh
|
|
. actions/labels-reconcile/labels-reconcile.sh
|
|
load_config .github/labels.conf
|
|
set_required_bots codex-bot-andresmgsl
|
|
|
|
# The DRAFT/HEAD_SHA/REQUESTED/REVIEWS_JSON assignments below are the state
|
|
# machine's inputs, consumed inside the sourced decide_state — not unused.
|
|
# shellcheck disable=SC2034
|
|
BOT1="${REQUIRED_BOTS[0]}" BOT2="${REQUIRED_BOTS[1]}" BOT3="${REQUIRED_BOTS[2]}"
|
|
pass=0 fail=0
|
|
|
|
expect() { # $1 = description, $2 = want, $3 = got
|
|
if [ "$2" = "$3" ]; then
|
|
pass=$((pass + 1))
|
|
else
|
|
fail=$((fail + 1))
|
|
printf 'FAIL: %s — want %s, got %s\n' "$1" "$2" "$3"
|
|
fi
|
|
}
|
|
|
|
rev() { # $1=login $2=state $3=commit $4=body $5=submitted_at → one review object
|
|
jq -n --arg u "$1" --arg s "$2" --arg c "$3" --arg b "$4" --arg t "$5" \
|
|
'{user: {login: $u}, state: $s, commit_id: $c, body: $b, submitted_at: $t}'
|
|
}
|
|
|
|
reviews() { jq -s '.' <<<"$*"; } # collect review objects into an array
|
|
|
|
# -- drafts are building, whoever is requested --------------------------------
|
|
DRAFT=true HEAD_SHA=head1 REQUESTED="" REVIEWS_JSON='[]'
|
|
expect "draft PR is building" state:building "$(decide_state)"
|
|
|
|
# -- fresh ready PR with bots requested ---------------------------------------
|
|
DRAFT=false REQUESTED="$BOT1
|
|
$BOT2
|
|
$BOT3" REVIEWS_JSON='[]'
|
|
expect "requested bots mean bots-reviewing" state:bots-reviewing "$(decide_state)"
|
|
|
|
# -- a bot that never reviewed keeps the round open ---------------------------
|
|
# With a live request that is the bots' ball; with NO request outstanding it
|
|
# is the agent's, because nothing is coming until somebody asks.
|
|
REQUESTED="$BOT3" REVIEWS_JSON="$(reviews \
|
|
"$(rev "$BOT1" APPROVED head1 "" t1)" \
|
|
"$(rev "$BOT2" APPROVED head1 "" t2)")"
|
|
expect "a missing bot WITH a live request is bots-reviewing" state:bots-reviewing "$(decide_state)"
|
|
REQUESTED=""
|
|
expect "...but with nobody asked it is the agent's ball" state:addressing "$(decide_state)"
|
|
expect "...and the blocker names the stall" blocker:unrequested "$(blockers)"
|
|
|
|
# -- a comment is a non-verdict, agreement body or not: the author escalates --
|
|
REVIEWS_JSON="$(reviews \
|
|
"$(rev "$BOT1" COMMENTED head1 "✅ **Reviewed — I agree with everything.**" t1)" \
|
|
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
|
"$(rev "$BOT3" APPROVED head1 "" t3)")"
|
|
expect "comment-only agreement still parks on the author" state:addressing "$(decide_state)"
|
|
# ...and the author's escalation — requesting the human — flips it
|
|
REQUESTED="$HUMAN"
|
|
expect "author escalation flips to needs-human" state:needs-human "$(decide_state)"
|
|
REQUESTED=""
|
|
|
|
# -- three formal approvals need no author judgment ---------------------------
|
|
REVIEWS_JSON="$(reviews \
|
|
"$(rev "$BOT1" APPROVED head1 "" t1)" \
|
|
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
|
"$(rev "$BOT3" APPROVED head1 "" t3)")"
|
|
expect "three formal approvals reach needs-human" state:needs-human "$(decide_state)"
|
|
|
|
# -- a comment WITHOUT a verdict parks the PR on the agent --------------------
|
|
REVIEWS_JSON="$(reviews \
|
|
"$(rev "$BOT1" COMMENTED head1 "🔧 Reviewed — I agree with most; feedback below." t1)" \
|
|
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
|
"$(rev "$BOT3" APPROVED head1 "" t3)")"
|
|
expect "comment without verdict is addressing" state:addressing "$(decide_state)"
|
|
|
|
# -- changes requested blocks, at any head ------------------------------------
|
|
REVIEWS_JSON="$(reviews \
|
|
"$(rev "$BOT1" CHANGES_REQUESTED old1 "blockers below" t1)" \
|
|
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
|
"$(rev "$BOT3" APPROVED head1 "" t3)")"
|
|
expect "changes-requested blocks even from an old head" state:addressing "$(decide_state)"
|
|
|
|
# -- a stale approval must not promote unreviewed code ------------------------
|
|
REVIEWS_JSON="$(reviews \
|
|
"$(rev "$BOT1" APPROVED old1 "" t1)" \
|
|
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
|
"$(rev "$BOT3" APPROVED head1 "" t3)")"
|
|
expect "stale approval is addressing (agent owes re-request)" state:addressing "$(decide_state)"
|
|
|
|
# -- a re-requested bot reopens the round even with an old approval on file ---
|
|
REQUESTED="$BOT1"
|
|
expect "re-requested bot means bots-reviewing" state:bots-reviewing "$(decide_state)"
|
|
REQUESTED=""
|
|
|
|
# -- only the LATEST review per bot counts ------------------------------------
|
|
REVIEWS_JSON="$(reviews \
|
|
"$(rev "$BOT1" CHANGES_REQUESTED head1 "blockers" t1)" \
|
|
"$(rev "$BOT1" APPROVED head1 "" t2)" \
|
|
"$(rev "$BOT2" APPROVED head1 "" t3)" \
|
|
"$(rev "$BOT3" APPROVED head1 "" t4)")"
|
|
expect "later approval supersedes earlier block" state:needs-human "$(decide_state)"
|
|
|
|
# -- an explicit human request outranks the bot rounds ------------------------
|
|
REQUESTED="$HUMAN" REVIEWS_JSON="$(reviews \
|
|
"$(rev "$BOT1" COMMENTED head1 "feedback, no verdict" t1)")"
|
|
expect "human requested outranks bots" state:needs-human "$(decide_state)"
|
|
REQUESTED=""
|
|
|
|
# -- human CHANGES_REQUESTED puts the ball back on the agent ------------------
|
|
REVIEWS_JSON="$(reviews \
|
|
"$(rev "$BOT1" APPROVED head1 "" t1)" \
|
|
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
|
"$(rev "$BOT3" APPROVED head1 "" t3)" \
|
|
"$(rev "$HUMAN" CHANGES_REQUESTED head1 "not yet" t4)")"
|
|
expect "human block with bots approving is addressing" state:addressing "$(decide_state)"
|
|
# ...and re-requesting the human hands it back to them
|
|
REQUESTED="$HUMAN"
|
|
expect "re-requested human is needs-human again" state:needs-human "$(decide_state)"
|
|
REQUESTED=""
|
|
|
|
# -- an old human comment must not wedge the handoff (codex, #85 round 3) -----
|
|
REVIEWS_JSON="$(reviews \
|
|
"$(rev "$HUMAN" COMMENTED old1 "early thoughts" t0)" \
|
|
"$(rev "$BOT1" APPROVED head1 "" t1)" \
|
|
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
|
"$(rev "$BOT3" APPROVED head1 "" t3)")"
|
|
expect "old human comment + three approvals is needs-human" state:needs-human "$(decide_state)"
|
|
expect "old human comment still needs a fresh request" needed "$(human_request_needed && echo needed || echo not-needed)"
|
|
# ...a stale human APPROVAL likewise needs a re-request for the new head
|
|
REVIEWS_JSON="$(reviews \
|
|
"$(rev "$HUMAN" APPROVED old1 "" t0)" \
|
|
"$(rev "$BOT1" APPROVED head1 "" t1)" \
|
|
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
|
"$(rev "$BOT3" APPROVED head1 "" t3)")"
|
|
expect "stale human approval needs a fresh request" needed "$(human_request_needed && echo needed || echo not-needed)"
|
|
# ...a HEAD-CURRENT human approval needs nothing more
|
|
REVIEWS_JSON="$(reviews \
|
|
"$(rev "$HUMAN" APPROVED head1 "" t0)" \
|
|
"$(rev "$BOT1" APPROVED head1 "" t1)" \
|
|
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
|
"$(rev "$BOT3" APPROVED head1 "" t3)")"
|
|
expect "head-current human approval needs no request" not-needed "$(human_request_needed && echo needed || echo not-needed)"
|
|
# ...and a live request suppresses re-requesting
|
|
REQUESTED="$HUMAN"
|
|
expect "live human request suppresses re-request" not-needed "$(human_request_needed && echo needed || echo not-needed)"
|
|
REQUESTED=""
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# #136: state:needs-human must mean "a human could merge this RIGHT NOW".
|
|
# Both cases below were observed live in this repo on 2026-07-20, and both
|
|
# showed state:needs-human while being unmergeable in different ways.
|
|
# ---------------------------------------------------------------------------
|
|
ALL_APPROVE="$(reviews \
|
|
"$(rev "$BOT1" APPROVED head1 "" t1)" \
|
|
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
|
"$(rev "$BOT3" APPROVED head1 "" t3)")"
|
|
|
|
# -- flavour 1: not mergeable. The merge button is disabled, yet the board
|
|
# said "your turn" on #119/#120/#127 for hours. The branch fact now rides
|
|
# the blocker axis; the state says whose ball it is, which is the agent's.
|
|
DRAFT=false HEAD_SHA=head1 REQUESTED="" REVIEWS_JSON="$ALL_APPROVE" MERGEABLE=CONFLICTING CHECKS=SUCCESS
|
|
expect "a CONFLICTING PR is the agent's, not the human's" state:addressing "$(decide_state)"
|
|
expect "...and says WHY on the blocker axis" blocker:conflict "$(blockers)"
|
|
REQUESTED="$HUMAN"
|
|
expect "...even with the human explicitly requested" state:addressing "$(decide_state)"
|
|
|
|
# -- red CI is the same claim, but NOT the same work: a rebase does not fix a
|
|
# failing test. Collapsing both into one needs-rebase label told the agent
|
|
# to do the wrong thing, which is why the axis split exists.
|
|
REQUESTED="" MERGEABLE=MERGEABLE CHECKS=FAILURE
|
|
expect "a red PR is the agent's" state:addressing "$(decide_state)"
|
|
expect "...and is distinguishable from a conflict" blocker:ci-red "$(blockers)"
|
|
REQUESTED="$HUMAN"
|
|
expect "...and a human request does not override red CI" state:addressing "$(decide_state)"
|
|
|
|
# -- both at once. The single-axis design could not say this at all: one label
|
|
# had to win, and the loser silently vanished off the board.
|
|
REQUESTED="" MERGEABLE=CONFLICTING CHECKS=FAILURE
|
|
expect "a conflicted AND red PR reports both blockers" "blocker:conflict
|
|
blocker:ci-red" "$(blockers)"
|
|
expect "...and is still just the agent's ball" state:addressing "$(decide_state)"
|
|
|
|
# -- UNKNOWN is NOT unmergeable. GitHub reports it for ~a minute after every
|
|
# merge while it recomputes; treating it as broken would flap every open PR
|
|
# on each merge — worse than the bug being fixed.
|
|
REQUESTED="" MERGEABLE=UNKNOWN CHECKS=PENDING
|
|
expect "UNKNOWN mergeability blocks nothing" state:needs-human "$(decide_state)"
|
|
expect "...and raises no blocker" "" "$(blockers)"
|
|
|
|
# -- blocker:unrequested — the stalled round. Nobody owes an answer because
|
|
# nobody was ever asked, yet the board read "waiting on the bots" until
|
|
# `stale` noticed 48h later.
|
|
MERGEABLE=MERGEABLE CHECKS=SUCCESS REQUESTED="" REVIEWS_JSON='[]'
|
|
expect "ready, nobody asked, nothing reviewed raises unrequested" blocker:unrequested "$(blockers)"
|
|
# ...the partial case is equally stalled: one verdict in, nobody asked for the rest
|
|
REVIEWS_JSON="$(reviews "$(rev "$BOT1" APPROVED head1 "" t1)")"
|
|
expect "one bot in, none requested is still unrequested" blocker:unrequested "$(blockers)"
|
|
# ...a STALE round with nobody asked is the same debt, and arguably worse: the
|
|
# page carries approvals that no longer describe the tree. Guarding on
|
|
# MISSING alone let this one through with no blocker at all.
|
|
REVIEWS_JSON="$(reviews \
|
|
"$(rev "$BOT1" APPROVED oldhead "" t1)" \
|
|
"$(rev "$BOT2" APPROVED oldhead "" t2)" \
|
|
"$(rev "$BOT3" APPROVED oldhead "" t3)")"
|
|
expect "a stale round with nobody asked is unrequested too" blocker:unrequested "$(blockers)"
|
|
expect "...and is still the agent's ball" state:addressing "$(decide_state)"
|
|
# ...but a live request means an answer IS coming
|
|
REVIEWS_JSON="$(reviews "$(rev "$BOT1" APPROVED head1 "" t1)")"
|
|
REQUESTED="$BOT2"
|
|
expect "a live bot request is not a stalled round" "" "$(blockers)"
|
|
# ...and a draft is exempt: the bots ignore drafts by design
|
|
DRAFT=true REQUESTED="" REVIEWS_JSON='[]'
|
|
expect "a draft with nobody asked is not stalled" "" "$(blockers)"
|
|
# ...as is an explicit human request — claiming a PR early is deliberate
|
|
DRAFT=false REQUESTED="$HUMAN"
|
|
expect "an early human claim is not a stalled round" "" "$(blockers)"
|
|
REQUESTED="" REVIEWS_JSON="$ALL_APPROVE" MERGEABLE=MERGEABLE CHECKS=SUCCESS
|
|
|
|
# -- flavour 2 (the dangerous one): mergeable, green, human requested, and
|
|
# NOBODY has reviewed this head. Observed on #119 after a rebase: every
|
|
# signal read "merge me" and nothing on the page contradicted it.
|
|
MERGEABLE=MERGEABLE CHECKS=SUCCESS REQUESTED="$HUMAN"
|
|
REVIEWS_JSON="$(reviews \
|
|
"$(rev "$BOT1" APPROVED oldhead "" t1)" \
|
|
"$(rev "$BOT2" APPROVED oldhead "" t2)" \
|
|
"$(rev "$BOT3" APPROVED oldhead "" t3)")"
|
|
expect "stale approvals outrank the human request (nobody reviewed this tree)" state:addressing "$(decide_state)"
|
|
|
|
# -- ...and a round that is BOTH unfinished and staled is still the agent's.
|
|
# Deciding inside the bot loop made this depend on BOTS order: the MISSING
|
|
# returned before any later bot's STALE was read, so the mixed round came
|
|
# out needs-human with nothing bound to the head. Pinned at both ends of
|
|
# the array, because the whole failure was one of ordering.
|
|
MERGEABLE=MERGEABLE CHECKS=SUCCESS REQUESTED="$HUMAN"
|
|
REVIEWS_JSON="$(reviews \
|
|
"$(rev "$BOT1" APPROVED oldhead "" t1)" \
|
|
"$(rev "$BOT2" APPROVED oldhead "" t2)")"
|
|
expect "stale approvals + a bot yet to review is addressing, not needs-human" \
|
|
state:addressing "$(decide_state)"
|
|
REVIEWS_JSON="$(reviews "$(rev "$BOT3" APPROVED oldhead "" t3)")"
|
|
expect "...and the same when the stale verdict is the LAST bot in BOTS" \
|
|
state:addressing "$(decide_state)"
|
|
|
|
# -- but an UNFINISHED round still yields to an explicit human request: a
|
|
# maintainer pulling a PR to themselves early is deliberate, and was the
|
|
# original precedence. MISSING differs from STALE — nobody has reviewed
|
|
# YET, versus everyone reviewed something else.
|
|
REVIEWS_JSON="$(reviews "$(rev "$BOT1" APPROVED head1 "" t1)")"
|
|
expect "an unfinished round still yields to an explicit human request" state:needs-human "$(decide_state)"
|
|
REQUESTED=""
|
|
expect "...and without that request the agent owes the ask" state:addressing "$(decide_state)"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# checks_state: the rollup classifier. It lived inline in main() for the first
|
|
# round of this PR, which is why nothing here caught it calling ERROR,
|
|
# CANCELLED and STALE green. Extracted so the enum can be pinned down.
|
|
# ---------------------------------------------------------------------------
|
|
rollup() { jq -n --argjson c "$1" '{statusCheckRollup: $c}'; }
|
|
run_() { jq -n --arg n "$1" --arg o "$2" --arg t "${3:-2026-07-20T15:00:00Z}" \
|
|
'{__typename:"CheckRun", workflowName:"ci", name:$n, conclusion:$o, completedAt:$t}'; }
|
|
ctx_() { jq -n --arg n "$1" --arg s "$2" --arg t "${3:-2026-07-20T15:00:00Z}" \
|
|
'{__typename:"StatusContext", context:$n, state:$s, createdAt:$t}'; }
|
|
|
|
expect "no checks at all is NONE" NONE "$(rollup '[]' | checks_state)"
|
|
# A failed fetch leaves no rollup KEY; a PR with no checks leaves an empty
|
|
# ARRAY. Collapsing the two let an API hiccup read as "nothing is failing" —
|
|
# the same unknown-certified-as-green shape as #136, in the one place that
|
|
# fix did not look. The caller skips an UNREADABLE PR rather than relabelling.
|
|
expect "a failed read is UNREADABLE, not NONE" UNREADABLE "$(echo '{}' | checks_state)"
|
|
expect "...and a real empty rollup is still NONE" NONE \
|
|
"$(echo '{"mergeable":"MERGEABLE","statusCheckRollup":[]}' | checks_state)"
|
|
expect "all green is SUCCESS" SUCCESS \
|
|
"$(rollup "[$(run_ a SUCCESS),$(run_ b SUCCESS)]" | checks_state)"
|
|
expect "a queued run is PENDING" PENDING \
|
|
"$(rollup "[$(run_ a SUCCESS),$(run_ b QUEUED)]" | checks_state)"
|
|
expect "a plain failure is FAILURE" FAILURE \
|
|
"$(rollup "[$(run_ a SUCCESS),$(run_ b FAILURE)]" | checks_state)"
|
|
|
|
# -- the round-1 gap: outcomes that are neither success nor pending, and that
|
|
# leave a required check unsatisfied. All three reached the old `else`.
|
|
expect "a commit status ERROR blocks" FAILURE \
|
|
"$(rollup "[$(run_ a SUCCESS),$(ctx_ lint ERROR)]" | checks_state)"
|
|
expect "a CANCELLED run blocks" FAILURE \
|
|
"$(rollup "[$(run_ a SUCCESS),$(run_ b CANCELLED)]" | checks_state)"
|
|
expect "a STALE run blocks" FAILURE \
|
|
"$(rollup "[$(run_ a SUCCESS),$(run_ b STALE)]" | checks_state)"
|
|
expect "an outcome the enum does not know blocks, it does not pass" FAILURE \
|
|
"$(rollup "[$(run_ a SUCCESS),$(run_ b SOME_FUTURE_STATE)]" | checks_state)"
|
|
|
|
# -- NEUTRAL and SKIPPED satisfy branch protection; path-filtered jobs skip
|
|
# constantly, and calling that red would park every PR on the agent.
|
|
expect "NEUTRAL and SKIPPED are not failures" SUCCESS \
|
|
"$(rollup "[$(run_ a SUCCESS),$(run_ b NEUTRAL),$(run_ c SKIPPED)]" | checks_state)"
|
|
|
|
# -- latest-wins. The rollup keeps superseded runs, so this PR's own tip
|
|
# carried a CANCELLED `scope` beside the SUCCESS `scope` that replaced it.
|
|
# Without collapsing, making CANCELLED block would strand it forever.
|
|
expect "a re-run supersedes the cancelled original" SUCCESS \
|
|
"$(rollup "[$(run_ scope CANCELLED 2026-07-20T15:19:39Z),\
|
|
$(run_ scope SUCCESS 2026-07-20T15:19:45Z)]" | checks_state)"
|
|
expect "...and the reverse order is not a re-run passing, it is one failing" FAILURE \
|
|
"$(rollup "[$(run_ scope SUCCESS 2026-07-20T15:19:39Z),\
|
|
$(run_ scope CANCELLED 2026-07-20T15:19:45Z)]" | checks_state)"
|
|
# same job name in a different workflow is a different context, not a re-run
|
|
expect "same name in another workflow does not supersede" FAILURE \
|
|
"$(rollup "[$(jq -n '{__typename:"CheckRun",workflowName:"labels",name:"scope",conclusion:"FAILURE",completedAt:"2026-07-20T15:00:00Z"}'),\
|
|
$(run_ scope SUCCESS 2026-07-20T15:19:45Z)]" | checks_state)"
|
|
|
|
# -- a run still IN FLIGHT. `run_()` cannot express this: it always carries a
|
|
# real completedAt, which is exactly why the supersede rule shipped dating
|
|
# runs by completion and nothing caught it. Both spellings of "no
|
|
# completion" are pinned, because `gh` emits the zero sentinel (a string,
|
|
# which `//` does not fall through) while the API emits null.
|
|
inflight_() { jq -n --arg n "$1" --arg t "$2" --arg c "${3:-0001-01-01T00:00:00Z}" \
|
|
'{__typename:"CheckRun", workflowName:"ci", name:$n, status:"IN_PROGRESS",
|
|
conclusion:"", startedAt:$t, completedAt:(if $c == "null" then null else $c end)}'; }
|
|
|
|
expect "a re-run in flight beats the success it superseded (zero sentinel)" PENDING \
|
|
"$(rollup "[$(run_ build SUCCESS 2026-07-20T15:00:00Z),\
|
|
$(inflight_ build 2026-07-20T15:10:00Z)]" | checks_state)"
|
|
expect "...and the same when the absent completion is null" PENDING \
|
|
"$(rollup "[$(run_ build SUCCESS 2026-07-20T15:00:00Z),\
|
|
$(inflight_ build 2026-07-20T15:10:00Z null)]" | checks_state)"
|
|
expect "a replacement in flight for a CANCELLED run is pending, not failed" PENDING \
|
|
"$(rollup "[$(run_ build CANCELLED 2026-07-20T15:00:00Z),\
|
|
$(inflight_ build 2026-07-20T15:10:00Z)]" | checks_state)"
|
|
# an entry carrying no usable timestamp is treated as newest, not oldest —
|
|
# ambiguity resolves toward "not settled" rather than toward a stale success.
|
|
# Guarded by the sort tiebreak rather than the dating expression: reverting
|
|
# only `at:` leaves this passing, so the two changes are separately pinned.
|
|
expect "an undateable in-flight run is not discarded for a stale success" PENDING \
|
|
"$(rollup "[$(run_ build SUCCESS 2026-07-20T15:00:00Z),\
|
|
$(jq -n '{__typename:"CheckRun",workflowName:"ci",name:"build",conclusion:"",startedAt:null,completedAt:null}')]" \
|
|
| checks_state)"
|
|
# ...and the reverse direction, which stops "in flight sorts last" being
|
|
# widened into "in flight always wins": a run that FINISHED after an earlier
|
|
# in-flight entry is the newer word, and the context is settled.
|
|
expect "a finished re-run supersedes an earlier in-flight run" SUCCESS \
|
|
"$(rollup "[$(inflight_ build 2026-07-20T15:19:00Z),\
|
|
$(run_ build SUCCESS 2026-07-20T15:19:45Z)]" | checks_state)"
|
|
|
|
# -- the wind-down window. A predecessor cancelled by the concurrency group
|
|
# does not stop the instant its replacement starts, so its completion
|
|
# routinely lands AFTER the successor's start — on box's aa5a6ba the
|
|
# replacement started 15:19:38 and the run it cancelled finished 15:19:51.
|
|
# Dating by "newest stamp of any kind" compares the dead run's completion
|
|
# against the live run's start, which is not an ordering on runs, and the
|
|
# predecessor wins. Every fixture above spaces completion before start, so
|
|
# none of them can see it. run_() cannot express the overlap either — it
|
|
# carries no startedAt — hence the explicit payloads.
|
|
overlap_() { jq -n --arg n "$1" --arg o "$2" --arg s "$3" --arg c "$4" \
|
|
'{__typename:"CheckRun", workflowName:"ci", name:$n, conclusion:$o,
|
|
startedAt:$s, completedAt:$c}'; }
|
|
expect "a predecessor finishing after its replacement started is still older (CANCELLED)" PENDING \
|
|
"$(rollup "[$(overlap_ scope CANCELLED 2026-07-20T15:19:00Z 2026-07-20T15:19:51Z),\
|
|
$(inflight_ scope 2026-07-20T15:19:38Z)]" | checks_state)"
|
|
expect "...and the same when it finished green — mid-flight is not mergeable" PENDING \
|
|
"$(rollup "[$(overlap_ build SUCCESS 2026-07-20T15:19:00Z 2026-07-20T15:19:51Z),\
|
|
$(inflight_ build 2026-07-20T15:19:38Z)]" | checks_state)"
|
|
|
|
# -- the classifier feeds the state machine: a cancelled required check must
|
|
# take the PR off the human's plate, which is the whole point of #136.
|
|
DRAFT=false HEAD_SHA=head1 REQUESTED="$HUMAN" REVIEWS_JSON="$ALL_APPROVE" MERGEABLE=MERGEABLE
|
|
CHECKS="$(rollup "[$(run_ a SUCCESS),$(run_ b CANCELLED)]" | checks_state)"
|
|
expect "a cancelled check reaches decide_state as the agent's ball" state:addressing "$(decide_state)"
|
|
expect "...via blocker:ci-red, not a conflict" blocker:ci-red "$(blockers)"
|
|
|
|
# -- the happy path survives all of the above.
|
|
REVIEWS_JSON="$ALL_APPROVE" MERGEABLE=MERGEABLE CHECKS=SUCCESS REQUESTED=""
|
|
expect "mergeable + green + three head-current approvals is needs-human" state:needs-human "$(decide_state)"
|
|
# -- and a draft outranks everything, including a conflict.
|
|
DRAFT=true MERGEABLE=CONFLICTING
|
|
expect "a draft is building even when conflicted" state:building "$(decide_state)"
|
|
DRAFT=false MERGEABLE=MERGEABLE CHECKS=SUCCESS REQUESTED="" REVIEWS_JSON='[]'
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# reconcile_pr's cold-start path. Everything above tests pure functions, which
|
|
# is exactly why a per-PR `return` in the label pre-flight got through review:
|
|
# the fixtures could not reach it. A missing state:* label must skip the label
|
|
# EDIT only — merge-next clearing and the stale sweep are independent of the
|
|
# taxonomy, and stranding them reintroduced the false-invitation bug (a
|
|
# `merge-next` claim surviving on a PR the board had moved to the agent).
|
|
# ---------------------------------------------------------------------------
|
|
reconcile_probe() { # $1 = REPO_LABELS content → the log lines reconcile_pr emits
|
|
(
|
|
REPO_LABELS="$1" REPO=owner/repo NOW="$(date +%s)"
|
|
LABELS="merge-next" # the PR carries a queue claim
|
|
DRAFT=false HEAD_SHA=head1 REQUESTED="" REVIEWS_JSON='[]'
|
|
MERGEABLE=MERGEABLE CHECKS=SUCCESS
|
|
PR_JSON='{"created_at":"2020-01-01T00:00:00Z"}'
|
|
run() { :; } # swallow mutations
|
|
gh() { :; } # no network
|
|
reconcile_pr 777 2>&1
|
|
)
|
|
}
|
|
|
|
cold="$(reconcile_probe "merge-next")" # state:* labels absent entirely
|
|
expect "a cold-start repo still clears merge-next" \
|
|
yes "$(grep -q 'cleared merge-next' <<<"$cold" && echo yes || echo no)"
|
|
expect "...and still runs the stale sweep" \
|
|
yes "$(grep -q 'stale (' <<<"$cold" && echo yes || echo no)"
|
|
expect "...while warning that the state label is missing" \
|
|
yes "$(grep -q "state label 'state:addressing' does not exist" <<<"$cold" && echo yes || echo no)"
|
|
|
|
warm="$(reconcile_probe "$(printf 'state:addressing\nmerge-next\nstale\nblocker:unrequested')")"
|
|
expect "a bootstrapped repo converges the state as well" \
|
|
yes "$(grep -q 'state -> state:addressing' <<<"$warm" && echo yes || echo no)"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# needs-ruling (#51): a pending human decision. Hand-set intent the machine
|
|
# reads and never writes — an EXCLUSION on needs-human, never a blocker and
|
|
# never a latch. #50's D8 by construction: needs-ruling and state:needs-human
|
|
# can never share a PR.
|
|
# ---------------------------------------------------------------------------
|
|
DRAFT=false HEAD_SHA=head1 REQUESTED="" REVIEWS_JSON="$ALL_APPROVE" MERGEABLE=MERGEABLE CHECKS=SUCCESS
|
|
LABELS=""
|
|
expect "the ruling-free fixture hands off (control)" state:needs-human "$(decide_state)"
|
|
LABELS="needs-ruling"
|
|
expect "a pending ruling excludes needs-human" state:addressing "$(decide_state)"
|
|
LABELS=""
|
|
expect "...and clearing it hands off again — an exclusion, not a latch" state:needs-human "$(decide_state)"
|
|
LABELS="needs-ruling" DRAFT=true
|
|
expect "a draft with a ruling pending is still building" state:building "$(decide_state)"
|
|
DRAFT=false
|
|
|
|
# blockers() must not know the label exists: it is not a branch fact, and the
|
|
# converge loop strips every BLOCKERS entry the facts do not re-derive —
|
|
# emitting it there is exactly the trap #51 names.
|
|
MERGEABLE=CONFLICTING
|
|
LABELS=""
|
|
expect "conflict fixture emits its blocker (control)" blocker:conflict "$(blockers)"
|
|
LABELS="needs-ruling"
|
|
expect "needs-ruling adds nothing to blockers()" blocker:conflict "$(blockers)"
|
|
MERGEABLE=MERGEABLE LABELS=""
|
|
|
|
# The guard the other fixtures cannot see: an UNGUARDED has_label read under
|
|
# set -u does not go red — bash treats the unset expansion inside the
|
|
# herestring redirection as a redirection error (bash 5.2: rc 127, the shell
|
|
# survives), so has_label fails OPEN, answering "label absent" with only a
|
|
# stderr complaint. For needs-ruling that would wave a live escalation
|
|
# through to needs-human in any caller that never set LABELS. Pinned by
|
|
# re-sourcing in a clean shell: the LABELS="" init keeps the read silent,
|
|
# and deleting the init turns this red.
|
|
guard_noise="$(bash -uc '. actions/labels-reconcile/labels-reconcile.sh
|
|
DRAFT=false HEAD_SHA=h REQUESTED="" REVIEWS_JSON="[]" MERGEABLE=MERGEABLE CHECKS=SUCCESS
|
|
decide_state' 2>&1 >/dev/null)"
|
|
expect "a fresh source reads LABELS cleanly (no unbound complaint)" "" "$guard_noise"
|
|
|
|
# The full-sweep probes ride the needs-human-otherwise fixture (three
|
|
# head-current approvals, mergeable, green). reconcile_probe cannot serve
|
|
# here — its empty round lands on addressing for its own reasons, and the
|
|
# exclusion must be the ONLY thing moving the state.
|
|
ruling_probe() { # $1 = the PR's labels → the log lines reconcile_pr emits
|
|
(
|
|
REPO_LABELS="$(printf 'state:addressing\nstate:needs-human\nmerge-next\nstale\nneeds-ruling')"
|
|
REPO=owner/repo NOW="$(date +%s)"
|
|
LABELS="$1"
|
|
DRAFT=false HEAD_SHA=head1 REQUESTED="" REVIEWS_JSON="$ALL_APPROVE"
|
|
MERGEABLE=MERGEABLE CHECKS=SUCCESS
|
|
PR_JSON='{"created_at":"2020-01-01T00:00:00Z"}'
|
|
run() { :; } # swallow mutations
|
|
gh() { :; } # no network
|
|
reconcile_pr 888 2>&1
|
|
)
|
|
}
|
|
|
|
ruled="$(ruling_probe "$(printf 'needs-ruling\nmerge-next')")"
|
|
expect "the exclusion drives the full sweep to addressing" \
|
|
yes "$(grep -q 'state -> state:addressing' <<<"$ruled" && echo yes || echo no)"
|
|
expect "...retracting merge-next: a PR awaiting a ruling is not merge-me-next" \
|
|
yes "$(grep -q 'cleared merge-next' <<<"$ruled" && echo yes || echo no)"
|
|
expect "...and the sweep never touches needs-ruling itself" \
|
|
no "$(grep -q 'needs-ruling' <<<"$ruled" && echo yes || echo no)"
|
|
|
|
# Staleness: waiting on a human is legitimately quiet (#50 D10) — same
|
|
# treatment as blocked, including taking an already-applied stale back off.
|
|
quiet="$(ruling_probe "needs-ruling")"
|
|
expect "quiet under a pending ruling is never stale" \
|
|
no "$(grep -q 'stale (' <<<"$quiet" && echo yes || echo no)"
|
|
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 ]
|