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).
Scanned 9/12/2026
Install to Claude Code
npx -y skills add rjmurillo/ai-agents --skill pr-autofix --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Pr Autofix?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/rjmurillo-pr-autofix-ai-agents)More formats (shields.io, HTML) on the badges page.
---
name: pr-autofix
version: 1.0.0
description: 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).
license: MIT
argument-hint: "[pull-request|mode]"
allowed-tools: Bash, Read, Edit, Write, Skill, mcp__github__pull_request_read, mcp__github__issue_read, mcp__github__get_check_run, mcp__github__get_job_logs
size-exception: true
user-invocable: true
---
# PR Autofix
<!-- # taste-lint: ignore file-size, this skill is one end-to-end PR workflow; splitting it would hide required 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 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 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`,
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 round cap still applies in this mode: it is what stops a T3/T4 PR
iterating without bound, and it is reconstructed by hand here like the other
verdicts in rule 2. Concurrency between two sessions working the same branch
is held by the Force-Push Safety SHA gate below, which is a hard gate rather
than an advisory one: it re-reads the ref immediately before the push and
pins `--force-with-lease` to an observed SHA, so a competing session's commit
makes the push fail rather than silently win. Git is also a separate
transport from the GitHub API (issue #3139 records a push succeeding while
the API was failing), so a session refused for the API can still push, and
the SHA gate is what makes that safe.
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
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
return 75
}
run_pr_mutation_if_live() {
if recheck_pr_live_state; then
"$@"
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: 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"
# 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."
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 (stored as
# a hidden marker comment) 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.
# Fail CLOSED here because 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."
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_merge_method | type) == "null" then "null" else "unknown" end' 2>/dev/null); then
AUTO_MERGE=""
fi
# Empty stdin or a jq parse error yields an empty string, not a verdict.
# A schema-invalid value such as `false` is also unreadable evidence here:
# `false // "null"` would launder it into "definitely unarmed" and bypass the
# disarm gate on malformed input. Accept only a string or a real null.
AUTO_MERGE=${AUTO_MERGE:-unknown}
if [ "$AUTO_MERGE" = "unknown" ]; then
echo "Cannot read auto-merge state for #$PR (context fetch or parse failed); skipping."
continue
fi
# "Not provably T1" rather than "not T1": a T1 whose evidence came from a
# truncated fetch has not earned the exemption, so it disarms with the rest.
if [ "$TIER" = "T1" ] && [ "$PAGES_COMPLETE" = "true" ]; then
TIER_TRUSTED_T1=yes
else
TIER_TRUSTED_T1=no
fi
if [ "$AUTO_MERGE" != "null" ] && [ "$TIER_TRUSTED_T1" != "yes" ]; then
if [ "$TIER" = "T1" ]; then
echo "Auto-merge armed on #$PR and the tier is T1, but the merge-readiness fetch was incomplete (fetched_pages_complete=$PAGES_COMPLETE); disabling before acting."
else
echo "Auto-merge armed on non-T1 PR #$PR (method: $AUTO_MERGE); disabling before acting."
fi
# No --output-format here. set_pr_auto_merge.py registers no such option,
# so argparse exited 2 before the mutation ran and this gate took its
# failure branch on every armed non-T1 PR, leaving auto-merge armed (issue
# #5551). The script already prints the AutoMergeEnabled object the
# verification checklist asks for, with no flag needed.
if run_pr_mutation_if_live \
python3 "$SCRIPTS_DIR/set_pr_auto_merge.py" \
--pull-request "$PR" --disable; then
MUTATION_RC=0
else
MUTATION_RC=$?
fi
# Captured in both arms rather than read from $? inside the else. The
# single-arm form holds the right value today only because nothing sits
# between the `if` and the read; this form does not depend on that.
if [ "$MUTATION_RC" -ne 0 ]; then
if [ "$MUTATION_RC" -ne 75 ]; then
# 75 is the wrapper's own skip and it already printed the reason.
# Any other status means the disable was attempted and did not
# land, so auto-merge is still armed on a PR whose readiness this
# session never verified. Say that. The old wording, "skipping to
# avoid unguarded merge", named the opposite of the state it left.
echo "Failed to disable auto-merge on #$PR (rc=$MUTATION_RC); auto-merge is still armed and GitHub can land the PR unattended. Skipping; this one needs a human."
fi
continue
fi
fi
# The unknown-tier and UNSUPPORTED arms resume here, one gate later than SKIP
# leaves, so both have had auto-merge stripped above. Everything below needs a
# tier with an action behind it, and neither of these has one, so both stop now.
if [ "$TIER_KNOWN" = "no" ] || [ "$TIER_TERMINAL" = "yes" ]; then
continue
fi
# T5 terminates here, and making it reachable is what created the need. The
# round-cap breaker below fires on T3 and T4 only, which was complete while
# --is-bot was never forwarded: a bot PR with threads or CI failures classified
# T3 or T4 and hit the breaker. Now the same PR classifies T5, so without this
# arm it would fall past a condition that no longer matches, into the tier
# actions, with no cap and no human handoff. That is strictly worse than the
# behavior the flag was added to fix: the breaker is the circuit added for issue
# #5056 after PR #1887 ran 11+ rounds over 46 hours, and T5 would have been the
# one tier running the unattended loop with it switched off.
# The tier table reads "| T5 | Bot PR that passes merge-state gates but has
# failures or threads | Handle individually |", so the executable arm is a
# handoff rather than a capped loop.
# Placed here rather than at the SKIP arm for the reason that arm records: SKIP
# names a state where disarming is meaningless or destructive, while a T5 PR is
# "armed but not provably T1", which is the disarm gate's own trigger condition.
# It therefore falls through that gate first, exactly like the unknown-tier exit
# one gate above, and stops before any tier action.
if [ "$TIER" = "T5" ]; then
echo "Tier T5 for #$PR (bot-authored, passed merge-state gates, has failure or threads); handing to a human."
continue
fi
if [ "$TIER" = "T3" ] || [ "$TIER" = "T4" ]; then
ROUND_CAP=$(python3 "$SCRIPTS_DIR/check_pr_round_cap.py" \
--pull-request "$PR" --output-format json)
ROUND_ACTION=$(echo "$ROUND_CAP" | jq -r '.Data.action // empty')
if [ "$ROUND_ACTION" != "ACT" ]; then
ROUND_REASON=$(echo "$ROUND_CAP" | jq -r '.Data.reason // "round-cap check failed"')
echo "Stopping thread-fix loop for #$PR: $ROUND_REASON"
# check_pr_round_cap.py already posted a human-readable PR comment
# naming the round count, wall-clock elapsed, and both caps
# (issue #5056 item 4: leave a note, do not just stop silently).
# A human must review the remaining thread(s)/CI failure(s) directly.
continue
fi
fi
# tier-dispatch:end
# Per-PR work ends here (push + post-push CI wait + merge).
# ... (tier actions) ...
```
LIVE-STATE SKIP verdicts are binding: do NOT push commits, do NOT arm
auto-merge, do NOT run `merge_pr.py` on a PR this gate classifies as SKIP.
The verdict's `reason` field names the cause (merged, closed, draft, fully
superseded by base) for the autofix log. An ACT verdict only proves the PR is
still actionable; the four-condition Ready-to-Merge gate still applies before
any merge.
Every command that mutates a branch or PR MUST run through
`run_pr_mutation_if_live`. This includes base fetches, merges, rebases, pushes,
auto-merge changes, and direct merges. The wrapper performs a new GitHub query
immediately before the command and compares the result with the head and base
identity captured by the current readiness cycle. A gate from an earlier review
or validation phase is stale. Exit 75 means the wrapper logged the live-state skip. Other nonzero exits come from the mutation command and
retain their existing error handling.
### Phase 3: Verify and gate
After all queued actions, re-check the 4-condition Ready-to-Merge gate. Enable
auto-merge only when all four conditions hold:
```bash
python3 "$SCRIPTS_DIR/run_completion_gate.py" \
--pull-request "$PR" \
--json \
--evidence-path ".agents/pr-comments/PR-$PR/gate-evidence.json"
```
Dispatcher exit contract (CWE-829 trust boundary, Issue #5072): exit 0 all criteria passed; exit 1 a criterion failed; exit 2 config error INCLUDING a `pr-review-config.yaml` that diverges from or is absent at the trusted ref (`origin/main`); exit 3 trust verification impossible (no git work tree, trusted ref missing or not remote-tracking). A trust halt (stderr starts with `HALT: completion-gate config`, statuses diverged/missing-base, and every exit 3) occurs before any dispatch, so NO criterion command was executed. Other exit-2 config errors (a malformed later criterion, an evidence-write failure) can fire mid- or post-dispatch, so earlier criterion commands may already have run; treat exit 2 as "verdict unusable", not "nothing happened". Neither exit is transient: do NOT retry, do NOT treat exit 3 as a passing external outage, and do NOT pass `--approve-untrusted-config` autonomously. The flag only applies to a trust-halt exit 2 (diverged or missing-at-base); an exit-3 halt cannot be overridden at all. Surface the stderr diff to a human and skip the PR.
## Workflow
1. Triage all open PRs into tiers T1-T5 using `test_pr_merge_ready.py`.
2. Process T1 (land-ready) first, then T2 (CI fix), then T3/T4 (threads). T5 is not processed by this loop: a bot-authored PR that reaches work-tier classification with a failure or unresolved threads is handed to a human, and the tier-dispatch block terminates it after the auto-merge disarm gate (issue #5208). Bot PRs classified BEHIND, BLOCKED, or DIRTY retain that merge-state tier.
3. **Before acting on any PR, call `check_pr_live_state.py`** and skip the row when it returns `Data.action=SKIP` (issue #2455). The triage snapshot from step 1 goes stale fast in a repo with heavy merge automation; the gate catches PRs merged/closed mid-walk and PRs whose diff is already on `main` via a sibling consolidated PR.
4. **Before any branch mutation, re-read the ref and pin the push.** The
Force-Push Safety SHA gate below is the concurrency boundary: match
`git rev-parse "refs/heads/$BRANCH"` against the PR's expected head SHA, and
pin `--force-with-lease` to that observed SHA so a competing session's commit
rejects the push instead of being overwritten (issues #3653, #3413).
5. **On every pass through a T3/T4 PR, call `check_pr_round_cap.py`** and stop working that PR when it returns `Data.action=ESCALATE` (issue #5056). It caps how many fix/review rounds and how many wall-clock hours the thread-fix loop may run before it hands the PR back to a human; PR #1887 ran 11+ rounds over 46 hours with no cap in place. The script posts the escalation reason as a PR comment itself; the agent does not need to.
6. For each PR that the live-state gate and round-cap gate cleared: address review threads, fix CI failures using known patterns, then choose the merge path from the four-condition gate.
## Ready-to-Merge Definition (4 conditions, ALL required)
1. Branch up to date with `main` (`mergeStateStatus` not `BEHIND`).
2. All required checks pass.
3. All conversations addressed: READ, TRIAGED, SOLVED (if Blocking), REPLIED with course of action, RESOLVED.
4. `mergeStateStatus` is `CLEAN` or `HAS_HOOKS` (or `UNSTABLE` with documented non-required failures).
`CanMerge=True` from `test_pr_merge_ready.py` alone is insufficient. Cross-check all four conditions.
**Checkout ownership for the readiness helper (issue #2443)**: when a PR modifies files under `.claude/skills/github/scripts/pr/`, run `test_pr_merge_ready.py` from that PR's own worktree, not from a shared checkout. A shared checkout runs whatever helper version is on its disk, which may predate the branch's fix and yield a stale `CanMerge` verdict. The readiness output records a `ScriptCommit` field with the helper revision that produced the verdict; if it does not match the PR branch's helper commit, re-run from the PR worktree before trusting the result.
## Tier Definitions
The `Tier` field in `test_pr_merge_ready.py` output is the authoritative
classifier. Pass `--is-bot` when the PR author is a bot: `classify_tier`
returns T5 only when `is_bot and (has_ci_failures or has_threads)`, and its
`is_bot` parameter defaults to `False`, so a call that omits the flag can never
return T5 and every bot PR that reaches work-tier classification lands in
T2-T4 instead (issue #5208).
Read the author's bot state from `get_pr_context.py --field author_is_bot`,
not from a `[bot]` login-suffix test. That field comes from
`github_core.bot_config.is_bot` after `canonicalize_login`, which maps the
`app/copilot-swe-agent` spelling `gh pr view --json author` returns onto
`copilot-swe-agent[bot]`; neither that spelling nor `Copilot` carries a `[bot]`
suffix, so a suffix test reads this repository's two most common bot authors as
humans. When the field is absent or not a boolean, pass `--is-bot` anyway: an
author nobody could classify must not enter the unattended loop. The
tier-dispatch block above implements exactly that.
Reaching T5 is only half of it. `check_pr_round_cap.py` caps the thread-fix loop
for T3 and T4, so a bot PR that used to classify T3 or T4 was capped; once it
classifies T5 that condition no longer matches it. The tier-dispatch block
therefore terminates a T5 PR outright, after the auto-merge disarm gate and
before the round-cap breaker. A T5 arm that fell through to the tier actions
would run the unattended loop with the breaker switched off, which is worse than
the defect issue #5208 reports.
The classifier is total: every `mergeStateStatus` GitHub can report reaches
exactly one row across the two tables below, and no state reaches T1 unless
this document names a merge script for it. `CLEAN`, `HAS_HOOKS`, and
`UNSTABLE` are that set. `BEHIND`, `BLOCKED`, and `DIRTY` take their own
merge-path rows. Every other value, `UNKNOWN` and a missing value today plus
anything GitHub adds later, blocks `CanMerge` and takes the `UNSUPPORTED` row.
`UNSUPPORTED` is a merge-path row rather than a work tier because the work
tiers name work this loop can do and there is none: such a PR routinely
carries zero unresolved threads and zero CI failures, and T3 and T4 dispatch
into the round-cap thread-fix loop, which would then terminate only by burning
the round cap and posting an escalation comment. It disarms auto-merge and
stops instead.
`HAS_HOOKS` is executable, not blocked. GitHub's GraphQL `MergeStateStatus`
reference defines it as "Mergeable with passing commit status and pre-receive
hooks", which is `CLEAN` plus pre-receive hooks, so the `CLEAN` scripts land it
unchanged. This repository's own push-time merge-state gate has always read it
that way too: its `PASS_STATES` holds `BEHIND`, `BLOCKED`, `CLEAN`,
`HAS_HOOKS`, and `UNSTABLE` against a `FAIL_STATES` of `DIRTY` alone, and
`test_pr_merge_ready.py` cites that file and its line range. Issue #4899: this
document briefly claimed `HAS_HOOKS` had no merge path at all, which left the
repository carrying two contradictory definitions of the same enum value and,
on any repository with pre-receive hooks (GitHub Enterprise, or push
rulesets), sent a fully green PR into the round-cap loop.
### Work-needed tiers
Every tier below is reached only from a `mergeStateStatus` this document names
a merge script for (`CLEAN`, `HAS_HOOKS`, `UNSTABLE`), so "then merge" in T3 is
always executable.
| Tier | Criteria | Action |
|------|----------|--------|
| T1 | `CanMerge=true` (`CLEAN`, `HAS_HOOKS`, or `UNSTABLE` with all non-required failures disposed) | Merge via the row for that state in "Merge path by `mergeStateStatus`" below; each of the three names its own script |
| T2 | CI failures only (required or undisposed non-required), no threads | Fix CI, verify required checks pass |
| T3 | Threads only (CI passing) | Walk full thread lifecycle, then merge |
| T4 | Both CI failures + threads | Fix CI first, then lifecycle threads |
| T5 | Bot PR that passes merge-state gates but has failures or threads | Handle individually |
### Merge-path states (not work tiers)
Every row below is a state that cannot reach a work tier. `CLEAN`, `HAS_HOOKS`,
and `UNSTABLE` are therefore absent by construction, not by omission: they are
the three states that do reach T1, so their rows are the T1 row above and their
per-state scripts are in "Merge path by `mergeStateStatus`". Adding `HAS_HOOKS`
here would re-assert the thing issue #4899 fixed.
| State | Criteria | Action |
|-------|----------|--------|
| BEHIND | `MergeStateStatus == "BEHIND"` | Update branch against main, then reclassify |
| BLOCKED | `MergeStateStatus == "BLOCKED"` (branch protection, pending reviews) | Wait for external gate (review approval, etc.) |
| DIRTY | `MergeStateStatus == "DIRTY"` (merge conflict) | Resolve conflict via the merge-resolver agent, then reclassify |
| SKIP | Draft, merged, or closed | No action |
| UNSUPPORTED | `MergeStateStatus` outside `CLEAN`/`HAS_HOOKS`/`UNSTABLE`/`BEHIND`/`BLOCKED`/`DIRTY` (`UNKNOWN`, missing, or a value GitHub adds later) | Disarm auto-merge, then stop and hand the PR to a human. Do not attempt a merge and do not enter the round-cap loop |
## Fix Patterns
- **CI-failure triage step 1 (issue #5073)**: for every failing check name, run `triage_red_check.py --check-name "<name>" --pull-request {pr}` before any log reading or local investigation. `RED_ON_MAIN` (exit 1) means the failure is inherited from main: cite the `EvidenceUrl` main run, do not debug the PR, and re-run checks after main recovers. `GREEN_ON_MAIN` (exit 0) means the PR introduced it: proceed to logs. `UNKNOWN` (exit 3) is a probe failure, never evidence of green.
- **PR description mismatch**: Remove file references not in the diff (use GitHub API to PATCH body).
- **Branch behind main**: Run each base refresh command through the late live-state wrapper:
```bash
run_pr_mutation_if_live git fetch origin "$BASE"
run_pr_mutation_if_live git merge origin/"$BASE" --no-edit
run_pr_mutation_if_live git push origin "$BRANCH"
```
- **Stale merge-state cache**: `test_pr_merge_ready.py` sets `StaleDirtySuspected=true` when GitHub reports `mergeable == "CONFLICTING"` or `mergeStateStatus == "DIRTY"`. This is advisory, not authoritative. A PR can merge or close during the review-fix cycle; acting on an earlier ACT result triggers a conflict merge into a deleted branch. In a worktree, use `run_pr_mutation_if_live git fetch origin "$BASE"`, then `git merge-base --is-ancestor "origin/$BASE" HEAD` (exit 0 = ancestor) and a guarded `run_pr_mutation_if_live git merge --no-commit --no-ff "origin/$BASE"` trial merge. Both clean means the conflict is stale. Disable existing auto-merge through the wrapper and verify `autoMergeRequest` is null before the final guarded merge and push (issue #3913). A failing trial merge means the conflict is real: resolve via merge-resolver agent. Evidence required: both live-state verdicts, the ancestry exit code, and the trial-merge result. See doc Stale merge-state cache section (issue #2368).
- **Stale CI check**: Push fresh commit to re-trigger; avoid `--no-verify` if possible.
- **Bot review threads**: Read, triage per Thread Severity, reply with disposition, resolve via `add_pr_review_thread_reply.py --resolve`.
- **Armed auto-merge + final thread**: `add_pr_review_thread_reply.py --resolve` posts the reply, disables armed auto-merge when that thread is the final unresolved one, then resolves the thread. If the guard cannot prove the unresolved count, the script exits 3 after posting the reply and leaves the thread unresolved so GitHub cannot merge before the completion gate.
- **Session validation failure**: Hand-edit the log to satisfy the session-log schema, then re-validate it.
## Force-Push Safety
Before any push: verify `git rev-parse "refs/heads/$BRANCH"` matches the PR's expected `head.sha` from `get_pr_context.py`. (Prefer `rev-parse` over plain-file reads of `.git/refs/heads/<branch>`: rev-parse resolves loose refs AND refs that have been compacted into `.git/packed-refs`; a plain-file read returns "missing ref" when the branch lives only in `packed-refs`.) If the local ref points to a bootstrap/sandbox commit, STOP. Investigate corruption before pushing. Force-push only with explicit user authorization, using SHA-pinned source with quoted refspec:
```bash
SHA="<known-good-sha>"
BRANCH="<branch-name>"
# The head.sha you already read from get_pr_context.py before starting work.
EXPECTED_REMOTE_SHA="<observed-head-sha>"
run_pr_mutation_if_live env FORCE_PUSH_OK=1 git push origin "${SHA}:refs/heads/${BRANCH}" \
--force-with-lease="refs/heads/${BRANCH}:${EXPECTED_REMOTE_SHA}"
```
`FORCE_PUSH_OK=1` is required, not optional. A pre-push hook cannot read argv,
so the repository's non-fast-forward guard sees a rewrite and exits 1 whether
or not a lease is pinned. The variable is the one escape the force-push rule
sanctions for a pinned lease, and it narrows that single guard while every
other pre-push job runs (issue #4293). This repository's safe-push helper sets
it for you, so prefer that helper over the raw command.
Pin the lease to an explicit SHA; never use bare `--force-with-lease` here.
Bare `--force-with-lease` takes its expected value from
`refs/remotes/origin/$BRANCH`, and any concurrent `git fetch`, including one run
by a sibling agent in the same checkout, silently advances that ref to the other
agent's commit. The lease then passes and the push destroys their work. Measured
on a two-clone repro: with a fetch between the two pushes the bare form
overwrote a sibling's commit, while
`--force-with-lease=refs/heads/$BRANCH:<observed-sha>` rejected the identical
push with `stale info` (issues #3653, #3413). The `rev-parse` check above is a
separate read and cannot close the window between check and push; only the
pinned lease is atomic.
Quote every variable expansion. The shell does not treat `:` specially in a refspec; the real reason to quote is that branch names can contain characters the shell DOES treat specially (`*`, `?`, `[`, whitespace), and unquoted `$BRANCH` will word-split or glob on those.
## Scripts
```bash
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)"
# Check merge readiness. Add --is-bot when the PR author is a bot; without it
# classify_tier cannot return T5, because its is_bot parameter defaults to
# False and T5 requires `is_bot and (has_ci_failures or has_threads)`
# (issue #5208). Source the answer from get_pr_context.py's focused
# `--field author_is_bot` mode and pass --is-bot when that field is absent or
# non-boolean.
# Derived rather than written as a `[--is-bot]` placeholder: every other line in
# this block runs as written once {pr} is substituted, so a bracketed token here
# reaches argparse as a positional argument and the readiness call dies on
# "unrecognized arguments" for anyone following the block literally.
# Read the verdict as a word, then map it to the flag, rather than emitting the
# flag straight from jq. Emitting it directly forces the non-bot answer and a
# failed fetch onto the same empty string, so an unreadable context would buy
# the human path: that is the fail-OPEN direction the tier-dispatch block above
# refuses, for the reasons recorded there. The absent-versus-unknown split that
# block makes is a diagnostic for the unattended loop and is deliberately not
# repeated here; both take the closed branch either way.
if ! CTX=$(python3 "$SCRIPTS_DIR/get_pr_context.py" --pull-request {pr} --field author_is_bot --output-format json 2>/dev/null); then
if ! CTX=$(python3 "$SCRIPTS_DIR/get_pr_context.py" --pull-request {pr} --output-format json 2>/dev/null); then
CTX=""
fi
fi
if ! IS_BOT=$(printf '%s' "$CTX" | jq -r 'if (.Data.author_is_bot | type) == "boolean" then (.Data.author_is_bot | tostring) else "unknown" end' 2>/dev/null); then
IS_BOT="unknown"
fi
IS_BOT=${IS_BOT:-unknown}
if [ "${IS_BOT:-unknown}" = "false" ]; then
IS_BOT_FLAG=""
else
IS_BOT_FLAG="--is-bot"
fi
# shellcheck disable=SC2086
# No --dispositions-file here either, for the reason recorded at the first
# tier probe above: this reads the PR branch before any trust comparison.
python3 "$SCRIPTS_DIR/test_pr_merge_ready.py" --pull-request {pr} $IS_BOT_FLAG
# Per-PR live-state gate (BLOCKING per Phase 2; issue #2455). Returns
# exit 0 + Data.action=ACT when safe to proceed, exit 1 + Data.action=SKIP when
# the PR is merged/closed/draft or fully superseded by base.
python3 "$SCRIPTS_DIR/check_pr_live_state.py" --pull-request {pr} --skip-fetch --output-format json
# Round-cap circuit breaker (BLOCKING for T3/T4 per Phase 2; issue #5056).
# Records one round against a hidden marker comment and returns exit 0 +
# Data.action=ACT when both the round-count and wall-clock caps hold, exit 1
# + Data.action=ESCALATE when either is exceeded. Defaults: 5 rounds / 4
# hours, each overridable via --max-rounds/--max-hours or
# $PR_AUTOFIX_MAX_ROUNDS/$PR_AUTOFIX_MAX_ROUND_HOURS.
python3 "$SCRIPTS_DIR/check_pr_round_cap.py" --pull-request {pr} --output-format json
# CI-failure triage step 1 (BLOCKING, issue #5073): before reading any log or
# starting any local investigation, ask whether the same check is red on
# origin/main. Exit 0 = green on main (the PR introduced it; investigate the
# PR). Exit 1 = red on main (inherited; cite Data.EvidenceUrl and fix or wait
# out main instead of debugging the PR). Exit 3 = cannot determine (probe
# failure is not absence; never treat UNKNOWN as green).
python3 "$SCRIPTS_DIR/triage_red_check.py" --check-name "<failing check name>" --pull-request {pr}
# Get CI check logs (step 2, only when the check is green on main)
python3 "$SCRIPTS_DIR/get_pr_checks.py" --pull-request {pr} | \
python3 "$SCRIPTS_DIR/get_pr_check_logs.py" --pull-request {pr} --checks-input -
# CLEAN path: try auto-merge only when there is pending branch-protection work to wait on.
# If GitHub rejects an already-CLEAN PR with "clean status", use the printed direct-merge fallback.
run_pr_mutation_if_live python3 "$SCRIPTS_DIR/set_pr_auto_merge.py" --pull-request {pr} --enable --merge-method SQUASH
# Direct merge: already-CLEAN fallback or UNSTABLE state with documented non-required failures.
run_pr_mutation_if_live python3 "$SCRIPTS_DIR/merge_pr.py" --pull-request {pr} --strategy squash
```
### Merge path by `mergeStateStatus`
GitHub refuses auto-merge for `UNSTABLE` PRs (issue #2439) and may also reject an already-`CLEAN` PR because there is nothing left to wait on (issue #2450). Pick the path that matches the state:
| `mergeStateStatus` | Path | Script |
|---|---|---|
| `CLEAN` | Auto-merge when waiting is useful; direct merge if GitHub returns the already-clean rejection | Guard `set_pr_auto_merge.py --enable`, then guard the `merge_pr.py --strategy squash` fallback |
| `HAS_HOOKS` | Same path as `CLEAN`. GitHub defines it as "Mergeable with passing commit status and pre-receive hooks" | Guard `set_pr_auto_merge.py --enable`, then guard the `merge_pr.py --strategy squash` fallback |
| `UNSTABLE` with documented non-required failures | Direct merge (immediate) | Guard `merge_pr.py --strategy squash` |
| `BEHIND` | Update branch first, then re-classify | Guard the fetch, merge, and push separately |
| `DIRTY`/`CONFLICTING` | See Stale merge-state cache pattern below | merge-resolver agent if real conflict |
| Anything else (`UNKNOWN`, missing, future) | No verified merge path in this document. `test_pr_merge_ready.py` reports `CanMerge=false` and tier `UNSUPPORTED` | Disarm auto-merge, then hand to a human. Refusing is deliberately conservative: GitHub may well accept the merge, but nothing here has been verified against that state |
`set_pr_auto_merge.py` detects the `UNSTABLE` and already-`CLEAN` rejections from GitHub's GraphQL API and emits the direct-merge fallback command in its error output (exit 3) so the operator never has to translate the generic "GraphQL request failed" message themselves.
### Merge-check exit codes: `test_pr_merged.py`
As of issue #2308, `test_pr_merged.py` exits **0** on any successful query
and reports merge state in the JSON `merged` field. This makes the script
behave like every other shell-friendly probe: exit 0 means "I answered your
question". Branch on the JSON, not the exit code.
Earlier history: the script used to exit **100** when the PR was merged
(Skill-PR-Review-007). Treating 100 as a failure caused wasted polling loops
on PRs #2240, #2269 (#2277), and made successful merge verification look
failed on PR #2289 (#2308).
When invoking from autofix code:
```bash
PR_NUMBER="123"
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)"
python3 "$SCRIPTS_DIR/test_pr_merged.py" --pull-request "$PR_NUMBER" | jq -e '.merged == true'
```
To restore the legacy skip-review sentinel (only for callers that already
encoded "100 = merged"):
```bash
PR_NUMBER="123"
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)"
python3 "$SCRIPTS_DIR/test_pr_merged.py" --pull-request "$PR_NUMBER" --exit-100-on-merged
```
The legacy `--exit-zero-on-merged` flag (from #2277) still parses as a no-op
for backward compatibility.
## Completion Gate
Run after all threads resolved and CI passes:
```bash
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)"
CONFIG_PATH="${PR_REVIEW_CONFIG_PATH:-.claude/skills/pr-review/pr-review-config.yaml}"
python3 "$SCRIPTS_DIR/run_completion_gate.py" \
--config "$CONFIG_PATH" \
--pull-request {pr} --json
```
The dispatcher enforces the Issue #5072 trust boundary before dispatching anything: exit 2 also means the config diverges from or is absent at `origin/main`, and exit 3 means trust verification was impossible; in both cases no criterion command ran and the failure is not transient. `--approve-untrusted-config` requires a human who has read the surfaced diff and applies only to exit 2; an exit-3 halt (verification impossible) cannot be overridden.
## Verification
Per PR processed:
- [ ] Tier classification recorded (T1-T5).
- [ ] Per-PR live-state gate ran immediately before the tier's action (issue #2455): `check_pr_live_state.py --pull-request $PR --skip-fetch --output-format json`. Verdict `Data.action=ACT` recorded; `Data.action=SKIP` aborted the action and recorded the reason (merged, closed, draft, or fully superseded by base).
- [ ] Auto-merge disarm ran after live-state ACT on any PR that is not provably T1 (issue #3913, and issue #5094 for the completeness half): that is every non-T1 PR, and also a T1 whose `fetched_pages_complete` was not the boolean `true`, since a tier derived from a truncated fetch has not earned the exemption. `auto_merge_method` was null or `set_pr_auto_merge.py --disable` succeeded and returned `AutoMergeEnabled: false` before any push.
- [ ] Round-cap circuit breaker ran on every pass through a T3/T4 PR, after the tier was known and after the auto-merge disarm gate above (issue #5056, ordering per issue #5094): `check_pr_round_cap.py --pull-request $PR --output-format json`. `Data.action=ACT` recorded and work continued; `Data.action=ESCALATE` stopped the thread-fix loop for that PR (round cap or wall-clock budget exceeded) and the script's own PR comment carries the reason, so nothing further was posted by the agent. The breaker's ESCALATE exit hands the PR to a human, so the disarm above has to have run first.
- [ ] Live-state gate re-ran immediately before any base refresh or conflict resolution (issue #4349): stale gate result from the start of the session is not sufficient; the PR can merge mid-cycle.
- [ ] Every base refresh, rebase, push, auto-merge change, and direct merge ran through `run_pr_mutation_if_live` immediately before mutation (issue #4349).
- [ ] Late mutation checks matched the head SHA, base ref, and base SHA captured by the current readiness cycle; any mismatch stopped mutation and restarted readiness checks.
- [ ] When `StaleDirtySuspected=true`: `set_pr_auto_merge.py disable` ran and `autoMergeRequest` confirmed null before any base-ref refresh push (issue #3913).
- [ ] CI-failure triage step 1 ran before any log reading (T2/T4 only, issue #5073): `triage_red_check.py --check-name "<name>"` verdict recorded per failing check; RED_ON_MAIN failures were attributed to main with the EvidenceUrl, not investigated on the PR.
- [ ] All required CI checks pass (T2/T4 only).
- [ ] Every review thread is READ, TRIAGED, SOLVED (if Blocking), REPLIED with course of action, and RESOLVED (T3/T4 only).
- [ ] `mergeStateStatus` is `CLEAN` or `HAS_HOOKS` (or `UNSTABLE` with documented non-required failures).
- [ ] Branch is up to date with `main` (`mergeStateStatus` not `BEHIND`).
- [ ] Force-push safety check ran before any push: `git rev-parse "refs/heads/$BRANCH"` matched the PR's expected `head.sha`.
- [ ] Correct merge path chosen by state: `set_pr_auto_merge.py --enable` for `CLEAN` and for `HAS_HOOKS`, which takes the `CLEAN` path unchanged, `merge_pr.py --strategy squash` for `UNSTABLE` with documented non-required failures (see "Merge path by `mergeStateStatus`" table; issue #2439).
- [ ] All four Ready-to-Merge conditions hold before the merge command runs (CanMerge=True is insufficient alone).
## Anti-Patterns
| Avoid | Why | Instead |
|-------|-----|---------|
| Mutating on a readiness verdict computed earlier in the session | A PR can merge, close, or move mid-cycle, so an old verdict authorizes a push against a tree that no longer exists | Re-run the live-state gate immediately before every mutation, through `run_pr_mutation_if_live` |
| Treating `TIER=UNKNOWN` as "the gates turn off" | It breaks the two gates in opposite directions: the round cap goes inert while the disarm gate fires on every armed PR and strips auto-merge from genuine T1s | Read the tier from `test_pr_merge_ready.py`, the authoritative producer, never from `check_pr_live_state.py` |
| Swapping the disarm gate and the round cap back | The breaker's ESCALATE hands the PR to a human with auto-merge possibly still armed, which is the CWE-284 case the ordering closes | Keep disarm first, then the cap, as the tier-dispatch block states |
| Reading CI logs before triaging the failing check | A check red on main is not this PR's failure, and investigating it on the PR spends a round on someone else's bug | Run `triage_red_check.py` first and attribute RED_ON_MAIN with its EvidenceUrl |
| Letting a T3/T4 PR iterate without a cap | PR #1887 ran 11+ bot rounds over 46 hours and PRs #1965 and #1979 ran 18 each; prose caps have been ignored every time | Call `check_pr_round_cap.py` once per pass and honor ESCALATE |
| Trusting `CanMerge=True` as the merge decision | It is one input, not the gate; all four Ready-to-Merge conditions have to hold | Evaluate the four conditions and pick the merge path from `mergeStateStatus` |
| Force-pushing without re-reading the ref | A ref you read at triage is not where the branch is now, and a bare `--force-with-lease` reads a tracking ref any fetch can advance | Match `git rev-parse "refs/heads/$BRANCH"` against the PR's expected head SHA, then pin the lease to that SHA |
## Extension Points
- **A new tier.** Add its row to the tier table and its branch to the tier-dispatch
block. `test_pr_merge_ready.py` owns the tier set, so the producer changes first
and `tests/commands/test_pr_autofix_tier_contract.py` fails until this file agrees.
- **A new merge state.** Add a row to the merge-path table and to the producer's
supported set together. A state with no row here must stay UNSUPPORTED rather
than fall through to a default; the test that pins that is deliberate.
- **A different round budget.** `check_pr_round_cap.py` owns both the count and the
wall-clock budget. Change them there and every caller moves together.
- **A different concurrency boundary.** The Force-Push Safety SHA gate owns it.
It is a hard gate (re-read the ref, pin `--force-with-lease` to the observed
SHA), so a replacement has to be at least as strong; an advisory marker is not.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!