diff --git a/LABELS.md b/LABELS.md index 6a5af1e..b8eaf55 100644 --- a/LABELS.md +++ b/LABELS.md @@ -83,7 +83,7 @@ and re-entry does not set `attention`. | Label | Color | Meaning | |---|---|---| | `stale` | `#B60205` | no activity for 48h — sweep-managed, never hand-applied | -| `blocked` | `#6A737D` | (see above — same label serves PRs waiting on another PR/issue; legitimately quiet, the staleness sweep skips it) | +| `blocked` | `#6A737D` | (see above — same label serves PRs waiting on another PR/issue; legitimately quiet, the staleness sweep skips it). The reconciler refuses `state:needs-human` while `blocked` stands — the PR falls to `state:addressing` (#180) | | `offsite` | `#CFD3D7` | issue deliverable is a PR in another repository; set by the builder with the draft link and cleared by the builder at handoff | | `needs-ruling` | `#D4C5F9` | a human-owned decision is required; use BUILDER.md's ruling template and ladder. Set by triage or the builder; a state, not a signal — it clears on agreement, not on a reply | | `attention` | `#D93F0B` | issue-only demand parked for the assignee; hand-set, and never written by the machine | diff --git a/actions/labels-reconcile/labels-reconcile.sh b/actions/labels-reconcile/labels-reconcile.sh index 25b1827..9e7d337 100755 --- a/actions/labels-reconcile/labels-reconcile.sh +++ b/actions/labels-reconcile/labels-reconcile.sh @@ -389,6 +389,20 @@ decide_state() { # → the one state:* label this PR should carry if [ "$s" = state:needs-human ] && has_label needs-ruling; then echo state:addressing; return fi + + # A directed hold disqualifies it the same way (#180): `blocked` is hand-set + # intent — triage sets it, anyone may correct it — and during the #111 + # freeze rig#126/#128 carried it beside state:needs-human, so the board said + # "mergeable right now" about PRs a hold said must not merge (rig#126 was + # merged seven minutes later). Not a BLOCKERS entry, deliberately: that + # array is machine-owned and the converge loop strips whatever the facts do + # not re-derive, so emitting the label there would strip a live hold on the + # next 15-minute tick — the same trap #51 names for `needs-ruling`. + # state:addressing is the accepted imprecision: under a hold the builder + # owes nothing, but "a human could merge this now" must not lie. + if [ "$s" = state:needs-human ] && has_label blocked; then + echo state:addressing; return + fi echo "$s" } diff --git a/changelog.d/180.md b/changelog.d/180.md new file mode 100644 index 0000000..2d619e1 --- /dev/null +++ b/changelog.d/180.md @@ -0,0 +1,3 @@ +- `decide_state()` refuses `state:needs-human` while the hand-set `blocked` + label stands — the PR falls to `state:addressing`, exactly parallel to the + `needs-ruling` exclusion; never emitted by `blockers()` (#180). diff --git a/test/labels-reconcile.test.sh b/test/labels-reconcile.test.sh index ed3439c..00fcafd 100755 --- a/test/labels-reconcile.test.sh +++ b/test/labels-reconcile.test.sh @@ -556,6 +556,34 @@ LABELS="needs-ruling" expect "needs-ruling adds nothing to blockers()" blocker:conflict "$(blockers)" MERGEABLE=MERGEABLE LABELS="" +# --------------------------------------------------------------------------- +# blocked (#180): a directed hold. Same shape as needs-ruling — hand-set +# intent the machine reads and never writes, an EXCLUSION on needs-human, +# never a blocker and never a latch. During the #111 freeze rig#126/#128 +# carried blocked beside state:needs-human, and rig#126 was merged seven +# minutes after the reconciler wrote the green label. +# --------------------------------------------------------------------------- +DRAFT=false HEAD_SHA=head1 REQUESTED="" REVIEWS_JSON="$ALL_APPROVE" MERGEABLE=MERGEABLE CHECKS=SUCCESS +LABELS="" +expect "the hold-free fixture hands off (control)" state:needs-human "$(decide_state)" +LABELS="blocked" +expect "a directed hold 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="blocked" DRAFT=true +expect "a draft carrying blocked is still building" state:building "$(decide_state)" +DRAFT=false + +# blockers() must not know this label exists either: it is not a branch fact, +# and the converge loop strips every BLOCKERS entry the facts do not +# re-derive — emitting it there would strip a live hold on the next tick. +MERGEABLE=CONFLICTING +LABELS="" +expect "conflict fixture emits its blocker (control)" blocker:conflict "$(blockers)" +LABELS="blocked" +expect "blocked 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