#!/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 run with BOOTSTRAP=yes also # bootstraps the taxonomy (label create --force) — the operator's manual # dispatch defaults the input to yes; every machine wake passes no. 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.test.sh. HUMAN="${HUMAN_REVIEWER:-danmt}" BOTS=() # Per-author panels (#224): parallel arrays because the conf is tiny and an # associative array buys nothing but a bash-4 dependency statement. One entry # per panel[]= row — PANEL_AUTHORS holds the login, PANEL_ROWS the # space-joined reviewer set at the same index. PANEL_AUTHORS=() PANEL_ROWS=() REQUIRED_BOTS=() STATES=(state:building state:bots-reviewing state:addressing state:needs-human) BLOCKERS=(blocker:conflict blocker:ci-red blocker:unrequested) # The PR's current labels, set per PR by the sweep. Initialized here because # the script runs under `set -u` even when sourced, and the pure-function # fixtures call decide_state — which now reads has_label — without ever # setting it (#51). An empty default keeps has_label honest for every caller. LABELS="" # 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)) # How long the facts behind blocker:unrequested must have stood still before it # is written (#236 D2). The operator's "more than 5 minutes", measured off the # inputs' own timestamps rather than off sweep memory — this script is # stateless per pass and stays that way. Overridable the way this file's other # constants are, for a caller whose round cadence is slower or faster. RECONCILE_UNREQUESTED_GRACE="${RECONCILE_UNREQUESTED_GRACE:-300}" # The workflow whose runs checks_state must never grade — its own (#208). # GITHUB_WORKFLOW is ambient in every Actions step and names the CALLER (the # consumer's PR-facing workflow, since consumers name the caller), so this # self-serves with no workflow-file change. The explicit override exists for # two readers: the fixtures, and #209's detached sweep caller, which will # need to point this at the PR-facing caller's name once reconcile no longer # runs inside it. Empty means "filter nothing" — a caller outside Actions # (a local rehearsal, an older pin) must not silently start dropping entries. SELF_WORKFLOW="${SELF_WORKFLOW:-${GITHUB_WORKFLOW:-}}" # The needs-ruling invariants (#52) — one implementation for both surfaces. # shellcheck source=lib/ruling.sh . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/ruling.sh" # shellcheck source=lib/forge.sh . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/forge.sh" # The attention target invariants (#232) — diagnosis only, both surfaces. # shellcheck source=lib/attention.sh . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/attention.sh" # The guarded read and its reason line (#101) — one implementation for both # surfaces. read_failure_reason lived here until the issue surface needed the # identical rule (#247); a second copy of it is the failure lib/ruling.sh's # own header was written to record. # shellcheck source=lib/read.sh . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/read.sh" 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 } blind_sweep_warning() { # $1 = unreadable PRs, $2 = all open PRs, $3 = sampled read-failure reason # Report, do not diagnose (#101 D5). The old text asserted the caller's # checks:/statuses: grants as THE cause — an inference #95 made from a # control case, and the merged consumer-side fix (incubator#48/PR #49) # left the symptom standing while the run emitting this warning held the # evidence that would have said so. Lead with what gh actually said this # sweep; the permissions hint stays, demoted to one named candidate. if [ "$2" -gt 0 ] && [ "$1" -eq "$2" ]; then local reason="${3:-}" if [ -n "$reason" ]; then echo "::warning::labels: every open PR was unreadable; sampled reason: $reason — one candidate is missing checks: read, statuses: read and actions: read in the caller (private repos do not imply them)" else echo "::warning::labels: every open PR was unreadable; no reason was captured — one candidate is missing checks: read, statuses: read and actions: read in the caller (private repos do not imply them)" fi fi } missing_core_labels_warning() { # $1 = declared rows, $2 = repo label names local rows="$1" repo_labels="$2" row name missing="" [ -n "$repo_labels" ] || return 0 while IFS= read -r row; do [ -n "$row" ] || continue name="${row%%|*}" if ! grep -qxF "$name" <<<"$repo_labels"; then if [ -n "$missing" ]; then missing="$missing, $name"; else missing="$name"; fi fi done <<<"$rows" if [ -n "$missing" ]; then echo "::warning::labels: missing core label(s): $missing; bump the ceremony pin, then re-dispatch workflow_dispatch to bootstrap the taxonomy" 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=() PANEL_AUTHORS=() PANEL_ROWS=() # shellcheck disable=SC2094 # parse_panel_author_row takes $conf for its # error messages only — nothing in this loop writes the file it reads while IFS= read -r line || [ -n "$line" ]; do [ -n "$line" ] || continue # The panel[ prefix is matched QUOTED (#224 D7): in a case pattern an # unquoted panel[abc]=* is a bracket expression that matches panela=…, # panelb=…, panelc=… — silently rerouting ordinary settings. The # panela= tripwire in test/labels.test.sh goes red if this regresses. 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 } ;; "panel["*) parse_panel_author_row "$line" "$conf" || return ;; triage-actors=*) ;; *) parse_label_row "$line" >/dev/null || return ;; esac done <"$conf" [ "$panel_seen" = true ] || { echo "labels: missing panel= line in $conf" >&2 return 1 } } parse_panel_author_row() { # panel[]= (#224) # Every failure here is a hard one that names the offending line (D3): a # conf error takes the whole board down, and the run log is the only place # the operator can read why. A malformed bracket is refused AS a bracket # (D4) — falling through to parse_label_row would report it as a # "malformed label row", the misleading diagnostic #224 was filed over. local line="$1" conf="$2" login rest existing case "$line" in "panel["*"]="*) ;; *) echo "labels: malformed panel[]= row (expected panel[]=): $line in $conf" >&2 return 1 ;; esac login="${line#panel[}" login="${login%%]=*}" [ -n "$login" ] || { echo "labels: empty login in panel row: $line in $conf" >&2 return 1 } # The login must be exactly one well-formed bracket pair of login # characters. Without this, panel[z]]=b parses: the case above only # establishes that SOME ]= occurs, ${login%%]=*} keeps the stray ] inside # the login (z]), and set_required_bots for the real z then silently falls # back to the base panel — the misroute D4 exists to refuse. GitHub logins # are [A-Za-z0-9-], per the #285 spec. case "$login" in *[!A-Za-z0-9-]*) echo "labels: malformed panel[]= row (a login is [A-Za-z0-9-] only): $line in $conf" >&2 return 1 ;; esac for existing in ${PANEL_AUTHORS[@]+"${PANEL_AUTHORS[@]}"}; do [ "$existing" != "$login" ] || { echo "labels: duplicate panel[$login]= row in $conf: $line" >&2 return 1 } done local -a row=() rest="${line#*]=}" read -r -a row <<<"$rest" [ "${#row[@]}" -gt 0 ] || { echo "labels: panel[$login]= must name at least one reviewer in $conf: $line" >&2 return 1 } PANEL_AUTHORS+=("$login") PANEL_ROWS+=("${row[*]}") } 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 # "panel["* quoted for the same D7 reason as load_config's case; skipping # the bracketed rows (D5) keeps a dispatch bootstrap from trying to # create a label named panel[]. case "$line" in panel=* | "panel["* | triage-actors=*) continue ;; esac parse_label_row "$line" || return done <"$conf" } panel_for_author() { # $1 = author → the effective panel, space-joined (#224 D2) # THE resolution point: the author's panel[]= row when the conf # defines one, the base panel= otherwise. Everything that computes a # required set goes through here, because two places computing the panel # is how the engine and the reconciler came to disagree in the first place. local author="$1" i for i in ${PANEL_AUTHORS[@]+"${!PANEL_AUTHORS[@]}"}; do if [ "${PANEL_AUTHORS[i]}" = "$author" ]; then printf '%s\n' "${PANEL_ROWS[i]}" return fi done printf '%s\n' "${BOTS[*]}" } set_required_bots() { # the PR author is recused by construction # Minus-the-author applies to WHICHEVER set panel_for_author returns (#224 # D2's safety net): an author who mistakenly appears inside its own # bracketed row is still recused. local author="$1" bot local -a effective=() read -r -a effective <<<"$(panel_for_author "$author")" REQUIRED_BOTS=() for bot in ${effective[@]+"${effective[@]}"}; do [ "$bot" = "$author" ] || REQUIRED_BOTS+=("$bot") done } # --------------------------------------------------------------------------- # The state machine. Pure functions over these globals, set per PR: # DRAFT true|false # HEAD_SHA the PR's current head commit # BASE_SHA the PR's base branch head (the release-shape guard's ref) # REQUESTED newline-separated logins with a review currently requested # REVIEWS_JSON JSON array of submitted, gradeable reviews # MERGEABLE MERGEABLE | CONFLICTING | UNKNOWN (GitHub's own verdict) # CHECKS SUCCESS | FAILURE | PENDING | NONE (the check rollup) # LABELS newline-separated labels currently on the PR # HEAD_COMMIT_AT the head commit's own date, ISO-8601; empty when unread # NOW this sweep's epoch seconds (main sets it once per run) # --------------------------------------------------------------------------- requested() { grep -qxF "$1" <<<"$REQUESTED"; } # outstanding_requests — the portable "who still owes a # verdict on THIS head" (issue #188, term 4). # # GitHub clears requested_reviewers when a verdict lands, so on that forge the # field already answers this question and the filter below removes nothing. # **Forgejo does not clear it.** Measured 2026-08-02: rig!140 listed all three # panelists with all three verdicts in, and rig!146 still lists three while # MERGED — the field is stale even on a closed PR, so it over-counts forever. # # Reading it raw on Forgejo pins a PR at state:bots-reviewing for life and # stops blocker:unrequested from ever being true: the sweep believes a round # is permanently live. So the requested set is intersected with "has not # submitted a verdict for the current head", which is derived from # /pulls/{n}/reviews — the read that is true on both forges. # # Pure over REVIEWS_JSON/HEAD_SHA so the fixtures can drive it; a reviewer # whose only verdict is STALE still owes one, which is why this asks # bot_verdict rather than merely "has any review". outstanding_requests() { local login while IFS= read -r login; do [ -n "$login" ] || continue case "$(bot_verdict "$login")" in APPROVE | BLOCK | FEEDBACK) continue ;; esac printf '%s\n' "$login" done <<<"${1-}" } 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. # # The list-what-passes rule has exactly one carve-out, and it is narrower # than an outcome: a CANCELLED entry is discarded when its context holds at # least one non-cancelled sibling (#139). The reconcile job queues in one # repo-global concurrency group, so any repo event — a sibling PR's push, # triage labelling an issue — evicts the queued duplicate AFTER it has # attached a check run to this PR's head, and that cancelled entry became # the context's newest word: blocker:ci-red on a PR whose real checks were # all green (#133/#136, evictable only by an empty commit). A cancelled run # said nothing about this head; a non-cancelled sibling is a real verdict # about exactly these bytes, whatever order the two arrived in — and for # this workflow the evictor performs the duplicate's work anyway, since # every sweep covers every open PR. This does not widen unknown-into-green: # a context whose entries are ALL cancelled never reported at all (a killed # or timed-out required job), so it keeps CANCELLED and still blocks — # discard needs a surviving verdict, never an empty context. # # And one exclusion that comes before every rule above: the label machine # never grades its own runs (#208). Every reconcile sweep serializes # through one shared concurrency group, and GitHub records a displaced # queued run as CANCELLED — there is no "superseded" conclusion for queue # displacement. When the displaced run was born from a pull_request_target # event, that cancelled entry attaches to the victim PR while its # SUCCESSOR — triggered by a different PR or an issues event — attaches # elsewhere, so the #139 carve-out's premise (a surviving sibling on the # same PR) fails structurally: on the victim the newest self entry stays # CANCELLED, the deny-list scores it FAILURE, and the sweep sets # blocker:ci-red off its own corpse — then re-affirms it every cadence. # Proven on crew#227: every real check green, the only red rollup entry # the sweep's own displaced run. So drop every entry belonging to # $SELF_WORKFLOW before the newest-per-context collapse. Accepted # consequences: a rollup of ONLY self entries scores NONE (honestly: no # checks — never SUCCESS), and a genuine reconcile failure surfaces on the # Actions tab instead of as blocker:ci-red, which is right because no PR # edit can fix the label machinery. An empty $self filters nothing — the # exclusion must never widen into dropping entries on a guess. jq -r --arg self "$SELF_WORKFLOW" ' 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". # The #208 exclusion (header above): self entries leave the rollup here, # BEFORE the group_by — a self-only context must vanish entirely, never # survive as an all-cancelled context that still classifies FAILURE. | [ (.statusCheckRollup // [])[] | select($self == "" or (.workflowName // "") != $self) | { 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) } ] # The #139 carve-out (header above): drop CANCELLED entries only when the # context keeps a non-cancelled survivor — BEFORE the sort, so a cancelled # entry that arrived newest cannot outvote the real verdict it displaced. # An all-cancelled context is left intact and still classifies FAILURE. | group_by(.ctx) | map( map(select(.outcome != "CANCELLED")) as $live | (if ($live | length) > 0 then $live else . end) | 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")" # This case grades a submitted verdict. The ingestion allow-list answers the # separate question of whether a row is a submitted review at all (#235). case "$state" in CHANGES_REQUESTED | REQUEST_CHANGES) # blocks at ANY head — both forges' 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 | COMMENT) # A comment is 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 ;; *) # An unknown state is not evidence that a reviewer answered. Keep the # round open and make the next forge vocabulary surprise visible (#235). log "$1: unrecognised review state $state" >&2 echo MISSING ;; esac } iso_epoch() { # $1 = ISO-8601 timestamp → epoch seconds; nothing, rc 1, when unreadable # An absent field reaches this as the empty string or as jq's literal "null"; # both are "we did not read a time", and neither may be graded as one. local at="${1-}" epoch case "$at" in "" | null) return 1 ;; esac epoch="$(date -d "$at" +%s 2>/dev/null)" || return 1 [ -n "$epoch" ] || return 1 printf '%s\n' "$epoch" } unrequested_quiescent() { # 0 when the unrequested facts have stood for the grace (#236 D2) # The stall blocker's supporting facts are the head and the round's newest # submitted review: the ask it demands is owed only once both have stopped # moving. Measured off those timestamps, not off sweep memory — ceremony#235 # was flagged inside the ~90 seconds between a round-answer push and the # author's re-request, because a sweep read the facts before the request # landed and wrote after it. That is a round in motion, not a dropped ball. # # "Newest submitted review" is any submitted review, COMMENTED included: a # non-verdict is still evidence the round is live, and counting it can only # delay a flag, never invent one. # # A timestamp we could not read refuses the blocker (the standing rule: an # unreadable fact never invents a verdict). This direction is deliberate and # asymmetric — a missed flag costs one sweep of the 15-minute cadence, a # false one flags a builder for doing exactly what BUILDER.md requires. local newest verdict_at verdict_epoch newest="$(iso_epoch "${HEAD_COMMIT_AT:-}")" || return 1 verdict_at="$(jq -r '[.[].submitted_at] | max // empty' <<<"${REVIEWS_JSON:-[]}")" if [ -n "$verdict_at" ]; then # A round WITH verdicts whose newest one cannot be dated is unreadable, not # quiescent; a round with no verdicts at all is simply the head's clock. verdict_epoch="$(iso_epoch "$verdict_at")" || return 1 [ "$verdict_epoch" -gt "$newest" ] && newest="$verdict_epoch" fi [ $((${NOW:-0} - newest)) -ge "$RECONCILE_UNREQUESTED_GRACE" ] } 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. # # And so is a head whose checks have not answered yet (#236 D1). This is the # one blocker that names an act the author must PERFORM, so it is the one # that has to know when performing it is permitted: BUILDER.md's review round # requires a green check at the head before requesting, so a builder waiting # out a pending run is complying, and flagging compliance teaches its readers # to ignore the label. Both 2026-08-03 instances were exactly that — # crew#318 at ~12:44Z carried state:addressing + blocker:unrequested while # the head's run was IN_PROGRESS, and ceremony#235 at 12:30Z caught the # ~90-second gap between a round-answer push and the re-request. # # PENDING and FAILURE each already have an owner, which is why gating loses # no coverage: on PENDING the next move is CI's and state:addressing / # state:bots-reviewing already say what the PR is doing; on FAILURE # blocker:ci-red owns that head, and stacking a second blocker on it # double-flags one stall. NONE joins SUCCESS because no checks configured is # nothing to wait for — the same reading the request rule gives the builder. # UNREADABLE never arrives here: the caller skips the PR before deciding. local checks_permit_the_ask=false case "${CHECKS:-NONE}" in SUCCESS | NONE) checks_permit_the_ask=true ;; esac if [ "$DRAFT" != true ] && [ "$checks_permit_the_ask" = 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 # The quiescence grace (#236 D2) is the last question, after the debt is # established: it asks whether the debt has stood long enough to be a # dropped ball rather than a round still in motion. if [ "$owed" = true ] && [ "$any_requested" = false ] && unrequested_quiescent; then echo blocker:unrequested fi fi } round_outranks_draft() { # 0 when the round's standing word survives a re-draft (#205) # A standing non-approving verdict outranks draft: a PR that took a round, # carries CHANGES_REQUESTED (or a comment owed a reply, or approvals a push # staled), and is then converted back to draft is a fix round in progress, # not a build — and hiding it behind state:building is a dropped ball the # staleness sweep reads as work in progress. Approvals do NOT outrank # draft: a re-draft after a passed round is deliberately building again, # and a draft must never read state:needs-human. # # A LIVE panel request on a draft also falls through — deliberately # surfaced, not absorbed (#205's must-not-paper-over): the bots ignore # drafts by design, so a draft wearing state:bots-reviewing on the board # is the visible symptom of a real defect (a request nobody cleared at # round close, or a hand-requested draft), and reading it as building # would hide exactly that. local b for b in "${REQUIRED_BOTS[@]}"; do requested "$b" && return 0 case "$(bot_verdict "$b")" in BLOCK | FEEDBACK | STALE) return 0 ;; esac done [ "$(bot_verdict "$HUMAN")" = BLOCK ] } decide_state() { # → the one state:* label this PR should carry # Draft decides the state only when the round implies nothing else (#205): # a draft with no round history reads state:building exactly as it always # has, and round_outranks_draft is what "nothing else" means. if [ "$DRAFT" = true ] && ! round_outranks_draft; then echo state:building return fi local s s="$(round_state)" # A draft disqualifies needs-human unconditionally (#205, round 1): with # the short-circuit above now conditional, a draft carrying a live human # request plus a standing bot block or comment fell through to # round_state, whose explicit-human-request precedence sits above the # BLOCK/FEEDBACK cases — and GitHub cannot merge a draft at all, so # "a human could merge this right now" would lie no matter what the # round says. state:addressing is the same honest landing the blocker/ # needs-ruling/blocked clauses below use: the round's word stands, only # the mergeable-now claim is off the table while the PR is a draft. if [ "$s" = state:needs-human ] && [ "$DRAFT" = true ]; then echo state:addressing return fi # 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 # A pending ruling disqualifies needs-human the same way (#51): while # `needs-ruling` is up, the human's turn lives in the THREAD — the flag # marks it — and "mergeable right now" must not read true beside an open # decision. state:addressing is the honest landing because the ball ON THE # PR is the builder's: the flag-setter judges when agreement is reached and # carries the ruling in (#50 D6). The label is deliberately NOT in BLOCKERS: # that array is machine-owned, and the converge loop strips every entry the # current facts do not re-derive — `needs-ruling` is hand-set intent the # machine reads and never writes (#50 D9), so parking it there would strip # a live escalation on the next 15-minute tick. if [ "$s" = state:needs-human ] && has_label needs-ruling; then echo state:addressing; return fi # A directed hold disqualifies it the same way (#180): `blocked` is hand-set # intent — triage sets it, anyone may correct it — and during the #111 # freeze rig#126/#128 carried it beside state:needs-human, so the board said # "mergeable right now" about PRs a hold said must not merge (rig#126 was # merged seven minutes later). Not a BLOCKERS entry, deliberately: that # array is machine-owned and the converge loop strips whatever the facts do # not re-derive, so emitting the label there would strip a live hold on the # next 15-minute tick — the same trap #51 names for `needs-ruling`. # state:addressing is the accepted imprecision: under a hold the builder # owes nothing, but "a human could merge this now" must not lie. if [ "$s" = state:needs-human ] && has_label blocked; then echo state:addressing; return fi echo "$s" } 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|Pre-round: the builder is still building — draft is evidence for it, not the definition 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 offsite|CFD3D7|Issue deliverable is a PR in another repository — claim clock paused needs-ruling|D4C5F9|A human decision is pending — question, options and a recommendation are in the comment attention|D93F0B|A demand is parked here for the assignee: pick up the thread, ack by removing this label 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 post-merge|006B75|Refs-linked PR merged; post-merge criteria remain and triage owns completion epic|5319E7|Organizes other issues via a dependency-ordered task list — builders never pick it EOF } retired_label_names() { # the GitHub defaults LABELS.md retires — a `question` is a discussion # One registry, kept beside core_label_rows() for the same reason those rows # are not in labels.conf: a rule that must hold in every governed repo # cannot live in a per-repo file. The six names match LABELS.md exactly. cat <<'EOF' duplicate invalid question wontfix help wanted good first issue EOF } bootstrap_labels() { # dispatch-only: ~20 upserts is too chatty for every cron tick local rows name 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 forge_label_create "$name" "$color" "$desc" done <<<"$rows" # LABELS.md publishes the defaults as deleted at bootstrap; until #93 # nothing deleted them — incubator's first dispatch ran green and left # `good first issue` standing. Deletion is dispatch-only like the upserts, # and never fatal: `gh label delete` exits non-zero on a label that is # already gone, the NORMAL case from the second dispatch on, and under # set -e an unguarded call aborts the whole run (#91's shape). A 403 # refusal gets the same tolerance — the bot bootstrap already 403s on # blocker:drill-pending, and a token that cannot delete must still get # the taxonomy it can create. Either way: log the name, keep going. while IFS= read -r name; do [ -n "$name" ] || continue run forge_label_delete "$name" \ || log "retire: '$name' not deleted (already absent, or refused) — continuing" done <<<"$(retired_label_names)" } has_label() { grep -qxF "$1" <<<"$LABELS"; } release_shape_warning() { # $1 = PR, $2 = head version, $3 = base version # The #128 incident's guard (#130): a release-shaped PR — bare X.Y.Z at # its head where the base says something else — reaching the board with # no `release` label is exactly the state whose merge would publish # nothing, so the sweep says so instead of letting the merge door # discover it. A WARNING, never a write: `release` is declared intent, # and the reconciler does not guess intent (LABELS.md's rule for # `blocked`/`release`). An unreadable version blocks nothing — the # sweep must not nag on facts it did not read. local n="$1" head_ver="$2" base_ver="$3" [ -n "$head_ver" ] || return 0 grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$' <<<"$head_ver" || return 0 [ "$head_ver" != "$base_ver" ] || return 0 echo "::warning::labels: #$n is release-shaped (version ${base_ver:-unreadable} -> $head_ver at its head) but carries no release label — the merge door reads that label as declared intent and will refuse without it; if this is the ceremony PR, apply release (#130; the #128 incident)" } tree_version() { # $1 = ref → that tree's version via the API, or nothing # Both backends, no checkout: a VERSION file first, package.json's # version field second (jq, not node — a read needs no npm machinery). # Every failure path prints nothing: the caller treats "could not read" # as "not release-shaped" rather than warning on a guess. local ref="$1" ver ver="$(forge_api "repos/$REPO/contents/VERSION?ref=$ref" --jq '.content' 2>/dev/null \ | base64 -d 2>/dev/null | tr -d '[:space:]')" if [ -z "$ver" ]; then ver="$(forge_api "repos/$REPO/contents/package.json?ref=$ref" --jq '.content' 2>/dev/null \ | base64 -d 2>/dev/null | jq -r '.version // empty' 2>/dev/null)" fi [ -z "$ver" ] || printf '%s\n' "$ver" return 0 } # label_write — every label mutation on this surface goes through # here (#192). A write that did not happen must reach main's exit code, and the # first version of this fix marked only the primary state edit: clearing # `merge-next` and the two `stale` edits could still fail into the generic # per-PR branch and finish with `reconciled.` and exit 0 # (@codex-reviewer-andresmgsl). One helper means a future call site cannot # reopen that by forgetting to mark itself. # # The marker is a log line rather than a return code because reconcile_pr runs # in a subshell whose STDOUT main reads — the same channel the degraded-read # warning already travels on. label_write() { local n="$1" shift if run forge_issue_edit "$n" "$@" >/dev/null; then return 0 fi log "#$n: label edit FAILED — attempted: forge_issue_edit $n $*; the write did not happen (reason on stderr above)" return 1 } reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch local n="$1" desired remove s args last_activity last_activity_epoch 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 forge_request_reviewer "$n" "$HUMAN" 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 label_write "$n" "${args[@]}"; then log "#$n: state -> $desired${add:+ +$add}${remove:+ (cleared $remove)}" else # A WRITE THAT DID NOT HAPPEN IS FATAL, not a warning (#192). This was # `log WARNING` and fell through, so the sweep printed `reconciled.` and # exited green over an edit the forge had refused — the # degraded-write-reports-success class #188 exists to eliminate, # surviving inside the reconciler that reports it. # # The old text also diagnosed a cause it had not established: it named a # missing label and told the operator to bootstrap, when the label was # present and the call had returned 500. #101's rule is report, do not # diagnose — so this says what was attempted and that it did not happen, # and leaves the backend's own stderr to say why. return 1 fi fi # ---- the release-shape guard (#130): a warning, never a write -------- # Drafts are exempt (the build phase is the builder's); the version # reads cost two API calls and only on PRs missing the label. if [ "$DRAFT" != true ] && ! has_label release; then release_shape_warning "$n" "$(tree_version "$HEAD_SHA")" "$(tree_version "$BASE_SHA")" 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 label_write "$n" --remove-label merge-next || return 1 log "#$n: cleared merge-next (state is $desired, not mergeable-by-a-human)" fi # ---- stale: real activity only, and blocked is legitimately quiet ---- # forge_pr_activity owns the portable half: issue comments + commits + # inline review comments. The flat /pulls/{n}/comments endpoint 404s on # Forgejo; the forgejo backend re-derives it from reviews with # comments_count > 0 (#188 / #4844). PR created_at and review submitted_at # stay here — they are already in hand and need no second fetch. last_activity="$( { jq -r '.created_at' <<<"$PR_JSON" jq -r '.[].submitted_at // empty' <<<"$REVIEWS_JSON" # Non-fatal degrade (pre-#188 same edge), but do NOT swallow stderr — # forge_api names failures loudly, and hiding them re-opens this issue's # silent-green class (#4879 / #101 D5). forge_pr_activity "$n" || true } | sort | tail -n1 )" last_activity_epoch="$(date -d "$last_activity" +%s)" age=$((NOW - last_activity_epoch)) # needs-ruling joins blocked here: waiting on a human is legitimately quiet # (#50 D10). The 7-day nudge is #52's, once for both surfaces. if has_label blocked || has_label needs-ruling || [ "$age" -le "$STALE_AFTER" ]; then if has_label stale; then label_write "$n" --remove-label stale || return 1 log "#$n: unstale" fi elif ! has_label stale; then label_write "$n" --add-label stale || return 1 log "#$n: stale ($((age / 3600))h quiet)" fi # ---- the ruling invariants (#52): the bare-flag check + the 7-day nudge -- # The stale EXEMPTION above is #51's; these are the sweep halves that ride # the same real-activity computation (lib/ruling.sh, shared with the issue # side). Behind the flag check so flag-free PRs — all of them, almost # always — cost no extra API reads. if has_label needs-ruling; then reconcile_ruling "$n" "$last_activity_epoch" "$NOW" fi # `attention` belongs on the assigned issue that owns the claim, never on # a pull request (#232). Behind the label gate so ordinary PRs pay no read. if has_label attention; then reconcile_attention "$n" pr "$(jq '.assignees | length' <<<"$PR_JSON")" "" fi } main() { # BEFORE anything reads the board (#188). Every call site below is still # `gh`, so that is what this declares — honestly, which is the point: on # a Forgejo consumer the preflight refuses here instead of letting the # sweep run blind and print "reconciled." over zero PRs (rig run 979). # The forge is decided once, here, before anything reads the board, and # the backend that can speak it is loaded (#188). The CEREMONY_FORGE_CLIENT # wrapper that stood here died with the call-site port: it declared "this # code uses gh", which stopped being true the moment every site went # through the shim, and leaving it would have defaulted the forgejo path # into the very client its own preflight refuses. forge_preflight || return 1 # "" means decide from the environment; forge_select takes an explicit # forge only in tests. forge_select "" || return 1 REPO="${REPO:?set REPO to owner/name}" LABELS_CONF="${LABELS_CONF:-.github/labels.conf}" load_config "$LABELS_CONF" NOW="$(date +%s)" # The bootstrap keys on the INPUT, never the event name. It used to test # GITHUB_EVENT_NAME = workflow_dispatch — correct while an operator's manual # dispatch was the only dispatch there was, and wrong from #209 on, when the # trigger job made EVERY event-woken sweep a workflow_dispatch run: the # bootstrap=no input became inert by construction, and every board event # re-upserted the taxonomy (ceremony#215 — runs 459/523, then venue drill # runs 16/17, which bootstrapped on a delivered "no" and caught this). if [ "${BOOTSTRAP:-no}" = yes ]; then log "bootstrap=yes: 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="$(forge_label_list 2>/dev/null || echo "")" [ -z "$REPO_LABELS" ] && log "WARNING: could not read the label set — applying labels unfiltered" missing_core_labels_warning "$(core_label_rows)" "$REPO_LABELS" local n output status total=0 unreadable=0 write_failures=0 sampled_reason="" while IFS= read -r n; do [ -n "$n" ] || continue total=$((total + 1)) status=0 output="$( ( PR_JSON="$(forge_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")" BASE_SHA="$(jq -r '.base.sha' <<<"$PR_JSON")" LABELS="$(jq -r '.labels[].name' <<<"$PR_JSON")" # This allow-list answers whether a row is a submitted, gradeable review; # bot_verdict separately answers what that submitted verdict says (#235). # PENDING drafts and Forgejo REQUEST_REVIEW request rows are not reviews. REVIEWS_JSON="$(forge_api --paginate "repos/$REPO/pulls/$n/reviews" --jq '.[]' \ | jq -s '[.[] | select(.state == "APPROVED" or .state == "CHANGES_REQUESTED" or .state == "REQUEST_CHANGES" or .state == "COMMENTED" or .state == "COMMENT")]')" # Read AFTER the reviews, because the raw field is not portable: Forgejo # never clears it, so it is intersected with who still owes a verdict on # this head (#188 term 4). A no-op on GitHub, which clears it itself. REQUESTED="$(outstanding_requests "$(jq -r '.requested_reviewers[].login' <<<"$PR_JSON")")" # 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. # The WHY goes to gh's stderr, and 2>/dev/null threw it away — a # permanent denial and a network hiccup left byte-identical evidence, # and #95 had to infer a cause from a control case instead of reading # it off a run (wrongly, it turned out). Captured into a file (#101 # D2), never left to interleave raw into the per-PR output block, # where an unlucky line could collide with a matched string. GH_VIEW_ERR_FILE="$(mktemp)" GH_VIEW="$(forge_pr_view "$n" 2>"$GH_VIEW_ERR_FILE" || echo '{}')" GH_VIEW_ERR="$(cat "$GH_VIEW_ERR_FILE")" rm -f "$GH_VIEW_ERR_FILE" 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 # Two lines on purpose (#101 D1): the sweep detects a wholly blind # pass by whole-line-matching the counted line below, so the reason # rides its OWN line — folding it in would silently break the # `unreadable` counter and the wholly-blind warning #96 landed. log "#$n: could not read mergeability/checks — left alone this pass" log "#$n: read failed: $(read_failure_reason "$GH_VIEW_ERR")" exit 0 fi # The head's own clock, for the blocker:unrequested grace (#236 D2). One # read, pinned to the head SHA — not `gh pr view --json commits`, which # asks for the FIRST hundred commits and would date a longer PR by a # commit that is not its head. Last of the fetches on purpose: a PR the # skip above walked away from must not pay for it, and neither do drafts, # which never reach that blocker. Empty (a failed read, or a body without # the field) leaves the blocker unjudged, by unrequested_quiescent. HEAD_COMMIT_AT="" if [ "$DRAFT" != true ]; then HEAD_COMMIT_ERR_FILE="$(mktemp)" HEAD_COMMIT_AT="$(forge_commit_at "$HEAD_SHA" \ 2>"$HEAD_COMMIT_ERR_FILE" || echo "")" HEAD_COMMIT_ERR="$(cat "$HEAD_COMMIT_ERR_FILE")" rm -f "$HEAD_COMMIT_ERR_FILE" case "$HEAD_COMMIT_AT" in "" | null) # Say why it degraded (#101 D2/D4), on its own line: this one # narrows a blocker rather than skipping the PR, so it must not # read as the wholly-blind shape the counted line above matches. HEAD_COMMIT_AT="" log "#$n: could not read the head commit's date: $(read_failure_reason "$HEAD_COMMIT_ERR") — blocker:unrequested not judged this pass" ;; esac fi reconcile_pr "$n" ) 2>&1 )" || status=$? [ -n "$output" ] && printf '%s\n' "$output" if grep -qxF "labels: #$n: could not read mergeability/checks — left alone this pass" <<<"$output"; then unreadable=$((unreadable + 1)) # the first observed reason stands in for the sweep in the blind warning if [ -z "$sampled_reason" ]; then sampled_reason="$(sed -n "s/^labels: #$n: read failed: //p" <<<"$output" | head -n1)" fi elif [ "$status" -ne 0 ]; then # The per-PR tolerance is right and stays: one bad PR must not blind the # sweep over the rest of the board. What was missing is the sweep-level # accounting — a failed WRITE has to reach main's exit code, or a builder # satisfies every task and the sweep still prints `reconciled.` over an # edit that never happened (#192, @kimi-reviewer-andresmgsl #5189). # # Reads stay tolerated: an unreadable fact is already reported by the # blind-sweep warning and leaves the board untouched. A write is # different — the board and the tree now disagree. if grep -q "^labels: #$n: label edit FAILED" <<<"$output"; then write_failures=$((write_failures + 1)) log "#$n: reconcile failed on a WRITE — continuing the sweep, but it will not report success" else log "#$n: reconcile failed — continuing with the remaining PRs" fi fi done < <(forge_pr_list) blind_sweep_warning "$unreadable" "$total" "$sampled_reason" if [ "$write_failures" -gt 0 ]; then # The line must not contain the literal "reconciled." ANYWHERE — "NOT # reconciled." still does, and a consumer grepping a job-log tail for that # token would find it after a write that did not happen # (@codex-reviewer-andresmgsl). The test asserts the whole output is free # of it, not merely that the success prefix is absent. log "$write_failures label write(s) attempted did not happen — sweep incomplete" return 1 fi log "reconciled." } # sourced by test/labels-reconcile.sh for the fixture tests; executed in CI if [ "${BASH_SOURCE[0]}" = "$0" ]; then main "$@" fi