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