# Pr Autofix

> Fix PRs autonomously. Triage open PRs by tier, address thread feedback, fix CI failures, and enable auto-merge when the 4-condition Ready-to-Merge gate passes. Use when you say `pr-autofix`, `autofix this pr`, or `monitor open prs`. Do NOT use to answer review comments interactively on one PR (use pr-review), and do NOT use to run the six quality axes over your working changes (use pr-quality-all).

- Skill: `rjmurillo/pr-autofix` (Agent Skill)
- Install (CLI): `npx skillmds@latest add rjmurillo/pr-autofix`
- Raw SKILL.md: https://api.skillmd.com/api/skills/rjmurillo/pr-autofix/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- License: MIT
- Author: rjmurillo (https://skillmd.com/u/rjmurillo)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/rjmurillo/pr-autofix

---


# PR Autofix

<!-- # taste-lint: ignore file-size, this skill is one end-to-end PR workflow; splitting it would hide required lease and mutation gates from the agent. -->

<!--
size-exception rationale (Issue #4016, carried forward under ADR-064).

What the check wants: the skill_size validator blocks a SKILL.md over 500 lines
or 24576 bytes and tells you to decompose it into references/.

Why the idiomatic fix does not apply: this file carries the entire Ready-to-Merge
protocol by design ("Nothing outside it is needed to run this skill"). The body
includes the tier ladder, Ready-to-Merge gate definition, thread-lifecycle state
machine, and CI-failure triage procedure, and the Process phases are one annotated
bash program whose ordering IS the safety argument. Splitting it into references/
moves a lease gate, a live-state gate, or a disarm gate out of the loaded body,
which is a behavioral change that must be measured against the eval harness before
shipping (Issue #3953 doctrine). ADR-064 changed where this file lives, not what
it carries, so the exception moves with it unchanged.
Preserved invariant: One loaded workflow owns lease, mutation safety, live-state revalidation, and merge readiness.
Behavioral tests: `tests/test_pr_autofix_late_live_state_gate.py`, `tests/test_pr_autofix_force_push_lease.py`, `tests/test_pr_autofix_worktree_identity.py`, `tests/skills/pr-autofix/test_check_pr_round_cap.py`
Review trigger: Revisit when a measured split keeps those tests green and Ready-to-Merge behavior unchanged.
vendor-portability: upstream-only. Test paths reference rjmurillo/ai-agents contributor fixtures; installed plugin consumers do not have these files.
-->

Autonomous PR monitor and fixer. This file carries the whole protocol,
including the Ready-to-Merge definition below. Two bundled dependencies are
required beyond it, both from the github skill: Phase 0 runs its transport
preflight, and MCP mode reads its routing reference. Everything else needed
to run this skill is here.

Migrated from the pr-autofix command under ADR-064, which makes skills the single
user-invocable surface. The command file is gone, so its path is named here in
plain text rather than as a citation to something a reader could open.

## Triggers

| Trigger phrase | Operation |
|----------------|-----------|
| `pr-autofix` | Triage all open PRs by tier and act |
| `autofix this pr` | Single-PR mode on the current branch's open PR |
| `monitor open prs` | Periodic triage without merging |
| `auto-merge ready prs` | Tier 1 only: enable auto-merge on land-ready PRs |
| `address pr feedback` | Tier 3/4 only: walk thread lifecycle |

## Process

Four phases. Tier-based dispatch decides which actions apply per PR.

### Phase 0: Transport preflight (BLOCKING, runs once)

Every script this command calls reaches GitHub through `gh`. An agent sandbox
that proxies egress leaves `gh` installed and `GH_TOKEN` set while refusing
GitHub for the whole session, so each script fails with HTTP 403 and none of
those failures says anything about the PR. Decide the transport once, before
triage:

```bash
# CLAUDE_PLUGIN_ROOT is set in a vendored install; falls back to .claude in-repo.
SCRIPTS_DIR="${COPILOT_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-.claude}}/skills/github/scripts/utils"
python3 "$SCRIPTS_DIR/check_github_transport.py"
```

Non-POSIX hosts do not run the block above. The authoritative, per-harness
launcher for this one command is `check_transport` in the `scripts` map of
`pr-review-config.yaml`: the `copilot` entry runs it under PowerShell and
resolves the interpreter (`python3`, then `py -3`, then `python`), because a
Windows host may expose only the launcher. Read it from the map for the harness
you are on rather than transcribing a second copy here, which would drift.

| Verdict | Action |
|---------|--------|
| `Transport: gh` | Continue. Every script below works as written. |
| `Transport: gh_unusable` | Continue in MCP mode; see the rule block below. |
| exit 3 | A quota window or a transport wobble, not a transport change. Stop and retry later. |
| exit 4 | A credential fault. Stop and report it; do not switch transports around a fixable token. |

**MCP mode rules.** The mapping from each script to its GitHub MCP operation
is in the github skill's `references/transport-routing.md`, which also gives
the per-harness spelling for the operation names. On top of it:

1. Read operations (triage, CI status, check logs, review threads, PR and
   issue bodies) all have MCP equivalents. Use them freely.
   Write operations (replies, resolving threads, arming auto-merge, merging)
   also have MCP equivalents, but rule 3 governs whether you may use them at
   all, and the grant in this file's frontmatter does not include any of them.
   Do not read this row as permission: it says the tool exists, not that the
   operation is allowed here.
   Pushing is not on that list and has no MCP equivalent. `push_files` builds
   a new remote commit out of file contents you supply; it does not transfer
   local commits or branch history, so it cannot stand in for `git push`.
2. `test_pr_merge_ready.py`, `check_pr_live_state.py`, `why_pr_blocked.py`,
   `triage_red_check.py`, `run_completion_gate.py`, `check_pr_round_cap.py`,
   and `pr_autofix_lease.py` have no MCP equivalent, because each computes a
   verdict from several `gh` calls plus local logic. `triage_red_check.py` is
   named explicitly because the CI-failure triage step below makes it
   mandatory before any log reading, and a blocked session must derive that
   verdict by hand rather than falling back to `gh`. Most of their inputs are
   reachable: gather the same fields with `pull_request_read` and apply the gate
   definitions in this file by hand. Record in the PR handoff that the verdict
   was derived, not scripted.
   One input is not reachable, and it is load-bearing. No exposed MCP operation
   reads a branch ruleset, so the required-context set is unavailable, which is
   exactly the case where a check-run list reads clean on a PR that cannot
   merge. `test_pr_merge_ready.py` therefore cannot be reconstructed as a PASS
   at all. Report the checks and merge state you did read, say the
   required-context set was not read, and treat merge readiness as unknown.
3. The lease and the round cap protect against two sessions fighting over one
   branch, and single-PR mode does not replace them: two sessions can each be
   handed the same PR explicitly and still race toward the same branch, which
   is the case `pr_autofix_lease.py` exists to stop. So without a lease, MCP
   mode is read-only. Triage, read threads, and report, but do not push, reply,
   resolve, arm auto-merge, or merge. If a mutation is genuinely needed, either
   implement acquire, renew, and release against the same marker-comment
   protocol using MCP operations first, or hand the PR back and say a lease
   could not be held. Do not sweep the open queue either way.
   This rule is not enforced by the tool grant and cannot be. The frontmatter
   withholds every MCP write, but `allowed-tools` is one static list for both
   modes, and `gh` mode legitimately pushes, so `Bash` stays unrestricted and
   `git push` remains available here. Git is also a separate transport from the
   GitHub API: issue #3139 records a push succeeding while the API was failing,
   so a refused session does not stop one. The read-only constraint is
   therefore yours to keep, and pushing in this mode races a branch no lease
   protects. Enforcing it needs a per-mode permission profile, which is #5519
   (Copilot review on PR #5509).
4. A transport failure is an unknown, never a verdict. Never classify a PR as
   T2 (CI fix), BLOCKED, or DIRTY because a call failed. Report the PR as
   untriaged with the transport as the reason.

### Phase 1: Triage

Run `test_pr_merge_ready.py` for every open PR. Classify each into a tier (T1-T5) using the table below. Sort the queue by tier ascending.

### Phase 2: Act per tier

Walk the queue. For each PR, apply the tier's action set. T1 first (land-ready), then T2 (CI fix), then T3/T4 (threads). T5 applies to bot-authored PRs that pass the merge-state gates (not BEHIND, BLOCKED, or DIRTY) but still have a failure or unresolved threads; the tier-dispatch block terminates such a PR after the auto-merge disarm gate so a human handles it (issue #5208). A bot PR whose merge state is blocked stays at that merge-state tier and the automated loop processes it.

**Per-PR live-state gate (BLOCKING, issue #2455).** Before any action runs on a PR (any tier: arming auto-merge, pushing a CI fix, posting a thread reply), call `check_pr_live_state.py` and branch on the JSON envelope `Data.action` field. The session-start triage snapshot is stale by the time the walk reaches each row in a repo with heavy merge automation, and the consequences of acting on a stale row are concrete: armed auto-merge on a redundant PR, conflict merges into a closed branch, duplicate logic landed twice.

```bash
# One outer fetch covers all per-PR calls; --skip-fetch keeps the loop cheap.
git fetch --quiet origin "+refs/heads/main:refs/remotes/origin/main"
resolve_pr_scripts_dir() {
  repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
  for root in \
    "${COPILOT_PLUGIN_ROOT:-}" \
    "${CLAUDE_PLUGIN_ROOT:-}" \
    "$repo_root/.claude" \
    "${HOME:-}/.copilot/installed-plugins/_direct/project-toolkit" \
    "${HOME:-}/.copilot/installed-plugins"/*/project-toolkit \
    "${HOME:-}/.claude/plugins/cache"/*/project-toolkit; do
    if [ -n "$root" ] && [ -d "$root/skills/github/scripts/pr" ]; then
      printf '%s\n' "$root/skills/github/scripts/pr"
      return 0
    fi
  done
  printf '%s\n' ".claude/skills/github/scripts/pr"
}
SCRIPTS_DIR="$(resolve_pr_scripts_dir)"

# late-live-state-guard:start
# lease-renewal:start
LEASE_RENEW_PID=""
LEASE_RENEW_FAILURE_FILE=""
LEASE_RENEW_INTERVAL_SECONDS="${LEASE_RENEWAL_INTERVAL_SECONDS:-300}"
LEASE_CLEANUP_DONE=0

renew_lease_once() {
    python3 "$SCRIPTS_DIR/pr_autofix_lease.py" renew \
        --pull-request "$PR" --session "$SESSION_ID" --output-format json >/dev/null
}

stop_lease_renewal() {
    if [ -z "$LEASE_RENEW_PID" ]; then
        return 0
    fi
    kill -- "-$LEASE_RENEW_PID" 2>/dev/null || true
    kill "$LEASE_RENEW_PID" 2>/dev/null || true
    wait "$LEASE_RENEW_PID" 2>/dev/null || true
    LEASE_RENEW_PID=""
}

lease_renewal_failed() {
    [ -n "$LEASE_RENEW_FAILURE_FILE" ] && [ -s "$LEASE_RENEW_FAILURE_FILE" ]
}

start_lease_renewal() {
    stop_lease_renewal
    LEASE_CLEANUP_DONE=0
    LEASE_RENEW_INTERVAL_SECONDS="${LEASE_RENEWAL_INTERVAL_SECONDS:-300}"
    LEASE_RENEW_FAILURE_FILE="$(mktemp)"
    (
        current_child=""
        stop_current_child() {
            if [ -n "$current_child" ]; then
                kill "$current_child" 2>/dev/null || true
                wait "$current_child" 2>/dev/null || true
            fi
        }
        trap stop_current_child EXIT INT TERM
        while true; do
            sleep "$LEASE_RENEW_INTERVAL_SECONDS" &
            current_child=$!
            wait "$current_child" || break
            current_child=""
            renew_lease_once >/dev/null &
            current_child=$!
            if ! wait "$current_child"; then
                printf '%s\n' "renewal failed while holding the lease" > "$LEASE_RENEW_FAILURE_FILE"
                break
            fi
            current_child=""
        done
    ) &
    LEASE_RENEW_PID=$!
    trap cleanup_pr_autofix EXIT
    trap 'cleanup_pr_autofix; exit 130' INT
    trap 'cleanup_pr_autofix; exit 143' TERM
}

cleanup_pr_autofix() {
    if [ "$LEASE_CLEANUP_DONE" -eq 1 ]; then
        return 0
    fi
    LEASE_CLEANUP_DONE=1
    stop_lease_renewal
    python3 "$SCRIPTS_DIR/pr_autofix_lease.py" release \
        --pull-request "$PR" --session "$SESSION_ID" --output-format json || true
}

release_pr_lease() {
    cleanup_pr_autofix
}

prepare_lease_for_mutation() {
    stop_lease_renewal
    if ! renew_lease_once; then
        printf '%s\n' "renewal failed before mutation" > "$LEASE_RENEW_FAILURE_FILE"
        return 1
    fi
    start_lease_renewal
}

stop_mutation_group() {
    local mutation_pid=$1 stop_attempt
    kill -TERM -- "-$mutation_pid" 2>/dev/null || true
    for stop_attempt in 1 2 3 4 5 6 7 8 9 10; do
        if ! kill -0 -- "-$mutation_pid" 2>/dev/null; then
            break
        fi
        sleep 0.05
    done
    kill -KILL -- "-$mutation_pid" 2>/dev/null || true
}

run_mutation_with_lease_monitor() {
    local mutation_pid mutation_pgid mutation_rc mutation_state start_attempt
    python3 -c \
        'import errno, os, sys
try:
    os.setsid()
except OSError as exc:
    if exc.errno != errno.EPERM or os.getpgrp() != os.getpid():
        raise
os.execvp(sys.argv[1], sys.argv[1:])' \
        "$@" &
    mutation_pid=$!
    mutation_pgid=""
    for start_attempt in 1 2 3 4 5 6 7 8 9 10; do
        mutation_pgid=$(ps -o pgid= -p "$mutation_pid" 2>/dev/null | tr -d ' ')
        if [ "$mutation_pgid" = "$mutation_pid" ]; then
            break
        fi
        mutation_state=$(ps -o stat= -p "$mutation_pid" 2>/dev/null | tr -d ' ')
        if ! kill -0 "$mutation_pid" 2>/dev/null || [ "${mutation_state#Z}" != "$mutation_state" ]; then
            if wait "$mutation_pid"; then
                mutation_rc=0
            else
                mutation_rc=$?
            fi
            if lease_renewal_failed; then
                stop_mutation_group "$mutation_pid"
                echo "Mutation completed as lease ownership was lost for #$PR"
                cleanup_pr_autofix
                return 75
            fi
            stop_mutation_group "$mutation_pid"
            return "$mutation_rc"
        fi
        sleep 0.01
    done
    if [ "$mutation_pgid" != "$mutation_pid" ]; then
        mutation_state=$(ps -o stat= -p "$mutation_pid" 2>/dev/null | tr -d ' ')
        if ! kill -0 "$mutation_pid" 2>/dev/null || [ "${mutation_state#Z}" != "$mutation_state" ]; then
            if wait "$mutation_pid"; then
                mutation_rc=0
            else
                mutation_rc=$?
            fi
            if lease_renewal_failed; then
                stop_mutation_group "$mutation_pid"
                echo "Mutation completed as lease ownership was lost for #$PR"
                cleanup_pr_autofix
                return 75
            fi
            stop_mutation_group "$mutation_pid"
            return "$mutation_rc"
        fi
        kill "$mutation_pid" 2>/dev/null || true
        wait "$mutation_pid" 2>/dev/null || true
        echo "Stopping mutation for #$PR: process group setup failed"
        cleanup_pr_autofix
        return 75
    fi
    if lease_renewal_failed; then
        stop_mutation_group "$mutation_pid"
        wait "$mutation_pid" 2>/dev/null || true
        echo "Stopping mutation for #$PR: lease ownership lost"
        cleanup_pr_autofix
        return 75
    fi
    while kill -0 "$mutation_pid" 2>/dev/null; do
        if lease_renewal_failed; then
            sleep 0.02
            mutation_state=$(ps -o stat= -p "$mutation_pid" 2>/dev/null | tr -d ' ')
            if ! kill -0 "$mutation_pid" 2>/dev/null || [ "${mutation_state#Z}" != "$mutation_state" ]; then
                stop_mutation_group "$mutation_pid"
                if wait "$mutation_pid"; then
                    mutation_rc=0
                else
                    mutation_rc=$?
                fi
                echo "Mutation completed as lease ownership was lost for #$PR"
                cleanup_pr_autofix
                return 75
            fi
            stop_mutation_group "$mutation_pid"
            wait "$mutation_pid" 2>/dev/null || true
            echo "Stopping mutation for #$PR: lease ownership lost"
            cleanup_pr_autofix
            return 75
        fi
        sleep 0.05
    done
    if wait "$mutation_pid"; then
        mutation_rc=0
    else
        mutation_rc=$?
    fi
    if lease_renewal_failed; then
        stop_mutation_group "$mutation_pid"
        echo "Mutation completed as lease ownership was lost for #$PR"
        cleanup_pr_autofix
        return 75
    fi
    stop_mutation_group "$mutation_pid"
    return "$mutation_rc"
}
# lease-renewal:end

recheck_pr_live_state() {
    local late_live late_rc late_action late_reason late_state late_head late_base
    if late_live=$(python3 "$SCRIPTS_DIR/check_pr_live_state.py" \
        --pull-request "$PR" --skip-fetch \
        --expected-head-sha "${EXPECTED_HEAD_SHA:-}" \
        --expected-base-ref "${EXPECTED_BASE_REF:-}" \
        --expected-base-sha "${EXPECTED_BASE_SHA:-}" \
        --output-format json); then
        late_rc=0
    else
        late_rc=$?
    fi

    late_action=$(printf '%s' "$late_live" | jq -r '.Data.action // empty' 2>/dev/null)
    if [ "$late_rc" -eq 0 ] && [ "$late_action" = "ACT" ]; then
        return 0
    fi

    late_reason=$(printf '%s' "$late_live" | jq -r '.Data.reason // "live-state check failed"' 2>/dev/null)
    late_state=$(printf '%s' "$late_live" | jq -r '.Data.state // "UNKNOWN"' 2>/dev/null)
    late_head=$(printf '%s' "$late_live" | jq -r '.Data.head_sha // "unknown"' 2>/dev/null)
    late_base=$(printf '%s' "$late_live" | jq -r '.Data.base_ref // "main"' 2>/dev/null)
    echo "Skipping mutation for #$PR: $late_reason"
    if [ "$late_state" = "MERGED" ]; then
        echo "Merged head SHA: $late_head"
        echo "Preserve unpushed commits or a net patch. Reapply them on a follow-up branch from current origin/$late_base."
    elif [ "$late_state" = "CLOSED" ]; then
        echo "Closed PR head SHA: $late_head"
        echo "Preserve unpushed commits or a net patch before leaving the old branch."
    fi
    cleanup_pr_autofix
    return 75
}

run_pr_mutation_if_live() {
    if lease_renewal_failed; then
        echo "Skipping mutation for #$PR: lease renewal failed"
        cleanup_pr_autofix
        return 75
    fi
    if ! prepare_lease_for_mutation; then
        echo "Skipping mutation for #$PR: lease renewal failed"
        cleanup_pr_autofix
        return 75
    fi
    if recheck_pr_live_state; then
        run_mutation_with_lease_monitor "$@"
        return $?
    fi
    return 75
}
# late-live-state-guard:end

# SESSION_ID must be set before the loop (e.g. from the session log or a uuid).
# Per PR, immediately before any per-tier action:

# Step 1: Acquire the branch-ownership lease (issue #3413, ADR-076 Phase 1).
# Exit 1 = SKIP. Branch on .Data.reason so a lease-store outage is surfaced as
# a distinct diagnostic instead of being silently misreported as contention
# (issue #4966 MEDIUM). .Data.held_by does not exist in the envelope; the
# machine-readable field is .Data.reason (held-by:<owner> or
# lease-store-unavailable).
LEASE=$(python3 "$SCRIPTS_DIR/pr_autofix_lease.py" acquire \
    --pull-request "$PR" --session "$SESSION_ID" --output-format json) || {
    LEASE_RC=$?
    if [ "$LEASE_RC" -eq 1 ]; then
        LEASE_REASON=$(echo "$LEASE" | jq -r '.Data.reason // "unknown"')
        case "$LEASE_REASON" in
            lease-store-unavailable)
                # Store unreachable: ownership is unknown and acquire fails
                # CLOSED (issue #4966). This is NOT contention. Surface a clear
                # diagnostic so a persistent outage cannot make every PR SKIP
                # forever with no alert; investigate the API/network path.
                echo "Lease store unreachable for #$PR (reason=$LEASE_REASON); ownership unknown, failing closed and skipping. Check GitHub API/network before retrying." >&2
                ;;
            held-by:*)
                echo "Lease held by ${LEASE_REASON#held-by:} for #$PR; skipping."
                ;;
            *)
                echo "Lease acquire returned SKIP for #$PR (reason=$LEASE_REASON); skipping."
                ;;
        esac
        continue
    fi
    echo "Lease acquire failed (exit $LEASE_RC) for #$PR; skipping to avoid racing."
    continue
}
start_lease_renewal

# Step 2: Live-state gate (BLOCKING, issue #2455).
LIVE=$(python3 "$SCRIPTS_DIR/check_pr_live_state.py" \
    --pull-request "$PR" --skip-fetch --output-format json)
ACTION=$(echo "$LIVE" | jq -r '.Data.action')
if [ "$ACTION" = "SKIP" ]; then
    REASON=$(echo "$LIVE" | jq -r '.Data.reason')
    echo "Skipping #$PR: $REASON"
    cleanup_pr_autofix
    # If Data.superseded_by_base.fully_superseded == true, recommend close
    # via the queue's close-handling path; do NOT push or merge.
    continue
fi
EXPECTED_HEAD_SHA=$(echo "$LIVE" | jq -r '.Data.head_sha // empty')
EXPECTED_BASE_REF=$(echo "$LIVE" | jq -r '.Data.base_ref // empty')
EXPECTED_BASE_SHA=$(echo "$LIVE" | jq -r '.Data.base_sha // empty')
if [ -z "$EXPECTED_HEAD_SHA" ] || [ -z "$EXPECTED_BASE_REF" ] || [ -z "$EXPECTED_BASE_SHA" ]; then
    echo "Cannot bind mutation to the live PR identity for #$PR; skipping."
    cleanup_pr_autofix
    continue
fi
# ACTION == "ACT": proceed with the tier's planned action set.

# tier-dispatch:start
# Step 2.5: Tier read, then the auto-merge disarm gate, then the round-cap
# circuit breaker (BLOCKING for T3/T4, issue #5056). The two gates run in that
# order for the reason given at the disarm gate below; do not swap them back.
# T3/T4 PRs iterate: post a fix, wait for CI or a bot review, repeat. Nothing
# capped how many times that loop could run, and prose caps have been
# ignored repeatedly (the documented failure mode, not a hypothetical one):
# PR #1887 ran 11+ bot review rounds over 46 hours wall clock before a human
# intervened (see the pr-1887-iteration-paradox retrospective in this repo's
# rjmurillo/ai-agents source, contributor-only); PRs #1965 and #1979 each ran
# 18 rounds (see the CI-FEEDBACK-SUBLOOP governance doc, same source, line
# 11). check_pr_round_cap.py records one round per call against a
# hidden marker comment on the PR (same storage pattern as
# pr_autofix_lease.py's ADR-076 lease) and returns Data.action=ESCALATE when
# either the round count or the wall-clock budget is exceeded. Call it once
# per pass through this loop for a T3/T4 PR, after the tier is known and after
# the auto-merge disarm gate below, before any thread-lifecycle or CI-fix
# action. It used to say "immediately after the tier is known", which was true
# until the disarm gate moved ahead of it to close CWE-284; the disarm gate and
# the unknown-tier exit now both run in between.
# Tier comes from test_pr_merge_ready.py, the authoritative tier source;
# check_pr_live_state.py emits no tier field, so reading $LIVE here pins TIER
# at UNKNOWN. Computed once, also consumed by the auto-merge disarm gate below.
# Read `.Tier`, not `.Data.Tier`: unlike the github_core.output emitters,
# test_pr_merge_ready.py has no --output-format flag and prints its result
# dict directly (`print(json.dumps(result, indent=2))`), so there is no Data
# envelope to traverse.
# A pinned UNKNOWN breaks the two gates in OPPOSITE directions, so do not
# read it as "the gates turn off": this gate tests TIER = T3 or T4, which
# UNKNOWN never satisfies, so it goes inert; the disarm gate below tests
# TIER != T1, which UNKNOWN always satisfies, so it fires on every armed PR
# and strips auto-merge from genuine T1 PRs too.
# tests/commands/test_pr_autofix_field_contract.py checks every read in this
# file against its producer's real schema, and
# tests/commands/test_pr_autofix_tier_dispatch_runtime.py executes the block
# between the tier-dispatch markers under bash with fake producers, so the two
# gate directions below are asserted behavior rather than described behavior.
if ! AUTHOR_CTX=$(python3 "$SCRIPTS_DIR/get_pr_context.py" --pull-request "$PR" \
    --field author_is_bot --output-format json 2>/dev/null); then
    # Focused mode keeps this read off the review-thread pagination path. A
    # stale installed helper can still reject `--field`; fall back once so the
    # existing absent-field diagnostic keeps naming a pre-#5208 copy rather
    # than collapsing it into the generic unreadable branch.
    if ! AUTHOR_CTX=$(python3 "$SCRIPTS_DIR/get_pr_context.py" --pull-request "$PR" \
        --output-format json 2>/dev/null); then
        AUTHOR_CTX=""
    fi
fi
# Bot-author lookup, read BEFORE the tier producer because the tier depends on
# it (issue #5208). classify_tier only reaches T5 when `is_bot and
# (has_ci_failures or has_threads)`, and its `is_bot` parameter defaults to
# False, so a producer call that omits --is-bot cannot return T5 at all: every
# affected bot PR that reached work-tier classification came back T2, T3, or T4
# and was processed in the unattended loop. T5 is reached only when the
# merge state passes the earlier gates (not BEHIND, BLOCKED, or DIRTY) AND
# `is_bot and (has_ci_failures or has_threads)`. A bot PR whose merge state
# is blocked by failing checks stays at its merge-state tier and does not
# reach the T5 handoff, which is correct: the automated loop handles it.
# Author identity is stable during one pass, so this answer is kept for tier
# production. The focused `--field author_is_bot` mode keeps the read down to
# one `gh pr view --json author` call instead of a full context walk. Auto-merge
# is mutation-sensitive and is fetched again at the disarm gate below, in the
# matching `--field auto_merge_method` mode. That split is deliberate: the
# one-fetch contract is retired because a stale auto-merge verdict fails open.
# The classification itself is NOT made here. get_pr_context.py emits
# `author_is_bot` from github_core.bot_config.is_bot, the repository's one
# authoritative bot-author rule. Re-deriving it here as a `[bot]` suffix test
# would disagree with that rule on the logins this repository actually sees:
# bot_config.py maps `app/copilot-swe-agent`, the spelling `gh pr view --json
# author` returns for the Copilot coding agent, onto `copilot-swe-agent[bot]`,
# and neither that spelling nor `Copilot` carries a `[bot]` suffix.
# jq can emit a valid first value then exit nonzero on a malformed suffix.
# The assignment must be the condition: checking the document with a second jq
# call can succeed on a later value even when this filter failed.
if ! IS_BOT=$(printf '%s' "$AUTHOR_CTX" | jq -r 'if (.Data | has("author_is_bot") | not) then "absent" elif (.Data.author_is_bot | type) == "boolean" then (.Data.author_is_bot | tostring) else "unknown" end' 2>/dev/null); then
    IS_BOT="unknown"
fi
# `absent` is a separate verdict from `unknown` because it has a separate cause
# and a separate remedy. resolve_pr_scripts_dir above tries $COPILOT_PLUGIN_ROOT
# then $CLAUDE_PLUGIN_ROOT, then $repo_root/.claude, and only after that the three
# installed-plugin caches; the caches never outrank the checkout, which is what
# ci-scripts.md MUST-8 requires. So $SCRIPTS_DIR/get_pr_context.py is a stale copy
# in exactly two situations: either plugin-root variable is set and points at an
# install that predates the fix, or this session is running outside a checkout that
# carries skills/github/scripts/pr and falls through to a cache. Diagnose in that
# order, plugin roots first. A copy predating issue #5208 emits no
# author_is_bot key at all, which fails closed like any other unreadable author
# and would otherwise reclassify EVERY PR with a failing check or an unresolved
# thread as T5, repo-wide, announced by one indistinguishable line. Naming the
# stale helper is what turns that from a silent reclassification into a fixable
# report. Same hazard the "Checkout ownership for the readiness helper (issue
# #2443)" section below records for test_pr_merge_ready.py.
# Empty stdin or a jq parse error yields an empty string, not "unknown", which is
# neither "false" nor any verdict the branch below can name; the fail-closed
# branch would then be taken with no message at all. The sibling AUTO_MERGE read
# further down documents the same trap. Normalize it here so every path out of
# this read is a verdict with a name.
IS_BOT=${IS_BOT:-unknown}
# Fail CLOSED: an author this session could not classify is treated as a bot.
# Same direction as the lease store's `lease-store-unavailable` verdict
# documented below, and for the same reason: the two errors are not symmetric.
# Guessing "human" on an unreadable author hands a PR nobody vouched for to the
# unattended loop, which is the outcome the T5 tier exists to prevent.
# Guessing "bot" costs a human one manual look at a PR that may not have needed
# one, which is recoverable. Only a real JSON boolean `false` buys the
# non-bot path; a missing field, a null, or a string spelling of a boolean is
# "absent" or "unknown" and takes the closed branch, matching the type-checked
# read the PAGES_COMPLETE guard below uses for the same reason.
# The notice fires on anything that is not the literal `true`, not on the two
# named verdicts, so a future value out of the jq expression cannot slip through
# the closed branch unannounced the way the empty string did.
if [ "$IS_BOT" = "false" ]; then
    IS_BOT_FLAG=""
else
    IS_BOT_FLAG="--is-bot"
    if [ "$IS_BOT" = "absent" ]; then
        echo "Cannot read author bot state for #$PR: $SCRIPTS_DIR/get_pr_context.py emits no author_is_bot field, so that helper predates issue #5208; reinstall the plugin or run from the repository checkout. Classifying as a bot PR (fail closed)."
    elif [ "$IS_BOT" != "true" ]; then
        echo "Cannot read author bot state for #$PR; classifying as a bot PR (fail closed)."
    fi
fi
# Unquoted on purpose, and this is the one place in this block where that is
# correct: the variable holds either the single literal token --is-bot or the
# empty string, and quoting it would pass an empty argument that argparse
# rejects. It is never attacker-influenced; both values are written above.
# shellcheck disable=SC2086
# The tier this produces gates the auto-merge disarm below via
# TIER_TRUSTED_T1, and this runs against the checked-out PR branch with no
# trusted-ref comparison, so it must NOT read --dispositions-file. A PR that
# edited the registry could dispose its own failing security check, reach T1,
# and keep auto-merge armed (CWE-829, CWE-284). Only the completion gate,
# which byte-compares every tracked file its criteria name against the
# trusted ref first, may honor a disposition.
if MERGE_READY=$(python3 "$SCRIPTS_DIR/test_pr_merge_ready.py" --pull-request "$PR" $IS_BOT_FLAG 2>/dev/null); then
    MERGE_READY_RC=0
else
    MERGE_READY_RC=$?
fi
# Deliberately no 2>/dev/null on either jq below. The producer's stderr is
# suppressed above, so a jq parse error is the only signal an operator gets that
# the producer emitted something unreadable, and both guards below would
# otherwise skip the PR with no explanation. Adding the redirect here was tried
# and the runtime suite failed it by name; see the malformed-producer case.
if ! TIER=$(printf '%s' "$MERGE_READY" | jq -r '.Tier // "UNKNOWN"'); then
    TIER="UNKNOWN"
fi
TIER=${TIER:-UNKNOWN}
# Captured once and read twice, because the tier alone does not say whether the
# evidence behind it was complete. classify_tier returns T1 on CanMerge, and
# CanMerge is `len(reasons) == 0` with fetched_pages_complete computed after it
# and never appended to reasons (test_pr_merge_ready.py, check_merge_readiness).
# So a fetch truncated at the pagination cap that happens to surface no
# unresolved thread and no failing required check classifies T1, which the
# producer's own docstring warns about: "a partial fetch that happens to find no
# failing checks is not evidence that no failing checks exist."
# This mattered only after the tier read above was fixed. While TIER was pinned
# at UNKNOWN, TIER != T1 held for every PR, so the disarm gate below stripped
# auto-merge from the truncated-fetch case by accident. Making T1 reachable
# removes that accident, so the exemption has to be earned rather than assumed.
# Anything other than the literal `true` denies the exemption: the real producer
# always emits the boolean, so healthy input is unaffected, and a missing or
# unreadable field is exactly the state that must not buy a merge.
# The read has been wrong twice, in opposite directions, so both are pinned.
# First it said `// "unknown"`, and jq's alternative operator fires on `false`
# as well as `null`, so a producer reporting an incomplete fetch was relabelled
# "unknown": the operator was told the field could not be read when it had been
# read fine. Both deny the exemption, which is why no test could tell them apart
# until one was added that reads the message.
# Then the repair used a bare `tostring`, which does not check the JSON type, so
# the *string* "true" came out as `true` and bought the exemption. That is the
# worse direction: malformed evidence granting a merge is the thing this guard
# exists to refuse. Only a real JSON boolean is accepted now; every other type,
# including a string spelling of a boolean, is "unknown" and denies.
if ! PAGES_COMPLETE=$(printf '%s' "$MERGE_READY" | jq -r 'if (.fetched_pages_complete | type) == "boolean" then (.fetched_pages_complete | tostring) else "unknown" end'); then
    PAGES_COMPLETE="unknown"
fi
PAGES_COMPLETE=${PAGES_COMPLETE:-unknown}
# Exit 1 is the producer's documented not-ready verdict and is valid for every
# non-T1 tier. Any higher exit, or T1 paired with exit 1, contradicts the
# payload and cannot buy a merge.
if [ "$MERGE_READY_RC" -gt 1 ] || { [ "$MERGE_READY_RC" -eq 1 ] && [ "$TIER" = "T1" ]; }; then
    TIER="UNKNOWN"
    PAGES_COMPLETE="unknown"
fi
# .claude/skills/pr-review/pr-review-config.yaml already ANDs this field into its
# completion-gate criterion, so this is the same safety rule applied at the
# other place a merge can be armed, not a new policy.
# Fail closed on a tier the producer never declared. Empty or malformed stdout
# and a JSON error object all normalize to UNKNOWN. That skips the T3/T4 breaker
# below AND satisfies
# TIER != T1 in the disarm gate, so without this guard the loop would keep
# acting on a PR whose tier it never learned. Acting is the harm here; disarming
# is not, so the guard stops the acting and still runs the disarm gate.
# Do not gate on exit status instead:
# test_pr_merge_ready.py exits 1 for any not-merge-ready PR, so T2 through T4 are
# legitimately non-zero.
# The accepted set is the producer's own, quoted verbatim from
# test_pr_merge_ready.py, which classify_tier's docstring names as the range of
# its return value:
#   _TIER_ORDER = (
#       "T1", "T2", "T3", "T4", "T5",
#       "BEHIND", "BLOCKED", "DIRTY", "SKIP", "UNSUPPORTED",
#   )
# The five beyond the T1-T5 ladder are real: SKIP for a draft, closed, or merged
# PR, BEHIND/BLOCKED/DIRTY from the merge-state lookup, and UNSUPPORTED for a
# mergeStateStatus this repository has no verified merge path for. Listing only
# the ladder rejected those as producer failures and silently disabled the
# documented BEHIND and DIRTY handling.
# tests/commands/test_pr_autofix_tier_contract.py pins this list against the
# producer so the two cannot drift apart again.
# Recognized and actionable are two different questions, and collapsing them is
# what put SKIP on the acting path. The tier table below reads
# "| SKIP | Draft, merged, or closed | No action |", so SKIP terminates here.
# Letting it reach the disarm gate means TIER != T1 holds and auto-merge is
# stripped from a PR that went draft, merged, or closed after the live-state
# gate ran.
# SKIP and an unknown tier both terminate the PR, but at different points, and
# the difference is which one has a reason not to disarm. SKIP names a state:
# the PR is a draft, merged, or closed, so stripping auto-merge is either
# meaningless or destroys a choice its author made deliberately. An unknown tier
# names no state at all, so "armed but not provably T1" is exactly true of it,
# which is the disarm gate's own trigger condition. It therefore falls through
# to that gate and stops immediately after, before the round-cap breaker and
# before any tier action.
# The cost of that direction is real and is accepted: a transient producer
# failure on a healthy T1 PR strips an auto-merge its author armed, and they
# have to arm it again. The other direction leaves a PR this session could not
# assess free to land itself, which is not recoverable. Copilot reported the
# arm as it first shipped, where it exited before the disarm gate; that made a
# producer crash the one path where this loop leaves auto-merge armed on a PR
# it never assessed, and contradicted this change's own claim that the armed
# set only shrinks.
# UNSUPPORTED is recognized and terminates, like SKIP, but it terminates one
# gate later, for the same reason the unknown-tier arm does. SKIP names a state
# whose author chose it, so stripping auto-merge would destroy that choice.
# UNSUPPORTED names a mergeStateStatus with no verified merge path, so "armed
# but not provably T1" is exactly true of it and the disarm gate below must run
# first. It is a separate arm rather than a T4 because T4 dispatches into the
# round-cap thread-fix loop, and an UNSUPPORTED PR routinely carries zero
# threads and zero CI failures: that loop would have no action to take and
# would terminate only by burning the round cap and posting an escalation
# comment on a PR with nothing to escalate.
TIER_KNOWN=yes
TIER_TERMINAL=no
case "$TIER" in
    SKIP)
        echo "Tier SKIP for #$PR (draft, merged, or closed); no action."
        cleanup_pr_autofix
        continue
        ;;
    UNSUPPORTED)
        MERGE_STATE=$(printf '%s' "$MERGE_READY" | jq -r '.MergeStateStatus // ""')
        echo "Tier UNSUPPORTED for #$PR (mergeStateStatus '${MERGE_STATE:-<missing>}' has no verified merge path); disarming auto-merge if armed, then skipping."
        TIER_TERMINAL=yes
        ;;
    T1|T2|T3|T4|T5|BEHIND|BLOCKED|DIRTY) ;;
    *)
        echo "Cannot determine tier for #$PR (tier producer failed or emitted no tier); disarming auto-merge if armed, then skipping."
        TIER_KNOWN=no
        ;;
esac
# Ordered before the round-cap breaker deliberately, and this order is load
# bearing. The breaker's ESCALATE path terminates the PR, and while it sat first
# that exit ran before this gate, so a T3 or T4 PR that reached its cap was
# handed to a human with native auto-merge still armed: GitHub can then land it
# on its own, with readiness never proven by this session (CWE-284). Copilot
# found it. Like the completeness case above it was opened by the tier-read fix
# rather than found beside it, since a pinned UNKNOWN never matched T3 or T4, so
# the breaker never fired and this gate disarmed every armed PR anyway.
# Disarming is not acting on a PR, it is taking a capability away from one, so
# there is no tier this is unsafe to run first. A test asserted the opposite
# contract and is flipped in the same change.
# Step 2.6: Auto-merge disarm gate (BLOCKING, issue #3913). Numbered after the
# tier read and before the round-cap breaker, which is where it now runs.
# If the PR has auto-merge armed but is not T1-ready, a conflict refresh or CI
# fix push could immediately land a PR whose readiness was never explicitly
# verified in this session.  Disable auto-merge now, before any commit or push.
# TIER and PAGES_COMPLETE were both read from the readiness producer above.
# Refresh context here because auto-merge can be armed while readiness is being
# fetched. Reusing the author lookup would let that stale null bypass disarm,
# so this gate deliberately spends a second context read for fresh evidence.
if ! CTX=$(python3 "$SCRIPTS_DIR/get_pr_context.py" --pull-request "$PR" \
    --field auto_merge_method \
    --output-format json 2>/dev/null); then
    CTX=""
fi
if ! AUTO_MERGE=$(printf '%s' "$CTX" | jq -r 'if (.Data | has("auto_merge_method") | not) then "unknown" elif (.Data.auto_merge_method | type) == "string" then .Data.auto_merge_method elif (.Data.auto

…(truncated)
