From 60e417a13c9bb5c11c207aafd8e233067c62010d Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl <304681515+codex-bot-andresmgsl@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:18:18 +0000 Subject: [PATCH 1/3] feat: centralize labels machinery --- .github/labels.conf | 5 + .github/workflows/labels-bootstrap.yml | 61 -- .github/workflows/labels.yml | 58 ++ actions/labels-reconcile/action.yml | 29 + actions/labels-reconcile/labels-reconcile.sh | 563 +++++++++++++++++++ docs/CONSUMERS.md | 48 ++ test/labels.test.sh | 38 ++ 7 files changed, 741 insertions(+), 61 deletions(-) create mode 100644 .github/labels.conf delete mode 100644 .github/workflows/labels-bootstrap.yml create mode 100644 .github/workflows/labels.yml create mode 100644 actions/labels-reconcile/action.yml create mode 100755 actions/labels-reconcile/labels-reconcile.sh create mode 100644 docs/CONSUMERS.md create mode 100755 test/labels.test.sh diff --git a/.github/labels.conf b/.github/labels.conf new file mode 100644 index 0000000..2568711 --- /dev/null +++ b/.github/labels.conf @@ -0,0 +1,5 @@ +panel=claude-bot-andresmgsl codex-bot-andresmgsl grok-bot-andresmgsl kimi-bot-andresmgsl +scope:release-flow|C5DEF5|The reusable release workflow, decide, the doors +scope:guards|C5DEF5|changelog-armed / changelog-monotonic / drill-recorded +scope:labels|C5DEF5|The labels workflow, reconciler, the taxonomy +scope:docs|C5DEF5|README doctrine, CONSUMERS.md, the role files diff --git a/.github/workflows/labels-bootstrap.yml b/.github/workflows/labels-bootstrap.yml deleted file mode 100644 index 12005d4..0000000 --- a/.github/workflows/labels-bootstrap.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: labels-bootstrap -# INTERIM — delete when #10 (the reusable labels workflow) lands: its -# bootstrap dispatch carries this same table and replaces this file. -# -# Why a job at all (the family's pattern — box/rig/cast labels.yml): label -# DEFINITIONS (create/edit) need write, and the agents team is deliberately -# triage-only — "only humans merge" as permission model, not doctrine. The -# Actions GITHUB_TOKEN is the repo's own token, so this workflow converges -# the taxonomy without granting any agent identity write. Run it from the -# Actions tab (workflow_dispatch needs write — the maintainer's click). -# -# The table is LABELS.md made executable; --force makes every row an -# upsert, so re-runs converge names, colors AND descriptions idempotently. -on: - workflow_dispatch: - -permissions: - issues: write - -jobs: - bootstrap: - runs-on: ubuntu-latest - steps: - - name: converge the label taxonomy (LABELS.md) - env: - GH_TOKEN: ${{ github.token }} - run: | - while IFS='|' read -r name color desc; do - [ -n "$name" ] || continue - gh label create "$name" -R "$GITHUB_REPOSITORY" --color "$color" --description "$desc" --force - done <<'EOF' - state:building|FBCA04|PR is a draft — the builder is still building - state:bots-reviewing|1D76DB|Waiting on the reviewer panel to finish the round - state:addressing|D93F0B|Round complete without full approval — builder owes the single reply + fixes - state:needs-human|8250DF|Zero blockers, panel approved the current head — a human could merge now - blocker:conflict|B60205|Does not merge — the builder owes a rebase - blocker:ci-red|B60205|A check is failing — the builder owes a fix (not a rebase) - blocker:unrequested|E99695|Somebody still owes a verdict and nobody was asked - blocker:drill-pending|B60205|A release PR whose version has no drills/X.Y.Z.md record — correct but unevidenced - stale|B60205|No activity for 48h — sweep-managed, never hand-applied - blocked|6A737D|Waiting on another issue or PR to land first - release|0E8A16|Release flow, versioning, packaging work - merge-next|0E8A16|Head of the merge queue — merge this one next - needs-triage|FBCA04|Did not come through triage — owes normalization or conversion to a discussion - ready|0E8A16|Triaged, spec complete, unblocked — a builder can start now and succeed - claimed|1D76DB|A builder owns it: assignee set, draft PR expected shortly - epic|5319E7|Organizes other issues via a dependency-ordered task list — builders never pick it - scope:release-flow|C5DEF5|The reusable release workflow, decide, the doors - scope:guards|C5DEF5|changelog-armed / changelog-monotonic / drill-recorded - scope:labels|C5DEF5|The labels workflow, reconciler, the taxonomy - scope:docs|C5DEF5|README doctrine, CONSUMERS.md, the role files - EOF - - name: default labels that are not part of the taxonomy are deleted - env: - GH_TOKEN: ${{ github.token }} - run: | - # delete is not an upsert: already-gone exits non-zero — swallow it - # so re-runs converge instead of erroring after first success. - for L in duplicate invalid question wontfix "help wanted" "good first issue"; do - gh label delete "$L" -R "$GITHUB_REPOSITORY" --yes 2>/dev/null || true - done diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml new file mode 100644 index 0000000..ae976b1 --- /dev/null +++ b/.github/workflows/labels.yml @@ -0,0 +1,58 @@ +name: labels +# Reusable half of the labels automation. Triggers and permissions live in +# the caller; docs/CONSUMERS.md carries the complete caller stub. +on: + workflow_call: + +env: + # A called workflow arrives without its repository. Keep this literal pin + # aligned with the ceremony release consumed by callers (issue #9 D3). + CEREMONY_SELF_REF: "0.1.0" + +jobs: + scope: + # Not on labeled/unlabeled: those events change no paths, so labeler has + # nothing new to derive — and label churn is precisely what they are. + if: >- + github.event_name == 'pull_request_target' && + github.event.action != 'labeled' && + github.event.action != 'unlabeled' + runs-on: ubuntu-latest + concurrency: + group: labels-scope-${{ github.event.pull_request.number }} + cancel-in-progress: true + steps: + - uses: actions/labeler@v5 + with: + # additive only — a hand-applied scope must survive the machine + sync-labels: false + + reconcile: + runs-on: ubuntu-latest + # ONE shared group: every reconcile sweeps every open PR, so cron and + # PR-event runs must serialize or two sweeps race the same PR's labels + # and both pass the request-the-human-once guard. + concurrency: + group: labels-reconcile + cancel-in-progress: false + steps: + # pull_request_target is required for fork PR write permission. It is + # safe here because no PR code is ever checked out or executed: labeler + # reads paths via the API, and reconcile checks out the BASE branch only. + # Keep it that way. + - uses: actions/checkout@v4 + with: + repository: ${{ github.repository }} + ref: ${{ github.event.repository.default_branch }} + - uses: actions/checkout@v4 + with: + repository: heavy-duty/ceremony + ref: ${{ env.CEREMONY_SELF_REF }} + path: .ceremony-src + - name: reconcile state + stale + uses: ./.ceremony-src/actions/labels-reconcile + with: + bootstrap: ${{ github.event_name == 'workflow_dispatch' && 'yes' || 'no' }} + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} diff --git a/actions/labels-reconcile/action.yml b/actions/labels-reconcile/action.yml new file mode 100644 index 0000000..36257fe --- /dev/null +++ b/actions/labels-reconcile/action.yml @@ -0,0 +1,29 @@ +name: Reconcile labels +description: Converge PR state and optionally bootstrap the label taxonomy +inputs: + bootstrap: + description: Whether to bootstrap labels before reconciling (yes or no) + required: true + default: "no" +runs: + using: composite + steps: + - name: validate bootstrap input + shell: bash + env: + BOOTSTRAP: ${{ inputs.bootstrap }} + run: | + case "$BOOTSTRAP" in + yes | no) ;; + *) echo "bootstrap must be 'yes' or 'no'" >&2; exit 2 ;; + esac + - name: reconcile labels + shell: bash + env: + BOOTSTRAP: ${{ inputs.bootstrap }} + LABELS_CONF: ${{ github.workspace }}/.github/labels.conf + run: | + if [ "$BOOTSTRAP" = yes ]; then + export GITHUB_EVENT_NAME=workflow_dispatch + fi + bash "$GITHUB_ACTION_PATH/labels-reconcile.sh" diff --git a/actions/labels-reconcile/labels-reconcile.sh b/actions/labels-reconcile/labels-reconcile.sh new file mode 100755 index 0000000..e045649 --- /dev/null +++ b/actions/labels-reconcile/labels-reconcile.sh @@ -0,0 +1,563 @@ +#!/usr/bin/env bash +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + set -euo pipefail +else + # Fixture tests source the pure functions and deliberately inspect failures. + set -u +fi + +# labels-reconcile.sh — the automation LABELS.md promises: state labels are +# written by machinery, never by hand. Every run derives each open PR's +# state:* from GitHub's own facts (draft flag, requested reviewers, submitted +# reviews) and converges the labels to it, so a killed run or a hand-moved +# label heals on the next pass. Stale is judged from real activity — commits, +# comments, reviews — never from label churn, or the sweep would un-stale its +# own mark every tick. +# +# The verdict contract (CONTRIBUTING.md): reviews end in approve or +# request-changes. Some live bots are comment-only and post agreement as a +# COMMENTED review — a non-verdict this machine refuses to guess about (body +# parsing is a heuristic, and a wrong guess promotes an unapproved PR). The +# judgment call belongs to the PR AUTHOR, who reads the round and escalates +# by requesting the human's review — an explicit request is a fact, and it is +# the one this machine trusts (see decide_state's top precedence). The +# machine auto-requests the human only in the no-judgment-needed case: every +# required verdict is a formal head-current approval. Any approval that counts must be bound to +# the CURRENT head SHA: GitHub keeps approvals alive across pushes, and a +# stale approval must never promote unreviewed code to the human. +# +# DRY_RUN=1 narrates every mutation instead of performing it (how this script +# is rehearsed against the live repo). A workflow_dispatch run also bootstraps +# the taxonomy (label create --force) — that heal is dispatch-only; the cron +# sweep tolerates a missing label rather than recreating it. +# +# The state machine below is pure (globals in, state out) and covered by +# fixture tests in test/labels-reconcile.sh. + +HUMAN="${HUMAN_REVIEWER:-danmt}" +BOTS=() +REQUIRED_BOTS=() +STATES=(state:building state:bots-reviewing state:addressing state:needs-human) +BLOCKERS=(blocker:conflict blocker:ci-red blocker:unrequested) +# Labels this machine used to own and no longer does. Cleared on sight so a +# retirement heals the board instead of stranding a label nothing recomputes. +RETIRED=(state:needs-rebase) +STALE_AFTER=$((48 * 3600)) + +log() { printf 'labels: %s\n' "$*"; } + +run() { # every mutation goes through here — DRY_RUN=1 logs instead of doing + if [ -n "${DRY_RUN:-}" ]; then log "DRY_RUN: $*"; else "$@"; fi +} + +load_config() { # $1 = consumer labels.conf; panel is mandatory, scopes optional + local conf="$1" line panel_seen=false + [ -f "$conf" ] || { + echo "labels: missing config: $conf (a panel= line is required)" >&2 + return 1 + } + BOTS=() + while IFS= read -r line || [ -n "$line" ]; do + [ -n "$line" ] || continue + case "$line" in + panel=*) + [ "$panel_seen" = false ] || { + echo "labels: duplicate panel line in $conf" >&2 + return 1 + } + panel_seen=true + read -r -a BOTS <<<"${line#panel=}" + [ "${#BOTS[@]}" -gt 0 ] || { + echo "labels: panel must name at least one reviewer in $conf" >&2 + return 1 + } + ;; + *) parse_label_row "$line" >/dev/null || return ;; + esac + done <"$conf" + [ "$panel_seen" = true ] || { + echo "labels: missing panel= line in $conf" >&2 + return 1 + } +} + +parse_label_row() { # exact name|color|description; pipes in descriptions are refused + local line="$1" name color desc extra + IFS='|' read -r name color desc extra <<<"$line" + if [ -z "$name" ] || [ -z "$color" ] || [ -z "$desc" ] || [ -n "${extra:-}" ]; then + echo "labels: malformed label row: $line" >&2 + return 1 + fi + printf '%s|%s|%s\n' "$name" "$color" "$desc" +} + +configured_label_rows() { # validated scope rows, excluding the panel setting + local conf="$1" line + [ -f "$conf" ] || return 0 + while IFS= read -r line || [ -n "$line" ]; do + [ -n "$line" ] || continue + case "$line" in panel=*) continue ;; esac + parse_label_row "$line" || return + done <"$conf" +} + +set_required_bots() { # the PR author is recused by construction + local author="$1" bot + REQUIRED_BOTS=() + for bot in "${BOTS[@]}"; do + [ "$bot" = "$author" ] || REQUIRED_BOTS+=("$bot") + done +} + +# --------------------------------------------------------------------------- +# The state machine. Pure functions over four globals, set per PR: +# DRAFT true|false +# HEAD_SHA the PR's current head commit +# REQUESTED newline-separated logins with a review currently requested +# REVIEWS_JSON JSON array of submitted (non-PENDING) reviews +# MERGEABLE MERGEABLE | CONFLICTING | UNKNOWN (GitHub's own verdict) +# CHECKS SUCCESS | FAILURE | PENDING | NONE (the check rollup) +# --------------------------------------------------------------------------- + +requested() { grep -qxF "$1" <<<"$REQUESTED"; } + +checks_state() { # rollup JSON on stdin → SUCCESS | FAILURE | PENDING | NONE | UNREADABLE + # UNREADABLE is the absence of the key itself, which is what a failed fetch + # leaves behind — distinct from a present-but-empty rollup, which honestly + # means this PR has no checks. Collapsing the two let an API hiccup present + # as "nothing is failing", i.e. as mergeable-by-a-human: the same + # unknown-certified-as-green shape as the bug this machine exists to stop. + # The caller skips the PR entirely rather than labelling on facts it did not + # read; blocking on it instead would flap the whole board on one bad call. + # The rollup mixes two node types with two different closed enums: CheckRun + # carries `conclusion` (CheckConclusionState), StatusContext carries `state` + # (StatusState). Rather than list the outcomes that block — the version that + # shipped in this PR's first round listed four, and ERROR, CANCELLED and + # STALE fell through its `else` into SUCCESS — this lists the outcomes that + # DON'T, and treats everything else as blocking. + # + # That direction is the point. An outcome we do not recognise is one we + # cannot certify as mergeable, and certifying the unrecognised as green is + # the exact shape of #136. The cost of being wrong is symmetric in form and + # not in consequence: a false FAILURE parks the PR on the agent, who looks; + # a false SUCCESS invites a human to merge a tree that will not merge. + jq -r ' + if (has("statusCheckRollup") | not) then "UNREADABLE" else + + # NEUTRAL and SKIPPED satisfy branch protection — a skipped required check + # is not a failed one, and path-filtered jobs skip constantly here. + ["SUCCESS", "NEUTRAL", "SKIPPED"] as $passing + # "" covers a StatusContext still reported with no state at all. + | ["", "PENDING", "IN_PROGRESS", "QUEUED", "WAITING", "REQUESTED", "EXPECTED"] as $waiting + + # A re-run does not evict the run it superseded — the rollup keeps both. + # This PR proved it: its own tip carried a CANCELLED `scope` (15:19:39) + # beside the SUCCESS `scope` (15:19:45) that replaced it, same workflow. + # Once CANCELLED blocks, judging every entry would strand this very PR in + # needs-rebase forever, so collapse each context to its newest entry first. + # Key on workflow + name because a bare job name is only unique within its + # workflow. + # + # Dating a run is the subtle part, and getting it wrong restores the bug. + # A run still in flight has no completion, but `gh` does not omit the + # field: its Go struct marshals the zero time as "0001-01-01T00:00:00Z", + # which is a string, so `//` will not fall through it. Ordering on + # completion therefore sorted the LIVE re-run to the bottom and let `last` + # pick the very run it superseded — reporting the old SUCCESS while a + # replacement was still running, which is #136 again. + # + # So: date a run by when it BEGAN, discarding both spellings of absent + # (null, and the zero sentinel) and falling back only if it never recorded + # a beginning. NOT by the newest stamp of any kind: `max` compares the + # completion of a finished run against the start of a live one, which are + # different quantities and not an ordering on runs. A run cancelled by the + # concurrency group does not stop the instant its replacement starts — the + # runner has to wind down — so predecessor.completedAt > successor.startedAt + # is the ordinary case, and `max` dated the dead predecessor newer than the + # live run that replaced it, narrowing both failures above without closing + # them. The list is already in preference order, so `first` IS that rule. + # + # An entry that carries no usable timestamp at all sorts LAST rather than + # first — something we cannot date is most likely the thing just created, + # and treating it as newest keeps an undateable in-flight run from being + # discarded in favour of a stale success. Every ambiguity resolves toward + # "not settled". + | [ (.statusCheckRollup // [])[] + | { ctx: [.workflowName // "", .name // .context // ""], + at: ([.startedAt, .createdAt, .completedAt] + | map(select(type == "string" and . != "" + and (startswith("0001-01-01") | not))) + | first // ""), + outcome: ((.conclusion // .state // "") | ascii_upcase) } ] + | group_by(.ctx) + | map(sort_by([(.at == ""), .at]) | last | .outcome) as $latest + + | if ($latest | length) == 0 then "NONE" + elif (($latest - $passing - $waiting) | length) > 0 then "FAILURE" + elif (($latest - $passing) | length) > 0 then "PENDING" + else "SUCCESS" end + + end' +} + +bot_verdict() { # $1 = login → MISSING | BLOCK | APPROVE | STALE | FEEDBACK + local review state commit + review="$(jq -c --arg u "$1" \ + '[.[] | select(.user.login == $u)] | sort_by(.submitted_at) | last // empty' \ + <<<"$REVIEWS_JSON")" + if [ -z "$review" ]; then echo MISSING; return; fi + state="$(jq -r '.state' <<<"$review")" + commit="$(jq -r '.commit_id' <<<"$review")" + case "$state" in + CHANGES_REQUESTED) + # blocks at ANY head — GitHub's own semantic: only a newer review + # from the same reviewer clears it + echo BLOCK ;; + APPROVED) + if [ "$commit" = "$HEAD_SHA" ]; then echo APPROVE; else echo STALE; fi ;; + *) + # COMMENTED and anything else: a non-verdict. The machine does not + # read bodies — if the comment is really an agreement, the AUTHOR + # says so by requesting the human's review. + echo FEEDBACK ;; + esac +} + +human_request_needed() { # 0 when needs-human requires a FRESH human request + # already requested → the handoff is live; head-current human approval → + # nothing left to ask. Anything else (never reviewed, an old comment, an + # approval of an older head) stalls the handoff unless we request — + # guarding on "has the human ever reviewed" wedged exactly that way. + if requested "$HUMAN"; then return 1; fi + if [ "$(bot_verdict "$HUMAN")" = APPROVE ]; then return 1; fi + return 0 +} + +blockers() { # → the blocker:* labels this PR should carry, one per line + # The second axis. These are FACTS ABOUT THE BRANCH, and they are mutually + # independent — a PR can be conflicted and red and unasked at once — so they + # are a set, not an ordering. That is the whole point of splitting them out + # of state:*: every precedence bug this machine has had (needs-human + # surviving a conflict, MISSING swallowing STALE) came from projecting + # independent facts onto one totally-ordered label. A set has no precedence + # to get wrong. + # + # UNKNOWN mergeability is deliberately NOT a conflict: GitHub reports it for + # about a minute after every merge while it recomputes, and flapping every + # open PR on each merge would be worse than the bug. Same for a failed read + # of either fact — both default to the "do not know" value, which blocks + # nothing. An unset global (an older fixture, a failed fetch) must never + # invent a verdict it did not read. + case "${MERGEABLE:-UNKNOWN}" in CONFLICTING) echo blocker:conflict ;; esac + case "${CHECKS:-NONE}" in FAILURE) echo blocker:ci-red ;; esac + + # Nobody is on the hook for a verdict somebody still owes. Distinct from + # bots-reviewing, which says a request is live and an answer is coming: + # here the round is stalled because no one was ever asked, and the board + # said "waiting on the bots" for the 48h it took `stale` to notice. + # A draft is exempt (the bots ignore drafts by design), and so is an + # explicit human request — a maintainer claiming a PR early is deliberate, + # not a dropped ball. + if [ "$DRAFT" != true ] && ! requested "$HUMAN"; then + local b v owed=false any_requested=false + for b in "${REQUIRED_BOTS[@]}"; do + requested "$b" && any_requested=true + # MISSING and STALE are both verdicts this head does not have: nobody + # reviewed it, or everybody reviewed something else. The agent owes an + # ask either way — the stale round is if anything the worse of the two, + # since it has approvals on the page that no longer describe the tree. + v="$(bot_verdict "$b")" + case "$v" in MISSING | STALE) owed=true ;; esac + done + if [ "$owed" = true ] && [ "$any_requested" = false ]; then + echo blocker:unrequested + fi + fi +} + +decide_state() { # → the one state:* label this PR should carry + if [ "$DRAFT" = true ]; then echo state:building; return; fi + + local s + s="$(round_state)" + + # The one rule joining the two axes: state:needs-human means a human could + # merge this RIGHT NOW, so it requires a clear branch. Any blocker at all + # means the work is the agent's — whatever the review round says — and the + # blocker label says which work it is. Nothing else in this function reads + # the branch, which is what keeps the ordering below purely about reviews. + if [ "$s" = state:needs-human ] && [ -n "$(blockers)" ]; then + echo state:addressing; return + fi + echo "$s" +} + +round_state() { # → the state the REVIEW ROUND alone implies; knows no branch facts + local b verdicts="" + for b in "${REQUIRED_BOTS[@]}"; do + if requested "$b"; then echo state:bots-reviewing; return; fi + done + # Collect the WHOLE round before applying any precedence. Deciding inside + # the loop let BOTS order pick the winner: a MISSING returned immediately, + # so a STALE belonging to a later bot was never even read, and the mixed + # round (one approval staled by a push, another bot yet to review) came out + # needs-human — the #136 headline shape, with zero reviews bound to the head. + for b in "${REQUIRED_BOTS[@]}"; do + verdicts="$verdicts $(bot_verdict "$b")" + done + case "$verdicts" in + # STALE = a verdict for an older head. Unlike MISSING, this outranks the + # human request: every approval it covers was invalidated by a push, so + # NOBODY has reviewed this tree. Handing that to the human is the #136 case + # where everything reads green — mergeable, CI passing, "waiting on the + # human" — over code no reviewer has seen. The agent owes a re-request. + # Checked before MISSING because "unfinished" must not swallow "and also + # stale": a round that is both is a push that outran the re-requests, not + # a maintainer deliberately claiming the PR early. + *STALE*) echo state:addressing; return ;; + esac + case "$verdicts" in + # No verdict at all from some bot, and nothing staled. An explicit human + # request still outranks an unfinished round — a maintainer pulling a PR + # to themselves early is a deliberate act, and the original precedence. + # + # Otherwise it is the AGENT's ball, not the bots'. The loop above already + # returned for every live bot request, so reaching here with a MISSING + # means somebody owes a verdict and nobody was asked for one — the round + # is not running. Calling that bots-reviewing was the lie that let a + # forgotten PR read "waiting on the reviewers" for the 48h it took the + # stale sweep to notice. blocker:unrequested says why. + *MISSING*) + if requested "$HUMAN"; then echo state:needs-human; return; fi + echo state:addressing; return ;; + esac + # an explicit human request outranks the remaining bot outcomes — it is the + # final gate, and a maintainer pulling a PR to themselves early counts too + if requested "$HUMAN"; then echo state:needs-human; return; fi + case "$verdicts" in + # FEEDBACK = a comment with no verdict → the agent owes the round-reply. + *BLOCK* | *FEEDBACK*) echo state:addressing; return ;; + esac + # the bots all approve — but if the human's standing word is + # changes-requested (and nobody re-requested them yet), the agent owes + # fixes, not the human a nag + if [ "$(bot_verdict "$HUMAN")" = BLOCK ]; then + echo state:addressing + else + echo state:needs-human + fi +} + +# --------------------------------------------------------------------------- +# The sweep: fetch facts, decide, converge. One PR's failure never aborts the +# others — each PR reconciles in a subshell and a failure just logs. +# --------------------------------------------------------------------------- + +core_label_rows() { + cat <<'EOF' +state:building|FBCA04|PR is a draft — the coding agent is still building +state:bots-reviewing|1D76DB|Waiting on the bot reviewers to finish the round +state:addressing|D93F0B|All bots reviewed — coding agent owes the single reply + fixes +state:needs-human|8250DF|No blockers, all bots approve — waiting on the human reviewer +blocker:conflict|B60205|Does not merge — the branch conflicts and the agent owes a rebase +blocker:ci-red|B60205|A check is failing — the agent owes a fix (not a rebase) +blocker:unrequested|E99695|Somebody still owes a verdict and nobody was asked for one +merge-next|0E8A16|Head of the merge queue — merge this one next (set by hand/agent, cleared here) +stale|B60205|No activity for 48h — needs a poke (sweep-managed) +blocked|6A737D|Waiting on another PR or issue to land first +release|0E8A16|Release flow and version/packaging work +needs-triage|FBCA04|Did not come through triage — owes normalization or conversion to a discussion +ready|0E8A16|Triaged, spec complete, unblocked — a builder can start now and succeed +claimed|1D76DB|A builder owns it: assignee set, draft PR expected shortly +epic|5319E7|Organizes other issues via a dependency-ordered task list — builders never pick it +EOF +} + +bootstrap_labels() { # dispatch-only: ~20 upserts is too chatty for every cron tick + local rows + rows="$(core_label_rows)" + if [ -f "$LABELS_CONF" ]; then + rows="$rows +$(configured_label_rows "$LABELS_CONF")" + fi + while IFS='|' read -r name color desc; do + [ -n "$name" ] || continue + run gh label create "$name" -R "$REPO" --color "$color" --description "$desc" --force + done <<<"$rows" +} + +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 + + desired="$(decide_state)" + + # encode the runbook's last step for the no-judgment case: every required + # verdict is a head-current approval → the human is asked, once. The guard asks whether + # a FRESH human review is needed for THIS head — never "has the human ever + # reviewed", which wedged the handoff after any earlier human comment. + # Idempotent (a live request suppresses it); race-free via the shared + # concurrency group in labels.yml. With a comment-only bot on the panel + # this path stays cold and the AUTHOR requests the human. + if [ "$desired" = state:needs-human ] && human_request_needed; then + run gh api "repos/$REPO/pulls/$n/requested_reviewers" -f "reviewers[]=$HUMAN" --silent + log "#$n: requested $HUMAN (round passed)" + fi + + # ---- converge both axes ---- + # state:* is exclusive (everything but $desired comes off); blocker:* is a + # set (each one on or off on its own); RETIRED always comes off. One edit + # call for all of it, so a PR never flickers through a half-applied board. + local want_blockers add="" + want_blockers="$(blockers)" + + remove="" + for s in "${STATES[@]}"; do + if [ "$s" != "$desired" ] && has_label "$s"; then remove="$remove,$s"; fi + done + for s in "${RETIRED[@]}"; do + if has_label "$s"; then remove="$remove,$s"; fi + done + for s in "${BLOCKERS[@]}"; do + if grep -qxF "$s" <<<"$want_blockers"; then + has_label "$s" || add="$add,$s" + else + has_label "$s" && remove="$remove,$s" + fi + done + add="${add#,}" + remove="${remove#,}" + + # Never NAME a label the repo does not have. `gh issue edit --add-label` + # rejects the WHOLE call on one unknown name — nothing is applied — so a + # single missing blocker would take the state convergence down with it, on + # exactly the PRs this change exists to fix, surfacing only as a log line. + # Batching state and blockers into one edit for anti-flicker is what widened + # that blast radius; filtering the add side is what closes it again. + # Removals need no filter: they are built from has_label, so the label + # provably exists. REPO_LABELS unreadable means no filtering rather than + # filtering everything out — a failed read must not silently strip the board. + local skip_edit=false + if [ -n "${REPO_LABELS:-}" ]; then + local kept="" missing="" want + for want in ${add//,/ }; do + if grep -qxF "$want" <<<"$REPO_LABELS"; then kept="$kept,$want" + else missing="$missing $want"; fi + done + add="${kept#,}" + # A missing STATE label skips only the EDIT — never the rest of this + # function. Everything below is independent of the state:* taxonomy, and + # returning here stranded it: `merge-next` kept claiming "merge this one + # next" on a PR the board had moved to the agent, and the stale sweep + # stopped running. That is the original false-invitation bug, reintroduced + # in the very fix meant to survive a cold-start repo — and a regression + # against the old behaviour, which failed the edit and fell through. + if ! grep -qxF "$desired" <<<"$REPO_LABELS"; then + log "#$n: WARNING: state label '$desired' does not exist — skipping the label edit; dispatch the workflow to bootstrap" + skip_edit=true + elif [ -n "$missing" ]; then + log "#$n: WARNING: missing label(s)$missing — state still converged; dispatch the workflow to bootstrap" + fi + fi + if [ "$skip_edit" = false ] && { ! has_label "$desired" || [ -n "$remove" ] || [ -n "$add" ]; }; then + args=(--add-label "$desired${add:+,$add}") + [ -n "$remove" ] && args+=(--remove-label "$remove") + if run gh issue edit "$n" -R "$REPO" "${args[@]}" >/dev/null; then + log "#$n: state -> $desired${add:+ +$add}${remove:+ (cleared $remove)}" + else + # a deleted label must not wedge the sweep — dispatch heals the taxonomy + log "#$n: WARNING: label edit failed (missing label? run the workflow manually to bootstrap)" + fi + fi + + # ---- merge-next: cleared, never set ---------------------------------- + # Queue order is INTENT — which PR should land first is a judgement about + # conflicts and dependencies that GitHub knows nothing about, so the + # reconciler must not guess it (LABELS.md's rule for `blocked`/`release`). + # What it CAN do is stop the label going stale the way needs-human did: + # the moment the PR is no longer the thing a human should merge next, the + # claim is removed. Setting it stays with whoever owns the queue. + if has_label merge-next && [ "$desired" != state:needs-human ]; then + run gh issue edit "$n" -R "$REPO" --remove-label merge-next >/dev/null + log "#$n: cleared merge-next (state is $desired, not mergeable-by-a-human)" + fi + + # ---- stale: real activity only, and blocked is legitimately quiet ---- + last_activity="$( + { + jq -r '.created_at' <<<"$PR_JSON" + jq -r '.[].submitted_at' <<<"$REVIEWS_JSON" + gh api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at' + gh api --paginate "repos/$REPO/pulls/$n/comments" --jq '.[].created_at' + gh api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date' + } | sort | tail -n1 + )" + age=$((NOW - $(date -d "$last_activity" +%s))) + if has_label blocked || [ "$age" -le "$STALE_AFTER" ]; then + if has_label stale; then + run gh issue edit "$n" -R "$REPO" --remove-label stale >/dev/null + log "#$n: unstale" + fi + elif ! has_label stale; then + run gh issue edit "$n" -R "$REPO" --add-label stale >/dev/null + log "#$n: stale ($((age / 3600))h quiet)" + fi +} + +main() { + REPO="${REPO:?set REPO to owner/name}" + LABELS_CONF="${LABELS_CONF:-.github/labels.conf}" + load_config "$LABELS_CONF" + NOW="$(date +%s)" + + if [ "${GITHUB_EVENT_NAME:-}" = workflow_dispatch ]; then + log "workflow_dispatch: bootstrapping the taxonomy" + bootstrap_labels + fi + + # The repo's label set, read ONCE per sweep — reconcile_pr filters every + # add against it, because one unknown name fails the whole edit call. + REPO_LABELS="$(gh label list -R "$REPO" --limit 200 --json name --jq '.[].name' 2>/dev/null || echo "")" + [ -z "$REPO_LABELS" ] && log "WARNING: could not read the label set — applying labels unfiltered" + + local n + for n in $(gh pr list -R "$REPO" --state open --limit 100 --json number --jq '.[].number'); do + ( + PR_JSON="$(gh api "repos/$REPO/pulls/$n")" + DRAFT="$(jq -r '.draft' <<<"$PR_JSON")" + AUTHOR="$(jq -r '.user.login' <<<"$PR_JSON")" + set_required_bots "$AUTHOR" + HEAD_SHA="$(jq -r '.head.sha' <<<"$PR_JSON")" + LABELS="$(jq -r '.labels[].name' <<<"$PR_JSON")" + REQUESTED="$(jq -r '.requested_reviewers[].login' <<<"$PR_JSON")" + # PENDING reviews are unsubmitted drafts in someone's browser — not a verdict + REVIEWS_JSON="$(gh api --paginate "repos/$REPO/pulls/$n/reviews" --jq '.[]' \ + | jq -s '[.[] | select(.state != "PENDING")]')" + # mergeability + the check rollup, the two facts the state machine was + # blind to (#136). `gh pr view` rather than the REST PR object: the API's + # `mergeable` is a tri-state boolean that GitHub computes lazily, while + # this returns the same MERGEABLE/CONFLICTING/UNKNOWN string the UI shows. + # Failure to read them is NOT fatal and NOT treated as broken — an API + # hiccup must never flap every PR into needs-rebase, so both degrade to + # the "do not know" value that triggers nothing. + GH_VIEW="$(gh pr view "$n" -R "$REPO" --json mergeable,statusCheckRollup 2>/dev/null || echo '{}')" + MERGEABLE="$(jq -r '.mergeable // "UNKNOWN"' <<<"$GH_VIEW")" + CHECKS="$(checks_state <<<"$GH_VIEW")" + # Read failed: leave this PR exactly as it is. Recomputing on facts we + # did not read is how an API hiccup turns into a false "merge me" — + # and the next tick is 15 minutes away, not 15 hours. + if [ "$CHECKS" = UNREADABLE ]; then + log "#$n: could not read mergeability/checks — left alone this pass" + exit 0 + fi + reconcile_pr "$n" + ) || log "#$n: reconcile failed — continuing with the remaining PRs" + done + log "reconciled." +} + +# sourced by test/labels-reconcile.sh for the fixture tests; executed in CI +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + main "$@" +fi diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md new file mode 100644 index 0000000..5361615 --- /dev/null +++ b/docs/CONSUMERS.md @@ -0,0 +1,48 @@ +# Consumer setup + +## 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`. + +The complete caller is: + +```yaml +name: labels +on: + schedule: [{cron: "*/15 * * * *"}] # advisory; the handoff label is the real wake + workflow_dispatch: # bootstraps missing labels on a fresh repo + pull_request_target: + types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled] +permissions: + contents: read + issues: write + pull-requests: write +jobs: + labels: + uses: heavy-duty/ceremony/.github/workflows/labels.yml@ +``` + +`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. + +`.github/labels.conf` has one mandatory panel setting followed by zero or +more scope rows: + +```text +panel=claude-bot example-codex-bot example-grok-bot +scope:cli|C5DEF5|The command-line surface +scope:docs|C5DEF5|Documentation +``` + +The panel is whitespace-separated. Label rows use exactly +`name|color|description`; blank lines are ignored and extra pipes are refused. +Core state, blocker, work-queue, and release labels come from ceremony. Scope +rows remain consumer-owned because paths and surfaces differ by repository. + +After adding the caller and configuration, run `workflow_dispatch` once to +bootstrap labels on a fresh repository. Scheduled and PR-triggered runs only +reconcile; they do not repeatedly upsert the taxonomy. diff --git a/test/labels.test.sh b/test/labels.test.sh new file mode 100755 index 0000000..1e5f71c --- /dev/null +++ b/test/labels.test.sh @@ -0,0 +1,38 @@ +#!/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=actions/labels-reconcile/labels-reconcile.sh +source "$ROOT/actions/labels-reconcile/labels-reconcile.sh" + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +printf '%s\n' \ + 'panel=one two three' \ + '' \ + 'scope:one|C5DEF5|First scope' \ + 'scope:two|C5DEF5|Second scope' >"$TMP/good.conf" + +check "config accepts panel, blanks, and scope rows" 0 "" load_config "$TMP/good.conf" +check "panel is parsed" 0 "one two three" bash -c \ + 'source "$1"; load_config "$2"; printf "%s\n" "${BOTS[*]}"' _ \ + "$ROOT/actions/labels-reconcile/labels-reconcile.sh" "$TMP/good.conf" +check "core and config rows merge" 0 "scope:two|C5DEF5|Second scope" bash -c \ + 'source "$1"; core_label_rows; configured_label_rows "$2"' _ \ + "$ROOT/actions/labels-reconcile/labels-reconcile.sh" "$TMP/good.conf" +check "missing scope config is an empty table" 0 "" configured_label_rows "$TMP/missing.conf" + +printf '%s\n' 'scope:bad|C5DEF5' >"$TMP/bad.conf" +check "wrong field count fails loudly" 1 "malformed label row" configured_label_rows "$TMP/bad.conf" +printf '%s\n' 'scope:bad|C5DEF5|description|with pipe' >"$TMP/pipe.conf" +check "pipe in description is explicitly refused" 1 "malformed label row" configured_label_rows "$TMP/pipe.conf" +printf '%s\n' 'scope:only|C5DEF5|No panel' >"$TMP/no-panel.conf" +check "missing panel fails loudly" 1 "missing panel= line" load_config "$TMP/no-panel.conf" +load_config "$TMP/good.conf" +set_required_bots two +check "PR author is recused from the required panel" 0 "one three" printf '%s\n' "${REQUIRED_BOTS[*]}" + +summary From 9755f634a0803150d94a8162c6694092182ae86d Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl <304681515+codex-bot-andresmgsl@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:19:49 +0000 Subject: [PATCH 2/3] test: cover labels state machine --- .github/scripts/actionlint-all.sh | 8 +- test/labels-reconcile.test.sh | 418 ++++++++++++++++++++++++++++++ test/labels.test.sh | 2 + 3 files changed, 423 insertions(+), 5 deletions(-) create mode 100755 test/labels-reconcile.test.sh diff --git a/.github/scripts/actionlint-all.sh b/.github/scripts/actionlint-all.sh index f1b1941..955ca3e 100755 --- a/.github/scripts/actionlint-all.sh +++ b/.github/scripts/actionlint-all.sh @@ -4,10 +4,9 @@ set -euo pipefail cd "$(git rev-parse --show-toplevel)" mapfile -t files < <( - { - git ls-files '.github/workflows/*.yml' - git ls-files 'actions/*/action.yml' - } | sort -u + # actionlint validates workflow syntax, not composite action metadata; + # feeding action.yml to it misclassifies the file as a workflow. + git ls-files '.github/workflows/*.yml' | sort -u ) [ "${#files[@]}" -gt 0 ] || { @@ -18,4 +17,3 @@ mapfile -t files < <( printf 'actionlint: linting %d files\n' "${#files[@]}" printf ' %s\n' "${files[@]}" actionlint "${files[@]}" - diff --git a/test/labels-reconcile.test.sh b/test/labels-reconcile.test.sh new file mode 100755 index 0000000..ecc809b --- /dev/null +++ b/test/labels-reconcile.test.sh @@ -0,0 +1,418 @@ +#!/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)" + +printf 'labels-reconcile tests: %d passed, %d failed\n' "$pass" "$fail" +[ "$fail" -eq 0 ] diff --git a/test/labels.test.sh b/test/labels.test.sh index 1e5f71c..a042f41 100755 --- a/test/labels.test.sh +++ b/test/labels.test.sh @@ -17,9 +17,11 @@ printf '%s\n' \ 'scope:two|C5DEF5|Second scope' >"$TMP/good.conf" check "config accepts panel, blanks, and scope rows" 0 "" load_config "$TMP/good.conf" +# shellcheck disable=SC2016 # expansion belongs to the nested bash check "panel is parsed" 0 "one two three" bash -c \ 'source "$1"; load_config "$2"; printf "%s\n" "${BOTS[*]}"' _ \ "$ROOT/actions/labels-reconcile/labels-reconcile.sh" "$TMP/good.conf" +# shellcheck disable=SC2016 # expansion belongs to the nested bash check "core and config rows merge" 0 "scope:two|C5DEF5|Second scope" bash -c \ 'source "$1"; core_label_rows; configured_label_rows "$2"' _ \ "$ROOT/actions/labels-reconcile/labels-reconcile.sh" "$TMP/good.conf" From 1ff660f539c02e683c9d2c007932dc9fa614d9a7 Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl <304681515+codex-bot-andresmgsl@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:21:11 +0000 Subject: [PATCH 3/3] docs: preserve labels workflow safety rationale --- .github/workflows/labels.yml | 18 ++++++++++++++++++ actions/labels-reconcile/labels-reconcile.sh | 8 +++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml index ae976b1..e682fdf 100644 --- a/.github/workflows/labels.yml +++ b/.github/workflows/labels.yml @@ -1,6 +1,23 @@ name: labels # Reusable half of the labels automation. Triggers and permissions live in # the caller; docs/CONSUMERS.md carries the complete caller stub. +# +# The caller uses pull_request_target, not pull_request: every PR in this +# family arrives from a fork, where pull_request runs with a READ-ONLY token +# and cannot label anything. _target is safe in this workflow because no PR +# code is ever checked out or executed — labeler reads changed paths via the +# API, and reconcile checks out the BASE branch only. Keep it that way. +# +# There is no pull_request_review_target, so a review landing cannot wake this +# workflow directly — and the */15 cron is advisory: GitHub deprioritises +# short intervals hard enough that a quiet repo goes hours between ticks. The +# handoff wakes the sweep itself: the author sets state:needs-human, and the +# caller's `labeled` event confirms or corrects that optimistic write within +# seconds. The cron stays as the last resort for a forgotten handoff. +# +# This cannot loop: reconciler writes use GITHUB_TOKEN, and GitHub does not +# create workflow runs from GITHUB_TOKEN-triggered events. Agent writes use a +# PAT and therefore do trigger — exactly the asymmetry wanted. on: workflow_call: @@ -24,6 +41,7 @@ jobs: steps: - uses: actions/labeler@v5 with: + # labeler reads the consumer's .github/labeler.yml via the API # additive only — a hand-applied scope must survive the machine sync-labels: false diff --git a/actions/labels-reconcile/labels-reconcile.sh b/actions/labels-reconcile/labels-reconcile.sh index e045649..8e72b35 100755 --- a/actions/labels-reconcile/labels-reconcile.sh +++ b/actions/labels-reconcile/labels-reconcile.sh @@ -22,7 +22,8 @@ fi # by requesting the human's review — an explicit request is a fact, and it is # the one this machine trusts (see decide_state's top precedence). The # machine auto-requests the human only in the no-judgment-needed case: every -# required verdict is a formal head-current approval. Any approval that counts must be bound to +# required verdict is a formal head-current approval. Any approval that counts +# must be bound to # the CURRENT head SHA: GitHub keeps approvals alive across pushes, and a # stale approval must never promote unreviewed code to the human. # @@ -32,7 +33,7 @@ fi # sweep tolerates a missing label rather than recreating it. # # The state machine below is pure (globals in, state out) and covered by -# fixture tests in test/labels-reconcile.sh. +# fixture tests in test/labels-reconcile.test.sh. HUMAN="${HUMAN_REVIEWER:-danmt}" BOTS=() @@ -394,7 +395,8 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch desired="$(decide_state)" # encode the runbook's last step for the no-judgment case: every required - # verdict is a head-current approval → the human is asked, once. The guard asks whether + # verdict is a head-current approval → the human is asked, once. The guard + # asks whether # a FRESH human review is needed for THIS head — never "has the human ever # reviewed", which wedged the handoff after any earlier human comment. # Idempotent (a live request suppresses it); race-free via the shared