Covers the PR lifecycle for Netresearch repos: branch and tooling checks
before opening a PR, commit discipline, merge strategies, review-thread
resolution, and the merge gate. See references/commit-conventions.md for
commit message formatting.
Do not probe the merge gate one endpoint at a time. mergeStateStatus: BLOCKED never says why, and the reason lives in five different places:
checks, rulesets, review threads, whether a review exists on the current
head, and the repository's allowed merge methods.
The script is not on PATH, and you run it from the repository under
inspection, so a relative ./scripts/… misses. Bind the skill directory once,
then call it by that path:
# sort -V, not plain sort: 1.10.0 must beat 1.9.0. A directly installed
# skill sorts after the cache paths and so wins when both exist.
GW=$(ls -d ~/.claude/skills/git-workflow \
~/.claude/plugins/cache/*/git-workflow/*/skills/git-workflow \
2>/dev/null | sort -V | tail -1)
bash "$GW/scripts/pr-status.sh" -R owner/repo 123 # human summary + NEXT action
bash "$GW/scripts/pr-status.sh" -R owner/repo 123 --json # for scripts and merge drivers
bash "$GW/scripts/pr-status.sh" -R owner/repo 123 --watchTwo API calls (a third, admin-only, when the PR is review-blocked — it reads
classic branch protection, whose review gates like require_last_push_approval
are invisible to the rules endpoint), and the output ends in a computed NEXT: — rebase, fix-ci,
triage-ci, resolve-threads, request-review, wait, or merge (with the method
this repo actually allows and a warning when a merge queue is active). The
JSON form carries each unresolved thread's threadId and commentId, which
is everything needed to reply and resolve without another query.
Measured on a 40-PR rollout that did not have it: 183 of 370 shell calls
were PR-status probing, the rulesets endpoint was queried exactly once, and
copilot_code_review blocked four merges by surprise.
GitHub only. The script speaks GitHub GraphQL, so it cannot answer for a
GitLab merge request — -R git.netresearch.de/group/project is accepted at the
command line and then fails with a bare pr-status: GraphQL query failed, and
--watch produces nothing at all until it is killed. Nothing in the name says
so, which is how a GitLab MR ended up behind a watcher that could never fire.
For GitLab the equivalent is one glab api call:
P=$(printf %s 'group/project' | jq -sRr @uri) # the path must be URI-encoded
glab api "projects/$P/merge_requests/123" \
| jq '{state, detailed_merge_status, pipeline: .head_pipeline.status}'
glab api "projects/$P/merge_requests/123/discussions" \
| jq '[.[] | select(.notes[0].resolvable==true and .notes[0].resolved==false)] | length'detailed_merge_status is GitLab's counterpart to mergeStateStatus and,
unlike it, does name the reason.
./scripts/pr-merge.sh -R owner/repo 123
./scripts/pr-merge.sh -R owner/repo 123 --dry-run # print the command onlyIt reads pr-status.sh --json and refuses unless NEXT is merge, printing
the gate that is shut instead. When it does merge it uses the method the
repository allows and drops --delete-branch where a merge queue is active.
Afterwards it reads the PR back and reports only what it observed — merged
when the state says so, queued when the PR really holds a queue entry, and a
failure with exit 2 otherwise. gh pr merge exiting 0 proves nothing on a
merge-queue repo; see A pending auto-merge request silently swallows the
enqueue below.
Both of those are silent traps for a hand-written gh pr merge --merge --delete-branch. A repository with allow_merge_commit: false answers
"Merge commits are not allowed on this repository"; one with a merge queue
answers "Cannot use --delete-branch when merge queue enabled". A 54-repository
rollout hit each of them three times before the detection was written down
once. Squash is never used — it discards the atomic commits and their
signatures.
An until [ pending == 0 ] loop learns nothing until the slowest matrix job
ends — long after the first failure was visible and workable. Across sessions,
45 such loops were written against 2 of any other shape. --watch returns as
soon as a check fails, a thread needs an answer, a review is missing, or the
required checks conclude. Start fixing what is already red instead of
waiting for green checks you do not need.
The first-event rule has one blind spot: an action already set at invocation
that retrying cannot clear — a request-review held open by an exhausted
Copilot quota — makes every re-arm return the same line within a second
(#165). Once you have seen that action and decided not to take it, re-arm
with --watch --ignore-action <action> (repeatable): the watch holds through
it, still returns on every other actionable event, and answers
SETTLED: NEXT is still the ignored action (exit 0) once the checks settle
with the ignored action still on top.
If pr-status.sh reports reviews: NONE on current head, do not merge.
Request one and say so:
gh api repos/OWNER/REPO/pulls/N/requested_reviewers -X POST \
-f "reviewers[]=copilot-pull-request-reviewer[bot]"A force-push invalidates a prior review: the old review stays attached to the
old commit, so a repo with a copilot_code_review rule goes back to BLOCKED
and needs a fresh request against the new head.
On a DRAFT PR the request is silently dropped. The REST call above answers
200, but the returned object's requested_reviewers stays [] and no review
ever starts — nothing errors, the request just does not take. A
copilot_code_review ruleset triggers its review only when the PR leaves
draft (gh pr ready). So do not diagnose a "broken" reviewer request on a
draft: mark ready first, then check requested_reviewers / the reviews list.
The 2xx-is-not-proof rule applies — read the response body, not the status.
(Observed 2026-08-26, netresearch/ldap-manager#659: two 200-acknowledged
requests on the draft, zero effect; the ruleset review fired within a minute
of gh pr ready.)
Copilot reports its own failures as a review — a normal COMMENTED row whose
body is the error:
Copilot encountered an error and was unable to review this pull request.
Copilot was unable to review this pull request because the user who requested
the review has reached their quota limit.
Nothing in the review's state distinguishes that from a real review, so
"a review exists on the head" is not the same as "this PR was reviewed".
pr-status.sh detects it and reports copilot_review_errored: true with
NEXT: request-review and a copilot_error_count. From the second failure on a
head it drops the retry command and tells you to review it yourself — in every
repo, with or without the copilot_code_review ruleset, since the generic
review gate re-requests the same bot. By hand, read the review body, not
just its state.
The action stays request-review even then, deliberately. The tool cannot
observe that a human read the diff — a review by the PR author is excluded from
the review gate by design — so a distinct "you are done now" action would be one
nothing could ever satisfy, and it would re-fire forever on the operator it was
written for.
The failure is also a failing copilot-pull-request-reviewer check-run — but
only in the REST check-runs API. It is absent from GraphQL
statusCheckRollup, so a rollup-based check cannot see it.
Re-request once. If it fails a second time, stop retrying and review it yourself: an outage may clear, but a quota ceiling does not clear by asking again, and the alternatives — merging unreviewed or waiting indefinitely on third-party infrastructure — are both worse than a self-review that says so.
A body naming the quota is not an outage, so it costs no second strike:
pr-status.sh withholds the retry command on the first failure and remembers
the wall in
${XDG_CACHE_HOME:-~/.cache}/pr-status/copilot-quota-exhausted-YYYY-MM. The
quota is account-wide and monthly while the evidence is per PR — a pull request
nobody ever requested a review on carries none at all — so without that file
the next PR in the next repo is handed a requested_reviewers POST the same
exhausted quota rejects (netresearch/maint #52 and #53, hours after the wall
was proven elsewhere). Later runs read the marker, report
copilot_quota_exhausted: true and answer with the self-review guidance and no
command. The month is in the filename, so the marker stops applying at the
reset rather than being aged out; delete the file to undo a verdict recorded in
error.
The quota wall has a second, earlier face: the requested_reviewers POST
itself can be silently dropped — HTTP 200, but the response's
requested_reviewers array is empty and a read-back seconds later still shows
no pending request, with no errored review anywhere yet. That empty read-back
right after the POST is the earliest quota tell there is; it looks like "the
bot will pick it up asynchronously" and is not (five requests across four repos
were swallowed this way on 2026-08-18 before a later errored review named the
quota). Read requested_reviewers back after the first request of a
session; empty means stop requesting — everywhere — and go straight to the
self-review path above.
"Review it yourself and say so in the PR" used to end outside the tooling: the
review-note comment satisfied the policy while pr-merge.sh still refused the
merge, so the merge ran as raw gh pr merge — three times in one sweep, past
the very script that exists to be the safe path. The fallback is now a
first-class input:
pr-merge.sh -R owner/repo 123 --self-reviewedposts a PR comment whose body carries the line Self-review: <head-sha> (as
the PR author — the flag refuses any other authenticated user; hand-written
markers need at least the first 12 sha chars, since an 8-char prefix is
grindable by vanity-sha tools), then re-reads the gate and merges.
pr-status.sh reads the attestation back from the last 100 comments and
honours it only where the refusing branch stamped
next.reason: bot-review-unsatisfiable — the quota wall, or two failed bot
reviews on the head; the flag keys on that reason, never on the account-global
quota state, so a satisfiable refusal (say, classic require_last_push_approval)
can never receive a false "unsatisfiable" attestation. That keying is necessary
and was not sufficient: until #214 the refusing branch itself stamped the reason
while an APPROVED review sat on the very same head, and seven approved PRs in
one sweep got the attestation anyway. The bot branches now also require that no
approval is on the current head — checked against the approval list rather than
has_review_on_head, which a failed Copilot review satisfies by being an
ordinary COMMENTED row. With a live review path
the attestation changes nothing, a non-author comment never counts, a human
CHANGES_REQUESTED or a host-required approval keeps it inert, --dry-run
previews the comment without posting it, and the next push invalidates the
attestation because the sha stops matching. This is an explicit operator assertion the tool reads back, not a
state it claims to observe — the same reason the old "review-yourself"
action was removed. The attestation comment is permanent PR history: post
it only when the diff was actually reviewed, and say what was looked at in
the review-note comment beside it.
Every script in scripts/ answers --version, and prints the resolved path beneath it:
$ pr-merge.sh --version
pr-merge.sh 1.27.0
path: /home/you/.agents/skills/git-workflow/scripts/pr-merge.sh
The version is read from the SKILL.md next to the script, never from a checkout elsewhere, so a cached copy reports the number it was packaged with. The path is there because the number alone is not enough: two installations on one machine declared the same version while shipping different scripts, and --self-reviewed existed in only one of them (#209). The flag was reported as missing — which reads exactly like a feature that was never built — and about a dozen PRs were merged by hand instead of through the attestation flow.
So when a documented flag is rejected as unknown, that is a question about the installation before it is a question about the tool. --version needs no repository, no network and no gh: it is the one thing that must still answer when everything else is broken.
When NEXT: rebase turns into a conflict, look at what the main side of the
hunk contains before resolving anything. If main's side already implements
what the PR implements — the same feature, ported independently or landed via a
sibling PR — the conflict is not a merge problem, it is the discovery that the
PR is superseded. This is the normal fate of a fix PR that sat for days while
the incident it came from was also worked elsewhere.
The reflexive resolution — keep the PR's side, it is what you came to merge —
is exactly wrong here: main's version has usually moved on (hardening, an extra
call, review feedback the PR never saw), and preferring the PR side silently
reverts that. Observed 2026-08-18 on netresearch/jira-skill #194: main's
copy of the identical stdin feature had a mention-gate call integrated
(check_mentions_cli); taking the PR's hunk would have merged green and
dropped the gate.
What to do instead: diff the PR's intent against current main and keep only the
delta main lacks (git reset --hard origin/main, re-apply just that delta,
reword the commit) — or close the PR outright if nothing remains. Either way,
update the PR title and body to describe what it now is; a re-scoped PR wearing
its old description misleads its reviewer. And when reviewing a PR that is more
than a couple of days old, check mergeable/mergeStateStatus first — a
CONFLICTING docs-or-fix PR is a "has this landed already?" prompt before it
is a content-review task.
Not every repo uses main — older repos often use master, and some use
develop or trunk. Before pushing, opening a PR, or scripting across many
repos, resolve the actual default branch instead of assuming:
gh repo view OWNER/REPO --json defaultBranchRef --jq '.defaultBranchRef.name'Assuming the wrong name silently pushes to (or creates) the wrong branch, or targets a PR at a branch that isn't the integration branch.
Working two PRs at once, a fix for PR B often means checking out B's branch
mid-task. Nothing switches you back afterwards, and the next edits land on B
while you believe you are on A. Because git status looks normal — modified
tracked files, no conflict — the mistake surfaces only later, e.g. when a value
you "already added" reads back as absent.
Re-assert the branch before resuming edits, and again before staging:
git branch --show-current # cheap; run it after ANY cross-PR detourIf edits did land on the wrong branch, move them rather than redoing them:
git stash push -m "misplaced work" -- <paths> # path-scoped: leaves the branch's own work alone
git checkout <intended-branch>
git stash popPrefer a separate worktree per PR (references/advanced-git.md) when the two
are worked in parallel — then no checkout is shared and the detour cannot
misplace anything.
For GitHub operations (PRs, issues, reviews, releases), reach for gh or the
GitHub MCP tools before hand-rolling curl/REST calls or clicking through the
web UI: consistent authentication, structured --json output, and clearer
errors. Drop to raw gh api only for endpoints the porcelain commands don't
cover yet.
Every gh call in a session draws from a shared pool — REST 5,000/h and
GraphQL 5,000 points/h are SEPARATE pools, each shared across watchers,
agents and scripts, plus short burst limits on top. A heavy session (fleet
survey, repeated preflight batteries, parallel reviewers) usually kills the
GraphQL pool first — and gh pr merge, gh pr view --json, and
pr-status.sh are GraphQL-backed, so exactly the merge you verified
everything for stops working (observed 2026-08-13, between "all gates green"
and the merge).
Two practices keep this from biting:
-
When the full gate was verified on head X and nothing was pushed since, the action is ONE call — not another preflight battery. The REST merge endpoint takes a
shapin whose 409 on mismatch IS the freshness check:gh api -X PUT "repos/$R/pulls/$PR/merge" \ -f merge_method=merge -f "sha=$(git rev-parse HEAD)"
Take the SHA from local git, never by retyping a short SHA into a long one — the pin rejects a fabricated tail exactly as it rejects a moved head.
-
GraphQL dead ≠ blocked. The REST twins keep answering: merge (above), branch delete (
gh api -X DELETE repos/$R/git/refs/heads/<branch>; a 422 means auto-delete beat you to it), reviews and comments lists.gh api rate_limitis exempt and tells you both pools' reset times. Prefer one--watchover repeated status reads, and stop any watcher whose answer you already have.
The rest of this file is about getting a change merged. This section is about making it reviewable. A reviewer who cannot see what changed has to reproduce your work before they can judge it.
If the change alters output, an error message, a rendered page, a CLI line or an API response, the PR body carries both states. Not a description of them — the captured text:
**Before**
Reason: An error occured on handling the request.
**After**
Reason: An error occured on handling the request. (HTTP 500, code 1603956982)Produce them the same way: run the old and the new code against the same
input. A stub server, a fixture, or git checkout <base> -- <file> for one run
and back again all work, and take a couple of minutes:
run_it > after.txt
git checkout <base> -- src/Thing.php # old behaviour; <base> is the PR's target, not always main
run_it > before.txt
git checkout HEAD -- src/Thing.php # restore; verify with git statusSay in the body that the transcripts are captured output rather than illustrations — the difference matters to a reviewer deciding how much to trust them.
Cover the shapes the change can meet, not only the motivating one. A table of input → before → after exposes the cases you would otherwise never run.
Negative claims escape verification because nothing looks wrong when you skip them. "The fallback keeps its wording", "existing callers are unaffected", "no behaviour change for empty input" — each asserts the result of a run you have to actually perform.
Build the before/after table before writing the summary sentence. Filling in the rows is what catches the case you assumed was untouched; writing the sentence first only records the assumption.
A body written months ago documents a state the branch has since left. Every
rebase, revert and upstream merge invalidates part of it, and nothing in the
tooling notices. Before publishing an update, re-derive each factual claim from
git diff <target>...HEAD — image tags, memory limits, thresholds, and above
all the New/Changed column: a job listed as New that the target already has
hides whatever your version alters about it.
Observed in one MR: a "switched coverage to PCOV" section after PCOV had been
reverted, php:8.3 images that were 8.4, MSI 70/80 against an actual
19/77, and a job marked New that existed upstream — which concealed that
the change also dropped its memory limit from 2G to 512M.
When a change is gone, delete its section. Rewording it to "X stays the coverage driver" replaces a false statement with a true but empty one: the body is the delta against the target, and something that does not change has no row in it.
Elapsed time is the obvious cause; a review is the frequent one, and it fires within the hour. The body was written to describe the first version of the change, the review found that version wrong, and the fix replaced the approach the body still explains. Seen on one PR the same day it opened: the body documented an escape hatch read from the environment that the head commit had already replaced with one read off the command line — the mechanism was gone, the explanation was not.
Re-derive the body after every push that answers a review, not only after a rebase, and re-measure every number it states rather than copying it forward. Figures survive edits that invalidate them: the same PR claimed a "63k-word corpus" while the code comment beside the measurement said 60k. Both had been measured — of different corpora, weeks apart in reading order and minutes apart in writing — which is exactly why the disagreement is worth catching. Two figures for one quantity means at least one is answering a question you are no longer asking.
A body that cites a commit can only be written once that commit is on the
remote, and the hash belongs in it by lookup — git rev-parse --short HEAD or
git log -1 --format=%h — never typed from memory. A reader who follows a
wrong hash finds nothing, and the claim it supported becomes unverifiable.
That ordering has a second, sharper reason: a denied command loses every side
effect it contained. Bundling the push, a heredoc that writes the body file,
and gh pr edit --body-file into one shell invocation means a gate that rejects
any part of it rejects all of it — and the parts that already ran do not roll
back. Observed: the push landed, the heredoc never ran because the same call was
rejected for naming a not-yet-pushed hash, and the following command failed with
no such file or directory — a confusing error two steps downstream of the
actual refusal.
# ✅ Three calls, each with one job
git push
git rev-parse --short HEAD # take the hash from here
# …write body.md…
gh pr edit 123 --body-file body.md
# ❌ One call: the gate rejects the whole thing, the push already happened,
# and the body file that the next step needs was never created
git push && cat > body.md <<'EOF' … EOF && gh pr edit 123 --body-file body.mdThe general rule: never bundle a file write with a push or an API call. Keep state-changing steps separable, so a refusal costs you the step and not the scaffolding around it.
Issue templates order evidence before solution — they are written for reports where the cause is still unknown. When you arrive with a diagnosis and a patch, that ordering buries the actionable part under everything that proves it, and a maintainer reading top-down never reaches it.
State the fix in the summary, then keep the evidence below it:
### Summary
<symptom, one paragraph>
**The fix is to <X>** — <why it is safe>. Diff under [Possible fixes](#possible-fixes)
below; everything in between is the evidence for the diagnosis.Keep the template's sections — reviewers navigate by them — and add the pointer rather than reordering them.
The project default is atomic commits preserved end-to-end. Squash is destructive: it loses GPG signatures, collapses bisection granularity, and destroys narrative. Never squash unless the user asks for it in this task.
- One commit = one self-contained logical change
- Each commit builds and passes tests independently
- No "WIP", "fixup", or "oops" commits in final history — rebase them away before merge
- Mixed changes get split (
git add -p,git commit --fixup,git rebase --autosquash)
- Rebase + merge commit (
gh pr merge --mergeaftergit rebase origin/main): linear feature history with an explicit merge point. Preserves signatures. This is the default for Netresearch repos. - Fast-forward merge (local
git merge --ff-only): when signed commits are required AND only rebase is allowed (see "Signed Commits with Rebase Merge" below). - Squash: only when the user explicitly asks.
Stop. Re-read the task. Did the user say "squash"? If not, use --merge or --rebase (with the signed-commits caveat). The correction "no squash! atomic commits!" is a repeat interruption — prevent it by defaulting to merge-commit.
Before composing review findings — your own or a subagent's — fetch the PR's
existing reviews and review threads in the same batch as the diff
(gh pr view --json reviews plus the unresolved-thread GraphQL query, or MCP
pull_request_read: get_reviews / get_review_comments). It costs no extra
round-trip, and the diff alone is not the review context:
- Dedupe against standing feedback. A finding another reviewer already raised gets referenced or skipped, not re-posted as new. Reposting reads as noise to the contributor and hides which comment is the actionable one.
- A moved head may BE the response to a prior review. When the latest commits were pushed after a CHANGES_REQUESTED review, new findings are often regressions introduced by exactly the changes that review demanded — which reframes severity and tone, and the connection is invisible from the diff alone.
- The merge gate is the standing blocking review, not your new comment. An open CHANGES_REQUESTED review keeps blocking after its points are addressed; the next action is that reviewer dismissing or re-reviewing, and a review posted without knowing that mis-states what happens next.
Observed (usercentrics-widgets#143): a four-comment review posted straight from the diff duplicated one point of the repo owner's own standing CHANGES_REQUESTED review and missed that the head under review was the contributor's response to it — the "new" case-sensitivity finding was a regression introduced by that review's requested guards, and the real next step (re-review of the standing blocker) surfaced only after posting.
Inline comments anchor to diff positions. A file:line pair reported by a review
subagent is a claim, not a coordinate — recompute it from the hunk headers
before posting (observed: a reported :185 pointed at a context }; the
finding's code sat at 184). A wrong anchor lands the comment on an unrelated
line or fails the review submission outright.
Never reply with "Addressed" or "Fixed" without citing the resolving commit SHA. Review threads are resolved on GitHub's side, not by agent assertion.
# After pushing the fix
SHA=$(git rev-parse HEAD)
gh api graphql -f query='
mutation($body: String!, $id: ID!) {
addPullRequestReviewThreadReply(input: {body: $body, pullRequestReviewThreadId: $id}) {
comment { id }
}
}' \
-f body="Fixed in ${SHA:0:7} — <1-sentence explanation of what changed and why>." \
-f id="PRRT_xxx"
# Then resolve the thread
gh api graphql -f query='mutation { resolveReviewThread(input: {threadId: "PRRT_xxx"}) { thread { isResolved } } }'These replies are banned:
Addressed(no SHA, no explanation)Fixed — merged(merged what? where?)Done(done how?)Good point, updated(updated what, in which commit?)
Every resolving reply must include: commit SHA (7+ chars), one sentence of what changed, one sentence of why if not obvious from the diff.
AI reviewers (GitHub Copilot, Gemini Code Assist, SonarCloud) mix correct findings with confident hallucinations. Before applying or declining a review comment, verify its load-bearing factual claim against an authoritative source — the framework/library code, official docs, or a quick local probe — not the reviewer's assertion alone.
- Applying blindly ships wrong code (e.g. an edit based on a false API claim, which may also fail your own linter/type-checker).
- Declining blindly dismisses real bugs — the same reviewer is often right about the next comment.
Reply citing the evidence either way. When you applied a change, the reply must still carry the commit SHA and the what/why required above (e.g. Verified against <source>: <fact> — applied in <SHA>, which …); when you declined, state the source and fact (e.g. Verified against <source>: <fact> — declining.). When the suggestion is a code change, run the project's checks (lint, types, tests) on it before resolving, so the reply cites a green result rather than a guess.
Intentional SAST findings on test code: dismiss the alert, don't contort the test. A static-analysis finding (SonarCloud, CodeQL / GitHub Advanced Security) that fires on a deliberate test input — an SSRF test hitting 169.254.169.254, a clear-text http://… URL a denial test asserts on, a synthetic secret fixture — is a false positive against the test's intent. Rewriting the test to satisfy the analyzer weakens the very case it exists to prove. Instead dismiss the alert at its source, which also clears the blocking github-advanced-security review thread that a plain reply cannot resolve:
# Find the alert number for the flagged file/rule, then dismiss:
gh api repos/$R/code-scanning/alerts --jq '.[]? | {number, rule: .rule.id, path: .most_recent_instance.location.path}'
gh api repos/$R/code-scanning/alerts/$N -X PATCH \
-f state=dismissed -f dismissed_reason='used in tests' \
-f dismissed_comment='Intentional test input — <one line why>.'dismissed_reason is one of false positive / won't fix / used in tests; use used in tests for deliberate test inputs. SonarCloud has the equivalent "Won't fix / Safe" transition in its UI (auto-analysis ignores sonar.issue.ignore.*, so mark it there, not in config). Reply to the thread citing the dismissal, then resolve it.
A SAST finding you actually fixed leaves its thread open too — and that thread still blocks the merge. The case above is the false positive. The opposite case looks identical from mergeStateStatus and must not be handled the same way: you push a real fix, the analyzer re-scans and closes the issue, the quality gate flips to green — and the github-advanced-security review thread stays open, holding the PR at BLOCKED with zero failing checks and nothing obvious to fix. The thread is a snapshot of the commit it was posted against; a re-scan does not retract it.
Before assuming either case, ask the analyzer which one you are in:
# SonarCloud: is this issue actually resolved on the PR?
# Header auth, not `curl -u` — keeps the token out of the process list, and
# secret scanners flag the `-u` form on sight.
curl -s -H "Authorization: Bearer $SONAR_TOKEN" \
"https://sonarcloud.io/api/issues/search?issues=$KEY&pullRequest=$PR&componentKeys=$PROJECT" \
| jq '.issues[] | {rule, status, resolution}'
# -> {"status":"CLOSED","resolution":"FIXED"} = you fixed it
# -> {"status":"OPEN"} = not fixed; fix or dismissThe issue key is embedded in the thread body as <!--SONAR_ISSUE_KEY:...-->. For CodeQL, gh api repos/$R/code-scanning/alerts/$N --jq '{state, fixed_at, dismissed_reason}' answers the same question.
On CLOSED / FIXED, treat it as an ordinary already-fixed bot thread: reply with the fixing SHA and resolve. Do not dismiss the alert — dismissal records "we decided not to act" on a finding you did act on, which is a false audit trail and hides the rule from firing again. Dismissal is only for the intentional-finding case above.
Observed 2026-07-30 on a docker:S8544 finding: gate OK, 0 open issues, 0 failing checks, mergeStateStatus: BLOCKED on one stale thread — a merge that looked inexplicably stuck until the thread was read.
Both forms (merge and --rebase) create the new commit server-side, signed by
nobody. In a repo that requires signed commits — including a requirement
living in classic branch protection, which neither the rulesets endpoint
nor a non-admin protection query can see — the PR then sits at
mergeStateStatus: BLOCKED with every visible gate green (observed on a PR
that reported request-review for an hour while the real blocker was the
signature). Rebase locally instead: signing is wired into git, so a plain
git rebase origin/main (or git commit --amend --no-edit when only the
signature is missing) re-signs, then push with --force-with-lease. Two
traps in that push: a checkout created from FETCH_HEAD has no lease
baseline and fails with stale info — pass the lease explicitly as
--force-with-lease=<branch>:<remote-sha>; and that remote SHA must be
measured (git ls-remote origin <branch>), never retyped from memory.
For "is this commit signed?" in a review, ask the API:
gh api repos/OWNER/REPO/commits/SHA --jq '.commit.verification' # verified: true|false, reasonLocal git log --show-signature / --format='%G?' only consults the reviewer's local keyring — a status of E means "key not imported here", NOT "unsigned". Flagging %G? = E as a missing signature produces a false finding the contributor cannot fix (one such finding had to be publicly retracted; GitHub showed "verified" all along).
When a PR fixes one code path of a failure that can occur on every path (one endpoint catching a session-expiry redirect while all endpoints share the failure mode), the review recommendation is NOT "keep it narrowly scoped + open a follow-up for centralization". Rework the fix at the central layer (session hook, base class, middleware) — the symptom-fix then becomes unnecessary or trivial. Opening a follow-up issue "for the real fix later" is the smell that the current PR sits in the wrong place: it cements the wrong location, ships a known-imperfect fix, and adds tracking overhead to undo. The only accepted split: the central fix has a hard external dependency that genuinely cannot land in the same PR.
When several reviewers (or parallel review agents) return a list of findings, do not sort them into "applied" vs "out of scope" as a batch. Fit-check each finding individually first:
- Does it require a code change at all? "Document the invariant" / "explain the discarded return" is a comment-only addition — it fits in any PR that touches the code it explains.
- If it needs code: is it under ~10 lines and inside already-touched files? Then it fits.
- Is there a doc-only alternative? A comment documenting the trade-off often satisfies the concern at near-zero cost.
Default to "fits" for comment- or docstring-only suggestions; reserve "out of scope" for real surgery (interface changes, cross-file refactors, unrelated pre-existing bugs). In one 12-finding round, batching hid two one-line doc-comment additions among genuinely out-of-scope refactors — the reviewer asked "none of them fit?" and the re-examination cost an extra round-trip.
When you do defer a finding, "filed as follow-up" is a claim of action: file the issue in the same turn and quote its URL in the summary, or ask explicitly whether to file — never write "will file" / "tracked separately" without the link in the same paragraph.
Reviewers read a diff, not its provenance. When a comment proposes tidying something that was copied verbatim — a captured transcript, a quoted log line, an error string, a fixture recorded from a real system — check the source of truth before touching it. Improving such a line does not improve the artifact; it turns evidence into an approximation, and quietly breaks anyone who greps their own logs for the string as it is actually emitted.
Real case: a review asked to correct "occured" to "occurred" in a before/after transcript. The misspelling belonged to the server under discussion:
// ter_rest/Classes/Http/RouteHandler.php:125, quoted as it stands
: $this->responseFactory->createErrorResponse($request, 1603956982, 'An error occured on handling the request.');Declined, citing that line. The reply matters as much as the decision — a bare "no" reads as resistance, while the quoted source makes the reason checkable in seconds.
The general form: before editing any line, know whether you are its author or its witness. Prose you wrote is yours to improve; recorded output is not.
A correct diagnosis does not guarantee a sufficient remedy. A reviewer sees the line they commented on, not the rest of the mechanism — so applying the suggestion exactly as written can leave a half-change that is worse than the original.
Real case: a comment correctly objected to time() in a test-setup example as
non-deterministic and asked for a fixed instant. Applying that to the shown line alone
would have frozen the clock the code reads while the fixtures around it still came
from time() — putting the two years apart and failing every assertion as expired. A
rare flake would have become a certain failure. The fix that works pins one
constant and drives both from it.
Trace the suggested change through the code it touches before committing to it. When it turns out to be incomplete, say so in the reply and describe what you did instead — that is the part a reviewer cannot see, and the reason to prefer it over the literal reading.
Distinct from a review comment: Copilot Autofix, Gemini "apply suggestion", and similar bot-authored commits already pushed onto the PR branch are patches, not settled work — they can carry real bugs, and they arrive looking done. In one /pr-finish run the autofix commits had (a) deleted a variable's initialization while keeping the line that reads it — an UnboundLocalError on every non-account query — and (b) added an unbounded while True pagination loop that later OOM-crashed the machine (see the Cap memory when the fix activates or relies on a loop bullet under Fixing the failure). Treat every bot commit like an untrusted patch:
- Read its net diff against your last human commit, not just the headline —
git diff <your-last-sha> HEAD -- <file>(orgit show <bot-commit-sha>for one specific commit). The fix a bot "applied" often removes or rewrites more than the comment implied. - Squash them into the atomic feature commit and re-run the FULL suite — not only the check that was failing. A green "the failing check now passes" does not clear a bug the bot introduced elsewhere; only the whole suite does (this is the Verify the activated code path rule — a bot fix can make dead code live).
- Their missing
Signed-off-by/signature is also why the DCO / signed-commits gate fails; squashing under your own signed, signed-off commit fixes correctness and the gate in one step.
On a repo with an incremental AI-reviewer ruleset (copilot_code_review, Gemini), every push re-triggers a fresh required review round that re-BLOCKs the PR — and AI reviewers surface semantic nits a linter never catches (heading structure, code-wrap conventions, cross-reference/notation consistency). Pushing one fix per comment turns this into 3+ rounds of request → wait (minutes each) → re-block. Collapse it:
- Semantic self-review before the first push. Lint/markdownlint passing is not enough — re-read the diff for the convention nits an AI reviewer will flag, and fix them pre-emptively.
- Batch all review fixes into ONE push, not per-comment. Each push restarts the round; one push = one new round.
- Pre-empt the recurring code-quality nit classes — AI reviewers reliably re-flag the same gaps, so fixing them before the first push removes whole rounds. On new code, self-check: bound every paginate-until-metadata loop with a hard cap that raises (never trust the response to signal "last page"); coerce external-payload fields to the expected type before downstream use (a field documented as an object can arrive as a string — guarantee the shape, don't assume it); availability probes treat 5xx and 401/403 as "unavailable," not only 404 (else an outage/auth failure selects the backend and dies on the real call); add a test for every new code path (an untested new path is both a coverage nit and where the worst bugs hide).
Expect 2–3 rounds even so; the loop mechanics (wait for the bot to review the latest head SHA, never merge over an in-flight re-review — see Merge Gate) still apply. This tactic reduces the number of rounds, not how you survive each one.
Before declaring a PR review-complete, re-fetch thread state from GitHub. Never trust your own belief about what you resolved:
gh api graphql -f query='
query($owner: String!, $repo: String!, $pr: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $pr) {
reviewThreads(first: 100) {
nodes { id isResolved comments(first: 1) { nodes { body author { login } } } }
}
}
}
}' -f owner=OWNER -f repo=REPO -F pr=NUMBER \
| jq '.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false) | {id, first_comment: .comments.nodes[0].body[:80]}'If that returns any rows, the PR is not merge-ready.
unresolved == 0 is not merge-ready if you sampled it right after a push.
GitHub Copilot (and Gemini, and similar bot reviewers) re-review the PR
asynchronously — typically 1–2 minutes after each push — and each round can
post entirely new review threads against the fresh head. Reasoning about
merge-readiness on a zero count that predates the bot's re-review produces a
premature "threads clear" call; the bot then lands more valid findings a minute
later. In one session, sampling too early gave a false all-clear twice, and
the bot posted five more legitimate findings on each following round.
So don't check thread count first — check that the bot has actually reviewed
the current head SHA first, then re-check threads. Poll until a bot review
whose commit_id equals the PR head has landed:
HEAD=$(gh pr view "$PR" --repo "$R" --json headRefOid --jq .headRefOid)
# A Copilot review keyed to the current head must exist before you trust the count.
SEEN=$(gh api "repos/$R/pulls/$PR/reviews" \
--jq "[.[]? | select(.user?.login? // \"\" | test(\"copilot\";\"i\")) | select((.commit_id? // \"\")==\"$HEAD\")] | length")
[ "${SEEN:-0}" -ge 1 ] || { echo "bot has not re-reviewed head $HEAD yet — keep polling"; }Only once SEEN >= 1 is the unresolved-threads query above meaningful. This is
the same "review the latest head SHA" gate the Merge-Gate Watcher
enforces — apply it here too, before ever declaring the review done.
# Creates a merge commit, preserves all history
git checkout main
git merge --no-ff feature/my-feature
# Result:
# * Merge branch 'feature/my-feature'
# |\
# | * feat: add feature part 2
# | * feat: add feature part 1
# |/
# * Previous main commitUse when:
- Want to preserve complete branch history
- Complex features with meaningful intermediate commits
- Audit trail required
# Combines all commits into one
git checkout main
git merge --squash feature/my-feature
git commit -m "feat: complete feature implementation"
# Result:
# * feat: complete feature implementation
# * Previous main commitUse when:
- Feature branch has messy history
- WIP commits, fixups, "oops" commits
- Want clean linear history
# Replays commits on top of main
git checkout feature/my-feature
git rebase main
git checkout main
git merge --ff-only feature/my-feature
# Result:
# * feat: add feature part 2
# * feat: add feature part 1
# * Previous main commitUse when:
- Clean commit history in feature branch
- Each commit is meaningful and tested
- Want linear history without merge commits
| Strategy | History | Complexity | Traceability |
|---|---|---|---|
| Merge | Preserved | High | High |
| Squash | Combined | Low | Medium |
| Rebase | Linear | Low | Medium |
Catching a fork up with its upstream looks like a merge-strategy question. It is mostly a scope question, and four traps sit between the two.
When a maintainer rejects a rebase because "rebasing would break our releases" and says "we need to merge", the load-bearing word is not merge — it is don't rewrite the SHAs our releases point at. Merge is one mechanism that satisfies that; cherry-pick satisfies it too, and so does doing nothing.
Establish the net delta before choosing the mechanism, and say it out loud:
UPSTREAM=hashicorp/some-project # the repo you forked
FORK=your-org/some-project # your fork
git fetch upstream
git log --oneline origin/main..upstream/main | wc -l # what we would gain
gh api "repos/$UPSTREAM/compare/main...${FORK%%/*}:main" --jq '{ahead: .ahead_by, behind: .behind_by}'
# Where the conflict surface actually lives — often one directory dominates
gh api "repos/$UPSTREAM/compare/main...${FORK%%/*}:main?per_page=100" \
--jq '[.files[].filename | split("/")[0]] | group_by(.) | map({dir: .[0], n: length}) | sort_by(-.n) | .[0:5]'For what we add, prefer git cherry over git log: it compares by patch-id,
so a change of yours that upstream already carries under a different SHA is
correctly reported as already-there. git log upstream/main..origin/main counts it
as yours and overstates the delta.
git cherry -v upstream/main origin/main | grep -c '^+' # genuinely ours
git cherry -v upstream/main origin/main | grep '^-' # already upstream, other SHAThis is not hypothetical: on the fork below, git log reported 27 commits while
git cherry reported 26 — the difference being the fork's own re-authored port
of an upstream fix, which git cherry matched to the upstream original despite a
different SHA, author, and commit message.
If the valuable delta is a handful of commits — or one typo fix — a merge of the full history buys you every conflict and every unsigned commit in that history to deliver it. Cherry-pick the delta instead; the SHA-preservation constraint is met either way.
Real case: a fork 22 ahead / 11 behind an archived upstream. The merge produced 548 conflicts and 11 DCO-breaking commits; the entire net gain was a two-line typo fix (the other 10 commits were vendor churn, the upstream's own release CI, and dependency bumps the fork had already surpassed). The delta had been measured before the merge and the merge was run anyway. The maintainer's correction — "if the typo is the only change, pull in the typo, nothing more" — was the whole job.
Tell: you are resolving conflicts in files your fork deliberately diverged on (vendor trees, CI, templates) to obtain something you could name in one sentence.
A repository that permits only rebase-merge cannot land a merge commit: --rebase
replays the branch and flattens the merge, rewriting exactly the SHAs the merge
existed to preserve. Discovering this at merge time means the work was mis-shaped from
the start.
gh api "repos/$OWNER/$REPO" --jq '{allow_merge_commit, allow_rebase_merge, allow_squash_merge}'Run it before you build the merge, not at step "merge". If merge commits are
disabled but a true merge is required, the options are: enable allow_merge_commit
(a repo-policy change affecting every future PR), a local fast-forward push (see
Signed Commits with Rebase Merge — main can fast-forward to the merge commit
when its first parent is main's head), or a different mechanism entirely.
git merge only reports conflicts for paths both sides touched. Files the other
side added that your side never had merge silently, with no conflict — so if
your fork deleted a directory upstream still maintains, resolving every conflict
still leaves you re-importing it.
# 545 conflicts resolved... and 252 files quietly staged as clean additions
git status --porcelain | grep '^A' | grep ' vendor/' | wc -l
git diff --cached --name-only -- vendor | wc -lReal case: a fork that had run chore: unvendor merged an upstream that still
vendors. 545 paths conflicted DU (deleted by us / modified by them) — and 252
more merged cleanly as additions, because upstream's vendor upgrade had added
files the fork never carried. Resolving the conflicts alone would have silently
re-vendored the project and reverted the unvendoring, with a green merge.
After any merge involving a path one side removed:
git rm -rfq --ignore-unmatch -- <path> # plain `git rm` refuses when the index has staged changes
git ls-files -- <path> | wc -l # must be 0The previous section is about files. The same silence applies inside a file: where both sides rewrote overlapping regions, git may resolve the hunk in one side's favour and report nothing. Entries the other side had are then simply gone, with no conflict marker to notice.
# After every merge, read what came out of the files both sides touched
git diff --numstat HEAD -- composer.json package.json # unexpected churn?
git show HEAD:composer.json | jq -S '.["require-dev"]' > /tmp/before.json
jq -S '.["require-dev"]' composer.json | diff /tmp/before.json -Real case: a merge of an 82-commit develop into a long-lived branch resolved
composer.json without conflict — and dropped two require-dev entries the branch had
added. One of them registered a PHPStan extension, so the analysis would have run
against a baseline generated with that extension while no longer loading it. Nothing
in the merge output mentioned either package.
When several conflicts share a shape, it is tempting to resolve them with one rule — "their change is a subset of ours, take ours". The rule is about the conflicting lines; whether it holds depends on code that is not in the conflict.
The recurring trap is a parameter whose nullability changed:
// theirs — a minimal deprecation fix
public function f(?int $max = null)
// ours — went further, and looks like a superset
public function f(int $max = 0)
// …but the body, untouched and outside the conflict:
$max = (int)($max ?? end($allLtsVersions) ?: 0); // ?? is now dead, $max stays 0Real case: six conflicts of exactly this shape, resolved with one rule. Four were
right. In the other two the branch had tightened ?Type $x = null to a non-null
default while the body still tested for null — one silently returned an empty version
range, the other resolved a path to / instead of the repository root. Both had been
failing for months; the merge preserved them.
After resolving by side-selection, read the body of every function whose signature you just decided on, and check the callers of any method whose parameters were reordered — positional call sites do not conflict and do not warn.
A DCO check requires every commit to carry a Signed-off-by matching its author.
Upstream's commits carry none, and you cannot sign off on someone else's authorship
— sign-off is a declaration about work you have the right to submit. So any fork
merging any third-party history fails DCO by construction. This is not a mistake to
fix; it is a property of the operation.
Do not follow the DCO bot's own advice here. It suggests git rebase HEAD~N --signoff,
which rewrites the upstream commits and flattens the merge — destroying the ancestry the
merge existed to record, and forging sign-offs on other people's commits.
Real options, in order:
-
Don't merge the history — port the change. Cherry-pick, then re-author under your own sign-off, crediting the original in the message.
git cherry-pick -xkeeps the original author and therefore still fails DCO;git commit --amend --reset-author -S --signoffmakes it your commit, which is honest for a two-line port and passes the gate:git cherry-pick -x <upstream-sha> git commit --amend --reset-author -S --signoff # message credits upstream <sha> + author
-
Check whether DCO is actually required before treating it as a blocker —
gh api repos/$R/branches/$BASE/protection --jq '.required_status_checks.contexts'. A red-but-not-required DCO is a policy call, not a gate. -
Third-party remediation via
.github/dco.yml(allowRemediationCommits: {thirdParty: true}) — a legal declaration on someone else's work. A human decides that, never an agent.
# .github/workflows/pr-checks.yml
name: PR Checks
on:
pull_request:
branches: [main, develop]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm run lint
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm test -- --coverage
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm run build
pr-size:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check PR size
run: |
ADDITIONS=$(gh pr view ${{ github.event.pull_request.number }} --json additions -q '.additions')
if [ "$ADDITIONS" -gt 1000 ]; then
echo "::warning::Large PR detected ($ADDITIONS lines). Consider splitting."
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}# Branch protection settings
required_status_checks:
strict: true
contexts:
- lint
- test
- build
- security-scan# .github/CODEOWNERS
# Default owners for everything
* @default-team
# Frontend owners
/src/components/ @frontend-team
/src/styles/ @frontend-team @design-team
# Backend owners
/src/api/ @backend-team
/src/database/ @backend-team @dba-team
# DevOps owners
/.github/ @devops-team
/docker/ @devops-team
/terraform/ @devops-team
# Documentation
/docs/ @docs-team
*.md @docs-team
# Security-sensitive files
/src/auth/ @security-team @backend-team
/src/crypto/ @security-teamBefore opening the PR, run the target repo's own contribution gate — all of it, from its CONTRIBUTING.md, not the subset that resembles your usual one. Their checklist is the review contract, and the items that look like boilerplate are the ones that fail.
gh api repos/OWNER/REPO/contents/CONTRIBUTING.md --jq '.content' | base64 -dThen actually run each item. Two that are routinely skipped and routinely red:
- The whole-tree analyzer, not the package you touched.
staticcheck ./...,go vet ./...,phpstan analyseat the configured level — scoping it to your directory hides failures your change caused elsewhere. - The spell checker. Repos running cspell in CI usually set
incremental_files_only: true, so it checks exactly the files your PR touched — a domain term absent from the project dictionary fails the build on a doc-only change. Run it before pushing, and add the word to the project word list using the repo's own tooling rather than rewording to dodge it.
Check SECURITY.md before filing anything security-flavoured as a public issue. Many projects route vulnerabilities to a private address, so a public issue is both wrong and unwelcome — and this applies to a finding you were going to mention in a PR body too.
Match the repo's commit conventions exactly: Conventional Commits type and scope, sign-off, signature, and any attribution trailer their CONTRIBUTING.md requires. A commit-message linter is a pre-commit hook in many repos and will reject the message locally before CI ever sees it.
Draft → Ready for Review → Changes Requested → Approved → Merged
↑______________|↑_____________________|
Draft is a state the PR returns to, not one it only starts in. Opening every PR with --draft is the well-known half. The half that gets missed: when work resumes on a PR that is already "ready for review" — a rebase, a round of review fixes, another commit of any kind — convert it back before the first push, with gh pr ready --undo <n> (glab mr update <iid> --draft). Mark it ready again as a separate step, once the checks are green and the user has asked for it. Watching the parked PR is compatible with this: pr-status.sh --watch holds through the draft while checks run and returns on the first real event — a red check, an open thread — or with NEXT: ready once nothing is running any more (with --ignore-action ready that same-poll return is labelled SETTLED instead); it does not need the PR to leave draft first (#228).
The reason is what "ready for review" tells everyone else. It is a standing request for a maintainer's time against a specific head, and mid-work heads do not deserve it: between the first fix commit and the last one, the PR advertises for review a state you already know is incomplete — sometimes one you know is broken, when the work is a response to a reviewer's finding. Reviewers who look during that window spend attention on a diff that is about to change, and a green CI run on an intermediate head reads as an endorsement of work that is not finished. The round-trip is two commands.
The tell that this was skipped is a PR whose recent commits are labelled as review fixes while it stayed out of draft throughout. Check before you start, not after — note that gh pr view --json spells the field isDraft, while the REST payload and gh api call it draft:
gh pr view "$PR" --json isDraft,headRefName,state --jq '{isDraft,headRefName,state}'The Draft → Ready edge only exists if the workflow listens for it. ready_for_review is not in the pull_request default type set (opened, synchronize, reopened). A workflow declaring a bare pull_request: therefore never runs on that transition. Where such a workflow carries the auto-approval — typically a pr-quality job gated on github.event.pull_request.draft == false — the job skips while the PR is a draft and nothing re-runs it when the draft is lifted. The PR then sits at reviewDecision: REVIEW_REQUIRED with nothing red and no pending job, which reads as "waiting for a human" and is really "waiting for an event that will never come".
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]Diagnose it by absence, not by the red. There is no failing check to find. Compare the auto-approve job's runs against the PR's events: if it ran once at opened and skipped, and no run exists for the ready transition, this is the cause — not the Copilot review quota, which is usually also true at that moment and is not why the PR is stuck.
The PR that adds the trigger does not deadlock itself. It is tempting to conclude that opening such a fix as a draft is a trap, since lifting the draft would need the trigger the PR is still adding. For pull_request it is not: GitHub reads the workflow file from the PR's merge ref, so the on: block — types: included — comes from the branch under review and is already in force on that PR. Verified on two PRs that introduced their own workflow (netresearch/ofelia#442, netresearch/git-workflow-skill#156): both show a run of the new workflow at their own head SHA with event=pull_request.
The trap is real only for pull_request_target, which deliberately loads the workflow from the base branch — there the new trigger takes effect for the next PR, not this one. Check which of the two the workflow uses before deciding the draft convention has to be set aside.
# Check PR status
gh pr status
gh pr view 123
# Request review
gh pr edit 123 --add-reviewer "@reviewer1,@reviewer2"
# Mark ready for review
gh pr ready 123
# Convert to draft — also the first step whenever work resumes on a ready PR
gh pr ready 123 --undo
# Approve PR
gh pr review 123 --approve
# Request changes
gh pr review 123 --request-changes --body "Please fix X"
# Merge PR
gh pr merge 123 --squash --delete-branch
# Close without merging
gh pr close 123Stacking — PR B opened against PR A's branch so B can build on text or code that exists only there — is the right shape when B has no anchor without A. GitHub retargets B to main automatically when A merges, which is the point of the pattern. What it also does is dismiss every review on B, because the base changed:
reviews : github-actions=DISMISSED decision=REVIEW_REQUIRED
Nothing about B changed. Its head SHA is the same, its checks are still green, no file moved. But REVIEW_REQUIRED is a host gate, not advice, so B cannot merge until an approval lands on that head again — and an automated approver only reruns on a new head. The recovery is to give it one, with the plain (merging) form of the command in Updating a PR branch without a local clone:
gh pr update-branch $PR --repo $OWNER/$REPOThat merges the now-advanced base into B, which re-triggers CI and the approval workflow. Budget the full check matrix again, not a re-check.
There is a cheaper recovery, and it is the usual case. update-branch is the right tool when B's tree has to catch up — the base advanced and B needs its content, or the two conflict. When nothing about B is stale and only the approval was dismissed by the retarget, re-running the workflow that produced the approval restores it without a new head and without a second matrix:
RUN=$(gh api --paginate "repos/$OWNER/$REPO/actions/runs?branch=$BRANCH&per_page=100" \
--jq '[.workflow_runs[]|select(.name=="<the approving workflow>")]|sort_by(.created_at)|last|.id')
gh api "repos/$OWNER/$REPO/actions/runs/$RUN/rerun" -X POSTThe head SHA never changes, so nothing else is dismissed and the existing green checks still count. Find the workflow name from the check-run that carries the approval (gh api repos/$OWNER/$REPO/commits/$SHA/check-runs), not from the review's author — the review is submitted by github-actions[bot] whatever produced it.
Observed 2026-08-21 on netresearch/t3x-nr-llm#871: update-branch would have re-run 93 checks to recover one dismissed approval on a two-file documentation PR whose tree was already correct.
A trap in the diagnosis, not the fix. pr-merge.sh reports the gate it knows how to name, and a dismissed approval is not one of them — on #871 it answered "Copilot is OUT OF REVIEW QUOTA" and asked for a self-review attestation that was already posted, correctly, for the current head. Both statements were true and neither was the cause. The cause is only visible in the timeline:
gh api --paginate "repos/$OWNER/$REPO/issues/$PR/timeline?per_page=100" \
--jq '.[]|select(.event|test("review_dismissed|reviewed|head_ref_force_pushed"))
|"\(.created_at) \(.event) \(.dismissed_review.dismissal_message // "")"'08:22:33 review_dismissed approved
08:23:10 reviewed
10:53:59 review_dismissed The base branch was changed.
Read reviewDecision before believing a merge tool's NEXT: line. REVIEW_REQUIRED on a PR whose checks are green and whose threads are resolved means an approval was dismissed, and the message naming a review path is describing the door it checked, not the one that is shut.
Note the difference in why you reach for it. That section's caution is that --rebase force-updates and can reset approvals; here the approvals are already gone before you touch anything, dismissed by the retarget itself, and the update is what restores them.
Two things follow when you plan a stack:
- Expect the second CI run. The stack saves you a conflict, not a pipeline. If B's diff would conflict only trivially with A, opening B against
mainand resolving once may be cheaper than a retarget plus a full rerun. - Merging the base advances
main, so the child may now conflict. The retarget makes B's diff correct, not its tree: anything both PRs touched — a sharedCHANGELOG.md"Unreleased" section is the usual one — conflicts on the update-branch. Resolve it there; do not merge the base into the child while the child is queued.
Observed 2026-08-13 on netresearch/t3x-nr-llm#759, stacked on #758 for a documentation anchor that existed only on that branch.
# .github/workflows/stale.yml
name: Mark Stale PRs
on:
schedule:
- cron: '0 0 * * *' # Daily
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-pr-message: 'This PR has been inactive for 14 days. Please update or close.'
days-before-stale: 14
days-before-close: 7
stale-pr-label: 'stale'When two PRs split out of one piece of work both need the same addition (a
catch block both paths now reach, an enum case both use, the same import),
there are three options and only one of them is good:
| Approach | Cost |
|---|---|
| Stack the second PR on the first | Second PR can no longer merge on its own |
| Put the hunk in one PR only | The other PR is broken until that one lands — a merge-order trap |
| Put the identical hunk in both | Git merges it once, in any order |
Git treats the same change at the same place as one change, so both PRs merge cleanly and the hunk appears exactly once in the result. That removes the ordering constraint entirely, which matters because maintainers merge in whatever order they review.
It only works if the text matches byte for byte — same wording, same
indentation, same position, and the same import placement (run the project's
formatter in both branches, or the import sorter will reorder one of them). Copy
it mechanically rather than retyping, and compare the two extracts. <first-line>
and <last-line> are the first and last lines of the hunk, used as sed
address patterns:
git show <other-branch>:<path> | sed -n '/<first-line>/,/<last-line>/p' > /tmp/hunk
diff <(sed -n '/<first-line>/,/<last-line>/p' <path>) /tmp/hunk && echo identicalThen prove the property instead of assuming it — merge every order in a throwaway clone and check the result. Abort the run on the first conflict: continuing would leave the repo mid-merge and every later result meaningless.
git clone -q --shared <bare-repo> /tmp/mergetest && cd /tmp/mergetest
for order in "<branch-A> <branch-B>" "<branch-B> <branch-A>"; do
git reset -q --hard <base>
for b in $order; do
git merge -q --no-edit "$b" || { echo "CONFLICT: $b in order [$order]"; git merge --abort; exit 1; }
done
done
# counts matching LINES, so use one distinctive line of the hunk, not the hunk
grep -cF '<one distinctive line of the hunk>' <path> # must be 1, not 2State in both PR bodies that the hunk is identical and why, so a reviewer does not "clean up" the duplicate in one of them.
# Update feature branch with latest main
git checkout feature/my-feature
git fetch origin
git rebase origin/main
# If conflicts occur
# 1. Edit conflicting files
# 2. Stage resolved files
git add <resolved-file>
# 3. Continue rebase
git rebase --continue
# Force push (only on feature branches!)
git push --force-with-lease# Option 1: Rebase (preferred for clean history)
git checkout feature/my-feature
git fetch origin
git rebase origin/main
# Resolve conflicts
git push --force-with-lease
# Option 2: Merge main into feature
git checkout feature/my-feature
git merge origin/main
# Resolve conflicts
git commit
git pushWhen several of your own branches wait on one another, each needs main merged
in again after the previous one lands. For an append-only section — a
CHANGELOG's [Unreleased], a toctree, an index — the second and later merges of
that series behave differently from the first, and a resolver that works on the
first silently corrupts the rest.
The trap is any resolver whose premise is "ours holds only my additions" —
fold-ours-into-theirs, git checkout --ours on a hunk, a script that appends one
side to the other. That premise holds for the first merge only. Afterwards
"ours" already contains everything the previous merge brought in, so each round
appends main's entries again. There are no conflict markers, the diff looks
plausible, and the damage is visible only by counting: five branches merged in
sequence produced ten [Unreleased] bullets standing three and four times over,
and two of the PRs carried it to main before anyone noticed.
Rebuild instead of merging. Take theirs (current main) verbatim, lift your
own block out of ours by a distinctive first line, and insert it under the
existing heading:
git show :3:CHANGELOG.md > /tmp/theirs.md # current main
git show :2:CHANGELOG.md > /tmp/ours.md # your branch
# extract your block from ours, insert into theirs under the matching headingThen assert before committing — the three checks that catch every variant of this failure:
- the heading count is unchanged (no second
### Added), - your block appears exactly once, above the newest released section,
- no top-level bullet's first line occurs twice in the section.
The same three assertions find it after the fact, which is how the duplication above was eventually caught.
To bring a conflict-free PR up to date with its base without checking it out, rebase its head branch remotely:
gh pr update-branch <number> --repo <owner>/<repo> --rebaseUnlike the plain gh pr update-branch (which merges base into the branch and leaves a merge commit), --rebase keeps linear history — compatible with rebase-only repos. It only succeeds cleanly when the PR has no conflicts (mergeable: MERGEABLE); on conflicts, fall back to a local rebase. It force-updates the branch, so it re-triggers CI and can reset review approvals — only worth it when staleness actually blocks the merge. Works well for a bulk "rebase all my open PRs that need it" sweep (gh search prs --author=@me --state=open → loop).
Judge "behind" correctly — don't trust mergeStateStatus alone. GitHub only reports mergeStateStatus: BEHIND when the base enforces "require branches to be up to date before merging." Without that rule a PR many commits behind base still shows CLEAN/BLOCKED, never BEHIND. To know how far behind a PR actually is, ask the compare API:
gh api "repos/<owner>/<repo>/compare/<base>...<headSha>" --jq '{behind: .behind_by, ahead: .ahead_by}'A conflict-free PR that is merely behind (no BEHIND flag, mergeable: MERGEABLE) does not need a rebase to merge — rebasing it is optional churn that re-runs CI. Reserve it for PRs the base blocks on staleness, or ones so far behind they should be re-validated against current base.
When you have uncommitted local changes that need to be pushed, the order matters:
# ✅ Correct — commit first, then sync, then push
git add <files>
git commit -m "message"
git fetch origin
git rebase origin/<branch>
git push
# ❌ Wrong — rebase aborts with "please commit your changes or stash them"
git fetch origin
git rebase origin/<branch> # aborts with error if working tree is dirty
git add <files>
git commit -m "message"
git push # rejected as non-fast-forwardThe "fetch+rebase before push" rule means before pushing, not before committing. git rebase requires a clean working tree — it aborts with an error when uncommitted changes are present, leaving the branch behind the remote. The subsequent push is then rejected as non-fast-forward, requiring an extra fix cycle.
A push can silently fail to land while a piped command swallows the signal — git push … | grep 'new branch' or … | tail can hide a non-zero exit (wrong remote/auth, or an empty commit because a path was silently excluded by .gitignore). Confirm the remote ref moved, by SHA — don't infer success from push output:
git push -u origin "$BR"
REMOTE_SHA=$(git ls-remote origin refs/heads/"$BR" | cut -f1) # no fetch, no fatal if branch absent
[ "$(git rev-parse HEAD)" = "$REMOTE_SHA" ] && echo "landed" || echo "DID NOT LAND"Likewise verify staging of any path that might be gitignored with git status --short before committing — an empty commit pushes "successfully" yet changes nothing.
The same trap applies to every command whose exit code gates the next step, not
just git push: in POSIX shells a pipeline's status is that of its last command
(tail, grep) unless set -o pipefail is active — and even with pipefail, a
trailing grep that matches nothing fails a green build. Real case:
docker build … 2>&1 | tail -2 && echo OK printed OK for a failed build, and
the broken branch was pushed before anyone noticed. Gate on the command's own exit
code; keep log inspection out of the gate:
docker build . > build.log 2>&1
rc=$?
tail -20 build.log # inspection only — never part of the gate
[ "$rc" -eq 0 ] || exit 1Two different causes produce this message: the tracking ref moved (below), or there is no tracking ref at all (the subsection after it). Neither is a reason to escalate to plain --force.
On PRs that bots touch (auto-approve, Renovate/Dependabot, a CI step that pushes), git push --force-with-lease can be rejected with stale info even when your local work is correct: a bot updated the remote branch since your last fetch, so the lease's expected ref (your origin/<branch> tracking ref) no longer matches and the push aborts. This is the safety check working — don't escalate to plain --force.
Fetch, see what arrived, then push — the lease now matches the ref you just fetched:
BR=feature/my-feature
git fetch origin "$BR"
git log HEAD..origin/"$BR" # what the bot pushed — safe to discard?
git push --force-with-lease origin "$BR" # lease compares against the fetched tracking refIf a bot keeps pushing inside the fetch→push window so the plain lease never matches, pin it to the head you just inspected. This pins, not skips, the check — it accepts exactly that SHA, so only run it right after the git log above confirms those commits are safe to discard:
git push --force-with-lease="$BR:$(git rev-parse origin/"$BR")" origin "$BR"Fetching does not help — and cannot — if the push names a URL:
git push --force-with-lease git@github.com:me/repo.git HEAD:my-branch
# ! [rejected] HEAD -> my-branch (stale info)A URL has no remote-tracking ref, so the lease has nothing to compare against and the push is rejected every time, however recently you fetched. This is not the safety check catching a real race; it is the check with no input. Typically hit when pushing to a fork that was never added as a remote — gh repo fork --clone=false creates the fork on the server but adds nothing locally.
Add the remote once, fetch it, then push to it by name:
git remote add fork git@github.com:me/repo.git
git fetch fork
git push --force-with-lease fork HEAD:my-branchgit remote -v before force-pushing is the cheap check: if the target is not listed there, the lease cannot work. Do not reach for plain --force — that discards the protection instead of supplying it.
# Use a merge tool
git mergetool
# Or use specific tool
git mergetool --tool=vscode
git mergetool --tool=meld
# Configure default tool
git config --global merge.tool vscode
git config --global mergetool.vscode.cmd 'code --wait $MERGED'- PR Size: Average lines changed
- Review Time: Time from creation to first review
- Time to Merge: Creation to merge
- Review Rounds: Number of change requests
- Throughput: PRs merged per week
# List PR stats
gh pr list --state merged --json number,title,createdAt,mergedAt,additions,deletions
# PR age analysis
gh pr list --state open --json number,createdAt | jq 'map({number, age: (now - (.createdAt | fromdateiso8601)) / 86400})'When addressing review feedback, reply directly to the thread (not a new comment):
# Find the thread ID for a comment
gh api repos/OWNER/REPO/pulls/NUMBER/comments \
--jq '.[] | {id, node_id, body}'
# Reply to a review thread via GraphQL
gh api graphql -f query='
mutation($body: String!, $threadId: ID!) {
addPullRequestReviewThreadReply(input: {
body: $body,
pullRequestReviewThreadId: $threadId
}) {
comment { id }
}
}' \
-f body="Fixed in commit abc123" \
-f threadId="PRRT_xxxxx"After addressing feedback and pushing fixes:
# Resolve a review thread
gh api graphql -f query='
mutation($threadId: ID!) {
resolveReviewThread(input: {threadId: $threadId}) {
thread { isResolved }
}
}' \
-f threadId="PRRT_xxxxx"
# List unresolved threads
gh api graphql -f query='
query($owner: String!, $repo: String!, $pr: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $pr) {
reviewThreads(first: 50) {
nodes {
id
isResolved
comments(first: 1) {
nodes { body }
}
}
}
}
}
}' -f owner=OWNER -f repo=REPO -F pr=NUMBERCritical: GitHub GraphQL API has a limit of 100 items per page. For PRs with many review comments (e.g., 127+ threads from automated reviewers), you MUST use pagination:
# Fetch ONE page of up to 100 threads; repeat with the returned endCursor
# until hasNextPage is false to cover all threads
gh api graphql -f query='
query($owner: String!, $repo: String!, $pr: Int!, $cursor: String) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $pr) {
reviewThreads(first: 100, after: $cursor) {
pageInfo {
hasNextPage
endCursor
}
nodes {
id
isResolved
comments(first: 1) {
nodes { body path }
}
}
}
}
}
}' -f owner=OWNER -f repo=REPO -F pr=NUMBER
# Loop until pageInfo.hasNextPage is false, passing each endCursor:
# -f cursor="Y3Vyc29yOnYyOpHOABCD..."Real-world lesson (PR #575): Automated reviewers can generate 100+ comment threads. Without pagination, only the first 100 threads are returned, leaving others unaddressed.
Job status answers "did the command exit 0". It does not answer "did the tests pass". Where a pipeline publishes a test report, read it — the two disagree more often than they should, and the report is the stronger statement.
# Same placeholders as the GitLab section below: GITLAB_HOST, $P the URL-encoded
# project path or numeric id, $TOKEN a PRIVATE-TOKEN.
export GITLAB_HOST=git.example.com
# GitLab: the report, not just the job list
curl -s -H "PRIVATE-TOKEN: $TOKEN" "https://$GITLAB_HOST/api/v4/projects/$P/pipelines/$PIPELINE/test_report" \
| jq '{total_count, failed_count, suites: [.test_suites[] | {name, failed_count, total_count}]}'
# GitHub: the rollup hides startup failures entirely — see the section below
gh run list --repo "$R" --commit "$SHA" --json name,status,conclusionTwo ways a check stops being a gate without anyone noticing:
- The command mutates instead of checking. A job running
php-cs-fixer fix(rather thanfix --dry-run) repairs the files in the container, throws them away with it, and exits 0. Every repaired file is recorded as a failed case in the JUnit report while the job is green. One project had run that way for three and a half years — 30 standing violations, a permanently green gate, and the evidence expiring with the artifact about an hour after each run. - Nothing is enforced because nothing fails.
allow_failure: true(GitLab) and non-required checks (GitHub) are legitimate, but they make the pipeline's colour a poor summary. Before reading a pipeline as approval, list which jobs can actually block:
curl -s -H "PRIVATE-TOKEN: $TOKEN" "https://$GITLAB_HOST/api/v4/projects/$P/pipelines/$PIPELINE/jobs" \
| jq -r '.[] | select(.allow_failure == false) | "\(.status) \(.name)"'When a report's numbers look implausible — everything failing, or nothing at all —
check the artifact before believing it. An expired or never-collected JUnit artifact
reports total_count: 0, which reads like "no failures" and means "no data".
Failure first-step, not pre-merge gate. The Merge Gate below uses
annotations_countas a warnings present? signal after success. This section is the inverse: when a workflow has failed and you don't yet know why, read the annotation text first, before any other diagnostic action.
When a GitHub Actions run fails — especially with startup_failure, "no jobs ran", "config invalid", or any failure where the PR summary view shows just a red X with no detail — do not:
- Speculate about transient infra issues
- Blame upstream commits or reusable-workflow regressions
- Diff the workflow YAML against the last known good revision
- Re-run the workflow hoping it passes
…before reading the check-runs annotations. The literal validator error is almost always sitting there in one line. Annotations are invisible in the PR summary view — they're only rendered in the Actions UI under each job's "Annotations" panel, easy to miss.
SHA=$(git rev-parse HEAD) # or the failing commit SHA
# 1. Find every check run on that commit that has annotations
# {owner}/{repo} are gh api placeholders — auto-resolved from cwd or $GH_REPO
gh api "repos/{owner}/{repo}/commits/$SHA/check-runs" --paginate \
--jq '.check_runs[] | select(.output?.annotations_count? // 0 > 0) | "\(.id)\t\(.name)"' |
while IFS=$'\t' read -r run_id name; do
echo "=== $name ==="
# 2. Print the annotation text (level, file, line, message).
# --paginate guards against runs with > 100 annotations (rare for startup
# failures, common for linters like reviewdog).
gh api "repos/{owner}/{repo}/check-runs/$run_id/annotations" --paginate \
--jq '.[] | "[\(.annotation_level)] \(.path):\(.start_line) \(.message)"'
echo ""
doneDrop this into the troubleshooting flow as step 0. If the annotations are empty, then fall back to logs (gh run view RUN_ID --log-failed) and YAML diffs.
A reusable-workflow caller failed with startup_failure and zero jobs. Multiple turns were spent blaming upstream netresearch/typo3-ci-workflows@main commits and even pinning to a known-good SHA as a workaround. The annotation said the actual cause in one line:
Error calling workflow '...'. The nested job 'preflight' is requesting 'actions: read', but is only allowed 'actions: none'.
Fix: one-line actions: read add to the caller's permissions: block (t3x-nr-passkeys-be@0533835). Reading the annotations first would have collapsed a 6-step diagnostic loop into a 2-step fix.
Three traps when fixing a red CI job:
- Reproduce the exact failing step, not a proxy. A passing local
make test/phpunitdoes not prove the failing CI job is fixed — the job may fail on a different step or matrix cell (e.g. aphp -llint sweep overvendor/on PHP 8.4/8.5, or a stricter runner version) that your proxy never runs. Read which job + step failed and run that command, on that version, before claiming the fix. - Make the push a separate step, gated on a verify you actually read. Bundling verify-and-push in one
&&block force-pushes before the result is seen — a run that printedFAILstill gets pushed. Capture the verify to a file, read it, and push only on a confirmed-clean result:
run_tests > /tmp/verify.log 2>&1; RC=$?
cat /tmp/verify.log; echo "rc=$RC" # read the log + exit code first
[ "$RC" -eq 0 ] && git push || echo "STOP — tests failed, do not push"- Cap memory when the fix activates or relies on a loop. A bug-fix can make previously-dead code live — and if the now-live path paginates or loops over an external (or mocked) response, an unbounded loop can exhaust RAM and OOM-crash the whole machine when you reproduce the test locally. (Real case: restoring a deleted variable unblocked a code path whose
while Truepagination loop then grew a MagicMock to >20 GB RSS and took down the VM.) Two defenses, apply both:- Run the repro under a memory cap so a runaway loop fails fast instead of freezing the box:
( ulimit -v 6000000; pytest tests/… )(≈6 GB virtual). Do this whenever a loop's termination depends on code you just changed.ulimit -vis a Linux mechanism — it is ignored on macOS/Darwin, so there run the repro inside a container instead (docker run --memory=6g …) or use another runtime-level cap. - Bound the loop itself —
for _ in range(MAX_PAGES): … else: raise RuntimeError(...)— rather than trusting the response's metadata (or a test mock) to signal the last page; and give the test a finite mock (side_effect=[page1, page2]), never a bare mock whose.get()is truthy forever.
- Run the repro under a memory cap so a runaway loop fails fast instead of freezing the box:
| Stage | Question | Endpoint |
|---|---|---|
| Failure diagnosis (this section) | "Why did the run fail?" | /check-runs/{id}/annotations (read messages) |
| Pre-merge gate (below) | "Are there warnings to clear before merging green CI?" | /commits/{sha}/check-runs (count > 0) |
Same endpoint family, different question — read the annotation text on failure, count it on success.
Before merging any PR, run this gate. If any check fails, stop and fix the underlying issue rather than overriding.
- All review threads resolved — no unresolved conversations
- No ongoing review, and the bot's latest review is on the head commit (if assigned) — a
copilot_code_reviewruleset can re-block when the head changes; see "Rulesets" below - Rulesets checked —
gh api repos/{owner}/{repo}/rules/branches/BASE, not just classic branch protection - Branch rebased on target — no stray merge commits in PR branch
- All CI checks pass — green status on every required check
- No CI annotations — check job annotations, not just pass/fail (see below)
- Signed commits — every commit in the PR is signed (see "Signing and DCO Failures" below if blocked)
- DCO sign-off — every commit has a
Signed-off-by:trailer matchinggit config user.{name,email}(required when theprobot/dcocheck is enabled) - No intermediate planning artifacts —
bash skills/git-workflow/scripts/spec-cleanup-guard.shexits 0; superpowers specs/plans (docs/superpowers/**) and other scratch planning files must not reach the base branch (see "Spec-Cleanup Guard" below andreferences/spec-cleanup.md)
A PR's checks are only the workflows that trigger on pull_request. A job gated on push: [main] (or the merge_group event) never runs on the PR, so a green PR does not clear it — the job fires after merge and can turn main red on a change the PR "passed". Before merging, diff each workflow's trigger against what actually ran on the PR; for any push/merge_group-only job (a deploy, a boot/smoke test, a container-compile), reproduce it locally on the merge result first.
This bites hardest on dependency bumps. A resolver succeeding (composer update / npm install resolves cleanly) is necessary but not sufficient: a loose constraint can select a released sibling whose code predates compatibility with the new dependency, so it installs but fails at runtime.
- Real case: a project bumped
nr-llm ^0.12 → ^0.22. Everypull_requestcheck passed. Thevalidatejob — gated onpush: main, so absent from the PR — boots the app and compiles the DI container; post-merge it failed because a released sibling (t3-cowriter v3.1.1, constraintnr-llm >=0.3 <1.0) referenced a class the newnr-llmhad removed. Resolution was green; the runtime compile was not. Recovery cost a sibling release plus a follow-up PR. - Verify the runtime path, not just resolution: run the actual boot/compile (or the exact
push:main-only job) against the resolved tree locally before merging. For the class-not-found family, a fast proxy is to confirm every referenced upstream symbol still exists at the resolved version.
The second way PR-green diverges from main-green needs no push:-gated job: a PR's lint/analysis runs are pinned to the config on the PR branch, and a sibling PR that adopts a stricter shared config (rector/eslint/phpstan rule sets) can merge between your last CI run and your merge. Both PRs are green in isolation; the merge result on main fails the very check both passed. Nothing lied — the config changed under you.
- Real case (2026-08-04, t3x-nr-vault): PR A was rebased and merged in parallel with PR B ("adopt the shared org rector config", which added
NewlineAfterStatementRector). PR A's green Rector run predated that config;ci / Rectorwent red onmainover one missing blank line. Recovery was a one-line fix-forward PR. - Rebasing before merge does not close the window — the config PR can land after your rebase. Only a merge queue or a "require branches to be up to date" rule makes it structurally impossible; where neither is on, expect this occasionally on active repos.
- Consequence: watching the post-merge run on
mainis part of the merge, because this failure class is visible nowhere else. When it fires, diffmain's history for config-touching merges since your branch's last CI run and fix forward against the new config — it is mechanical, not an investigation into which check was wrong.
A pull request's base must be a branch in the repository the PR is opened
against. Stacking PR2 on PR1's branch therefore requires that branch to exist
there — which it does when you push branches to the target repo, and does not
when you contribute from a fork. Upstream has no feature/x; it lives in your
fork, and gh pr create --base feature/x answers with four clauses, of which
only the last is the cause:
pull request create failed: GraphQL: Head sha can't be blank, Base sha can't be
blank, No commits between upstream:feature/x and myfork:follow-up, Base ref must
be a branch (createPullRequest)
No commits between is the misleading one — the branches differ, and diffing
them to find out why wastes the time this note exists to save.
Check before you plan around a stack, not after you have built it:
gh api "repos/$UPSTREAM" --jq '.permissions.push' # false → no stack thereWith push: false the options are a single PR, or a second PR that waits for
the first to merge and then targets the default branch. Opening the follow-up in
your own fork keeps it reviewable and off the upstream's list, but it is not a
stack: the maintainers cannot see it in the queue.
This bites hardest when a review suggests splitting one PR into two, since the split is proposed in the same breath as the stack that would deliver it. On a fork, "I'll split this into a base PR and a follow-up" is a promise about the merge order, not about what can be opened today — say which one you mean.
A split is only real if both halves stand on their own. The half that carries a test and the half that makes CI accept it are the common trap: they read like "the change" and "the infrastructure for the change", and they are one commit's worth of coupling. Check it the cheap way — build each half and run the gates on it — before proposing the split, because a proposal is what the reviewer will plan around.
The tell that they cannot be separated is usually already in front of you: if you diagnosed a CI failure that one half causes and the other half fixes, the question is settled and the split is a promise you cannot keep. Reviewing your own diagnosis before proposing is faster than reversing a split after the branch, the PR and the description have been rebuilt around it.
A stacked chain (PR2 based on PR1's branch, PR3 on PR2's, …) merges bottom-up — but two GitHub behaviours break the naive loop:
gh pr merge --delete-branchon a stacked base CLOSES the child PR. GitHub's automatic retargeting of dependent PRs is unreliable: when the base branch disappears, the child can be closed instead of retargeted to the default branch (observed 2026-08-01: child PR closed mid-stack, its base still pointing at the deleted branch). Recovery, if it happens: re-push the deleted base branch (the local copy still has it),gh pr reopen <CHILD_NUMBER>, thengh pr edit <CHILD_NUMBER> --base main— the base of a closed PR cannot be edited, so reopen first.mergeStateStatusneeds time and onlyCLEANis trustworthy. After a retarget it cycles throughUNKNOWN/BLOCKED/UNSTABLEbefore settling.UNSTABLEmeans a non-required check is failing — decide explicitly whether that is acceptable; a merge gate that requiresCLEANtreats it as blocked.
The robust bottom-up sequence for each child after its parent merged:
gh pr edit <CHILD_NUMBER> --base main # retarget FIRST, while everything is open
# wait until mergeStateStatus == CLEAN # separate step — state recomputes async
gh pr merge <CHILD_NUMBER> --merge # NO --delete-branch mid-stack
# verify: git ls-remote origin main == the PR's mergeCommit.oidDelete all stack branches in one pass after the last PR merged (a repo with "automatically delete head branches" usually does it for you).
The sequence above is for a hand-built chain. GitHub's native Stacked PRs
feature (public preview, gh stack) behaves differently and the difference is
the whole point of using it: merging the top PR merges every PR beneath it in
one operation. Walking the stack bottom-up there is not merely slower — on a
merge-queue repo it is actively worse, because each PR costs its own queue cycle
and the queue's merge-correctness validation rejects two chained entries that are
in flight at the same time ("invalid changes in the merge commit").
gh pr merge <TIP_NUMBER> --merge --auto # the whole stack landsVerify containment before assuming a lower PR is covered, rather than reading it off the UI:
git merge-base --is-ancestor "$(gh pr view <LOWER> --json headRefOid --jq .headRefOid)" \
"$(gh pr view <TIP> --json headRefOid --jq .headRefOid)" \
&& echo "LOWER is contained in TIP"Observed 2026-08-09 (netresearch/t3x-nr-llm): merging only #665 landed #663, #664 and #665 together; the preceding attempt to queue all three separately was rejected by the queue three times. The PRs that were merged this way close as
MERGED; PRs whose commits reach main through a different PR (see batching,
below) close as CLOSED and need their issues closed by hand.
One preview-era caveat worth knowing before relying on it: deleting a lower
branch can close the PR above it rather than retarget it, and a closed PR of
this kind cannot be reopened. Leave --delete-branch off until the stack is
fully merged.
Related: a workflow rerun executes the frozen merge commit — it does not
re-resolve refs/pull/N/merge against the moved base. A check that depends
on base state (template drift, conflict detection) stays wrong after main
moved; push a base-merge into the PR branch instead of rerunning.
A merge queue serializes by design, so N ready PRs cost N queue cycles — and if
they all touch one accumulating file (CHANGELOG.md, a docs index), each merge
also invalidates the next one's merge commit, so every PR needs a forward-merge
before its turn. Thirteen PRs took several hours this way and merged one per
hour at best.
When the PRs are genuinely independent, merge them locally into one integration branch and send that through the queue instead. Two cycles replaced thirteen (netresearch/t3x-nr-llm#685 with ten PRs, #686 with three chain tips, both 2026-08-09).
git -C .bare worktree add ../integration -b integration/batch-$(date +%F) origin/main
cd ../integration
for pr in 648 653 676 677; do
git merge --no-edit "$(gh pr view $pr --json headRefOid --jq .headRefOid)"
doneFour things decide whether this pays off, and skipping any of them costs more than the batching saved:
- Resolve the accumulator files once, deliberately. The conflicts are almost always confined to append-only files. Auto-resolving them by keeping both sides works for the content but duplicates entries and flattens section assignment when the same entry was reworded on both sides — verify the merged file afterwards (count entries, check for duplicates) instead of trusting the resolver. Anything outside that known set is a real conflict: stop and resolve it by hand.
- Run the full gate on the integration branch, not on the individual PRs. Combination breaks exist that no single PR can show: a constructor argument another PR makes mandatory, two PRs extending the same factory differently. Three such breaks appeared across two batches here — all invisible per-PR, all caught by the batch gate.
- Prove containment before closing anything.
git merge-base --is-ancestor <pr-head> origin/mainper PR, after the batch merges. This is the only evidence that a PR's work actually landed. - The batched PRs close as
CLOSED, notMERGED, so GitHub does not run theirCloses #Nkeywords. Close those issues explicitly and say which PR carried them, or the backlog silently keeps them open.
Do not batch PRs that are chained (a stack) — merge the tip instead, see above.
Once auto-merge (or a queue) is armed, a fast merge plus branch auto-delete can
land the PR before your follow-up commit does. git push to the deleted branch
then silently re-creates it — off a now-stale base, with your commit
dangling and no PR attached; nothing errors and nothing merges.
Before pushing any follow-up to an armed branch, confirm the PR is still open:
gh pr view NUMBER --json state --jq '.state' # must print OPENAny other state (MERGED, CLOSED) is a stop signal: put the follow-up on a
fresh branch off updated main and open a new PR. If a dangling re-created
branch already exists, delete it and rescue the commit via cherry-pick.
gh pr merge --auto is a deferred merge with no human in the loop — and a
merge queue only re-runs required checks; it does not wait for review
threads, bot reviews in flight, or Sonar-style informational checks. Arming at
PR creation therefore merges over unaddressed review feedback the moment CI is
green.
Arm auto-merge / enqueue only when all four hold:
- Zero unresolved review threads (GraphQL
reviewThreads, not the UI). - All checks green — including non-required ones you intend to honor.
- No pending review request (
gh pr view --json reviewRequestsis[]) — a re-requested bot review that has not landed yet counts as pending. - A quiet runner pool — see below.
Do not enqueue into a busy runner pool. A merge queue drops an entry whose
required checks do not report within check_response_timeout_minutes. The REST
docs state no default for that parameter — read the configured value from the
ruleset (the query below shows it; the two netresearch repos checked run 30
and 60 minutes).
That clock starts at enqueue and covers the wait for a runner, not just
the run: with the pool saturated, the jobs sit in queued with no runner and
the entry is discarded having executed nothing.
Retrying makes it worse. Each attempt spawns a full set of runs on a
gh-readonly-queue/* branch, and a dropped entry does not cancel them — so
every retry leaves more runs holding slots and slows the next attempt. In
netresearch/ofelia this piled up 18 unfinished runs and turned a four-attempt
loop into a guaranteed failure, while the same required checks concluded in
2.0 minutes in PR context, well inside the window.
Tell a busy pool from a slow one before deciding. queued and
in_progress are different states and the API reports them separately;
collapsing both into "pending" reads as "CI is running" when in truth no runner
has picked anything up. pr-status.sh keeps them apart — the checks line shows
N pending (X running, Y queued) — and answers await-capacity instead of
wait when a required check is queued while nothing at all is running. That
is the precondition for the paragraph above: enqueueing in that state is what
gets the entry dropped.
The concurrency limit itself is not readable. GET /orgs/{org}/actions/hosted-runners/limits
answers 404 "GitHub hosted runners are not supported for this organization"
outside the larger-runner product, and the per-plan ceiling for standard runners
exists only in the documentation. So do not try to compare against a number —
observe the state instead: required checks queued with zero running is
sufficient on its own, and needs no knowledge of the plan.
Gate on it instead: enqueue when the repo has at most ~2 unfinished runs.
gh run list --repo "$R" --limit 25 --json status \
--jq '[.[]|select(.status!="completed")]|length'Done that way, three consecutive PRs merged on the first attempt after one had been thrown out four times from a loaded pool. If an entry still drops on a quiet pool, the timeout is genuinely too short — read it rather than retrying:
gh api "repos/$R/rules/branches/main" \
--jq '.[]|select(.type=="merge_queue")|.parameters'Before that one retry, cancel your own orphaned queue runs — only your PR's, since other PRs thrash the same way and their runs are not yours to kill:
gh run list --repo "$R" --limit 40 --json databaseId,status,headBranch \
| jq -r --arg p "gh-readonly-queue/main/pr-$PR-" \
'.[] | select(.status != "completed")
| select(.headBranch | startswith($p)) | .databaseId' \
| xargs -r -n1 gh run cancel --repo "$R"Ejection with ZERO dispatched runs is a third failure mode — not a busy
pool (runs would exist as queued) and not a short timeout (runs would exist
and be running). Here the merge_group events never reach Actions at all:
gh run list --repo "$R" --event merge_group --limit 5 \
--json createdAt,headBranch # newest run predates your enqueue → no dispatchThe tell: every queue entry drops at the same instant when the oldest
entry's response timeout expires, timeline events show
removed_from_merge_queue with no reason, and gh run list has no
gh-readonly-queue/* runs for any of them — while pull_request/push
workflows on the same repo dispatch normally. That is a transient GitHub-side
dispatch outage, not a workflow or ruleset problem (verify the required
contexts' workflows do carry the merge_group: trigger before concluding
this). Requeue once later and watch for the first merge_group run within a
few minutes — it decides between outage-over and genuinely-broken. (Observed
2026-08-25, netresearch/ldap-manager — its check_response_timeout_minutes
is 60, hence the hour: two entries starved ~60 min with zero runs and dropped
together at 23:59; the same PR requeued 40 min later dispatched immediately
and merged.)
Bot reviews (Copilot, Gemini) land 2–5 minutes after each push — wait that window out before concluding "no threads".
A cancelled run leaves rows behind, and they are not failures. When a push
supersedes a run, its jobs conclude CANCELLED and those check-runs stay on the
earlier commit forever. Where a workflow's name: is an unevaluated
expression they are doubly confusing, showing up as rows literally called
e2e / matrix.typo3 != '' && …. pr-status.sh splits them: a cancelled context
that later reported under the same name is counted as stale and dropped from
the numbers, while one that never reported again still shuts the gate and is
listed under cancelled — re-run, do not debug. Before this split, two stale
e2e rows read as 2 fail with NEXT: fix-ci on a pull request GitHub itself
called CLEAN, which cost an hour of log archaeology on a run that had been
cancelled on purpose.
Review on an earlier head + CLEAN: decide via the timeline, not the
review list. After a follow-up push (docs-only changes often do not
re-trigger Copilot), the only review on record may sit on a previous commit
while mergeStateStatus reports CLEAN off it. Whether that is mergeable
depends on one question: was any review (re)announced after the latest
push? The reviews list cannot answer it — query the timeline events:
R=<owner/repo>; PR=<number>
gh api repos/$R/issues/$PR/timeline --jq \
'[.[] | select(.event=="review_requested" or .event=="reviewed")
| {event, actor: (.actor.login // .user.login), at: (.created_at // .submitted_at)}]'- Last
review_requestedis before the latest push and a matchingreviewedfollowed it, no newer request → no review is in flight; the old-head review +CLEANis mergeable. - A
review_requestedafter the latest push with noreviewedyet → a review is in flight; wait (see Never merge over an announced review).
Recovery when armed too early:
# A PR already picked up by the queue rejects --disable-auto AND branch pushes
# ("Pull request is already queued to merge"). Dequeue it via GraphQL:
PRID=$(gh api graphql -F owner=OWNER -F repo=REPO -F pr=NUMBER \
-f query='query($owner:String!,$repo:String!,$pr:Int!){repository(owner:$owner,name:$repo){pullRequest(number:$pr){id}}}' \
--jq .data.repository.pullRequest.id)
gh api graphql -F id="$PRID" \
-f query='mutation($id:ID!){ dequeuePullRequest(input:{id:$id}) { mergeQueueEntry { id } } }'
# Branch is pushable again; fix threads, then re-arm through this gate.The push rejection is a protected-branch hook, not a permissions problem:
remote: error: GH006: Protected branch update failed for refs/heads/<branch>.
remote: - A pull request for this branch has been added to a merge queue.
remote: Branches that are queued for merging cannot be updated.
Note the mutation input field is id:, not pullRequestId: — the latter fails
with InputObject 'DequeuePullRequestInput' doesn't accept argument.
An enqueued PR still reads as CLEAN, so "clean, go merge" is the wrong
verdict. mergeStateStatus describes the branch, not the queue: a PR sitting
in the queue keeps answering CLEAN with every check green and no open thread,
which is exactly what a merge-readiness check looks for. Ask the PR itself
whether it is already in line:
gh api graphql -f query='{repository(owner:"OWNER",name:"REPO"){
pullRequest(number:NNN){ mergeQueueEntry{ state position estimatedTimeToMerge } }}}'mergeQueueEntry is null unless this PR holds an entry; when it does, it
carries position, state (QUEUED, AWAITING_CHECKS, MERGEABLE,
UNMERGEABLE, LOCKED) and an ETA in seconds. The repo-level signal — a
merge_queue ruleset exists — answers a different question and cannot stand in
for it. pr-status.sh reports both, as queue= and an in queue line, and
answers wait rather than merge while an entry is held; pr-merge.sh
refuses on that same verdict. Enqueueing an entry that already exists only
restarts its checks and pushes the merge further out.
--delete-branch is rejected while a merge queue is enabled. gh pr merge
fails outright with:
Cannot use -d or --delete-branch when merge queue enabled
This is a hard error, not a warning: the merge does not happen. In a batch
merge loop the flag silently costs a full round per PR — four rounds were burned
on it in one rollout before the message was read. Drop --delete-branch from
any script that merges across repos where some enable a queue, and let the
repo's own "automatically delete head branches" setting handle cleanup. Passing
a merge-method flag (--merge / --rebase) is likewise pointless once a queue
owns the branch; it responds with The merge strategy for main is set by the merge queue.
Verify a "dropped" queue entry via the issue timeline before re-arming.
Right after a queue merge, gh pr view --json state can report OPEN and the
queue listing can show the entry gone for several minutes — the exact signature
of a silent drop, except the PR already merged. Re-arming (or re-diagnosing) on
that stale read wastes a round-trip. The issue timeline is authoritative:
gh api repos/{owner}/{repo}/issues/NUMBER/timeline --paginate \
--jq '.[]? | select(.event | IN("added_to_merge_queue", "removed_from_merge_queue",
"merged", "closed")) | "\(.created_at) \(.event)"'
# removed_from_merge_queue immediately followed by merged -> it landed; do nothing.
# removed_from_merge_queue with NO merged event -> real silent drop; re-arm.Leaving bot PRs alone is right because an auto-merge workflow lands them once
they are green. Where no such workflow is installed, the rule has no mechanism
behind it and simply strands the PRs. One repo in a sweep carried 12 open bot
PRs — including a tar security update sitting CLEAN and untouched for five
weeks — because its .github/workflows/ held only ci.yml and a publish
workflow.
Check for the mechanism before deferring to it:
gh api "repos/$R/contents/.github/workflows" --jq '.[].name'- A deps auto-merge workflow exists → the rule applies. Do not merge by
hand; bring the PR green and let the workflow land it. If it is green and
still open, the workflow ran before the checks passed — re-run it rather than
merging (
gh run rerun <id>on that workflow's run for the PR's head SHA). - No such workflow → the rule does not apply. Merging bot PRs by hand is not
forbidden, it is simply unautomated: report the stranded PRs and the missing
automation instead of leaving them to rot. Flag security updates and majors
separately — a
taradvisory fix and ajestv30 major do not carry the same risk and should not be swept together.
Also note: queue membership is GraphQL-only — isInMergeQueue /
mergeQueueEntry are not gh pr view --json fields (the call errors);
query pullRequest { mergeQueueEntry { state position } } via gh api graphql.
autoMergeRequest: null does NOT mean "not armed" on a merge-queue repo.
When you arm a queue PR, gh pr merge --auto prints "merge strategy set by the
merge queue" and returns immediately — and gh pr view --json autoMergeRequest
then reports null, because the queue owns the merge, not GitHub's auto-merge
feature. Reading that null as "arming failed" and re-running --auto is a
wasted round-trip (and can error "already queued"). Confirm the PR is enqueued
via the GraphQL mergeQueueEntry { state position } or the timeline
added_to_merge_queue event — never via autoMergeRequest.
A pending auto-merge request silently swallows the enqueue. The converse of
the note above, and the more expensive one: when an auto-merge request is
attached to the PR, gh pr merge <n> --repo <r> --merge on a merge-queue
repository prints its usual success line and exits 0 while adding nothing to
the queue. Measured on one PR: immediately after the call isInMergeQueue was
false and the queue was empty; after gh pr merge <n> --repo <r> --disable-auto the identical call put it at position 1. Nothing shows the
failure — gh pr checks is green, mergeStateStatus stays CLEAN, no timeline
event is written, and the shell sees a zero exit — so a driver that trusts the
exit status reports "queued" for a PR that will never merge. This cost several
hours of misdiagnosis before the queue was read back. Two habits follow: never
report an enqueue you have not read back, and check autoMergeRequest first
when a queue entry fails to appear.
gh api graphql -f query='{repository(owner:"OWNER",name:"REPO"){
pullRequest(number:NNN){ state isInMergeQueue
autoMergeRequest{ enabledBy{ login } } }}}'
# state MERGED -> it landed.
# isInMergeQueue true -> really queued.
# neither, autoMergeRequest non-null -> the request swallowed it: --disable-auto, then retry.pr-merge.sh does exactly this after every merge call, polls a few seconds for
the entry to register, and exits 2 with the --disable-auto remedy rather than
claiming an enqueue that did not happen. It reports only — clearing someone
else's auto-merge request is a caller decision, not a side effect of asking to
merge.
Two failures hide behind "signing is broken", and they surface at opposite ends of the run. A local signing failure surfaces immediately: git commit -S aborts and the branch does not move. A host verification failure — the key signs fine but GitHub does not recognise it — surfaces only at the merge gate (BLOCKED on DCO / "verified signatures"), i.e. after all the work is committed and pushed, forcing a full re-sign cycle. Preflight both before a commit-heavy run (e.g. /pr-finish): confirm a signing key is actually available and that git commit -S will sign, rather than assuming it.
scripts/signing-preflight.sh # exit 0 READY · 1 NOT READY · 2 could not probesigning-preflight.sh is the mechanical form of everything below: it probes on a throwaway branch through a temporary index (so a staged change is never swept into the probe commit), asserts on the commit object, retries once with --no-verify to tell a hook rejection apart from a signing failure, and removes the branch either way. --check-commit <rev> answers the same question for an existing commit, --config-only reports the config without committing. Its regression suite is tests/test_signing_preflight.sh; checkpoint GW-17 applies the same rule to a repository being assessed.
By hand, where the script is not available:
# SSH-signing setups: is a key the agent can sign with actually loaded?
ssh-add -l # "no identities" → signing (and any SSH git auth) will fail until re-added
# Definitive probe: a throwaway signed commit carries a signature, then drop it —
# on a throwaway branch, not on `main` (see commit-conventions.md, "Verify signing capability without committing on `main`")
git switch -c tmp/sign-probe
# conventional msg — a commit-msg hook rejecting `probe` aborts the commit and reads as a signing failure
git commit -S --allow-empty -m "chore: signing probe" \
&& (git cat-file commit HEAD | sed -n '/^$/q;p' | grep -qE '^gpgsig(-sha256)? ' && echo "SIGNING READY" || echo "SIGNING NOT READY") \
|| echo "SIGNING NOT READY — commit failed"
git switch - && git branch -D tmp/sign-probeAssert on the commit object, not on local verification. git log --show-signature / %G? answer the narrower question "can this machine verify the signature", and report failure on setups that sign perfectly well. With gpg.format=ssh and no gpg.ssh.allowedSignersFile, git log --show-signature -1 prints error: gpg.ssh.allowedSignersFile needs to be configured and exist for SSH signature verification and then No signature, and %G? returns N — on a commit that carries a valid signature and that the host reports as verified: true once the key is registered as a signing key. The command still exits 0 while printing that error, so a driver reading $? sees success too.
N is indistinguishable from genuinely unsigned, which makes it more dangerous than the E case in Signature verification: the GitHub API is the source of truth, not your keyring above — E at least reads as "could not check here". Setting gpg.ssh.allowedSignersFile flips that same unchanged commit to Good "git" signature / %G? = G (measured on git 2.54.0), which is the fix if you also want local verification to work.
The gpgsig header depends on no verification config and is written by both backends (-----BEGIN SSH SIGNATURE----- under gpg.format=ssh, -----BEGIN PGP SIGNATURE----- under GPG), so it answers exactly what the preflight asks: did git commit -S produce a signature. Cut the header at the first blank line (sed -n '/^$/q;p') — matching ^gpgsig against the whole object also matches a message body line starting with gpgsig, and reports an unsigned commit as signed.
Do not assert on Good either. Unanchored, it matches the Author: line, so an unsigned commit by an author named e.g. "Goodwin" reports SIGNING READY. And a local Good only proves your own allowed-signers file accepts your key, never that GitHub does — that is the unknown_key API check under Signing and DCO Failures below. Keep the commit chained to the check with &&. git commit -S cannot silently produce an unsigned commit: with a key it cannot load it aborts (fatal: failed to write commit object, exit 128, HEAD unmoved). But an unchained check then reads the parent commit, which in signed history carries its own gpgsig header — so it prints SIGNING READY for a probe that never happened.
If the probe fails (no askpass, a locked/dropped key, or an unloadable user.signingkey), resolve it before doing the work — the mid-run remedy is the same rebase --exec re-sign as a reactive failure, but you avoid discovering it at the gate. commit failed is not by itself a signing verdict: a commit-msg hook rejecting the probe message aborts the commit exactly the same way, so read the commit output before chasing keys. See Signing and DCO Failures below for that remedy.
When mergeStateStatus: BLOCKED and the blocking check is dco or a "Commits must have verified signatures" branch-protection rule, act on these in order:
If the unsigned commits are someone else's — e.g. a fork merging upstream history — none of the steps below apply: you cannot sign off on another author's work, and the rebase in Step 2 would forge it. See Merging Divergent Upstream History (Forks).
Step 1 — Verify git identity is correct (not swapped).
A swapped name/email pair silently produces a malformed Signed-off-by: trailer that the DCO bot rejects:
git config user.name # must look like "Firstname Lastname", NOT an email address
git config user.email # must contain "@", NOT a plain name
# Fix if swapped:
git config --global user.name "Firstname Lastname"
git config --global user.email "you@example.com"Step 2 — Add missing sign-offs to all commits in the branch.
Rebase with --exec to amend every commit at once. Use --signoff for DCO, -S for signature, or both:
# Both DCO sign-off and GPG/SSH signature in one pass:
git rebase origin/main --exec 'git commit --amend --no-edit --signoff -S'
git push --force-with-leaseStep 3 — If signatures still show reason: unknown_key, the SSH key is not registered as a Signing Key on GitHub.
Auth keys and signing keys are separate registrations. An authentication key cannot verify commits:
# Check commit verification after pushing:
gh api /repos/{owner}/{repo}/commits/HEAD --jq '.commit.verification | {verified, reason}'
# "reason":"valid" → OK
# "reason":"unknown_key" → key is not registered as a signing key
# "reason":"unsigned" → -S flag was not used or signing config is missingIf unknown_key: go to github.com → Settings → SSH and GPG keys, find your key, and add it again under New signing key (same public key, different "Key type"). After adding, re-verify with the API call above.
# The gate is TWO queries. `reviewThreads` is NOT a valid `gh pr view --json`
# field — gh errors "Unknown JSON field: reviewThreads" (its whitelist has
# reviews / reviewRequests / reviewDecision, not reviewThreads), and passing it
# fails the WHOLE call. Thread resolution is only available via GraphQL.
#
# (1) PR-level fields via gh pr view (--json takes a no-spaces comma list):
gh pr view NUMBER --json reviewDecision,mergeStateStatus,mergeable,statusCheckRollup
# (2) unresolved-thread count via GraphQL (must be 0):
gh api graphql -f query='{repository(owner:"OWNER",name:"REPO"){pullRequest(number:NUMBER){
reviewThreads(first:100){nodes{isResolved}}}}}' \
--jq '[.data.repository.pullRequest?.reviewThreads?.nodes[]? | select(.isResolved==false)] | length'
# Merge-ready requires ALL of:
# reviewDecision == "APPROVED" OR "" (empty = no
# human-approval rule; CLEAN then
# already encodes the gate — do
# NOT treat "" as a blocker)
# mergeStateStatus == "CLEAN"
# mergeable == "MERGEABLE"
# every statusCheckRollup[].conclusion == "SUCCESS"
# unresolved-thread count (query 2) == 0The gate and the merge are two separate invocations. Run the gate query,
read its output, and only then issue gh pr merge as a new command. Never
chain them (gate-query && gh pr merge, or query-then-merge in one
heredoc/compound command): shell chaining decides on exit codes, not on
the gate's content — gh pr view exits 0 whether it reports zero unresolved
threads or three, so the merge fires before anyone has read the gate's
output. And mergeStateStatus: CLEAN does not imply zero unresolved
threads — GitHub only couples the two when the "require conversation
resolution" branch-protection rule is enabled, which most repos don't turn on.
# ❌ Wrong — merge already executed by the time the gate output is visible
gh pr view 42 --json mergeStateStatus && gh pr merge 42 --merge
# ✅ Right — run the gate queries, READ the output, then merge as a new command
gh pr view 42 --json reviewDecision,mergeStateStatus,mergeable,statusCheckRollup
gh api graphql -f query='{repository(owner:"OWNER",name:"REPO"){pullRequest(number:42){reviewThreads(first:100){nodes{isResolved}}}}}' --jq '[.data.repository.pullRequest?.reviewThreads?.nodes[]?|select(.isResolved==false)]|length'
# READ both: all threads resolved (count 0)? all checks green? Only then:
gh pr merge 42 --mergeBLOCKED tells you the PR is not mergeable; it never tells you why. Derive the cause from the gate fields above — not from the branch-protection / ruleset inventory (gh api repos/{repo}/rules/branches/{branch} or …/branches/{branch}/protection). That inventory lists which rules exist, not which one is currently failing; reading "copilot_code_review is configured" and concluding "Copilot is blocking" is a classic false attribution. Walk the decisive evidence in this order:
| Symptom in the gate output | Actual blocker |
|---|---|
reviewDecision: "REVIEW_REQUIRED" |
a required approving review is missing — request/await it |
reviewDecision: "" and still BLOCKED |
not a review-approval block — keep looking (this is the field that disproves "a reviewer is blocking it") |
any statusCheckRollup[].conclusion != "SUCCESS" (incl. pending) |
a required check — name that check, not a rule |
reviewThreads[].isResolved == false exists |
unresolved conversations + the repo's required_conversation_resolution toggle — resolve the threads |
| all the above clean, still BLOCKED | branch behind base (needs update), or merge-queue / required-deployment gate |
When unsure which protection toggle couples to the symptom, read it directly: gh api repos/{repo}/branches/{branch}/protection --jq '{conversation: .required_conversation_resolution, reviews: .required_pull_request_reviews, checks: .required_status_checks.contexts, strict: .required_status_checks.strict}'. State the cause only once you can point at the field that proves it.
The PR-level gate above covers review decision, merge state, required checks, and thread resolution in one response. A second check is needed for CI annotations (warnings — reviewdog / actionlint / CodeQL deprecations — that don't fail their check but still need addressing). These are a commit-level property, not a PR-level one:
gh api "repos/{owner}/{repo}/commits/SHA/check-runs" \
--jq '.check_runs[] | select(.output.annotations_count > 0) | {name: .name, annotations: .output.annotations_count}'Important: CI annotations are invisible in the PR summary view but visible in the job detail "Annotations" section on the Files Changed tab. Always check for annotations before declaring a PR clean.
For automated enforcement at tool-invocation time, see the merge-gate.sh hook recipe in references/claude-code-hooks.md. The hook enforces the runtime-checkable subset — mergeStateStatus and unresolved thread count — which covers the most common block reasons. It deliberately does not gate on reviewDecision: repos without a required-approval rule report reviewDecision "" and merge legitimately when CLEAN, so that gate would false-positive-block them; mergeStateStatus == CLEAN already encodes any required-approval rule. Signed-commits and CI-annotations checks are not enforced by the hook (annotations in particular require the commit-level API call above); rely on the repo's branch-protection rules and local pre-commit hook for those.
Important: CI checks can PASS while emitting warning annotations (e.g., actionlint/shellcheck via reviewdog, CodeQL deprecation notices). These are invisible in the PR summary view but visible in the job detail "Annotations" section. Always check for annotations before declaring a PR clean.
Intermediate planning artifacts (superpowers specs/plans, ad-hoc PLAN.md,
planning-tool output) must not ride into the base branch. The guard is
deterministic and read-only — it detects and reports, never deletes.
# Exit 0 = clean; exit 1 = artifacts found (resolve before merge); exit 2 = config error.
bash skills/git-workflow/scripts/spec-cleanup-guard.shIf it exits 1, resolve via the /pr-finish spec-cleanup step (convert to an ADR /
remove / acknowledge) so the branch is clean, then re-run. Full capability —
config, three-state detection, Capture flow — is in references/spec-cleanup.md.
mergeStateStatus: BLOCKED with reviewDecision: "", every check green, and
every thread resolved almost always means a repository ruleset — rulesets
are evaluated for merge but are invisible to both the merge-gate gh pr view
and the classic branches/{branch}/protection API. Don't discover this by
trial-and-error; fetch the effective rules as part of the gate:
# gh resolves {owner}/{repo} from git context but NOT the branch — fill in BASE,
# the branch you merge INTO (e.g. main / develop), not the feature branch.
# The endpoint returns an array of rule objects, so group_by(.type) works:
gh api repos/{owner}/{repo}/rules/branches/BASE \
--jq 'group_by(.type)[] | {type: .[0].type, n: length}'The common culprit is a copilot_code_review rule: it requires a Copilot
review on the latest commit. A push may trigger a fresh review, but not
always, and Copilot is not reliably re-requested automatically — so never
assume the review state tracks your latest commit. If the gate is blocked and
the bot's latest review is on a commit that predates the head, re-request
explicitly, then re-poll the gate:
gh api repos/{owner}/{repo}/pulls/NUMBER/requested_reviewers \
-X POST -f 'reviewers[]=copilot-pull-request-reviewer[bot]'(gh pr edit --add-reviewer rejects the bot login with "Could not resolve
user"; the REST requested_reviewers endpoint is the working path.)
A failing check is not automatically a blocker, and neither gh pr checks nor
statusCheckRollup says which ones the base branch requires — so a red
non-required check gets chased for nothing while the real blocker stays
invisible. isRequired(pullRequestNumber:) on the rollup contexts answers it
directly, and unlike repos/{owner}/{repo}/rules/branches/BASE it does not need
permissions that a token may lack:
gh api graphql -f query='
{ repository(owner:"OWNER", name:"REPO") {
pullRequest(number:NUMBER) {
mergeable mergeStateStatus
commits(last:1){nodes{commit{statusCheckRollup{contexts(last:100){nodes{
...on CheckRun{name conclusion isRequired(pullRequestNumber:NUMBER)}
...on StatusContext{context state isRequired(pullRequestNumber:NUMBER)}}}}}}}
} } }' --jq '
[.data.repository.pullRequest.commits.nodes[0].commit.statusCheckRollup.contexts.nodes[]
| select(.isRequired==true) | "\(.name // .context) = \(.conclusion // .state)"] | .[]'A null in that output is a required check still running — that, not the red
one, is what BLOCKED is waiting on. Observed 2026-08-09
(netresearch/t3x-nr-llm#687): copilot-pull-request-reviewer was red for the
whole run and never appeared in the required list; the PR merged on it. Note the
number is passed twice — once to pullRequest(number:) and once to each
isRequired(pullRequestNumber:); omitting the second yields a schema error, not
a default.
Always check for an ongoing review before merging — don't merge on a
transient CLEAN. A bot review can be in progress (after a re-request, and
sometimes after a push): mergeStateStatus can read CLEAN for a few seconds
before the bot posts its comments, and merging then strands fresh review
threads on a closed PR. A pending review request is the in-progress signal —
treat the PR as not ready while it persists. Poll until the request clears
and the bot's latest review matches the head commit oid:
gh api graphql -f query='{repository(owner:"OWNER",name:"REPO"){pullRequest(number:NUMBER){
headRefOid
reviewRequests(first:10){nodes{requestedReviewer{... on Bot{login} ... on User{login}}}}
reviews(last:20){nodes{author{login} state commit{oid}}}}}}' # last:N must exceed the review count
# Ready only when: no pending reviewRequests AND the bot's latest review.commit.oid == headRefOid.Other ruleset rules to expect: required_approving_review_count, required_review_thread_resolution, non_fast_forward.
Front-load the whole picture. Gather merge state, checks, rulesets, requested reviewers, and thread IDs in one mechanical block before reasoning about merge-readiness — see the Merge-Gate Command above plus this ruleset call. Discovering gates one round-trip at a time is the anti-pattern.
When you drive your own PR to merge (e.g. via /pr-finish), the auto-mode
permission classifier blocks self-merges and admin self-bypass — a self-authored
merge is treated as requiring a human. Do not attempt the merge twice and
then bounce to the human; two denials read as stalling.
- Recognize up front that finishing a self-authored PR will hit the classifier, and settle the merge path before starting.
- For archive/cleanup tasks, take the local-clone + signed-commit + PR path from the start — not a Contents-API commit or an admin bypass the classifier will reject.
- If a human merge is genuinely required, ask one structured question up front rather than discovering the block via two denials.
When several agent jobs run under the same git/GitHub identity (a shared bot/CI account), a PR can be force-pushed or merged out from under your review or take-over by a parallel job — and you cannot prove your own job didn't do it. Defend:
-
Snapshot head + merge state at the start of any review/take-over, and re-check immediately before acting; abort or rebase if it moved:
gh pr view <NUMBER> --json headRefOid,state,mergeStateStatus
-
Never trust a pre-existing shared worktree for review/fix — a parallel job may churn or delete it mid-task. Create your own isolated worktree for the PR branch (or off a freshly-fetched
origin/mainif starting a new branch). -
gh pr diffvs the file youReaddisagree? The branch was force-pushed between calls — re-fetch and re-derive from the committed state on origin.
When one PR in a related set introduces a new check — a linter, a security scan (zizmor/trivy), a conformance script — that check applies to the whole repo on every push and PR, not just the PR that added it. Two failure modes follow, and both surface only after a merge:
-
Sibling PRs that predate the gate. A second open PR adding a new file (e.g. a new reusable workflow) was written before the gate existed. The moment the gate PR merges,
main— and that sibling PR's own CI — goes red, because the new file was never hardened to pass a check that didn't exist when it was written. Harden every sibling artifact to the new gate before either PR merges. -
"Validated earlier" was validated against the old criteria. If you ran
actionlint + yamllinton an artifact last week and then addedzizmorto the gate this week, the artifact was never checked by zizmor. Passing the previous gate is not passing the current one — re-run the full current gate over anything you're about to merge, not the subset that existed when you first validated it.
Catch it before merging, in any order. Simulate the merged tree of the whole PR-set and run the complete gate over it — don't reason about it:
# Three-way merge of two branches without touching either working tree.
# `--write-tree` exits non-zero on conflict; check that status with `if` rather
# than masking it through a pipe — `... | head -1` would swallow the conflict
# exit code and hand you a tree with conflict markers to lint.
git -C .bare fetch origin
if MERGE_OUT=$(git merge-tree --write-tree origin/pr-a-branch origin/pr-b-branch); then
TREE=$(printf '%s' "$MERGE_OUT" | head -1) # first line is the merged tree OID
# Materialize $TREE and run every gate check (lint, security, conformance),
# or just run the checks in each PR's branch after rebasing it on the other.
else
echo "PRs conflict on merge — resolve the conflict before checking the gate"
fiIf the gate is green on both PRs individually and on their merged tree, they are safe to merge in any order. If only the individuals are green, the first merge will break the second.
When a repository requires:
- Signed commits AND
- Only rebase merge (no merge commits, no squash)
GitHub cannot sign rebased commits automatically:
gh pr merge 123 --rebase
# Error: Base branch requires signed commits.
# Rebase merges cannot be automatically signed by GitHub.Since commits are already signed locally, merge locally and push:
# 1. Ensure local main is up to date
git checkout main
git pull origin main
# 2. Verify feature branch is rebased (should be fast-forward)
git log --oneline main..feature-branch
# 3. Fast-forward merge (preserves original signatures)
git merge feature-branch --ff-only
# 4. Push to main
git push origin main
# 5. Close the PR (it will auto-close if commits match)
# Or manually: gh pr close NUMBER- Original commits retain their GPG/SSH signatures
- Fast-forward merge doesn't create new commits
- GitHub recognizes the commits and auto-closes the PR
| Scenario | Solution |
|---|---|
| Signed commits required + squash allowed | gh pr merge --squash (GitHub signs) |
| Signed commits required + merge commit allowed | gh pr merge --merge (GitHub signs merge commit) |
| Signed commits required + rebase only | Local fast-forward merge (this solution) |
#!/bin/bash
# merge-signed-pr.sh - Merge PR with signed commits via fast-forward
PR_NUMBER=$1
BRANCH=$(gh pr view $PR_NUMBER --json headRefName -q '.headRefName')
git fetch origin
git checkout main
git pull origin main
# Verify it's a fast-forward
if ! git merge-base --is-ancestor main origin/$BRANCH; then
echo "Error: Branch needs rebase first"
exit 1
fi
git merge origin/$BRANCH --ff-only
git push origin main
echo "PR #$PR_NUMBER merged via fast-forward"Everything above assumes GitHub. GitLab has the same concepts under different
field names, a different CLI and a different thread model — translate it, don't
improvise mid-run. Export GITLAB_HOST (or pass --hostname) for self-managed
instances.
Before concluding that a project takes issues, forks or merge requests only from members, read the project's own capability flags:
curl -s -L -H "PRIVATE-TOKEN: $TOKEN" "https://$GITLAB_HOST/api/v4/projects/$P" \
| jq '{issues_enabled, merge_requests_enabled, forking_access_level, permissions}'Two traps sit on that call:
- Anonymous requests null the flags. Without a token the same fields come
back
null, which reads like "disabled" and means "not visible to you". Only an authenticated answer distinguishes the two. - Follow redirects. Instances reachable under two hostnames answer the API
on one of them; without
-Lyou get a307/404and may conclude the project or its API does not exist.
Do not substitute observation for the flags. "The last 20 MRs all came from
in-repo branches" is consistent with forks being forbidden and with nobody
having needed one — a project whose forking_access_level is enabled will
show exactly the same history if all its contributors are members. Likewise, an
open_issues_count you cannot read is not an absent tracker.
The flags also separate two failure modes that look identical from outside: a
project that refuses outside contributions, and an account that may not create
projects (Limit reached — You cannot create projects in your personal namespace). The first is a policy to respect; the second is a permission to
request, and saying which one blocks you is the difference between a useful
report and a shrug.
The GitLab analogue of the gh pr view + rulesets + reviewThreads block.
Run it once, up front, and re-run only after a state-changing push. Note that
glab api has no --jq flag (verified on glab 1.95.0) — unlike gh api, pipe
its output to jq:
export GITLAB_HOST=git.example.com
P=<group>%2F<project> # URL-encoded path, or the numeric project id
M=<mr_iid>
# (1) MR state and WHY it is blocked. detailed_merge_status names the blocker;
# merge_status alone does not.
glab api "projects/$P/merge_requests/$M" \
| jq '{state,draft,merge_status,detailed_merge_status,has_conflicts,
blocking_discussions_resolved,user_notes_count,sha,target_branch,title}'
# (2) approval rule and who approved (approvals_required null = no rule)
glab api "projects/$P/merge_requests/$M/approvals" \
| jq '{approvals_required,approved_by:[.approved_by[].user.username]}'
# (3) threads. individual_note==true is a plain comment, not a thread.
glab api "projects/$P/merge_requests/$M/discussions" \
| jq '[.[] | select(.individual_note|not)
| {id, resolved: .notes[0].resolved, author: .notes[0].author.username}]'
# (4) what protects the target branch (GitLab's answer to rulesets).
# Use the target_branch from (1) — it is not always the default branch.
glab api "projects/$P/protected_branches/<target-branch>"
# (5) pipeline on the MR head SHA
glab ci status --branch <source-branch>
# (6) how this project merges — so you do not squash by accident
glab api "projects/$P" | jq '{merge_method,squash_option,
only_allow_merge_if_pipeline_succeeds,remove_source_branch_after_merge}'| GitHub | GitLab |
|---|---|
mergeStateStatus (BLOCKED / CLEAN) |
detailed_merge_status (draft_status, not_approved, discussions_not_resolved, ci_must_pass, mergeable, …) |
mergeable |
merge_status + has_conflicts |
reviewDecision |
approvals_required + approved_by (/approvals) |
GraphQL reviewThreads[].isResolved |
/discussions → notes[0].resolved; aggregate flag blocking_discussions_resolved |
resolveReviewThread mutation |
PUT /merge_requests/:iid/discussions/:id?resolved=true |
Rulesets (/rules/branches/main) |
/protected_branches/:name + approval rules |
statusCheckRollup |
glab ci status / /pipelines |
| Draft PR | draft: true → glab mr update <iid> --ready |
detailed_merge_status: draft_status means the only blocker is the draft
flag. That is the most common "the MR refuses to merge and nothing looks wrong"
case — check it before hunting for approvals or failing jobs.
Reply into the thread, never as a new top-level comment, then resolve and verify — the same discipline as the GitHub GraphQL flow:
glab api -X POST "projects/$P/merge_requests/$M/discussions/<discussion_id>/notes" \
-f body="Fixed in <sha>. <what changed and why>"
glab api -X PUT "projects/$P/merge_requests/$M/discussions/<discussion_id>?resolved=true"
# verify — the aggregate flag must be true before merging
glab api "projects/$P/merge_requests/$M" | jq .blocking_discussions_resolved# Check merge_method FIRST (preflight 6): "merge" -> merge commit,
# "ff" -> fast-forward only, "rebase_merge" -> semi-linear.
# Never pass --squash unless the user asked for it.
glab mr merge "$M" --remove-source-branch --yesglab mr merge waits for the pipeline and refuses while it is still running, so
a green pipeline is a precondition of the command rather than something to
re-check afterwards.
The merge itself is asynchronous — the command returns before the target
branch has moved. A git pull fired right after races the server-side merge
and can deliver the pre-merge tip while the MR already reports
state=merged; anything derived from that checkout (a release tag, a branch
cut from "main") then pins the old commit. One session tagged v1.1.0 on the
pre-merge tip this way and the tag pipeline published an image without the
feature. Before tagging or branching off a freshly merged target:
# the remote target must equal the MR's merge commit
test "$(git ls-remote origin "$TARGET" | cut -f1)" \
= "$(glab api "projects/$P/merge_requests/$M" | jq -r .merge_commit_sha)"And never swallow the pull's outcome (git pull --ff-only 2>&1 | tail -1
hides a refused fast-forward) — confirm with git rev-parse HEAD that the
checkout actually sits on the expected commit.
On projects with merge_method: rebase_merge (semi-linear history) a behind
branch IS a blocker: glab mr merge fails with a bare
{message: 405 Method Not Allowed} and the MR shows
detailed_merge_status: need_rebase. The light path is a server-side rebase —
no local fetch dance needed:
glab mr rebase <MR>
# poll until the status leaves need_rebase (it passes through "checking")
glab mr view <MR> --output json | jq -r '.detailed_merge_status'The rebase creates a new head SHA, so wait for the freshly triggered pipeline before retrying the merge.
On plain merge-commit projects GitLab will happily report mergeable while
the branch is behind the target — being behind is not a blocker there, unlike
a GitHub merge queue. If the
procedure calls for a rebase, drive it locally and fetch the base explicitly
(bare-repo worktrees often lack remote.origin.fetch, and --force-with-lease
then fails with "stale info"):
git fetch origin <target-branch>:refs/remotes/origin/<target-branch>
git rebase origin/<target-branch>
git push --force-with-lease origin <source-branch>Re-run the preflight afterwards: the rebase produces a new head SHA, so the pipeline result you looked at no longer applies.
In your own repository, churn is private until someone looks. In a foreign repository it is the contribution. A maintainer sees each force-push, each body rewrite and each comment as it happens, and a PR that changes shape three times costs them the attention they were going to spend reviewing it.
The single most expensive mistake in this shape: a maintainer asks whether something should also happen, and you build it.
"
SKIP_LINKSaffects everylistContents()call, not just the index lookup. ShouldFileCollectoralso checklistSkippedLinks()and warn when non-empty?"
That was read as an instruction. The answer produced roughly 250 lines of diagnostics, a logger threaded into a collector, a path-traversal guard and a second directory walk — all of which were deleted two hours later when the right answer turned out to be "remove the half-wired diagnostic instead of wiring it everywhere". The maintainer had spotted an inconsistency, not specified a feature.
Answer the question first, in a comment, with the evidence. Build only what the answer turns out to require. If a proposal is worth building, say what it would involve and let the maintainer choose — the reply costs one comment, the build costs a review cycle for both of you.
The fix that closes the reported bug and the improvement you noticed while reading the code are two contributions. Shipping them together means the bug fix waits for agreement on the improvement.
The same branch above finished as two changed lines and one regression test. Everything else became its own issue and its own pull request, and each could then be accepted or rejected on its own merits. Split at the point where a maintainer could plausibly say "yes to this, no to that".
Three consecutive comments answering one review are three notifications and one readable answer. A body rewritten after every push is a diff nobody can follow.
- Answer a review in one comment, after the work, not as you go.
- Rewrite the body once, when the branch has its final shape.
- Prefer one force-push over four: finish the local sequence, verify it, then push.
- When you do rewrite history on a PR under review, say in a comment what changed and why the previous discussion still applies — or no longer does.
If a PR references an issue that catalogues everything out of scope, the PR body does not need that catalogue. It needs what this diff does, why it is safe, how it was verified, and one line pointing at the issue. Restating the issue is filler that pushes the reviewable part below the fold.
The README and the parts that encode rules no prose mentions. Reading only the second half is a trap of its own: a small repository often has no CONTRIBUTING at all and states the entire contract in the README — target branch, sign-off, the one command CI runs, whether issues are wanted before PRs. A preflight that skips it reports "no contribution docs" about a repository that documented everything.
scripts/repo-contribution-preflight.sh returns all of it in one call: contribution and community-health docs (CONTRIBUTING, CODE_OF_CONDUCT, SUPPORT, GOVERNANCE, SECURITY, found case-insensitively under the root, .github/ and docs/) with the pages they link, the README headings that carry rules, issue and PR templates including the PULL_REQUEST_TEMPLATE/ directory form, the CI matrix that actually runs, .gitattributes export rules, and the pinned tool versions the pipeline uses.
Two things it deliberately does not do. It never answers "there are no rules": when a repository has no contribution docs of its own, GitHub serves the owner's .github repository defaults instead, and those bind identically — so the script prints the query for that fallback rather than an absence it did not check. And it does not treat a missing heading as a missing rule: a three-line README can require a sign-off in running prose.
Discovering CONTRIBUTING.rst after three artifacts are already public means retrofitting them in view of the people you are contributing to.
Complete end-to-end workflow for merging a PR, from CI verification through post-merge cleanup.
# Check all checks
gh pr checks <NUMBER>
# If failing, get detailed error logs
gh run view <RUN_ID> --log-failed 2>&1 | grep "There were"
# Check annotations (warnings that don't block but should be fixed)
gh api "repos/OWNER/REPO/commits/SHA/check-runs" \
--jq '.check_runs[] | select(.output.annotations_count > 0) | {name, annotations: .output.annotations_count}'Work threads the moment they land — decoupled from CI. Review comments
are workable input 2–5 minutes after a push; there is no reason to wait for
the full check matrix before starting on them. Poll reviewThreads
independently of gh pr checks (a watcher that gates thread reporting on
"all checks settled" hides actionable feedback for the length of the longest
job). Bot reviews also race your pushes: a thread may flag code a commit you
just pushed already fixed — answer it with the fixing SHA and resolve; no
churn needed.
# List unresolved threads
gh api graphql -f query='query {
repository(owner: "OWNER", name: "REPO") {
pullRequest(number: NUMBER) {
reviewThreads(first: 30) {
nodes {
id
isResolved
comments(first: 1) {
nodes { body author { login } }
}
}
}
}
}
}' --jq '.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false) | {id, author: .comments.nodes[0].author.login, comment: .comments.nodes[0].body[:100]}'
# Reply to a thread
gh api graphql -f query='mutation($body: String!, $id: ID!) {
addPullRequestReviewThreadReply(input: {body: $body, pullRequestReviewThreadId: $id}) {
comment { id }
}
}' -f body="Fixed in latest commit." -f id="PRRT_xxx"
# Resolve a thread
gh api graphql -f query='mutation { resolveReviewThread(input: {threadId: "PRRT_xxx"}) { thread { isResolved } } }'# Auto-detect merge strategy and queue.
# Prefer atomic-history methods; NEVER auto-pick squash (see "Never squash
# unless the user asks" above). Squash is selected only if it is the sole
# method the repo allows — and then warn, because it rewrites history.
STRATEGY=$(gh api "repos/OWNER/REPO" --jq '
if .allow_merge_commit then "--merge"
elif .allow_rebase_merge then "--rebase"
elif .allow_squash_merge then "--squash"
else "" end') || { echo "ERROR: could not query repo merge methods" >&2; exit 1; }
# Fail fast rather than fall through to gh's default method (which may be squash).
[ -z "$STRATEGY" ] && { echo "ERROR: no merge method enabled on this repo" >&2; exit 1; }
[ "$STRATEGY" = "--squash" ] && echo "WARNING: only squash is enabled — this rewrites history and drops signatures" >&2
gh pr merge <NUMBER> --auto "$STRATEGY"
# For repos with merge queue, queue it — but ONLY after passing the
# "Auto-Merge / Merge-Queue Arming Gate" above (the queue ignores
# unresolved review threads and in-flight bot reviews).
gh pr merge <NUMBER> --auto# Switch to main and pull
git checkout main && git pull
# Delete local feature branch
git branch -d <branch-name>
# Remote branch is auto-deleted if repo setting enabled, otherwise:
git push origin --delete <branch-name>| Blocker | Diagnosis | Fix |
|---|---|---|
REVIEW_REQUIRED but no pending reviewers |
Auto-approve raced with Copilot review | Re-run PR Quality Gates workflow |
BLOCKED with all checks green |
Unresolved review threads (even from old commits) | Resolve all threads via GraphQL |
| Auto-merge dropped after push | New commits nullify autoMergeRequest |
Re-queue with gh pr merge --auto |
| CI annotations but status green | Reviewdog warnings don't block by default | Fix annotations or set fail_level: error |
startup_failure / "no jobs ran" / config invalid |
Workflow validator rejected the run before any job started | Read annotations first (see Diagnosing CI Failures (Annotations First) above) — the literal validator error is in one line |