Start here: read the mergeable state, not the PR page
A GitHub pull request that "won't merge" can be blocked for at least eight distinct reasons, and they need different fixes — so the first move is never to guess from the PR page, it is to read the mergeable state. The authoritative source is the GraphQL MergeStateStatus enum, whose values map one-to-one to the lowercased strings the REST API returns (MergeStateStatus enum): dirty is a merge conflict, behind is out of date under strict checks, blocked is an unmet required review/check/conversation, unstable is mergeable but a non-required check is red, and clean is green. One lookup collapses the whole investigation into a route. This guide is the methodology layer; each failure below links to a page that carries the full fix.
gh api repos/{owner}/{repo}/pulls/{number} \
--jq '{mergeable, mergeable_state, draft}'
If mergeable_state is unknown, GitHub has not finished computing mergeability — query again in a second. (GitHub describes the REST mergeable_state field itself as undocumented and "in flux" in community discussion #24299, so trust the GraphQL enum for definitions and treat the REST strings as lowercased equivalents.)
Why a systematic read beats clicking through tabs
The GitHub PR page shows a grey button and a stack of green-and-grey rows, and several genuinely different problems look nearly identical there. "This branch is out of date" (behind) and "can't automatically merge" (dirty) both disable the button but need opposite actions — one is a branch update, the other is conflict resolution. A required check stuck on "Expected" looks the same as one that is simply still running, but the first will never finish. The state read disambiguates all of these before you touch anything, which is why it is the first step rather than a fallback.
The other reason: required status checks are matched by name only — "Required status checks do not take workflow, matrix, or event trigger types into account" (Troubleshooting rules). So a "stuck" check is frequently a name that never arrives, not a slow job. Re-running the job cannot fix a name mismatch; only reading the produced names can.
The triage framework
Step 1 — Read the mergeable state
Run the gh api call above and route on mergeable_state:
dirty→ merge conflict. Resolve locally (Step 2A).behind→ out of date under strict required checks. Update the branch (Step 2B).blocked→ a required gate is unmet — review, check, or conversation. Enumerate which (Step 3).unstable→ mergeable; a non-required check is red or pending. You can merge; decide whether the red check matters.clean→ not blocked by protection. If the button is still grey, it is a permission problem, not a rule.
Step 2A — Conflict (dirty)
Bring the base into your branch, resolve, and push:
git fetch origin
git switch <your-branch>
git merge origin/<base-branch> # or rebase
# resolve conflicts, then commit and push
git push
Step 2B — Behind (behind)
Update the branch (or use the PR's "Update branch" button). Under strict mode the branch "must be up to date with the base branch before merging" (About protected branches), so the update produces a new tip that must satisfy the required checks before merge is allowed. On a busy base branch this can loop — see the recommendation on merge queues below. Full walkthrough: branch protection is blocking my PR merge.
Step 3 — Blocked (blocked)
List what the branch requires so you know which gate to clear:
gh api repos/{owner}/{repo}/branches/{branch}/protection \
--jq '{
reviews: .required_pull_request_reviews,
checks: .required_status_checks.contexts,
strict: .required_status_checks.strict,
conversation: .required_conversation_resolution.enabled
}'
Then handle the specific gate:
- Required reviews unmet — get the approvals. If approvals keep vanishing after pushes, the branch dismisses stale approvals on new commits (About protected branches) — that is configured behaviour; land reviews after the last push.
- Required conversation resolution — resolve every open thread.
- Required status check not satisfied — a required check is not
success,skipped, orneutral. If it is stuck on "Expected," it is almost always a name mismatch or a skipped workflow; the full diagnosis is in required status check stuck. - Required linear history — switch the merge method to squash or rebase; a merge commit is disallowed.
Remember rulesets layer on top of branch protection, with "the most restrictive version of the rule" winning (About rulesets) — a branch with no classic protection can still be blocked by a ruleset, so check both.
The check-never-reports case, in detail
When blocked is caused by a required check stuck on "Expected," read the names that actually reported on the head commit and compare against the required name:
gh api repos/{owner}/{repo}/commits/{ref}/check-runs \
--jq '.check_runs[] | {name, status, conclusion, app: .app.slug}'
That endpoint is GET /repos/{owner}/{repo}/commits/{ref}/check-runs (Check runs REST API); the combined commit status is GET /repos/{owner}/{repo}/commits/{ref}/status (Commit statuses REST API). If a name reported but not the required one, it is a mismatch (often a matrix suffix like build (ubuntu-latest) or a CI / prefix). If nothing reported, the workflow did not run — check whether path/branch filtering skipped it, which leaves the check Pending and blocks the merge (Troubleshooting required status checks).
The hidden failure: the event that never arrived
One class of "stuck PR" is not on the PR at all — it is a missing webhook. If your CI or a bot relies on a GitHub webhook to start work and that delivery failed, the check never even begins. GitHub expects a 2xx within 10 seconds and does not auto-retry failed deliveries (Best practices for webhooks, Handling failed deliveries). When a required check is mysteriously absent and the workflow shows no run, check Recent Deliveries — the full diagnosis is in GitHub webhook not delivering.
Recommended approach (with tradeoffs)
Always lead with the mergeable state; it is the cheapest, most decisive signal and it routes every other step. Beyond that, two structural choices matter:
- Prefer rulesets over scattered branch-protection toggles for new repos — they layer predictably and are visible to anyone with read access, so "why is this blocked" is answerable without admin (About rulesets). The tradeoff is a newer model your team has to learn.
- Use a merge queue instead of strict "up to date before merging" on high-traffic branches. Strict mode guarantees every merge was tested against the current base, but on a busy branch it forces each author to race the base in an update-retest loop; a merge queue batches that for you. The cost is queue configuration and the
merge_groupevent wiring.
The blast radius of getting these wrong is wasted CI time and stalled PRs, not data loss — but a chronically blocked merge erodes trust in the gate, and the usual "fix" (an admin bypass) quietly defeats the protection the branch exists for.
Validation
After clearing the cause, confirm the state flipped before relying on the button:
gh api repos/{owner}/{repo}/pulls/{number} --jq '.mergeable_state'
# want: "clean" (or "unstable" if you have accepted a non-required red check)
Operational lessons
The recurring lesson across every one of these failures is the same: read the state, not the page. The state is one word that names the cause; the page is a wall of ambiguous rows. behind and dirty look identical and need opposite fixes; an "Expected" check that will never report looks identical to one that is merely slow; a required check that never started can actually be a dropped webhook three systems away.
When a merge is blocked, the question an on-call engineer really has is "what change is sitting behind this gate, and what does it need to ship?" The mergeable state, the required-check results on the head commit, the reviewers, and the commit that triggered it are all parts of one answer. Correlating them in one view — instead of clicking through the PR, the Actions tab, the webhook settings, and the branch rules — is what turns "it's stuck" into "needs one more approval and a rebase, and a check that never reported because the workflow name changed last week."