Drive PR to Merge
Single source of truth for the "watch a PR and clear every blocker until it merges" loop. Other skills delegate here instead of re-implementing it. Runs inline (the current agent does the fixes — it does not require an agent team).
Inputs ($ARGUMENTS, all optional)
pr=<number|url>— the PR to drive. Default: the PR for the current branch (gh pr view --json number,url,baseRefName,headRefName,state).merge_method=<merge>— strategy for both auto-merge and the direct-merge fallback. Default and only accepted value:merge.- Never squash — squashing flattens
chore(release): X.Y.Z [skip ci]commits and breaks release promotion detection. - Never rebase. REJECT
merge_method=rebaseup front with a clear message rather than accepting it and verifying it wrongly. GitHub's rebase-and-merge rewrites the commits — new SHAs — and creates no merge commit, so the shipped-verification in section 3 cannot succeed:verify_commitis the pre-rebase SHA and is not an ancestor of the base branch afterwards, and the merge-parent assertion has nothing to assert against. A successful merge would then report as a FAILED verification and drive a false fix-forward, which is worse than refusing the input. See CodySwannGT/lisa#2316.
- Never squash — squashing flattens
verify_commit=<sha>— the commit that MUST end up in the merged base (for the ancestry check). Default: the PR head at the time this skill starts.auto_merge=<true|false>— whether this skill is allowed to merge the PR at all. Defaulttrue.trueis permission to merge, not an instruction to arm the latch immediately: arming is gated on the review context having done work (section 1's arm gate), and under a vacuous context the merge is made here, in the open, or not at all. Withauto_merge=falsethe PR is deliberately left for a human: skip the entire "## 1. Enable auto-merge" step — including its direct-merge capability fallback — and never run anygh pr mergevariant. Still drive every blocker peron_blocker(green checks, resolved reviews, synced branch), then stop at theawaiting-humanterminal state below. A green, open, un-merged PR is the success outcome of this mode, not a hang. Used by learning-persistence flows whose low-confidence PRs must wait for a human (lisa-persist-learning).auto_mergeis caller-time and nothing re-reads it. That is what the hold label (section 0) exists for: the same behaviour, raisable by anyone at any point while the loop runs. Both routes land inawaiting-human; they differ only in who can take them and when.on_blocker=<fix|report>— what to do when a blocker needs code or review work. Defaultfix.fix(the full loop): resolve conflicts, fix failing checks, address + resolve review comments, dismiss stale review gates — drive until merged.report(diagnose & mechanically nudge only): perform just the safe, idempotent, non-destructive actions — ensure auto-merge is enabled (whenauto_merge=trueand the arm gate cleared; arming against a review that did no work is authorising a merge, which is not diagnosis) and, if the PR isBEHINDbut otherwise clean, rungh pr update-branchonly when the base branch requires strict up-to-date checks. For anything that would require editing code, resolving threads, or dismissing a review, do not act — stop and return a structured blocker classification (merged/will-merge-after-resync/blocked:<conflict|checks|changes_requested|deploy|pending-auto-fix|unreviewed>) so the caller applies its own policy. This is the moderepair-intakeand the build-intake skills use to diagnose-and-route without fixing in place.
Resolve <owner>/<repo> from gh repo view --json nameWithOwner (or the PR URL).
Use plain gh + git so Claude and Codex execute identically.
0. Take the babysitter lease
This skill is the branch's owner while it runs. Declare that ownership so any CI repair automation stands down instead of pushing competing fixes to the same branch (the single-writer rule):
gh label create "lisa:babysitter-on-duty" \
--description "A drive-pr-to-merge session is actively driving this PR; CI auto-fix must stand down" \
--color FBCA04 || true # tolerate only already-exists; check the next step
gh pr edit <pr> --add-label "lisa:babysitter-on-duty"
gh pr view <pr> --json labels \
--jq '[.labels[].name] | contains(["lisa:babysitter-on-duty"])'
Verify the final command prints true before driving. If the label could not
be attached (for example, no label-write permission), retry once; if it still
fails, surface a warning that the branch is unleased — the CI auto-fix
workflow may engage in parallel — and watch for its claude-auto-fix-* PR
per section 2f while driving.
The auto-fix workflow reads freshness from the label's most recent labeled
timeline event and treats stamps older than its TTL (default 90 minutes) as
stale. Refresh the lease whenever more than ~30 minutes have passed since
the last stamp while the watch loop is still running — a refresh is a
remove + re-add (re-adding an existing label does not create a new timeline
event):
gh pr edit <pr> --remove-label "lisa:babysitter-on-duty"
gh pr edit <pr> --add-label "lisa:babysitter-on-duty"
Release the lease (remove the label) at every terminal state — merged, closed, or a hard block handed to a human. A crashed session that never releases is why the TTL exists; do not rely on it as the normal release path.
The hold gate — a stop anyone can raise
Section 0 owns this skill's two PR labels: the lease it writes to declare ownership, and the hold it reads to be told to stop. Same mechanism, pointed at different things — a lease is a claim, a hold is a decision.
The label is lisa:hold by default, overridable per project at
github.labels.merge.hold, resolved local-over-global like every other
configured name. Resolve it ONCE at startup and reuse the resolved string; a
name re-resolved each iteration can change identity mid-loop for no reason a
reader could reconstruct afterwards.
HOLD_LABEL=$(
jq -r 'first(.github.labels.merge.hold | strings)' \
.lisa.config.local.json .lisa.config.json 2>/dev/null | head -n1
)
HOLD_LABEL="${HOLD_LABEL:-lisa:hold}"
What it means: exactly auto_merge=false, for as long as it is present. Not
a new concept and not a new terminal state — the same disarm, the same
fail-closed re-read, the same awaiting-human landing. The only new surface is
one label read.
Why it exists. auto_merge is decided by the caller at invocation, and
nothing re-reads it, so the only person who can stop the merge is the person who
started it. Anyone else — a reviewer who opens the PR while the loop is driving
it, someone holding a PR another session armed — has no lever and loses the
race. Measured (#3558): four explicit gh pr merge --disable-auto calls were
each followed by a re-arm 7–25 seconds later, and the PR merged two seconds
after its final check went green. The hold did not fail; it lost a race. A
signal the loop itself reads cannot be raced, because the loop is what reads it.
Evaluate it in two places, and re-read it every iteration
- Before arming (section 1) — a PR already carrying the label is never armed.
- On every watch-loop iteration (section 2), before acting on any blocker.
Re-reading is the entire point. A check that runs once at startup is a caller-time signal wearing a loop's clothing and fixes nothing, because the case that matters is a label applied after the loop is already running. If you are changing this and it is convenient to read the label once, you have reintroduced the defect.
gh pr view <pr> --json labels --jq "[.labels[].name] | index(\"$HOLD_LABEL\") != null"
Match the resolved name exactly — never a prefix. lisa:babysitter-on-duty
is a label this skill applies to itself and shares the lisa: prefix; a
prefix match would make the loop stop on its own lease, which is a deadlock
that looks like a human decision.
When held
- Run the existing
auto_merge=falsedisarm (section 1) — disable the latch once and prove with the fail-closed re-read that it took. - Return
awaiting-human:held(section 4). - Do not remove the label. Removing a human's signal is unrecoverable from inside the loop; leaving a stale one costs a cycle and is visible. Only whoever applied it takes it off, and a later invocation then resumes normally.
Do not keep polling while held. Held is terminal for this run, not a pause to wait out — the loop has been told a human is looking, and continuing to drive is the behaviour the label exists to stop.
The disarm is once and terminal: never re-arm the latch afterwards, and never
resume driving in the same run. This is what keeps a hold compatible with the
armed-across-fix-pushes rule in section 1, which otherwise forbids disarming on
the auto_merge=true path. That rule exists to prevent a race — disarm, then
re-arm, repeatedly, while still driving — and a hold does neither half of it: it
turns the latch off exactly once and then stops. A hold that re-armed, or that
kept driving afterwards, would be the race that rule names, wearing a label.
When the read fails
Fail toward hold. The asymmetry decides it: a false hold costs one cycle and is visible in a terminal report someone can act on, while a false no-hold merges past a human's objection and cannot be undone. Where the two errors are unequal, take the recoverable one.
But a read that keeps failing must not become a silent deadlock, so the failure is reported as its own outcome rather than dressed up as a decision:
- retry the read once;
- if it still fails, disarm as above and return
awaiting-human:hold-unknown, naming the error.
awaiting-human:held and awaiting-human:hold-unknown are different facts and
an operator needs to know which: the first says a human stopped this, the
second says I could not tell whether a human stopped this. Collapsing them
would hide a broken permission behind a human decision, and nobody would ever
look.
A PR with no hold label is driven exactly as it is today. This gate adds a stop; it does not add a block. If the label is absent and the read succeeded, nothing about the run changes.
1. Enable auto-merge
Gate: the hold label is absent (section 0's hold gate), and auto_merge=true
(the default). Evaluate the hold gate BEFORE anything in this section,
including the arm gate below and the direct-merge capability fallback: a held PR
is not armed, not merged directly, and not driven — it disarms once and returns
awaiting-human:held. Checking after arming would authorise the merge the label
exists to prevent, which is the same ordering error the arm gate itself fixes.
When auto_merge=false,
skip the enable step and its capability fallback — do not enable auto-merge,
and do not use the capability fallback below: on a repo that disallows
auto-merge, an auto_merge=false PR must stay OPEN for human triage, never be
silently direct-merged.
With auto_merge=false, also disarm any pre-existing auto-merge latch
before entering the watch loop — skipping the enable step is not enough when a
prior session (or lisa-git-submit-pr's default path) already armed the PR,
because an armed latch would still merge the instant checks go green:
armed=$(gh pr view <pr> --json autoMergeRequest -q .autoMergeRequest)
if [ "$armed" != "null" ] && [ -n "$armed" ]; then
gh pr merge <pr> --disable-auto
fi
gh pr view <pr> --json autoMergeRequest -q .autoMergeRequest # must print null
Then declare the hold, so the deliberate case stays distinguishable from the
defect. /lisa:queue-status's arming sweep (#3903) reports every open PR whose
autoMergeRequest is null, because a green unarmed PR waits forever and no
other surface says so. An undeclared deliberate hold appears there as a finding
on every run, and a report that is wrong every run is one operators learn to
ignore:
gh pr edit <pr> --add-label "lisa:auto-merge-off"
Where that label does not exist in the repo, put the marker in the PR body
instead — the sweep reads either spelling — and give it a reason, because
the sweep prints one and prints no reason declared when it cannot:
<!-- [lisa-auto-merge-off] reason=<why a human owns this merge> -->
Declaring the hold suppresses it from the findings, not from the report: the sweep still counts and names every held PR. That is deliberate — a label that turned a red sweep green and left no trace would be a bypass wearing the costume of a fix.
If the disarm fails or the re-read still shows an armed autoMergeRequest,
fail closed: treat the PR as a hard block (section 4) and report that the
awaiting-human state was NOT reached — never proceed to a state in which the
PR could merge without a human. Once disarmed (or already unarmed), proceed
straight to the watch loop (section 2).
The arm gate — never arm against a review that did no work
This gate governs the auto_merge=true path, and it decides WHETHER to arm.
It does not touch the rule below that a latch, once armed, stays armed. Those
are two different questions with two different answers: arming prematurely is
what this gate prevents; turning off a live latch is what the rest of section
1 forbids.
Scope: repositories that declare a review check. The defect is a required
review context reporting satisfied having read nothing, so where no such
context exists there is no false green to withhold the latch from, and arming is
unchanged. A repository with no evidence_bearing_checks in
.github/required-checks.json — or none at all — passes this gate immediately.
Holding those PRs would redden a whole fleet for a gate none of them asked for,
which is how a guard gets deleted rather than adopted.
Arming is a decision to merge, made in advance and executed by GitHub without
you. Everything this skill knows about the review — step (d)'s reviewed /
NOT REVIEWED verdict, the five sole-gate conditions, the mandatory
MERGED — NOT REVIEWED report line — is evaluated in the watch loop, which runs
AFTER the latch is on. So an armed latch never merges on that reasoning. It
merges on green checks, and whatever the loop would have concluded is simply
never consulted.
Measured (#3439): auto_merge defaults to true and lisa-git-submit-pr armed
the latch at submit time, before any review existed. Step (d)'s exception —
five live-verified conditions and a mandatory report line — could not fire on
any pull request, because GitHub had already merged it the moment CI went green.
A guard that cannot be reached is not a guard: the merge happened on the latch,
not on the reasoning.
So the latch may only ever be armed against a review context that did work.
The consequence is the whole point. An unreviewed merge then has exactly one
route left — step (d), which verifies its five conditions against a live poll
and is required to write MERGED — NOT REVIEWED. This does not forbid the
unreviewed merge; the owner's ruling on #3221 is explicit that a vendor
entitlement state must not redden every pull request. It makes the unreviewed
merge VISIBLE, by leaving it only one path that can produce it, and that path
reports itself.
Classify before arming
Vacuity is the prover's answer, not a string you match here. The repository already ships the detector; consult it on the merge path instead of re-deriving its vocabulary, which is how the two drift apart:
node scripts/check-skipped-required-checks.mjs --vacuity --pr=<pr> --json
When scripts/ carries no installed copy, try the in-repo template at
typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs — the same
second address review-evidence.yml resolves. --vacuity is the mode that WAITS
for the declared checks to settle, which is exactly what "before arming" needs:
a pull request carries several intermediate statuses on one head SHA, and
judging the first one seen would classify every PR as vacuous.
Read violations[].kind and inspected, never the exit code — a named
entitlement waiver is report-only under every flag by the #3221 ruling, so a
zero exit is not evidence that a review happened:
| reading | what it means | arm? |
|---|---|---|
inspected: true, no review-related violation |
the check did work | arm |
review_evidence_unsatisfied |
a review RAN AND OBJECTED | no — a real blocker; steps (d)/(e) |
review_evidence_waived |
a named vendor entitlement | no — vacuous |
vacuous_required_check |
success carrying a no-work description | no — vacuous |
unproven_required_check |
a description proving nothing either way, or no status at all | no — vacuous |
inspected: false with vacuity_none_declared |
this repository declares no review check | arm — out of scope, see above |
inspected: false, any other refusal |
something was supposed to be read and was not | no — an empty inspection is not a pass |
The last two rows are the same field and opposite answers, so read the refusal
kind rather than the inspected flag. vacuity_none_declared means there is
nothing to inspect; vacuity_pr_unresolved, vacuity_checks_unreadable and
vacuity_no_checks_reported mean there is, and it was not inspected — most
often a missing actions: read, which makes gh exit non-zero with empty
stdout and never says the word "permission". Collapsing those into one answer is
this file's own thesis about false greens, aimed at itself.
If the prover cannot be run at all, fall back to reading the check yourself and require a POSITIVE reading before arming:
gh pr checks <pr> --json name,state,description,bucket \
--jq '.[] | select(.name == "<review-context>")'
<review-context> is whatever .github/required-checks.json declares under
evidence_bearing_checks — do not assume CodeRabbit, and if nothing is
declared, arm normally (the scope rule above). Where one IS declared, absent a
description that positively states a review ran, treat the context as vacuous.
Fail closed: absence of evidence is not evidence of a review.
Vacuous and transient are different properties
A vacuous context is not automatically something to wait out, and this is the distinction a naive wait-and-arm gets wrong — it waits forever on a repository where the state never clears. Only the rate limit has both properties.
| description | vacuous? | clears on its own? |
|---|---|---|
Review rate limited |
yes | yes — a throughput window |
Review skipped: manual review required for this OSS repository |
yes | no — a standing vendor policy for public repositories |
Review skipped: N files exceed the limit of M |
yes | no — not without splitting the pull request |
Review skipped (bare, no stated reason) |
yes | unknown — treat as standing |
A bare skip carries no machine-readable reason, so nothing distinguishes it from the standing forms. Requiring a stated reason before treating a skip as vacuous is how the commonest form gets missed.
- Transient (the rate limit, and only the rate limit) — do not arm yet. Wait
the window out and re-request the review at most once, then re-classify.
If it now proves work, arm. If it does not, stop and treat it as standing.
Never loop: a retry loop re-triggers the very limit it is escaping, and
measured on #3220 a single explicit re-request in this repository was
acknowledged, went
Review in progress, and settled back atReview rate limitedtwo seconds later having reviewed nothing — minting a second hollow green. One attempt, then report. - Standing — do not wait at all, and do not re-request. There is no window to wait out, so waiting produces a stall that reads as progress. Go straight to the watch loop and let step (d) adjudicate the merge in the open.
A latch somebody else armed
Not arming is not enough. lisa-git-submit-pr, an older Lisa version, or a
previous session may have left the latch on, and an armed latch merges the
instant checks go green no matter what this gate concluded. So when the gate
says do not arm, turn any existing latch off and prove it took — the same
mechanism and the same fail-closed re-read the auto_merge=false contract above
uses:
armed=$(gh pr view <pr> --json autoMergeRequest -q .autoMergeRequest)
if [ "$armed" != "null" ] && [ -n "$armed" ]; then
gh pr merge <pr> --disable-auto
fi
gh pr view <pr> --json autoMergeRequest -q .autoMergeRequest # must print null
If the re-read still shows an armed autoMergeRequest, fail closed: report a
hard block (section 4) rather than proceeding into a state where the PR can
merge with neither a review nor step (d)'s recorded decision.
What this costs, stated plainly. While the review context is vacuous the PR
cannot merge unattended, so a run that ends early leaves it open. That is the
opposite trade from the armed-across-fix-pushes rule below, and it is deliberate:
there, the thing at risk was a PR sitting unmerged; here, the thing at risk is
code shipping that nothing read. The cost is bounded — the moment the context
proves work, the latch is armed and the unattended behaviour returns — and the
open PR is REPORTED (blocked:unreviewed, section 4), not silently abandoned.
A nightly-E2E waiver is RE-DERIVED here, never replayed
Everything above reads STORED check results. That is fine for a gate whose
input is the code, and weaker than it looks for one whose input is mutable
pull-request state a human can edit — which is exactly what a nightly-E2E
bypass waiver is. A stored bypassed says what was true when the gate ran, and
the merge happens later.
Every way a waiver can change fires a pull-request event the gate subscribes to — applying the label, removing it, editing the body — except one. A waiver that runs out of hours produces no event at all, so no amount of re-running sees it, and the stored green goes on saying green. Expiry is only visible to re-deriving.
So before arming, ask the guard what is true NOW:
NIGHTLY_PR_NUMBER=<pr> node scripts/check-nightly-e2e-health.mjs --waiver-verdict --json
The same second address applies as above:
typescript/copy-overwrite/scripts/check-nightly-e2e-health.mjs. The mode needs
only a token, GITHUB_REPOSITORY and the pull request number — no suite table,
because the question here is "is the waiver still good?", not "is the nightly
green?".
Read state, and treat the exit code as its shorthand rather than its source:
state |
what it means | arm? |
|---|---|---|
none |
nobody asked for a waiver; the gate stands on suite evidence | arm |
waived |
a waiver is valid at this moment | arm — the escape hatch working |
refused |
a waiver was requested and no longer holds | no — a stored bypassed must not carry this merge |
not_determined |
the live pull request could not be read | no — nothing was established |
refused and not_determined are kept apart on purpose. The first names a
lapsed waiver, what it covered and a remedy; the second says the question could
not be answered. Collapsing the second into "fine" is the failure the whole mode
exists against, and collapsing it into "the waiver is bad" invents a fact.
Do not weaken the gate to get past this. A genuine, still-valid waiver keeps
merging — that is the waived row, and it is the row to protect. If the waiver
has lapsed, either fix the red suite or re-apply a fresh waiver so a
maintainer's grant is dated from now; report blocked:nightly-waiver-lapsed
(section 4) rather than merging on the earlier green.
If the repository ships no nightly-E2E gate at all, the guard is absent and this subsection does not apply — the same scope rule the vacuity gate uses.
Before enabling auto-merge, capture the live PR head and compare it to
verify_commit:
head_sha=$(gh pr view <pr> --json headRefOid -q .headRefOid)
test "$head_sha" = "<verify_commit>"
If they differ, reset verify_commit to the live head only after confirming the
new head contains the intended fix, or stop and report the mismatch. Never enable
auto-merge against a stale head you have not verified.
gh pr merge <pr> --auto --<merge_method>. Enabling auto-merge is not terminal
— continue the loop below until the PR is actually MERGED or CLOSED.
With auto_merge=true, leave the latch ARMED — never disable auto-merge.
(Under auto_merge=false the deliberate disarm above still applies: that mode
must leave the PR open for a human, so a pre-existing latch is removed on
purpose. Everything below is the auto_merge=true path.)
Once a fix is PUSHED the latch is safe: GitHub evaluates required checks against the PR's current head, so a new commit whose checks have not reported leaves the PR blocked. Auto-merge cannot ship a commit nothing has verified.
The only window a disarm ever protected is the gap between deciding to fix
something and that fix landing — during which the PR is genuinely green and
genuinely mergeable, and auto-merge firing is GitHub behaving correctly. Two
merges in this repo's history are attributed to that window (#1392, and the
release that shipped the ./hooks/ Cursor bug). Both were fixed forward within
minutes; one was a one-line docs inconsistency.
That evidence is also weaker than it looks: auto-merge attributes the merge to whoever enabled it, so an auto-merge and a human pressing Merge are indistinguishable in the timeline. The record cannot tell us those PRs were not simply merged by hand while the latch happened to be armed.
Against that, disarming costs something certain. Disabling is a durable state
change on GitHub; re-enabling is one more step the run has to reach. When a run
ends in between — turns exhausted, job timeout, or you concluding the work while
checks are still pending — the latch stays off and nothing restores it. The PR is
left WORSE OFF THAN IF THIS SKILL HAD NEVER RUN: it has lost the mechanism that
merges it while no agent is watching, and the run reports success. Measured on
acmeorgc/frontend#282, the latch went off 14s before the fix commit and the
PR sat 26 minutes after going green, against ~3 minutes for PRs this skill never
touched.
So the trade is a rare, unproven miss that costs a fix-forward PR, against a frequent, silent stall on every PR this skill repairs. Take the rare one.
What still applies on a push: immediately re-read headRefOid and reset
verify_commit to the pushed head, so the shipped-verification in step 3 checks
what you actually pushed rather than the commit you replaced. That ancestry check
is what CATCHES a raced merge — it fails loudly when the fix SHA is not an
ancestor of the base branch — so the rare miss is detected rather than silent.
Invariant: never terminate having left auto-merge OFF on an open PR when
auto_merge=true and the arm gate cleared. If some future path does turn it
off, restoring it is a terminal obligation on EVERY exit — including give-up,
budget-exhausted and error paths — not a later step in a sequence. A PR the arm
gate held unarmed is outside this invariant: holding it is the gate's purpose,
and blocked:unreviewed (section 4) is how that exit is reported rather than
passed off as success.
- Capability fallback (
auto_merge=trueonly): if the repo disallows auto-merge, do not fail. Keep watching; once checks are green, the arm gate clears — a review context that proved work, not merely a green one — andmergeable == MERGEABLE, rungh pr merge <pr> --<merge_method>directly. A green review context that did no work does NOT clear the gate here either: on a repo without auto-merge this fallback is the merge, so exempting it would reinstate the whole defect one layer down. Under a vacuous context the merge belongs to step (d), with its conditions and its report line, or toblocked:unreviewed. This fallback lives inside the gated section above — withauto_merge=falseit never fires; the PR remains open awaiting a human.
The mergeability gate — never arm a PR that cannot merge
The arm gate above asks did the review context do work? This one asks a
different question — is there a verdict standing in the way? — and they are
not the same. A CHANGES_REQUESTED review unambiguously did work, so it
sails through the arm gate and the latch goes on over the top of it.
Read reviewDecision explicitly. Never infer it from a check count.
gh pr view <pr> --json reviewDecision,reviewThreads \
--jq '{decision: .reviewDecision, unresolved: [.reviewThreads[]? | select(.isResolved == false)] | length}'
reviewDecision is not part of statusCheckRollup. That is the whole reason
this stayed invisible: a failing-check count reads zero on a PR that can
never merge, and the checks tab is entirely green. Any rule keyed on "no red
checks" is satisfied by exactly the PR this gate exists to catch (#3720).
Do not arm while reviewDecision == CHANGES_REQUESTED. Arming is a claim
that this PR will merge, and that claim is false here. Clear the verdict through
step (e) first — which discriminates a live objection from a stranded one by
unresolved thread count, not by reviewDecision — and arm afterwards.
Two ruleset settings decide whether a standing verdict ever clears itself, and
in this repository both say no. Verify them for the repo you are in rather
than assuming, with gh api repos/<owner>/<repo>/rulesets:
dismiss_stale_reviews_on_push: false— the review stays attached to the commit it was made on, so pushing the fix never clears it. The intuition that a new head resets the verdict is wrong here.required_approving_review_count: 0— no approval is required to merge, so a standingCHANGES_REQUESTEDis not holding a slot for a review that would otherwise arrive. Nothing is scheduled to clear it.
Together those mean the block is indefinite, not slow. Waiting is not a strategy; something has to act.
Unresolved threads block independently of the verdict. This repository sets
required_review_thread_resolution: true, so a PR can have a clear
reviewDecision and still be unmergeable on threads alone — a second blocker
that is equally absent from the check rollup. Read both, and name whichever one
is standing; reporting the wrong one sends the operator to the wrong place.
2. The watch loop
Poll the live state each iteration:
gh pr view <pr> --json state,mergeStateStatus,mergeable,reviewDecision,statusCheckRollup,headRefName,baseRefName
Re-read the hold label on every iteration, before handling any blocker
(section 0's hold gate). It is polled separately from the line above rather than
folded into it, because labels is not part of the blocker state: the hold is a
question about whether to keep going at all, asked before the blocker questions,
and a run that answers it once at startup has not implemented it.
Handle every blocker class; after any fix, re-poll and continue. Do not stop while the PR is still open and progress is possible. On each iteration, refresh the babysitter lease if its last stamp is older than ~30 minutes (section 0).
Poll by PR number, and never discover the PR set from "what is currently open".
A watcher whose target list comes from gh pr list --state open cannot observe a
merge: the moment the PR merges it leaves that list, so the branch that would
report MERGED is unreachable and the watch ends in silence that looks like
"still running". The same hole hides CLOSED. This is why the poll above names
<pr> explicitly.
The general shape, worth recognising anywhere a watcher is built: a set defined by a current state cannot witness a member leaving that state. If a watcher must discover its targets dynamically, it has to remember what it discovered and keep inspecting each one after it drops out of the discovery query — discovery and inspection are separate lists.
Prefer this skill over a hand-rolled multi-PR watcher for exactly that reason. If
you do build one, give it the same coverage this loop has: not just MERGED and
failing checks, but BEHIND/DIRTY and unresolved review threads — a PR stalled
on any of those is indistinguishable from one still running, and silence is not
evidence of progress.
With auto_merge=false, the loop's goal changes from "merged" to "clean and
waiting": drive blockers exactly the same, but exit successfully at
awaiting-human (section 4) once the PR is open with green checks, a clear
review gate, and mergeable == MERGEABLE. Never enable auto-merge or merge
directly in this mode.
In on_blocker=report mode, only the mechanical step (a) and auto-merge enabling
(when auto_merge=true and the arm gate cleared) apply; for any of (b)–(f) do
not act — classify the blocker
and return per the input contract above. That includes (f): adjudicating a pending
auto-fix PR (merging, closing, or deleting its branch) is destructive work, not
diagnosis — return its classification (blocked:pending-auto-fix) instead.
The hold gate binds in report mode too, and it binds first: a held PR
returns awaiting-human:held without arming, without the mechanical step (a)
nudge, and without an update-branch. Hold is not a blocker classification and
never returns as one — blocked:* says this PR cannot proceed yet, while a
hold says someone asked me not to proceed, and reporting the second as the
first would file a human's decision as a defect for something else to clear.
The arm gate binds in report mode too, and this is the mode where forgetting it
does the most damage: a diagnose-only run that arms the latch has not diagnosed
anything, it has authorised a merge. Under a vacuous review context, report mode
neither arms nor takes step (d)'s exception — it returns blocked:unreviewed.
a. Branch behind base (mergeStateStatus == BEHIND)
Before proactively syncing a clean BEHIND PR, check whether the base branch
actually requires up-to-date branches:
owner_repo=$(gh repo view --json nameWithOwner -q .nameWithOwner)
base=$(gh pr view <pr> --json baseRefName -q .baseRefName)
strict=$(gh api "repos/$owner_repo/rules/branches/$base" \
--jq '[.[] | select(.type == "required_status_checks") | .parameters.strict_required_status_checks_policy // false] | any')
If that rules endpoint is unavailable, fall back to classic branch protection:
strict=$(gh api "repos/$owner_repo/branches/$base/protection/required_status_checks" \
--jq '.strict // false')
Only when strict == true, once required checks are green, run
gh pr update-branch <pr> and keep watching the new head while checks rerun.
If strict == false, do not update the branch solely because the base moved:
continue the mergeability loop and let GitHub merge the existing head once the
checks/reviews are acceptable. This avoids cancellation storms in repos whose CI
uses concurrency.cancel-in-progress: true.
Still sync when it is necessary to resolve a genuine merge conflict, and it is acceptable to perform one final sync immediately before a direct merge if the merge attempt proves the head must be updated.
b. Sync/merge conflict
Check this FIRST, before waiting on any check. A conflicted PR runs zero workflows — not red ones, none — and an empty check list is indistinguishable from an Actions outage, a slow queue, or workflows not being configured. Time gets lost waiting for CI that was never dispatched.
The mechanism: pull_request workflows are evaluated against GitHub's computed
merge ref — "base with this PR merged in". A conflict means that ref cannot
be built, so there is nothing to dispatch against and no run is created.
The tell is two facts TOGETHER:
gh pr view <pr> --json mergeable,mergeStateStatus --jq '"\(.mergeable) \(.mergeStateStatus)"'
gh api "repos/<owner>/<repo>/actions/runs?head_sha=<head>" --jq .total_count
mergeable == CONFLICTING and total_count == 0 is a conflict, not a CI
problem. If CI were merely slow you would see runs QUEUED, not absent. Resolve
the conflict and the runs appear; nothing else will make them appear.
<head> must be the FULL 40-character SHA. head_sha= does not match a
prefix and does not reject one: an abbreviated SHA returns a clean
total_count: 0 — not a 404, not a validation error, an absence shaped exactly
like "no workflow ran". Measured on one pull request's own head: 77f223b9d →
total_count 0, 77f223b9d8ffe7b172e9cac48b068b3bcac304c3 → total_count 4.
Take the SHA from gh pr view <pr> --json headRefOid --jq .headRefOid, never
from git log --oneline or a truncated line in a log or a UI. Copying a short
SHA here makes EVERY pull request read as having no CI, and the natural next
action — rebase and force-push — throws away the live run you could not see
(#3848).
mergeable is computed asynchronously. GitHub returns null while it is
still working it out, so a single read on a freshly-opened PR can say null on
a perfectly clean branch. Treat null as "cannot tell yet" and re-read — never
as "fine". A false all-clear here is the same defect this check exists to catch,
pointed at yourself.
This is the pre-merge twin of the zero-deploy-run rule below: an absence is evidence of something, and the something is rarely "it is fine".
mergeStateStatus is a cached computation, so it is a HINT, never proof
of a conflict — and never the verdict.
Before entering the resolution path, re-derive the answer from primary evidence
at the moment of asking — a merge trial against the actual base:
base=$(gh pr view <pr> --json baseRefName --jq .baseRefName)
head=$(gh pr view <pr> --json headRefOid --jq .headRefOid)
git fetch --quiet origin "$base" "pull/<pr>/head" || readable=no
git rev-parse --verify --quiet "origin/$base^{commit}" >/dev/null || readable=no
git rev-parse --verify --quiet "$head^{commit}" >/dev/null || readable=no
out=$(git merge-tree --write-tree "origin/$base" "$head" 2>/dev/null); code=$?
git merge-tree --write-tree performs a real three-way merge into the object
store. It touches no working tree, no index and no branch, so it is safe to run
mid-loop on a dirty checkout — which is why it, and not a scratch clone or a
throwaway git merge, is the trial this skill runs.
Read the exit code together with stdout. The exit code alone cannot tell you
which of the three states you are in. Measured on git 2.53.0: a trial naming a
ref that does not exist exits 1 — the very same code a genuine conflict
returns — printing nothing on stdout and merge-tree: <ref> - not something we can merge on stderr. The discriminator is stdout: a trial that ran always
prints the resulting tree OID on its first line, and one that could not run
prints nothing at all.
| Outcome | State | What to do |
|---|---|---|
readable=no, or $out empty |
NOT DETERMINED | Neither path. Re-fetch and retry once; if it is still unreadable, report not_determined and let the next loop iteration ask again. |
code == 0 |
CLEAN | Do not enter the resolution path, whatever mergeStateStatus says. |
code == 1 and $out non-empty |
CONFLICTED | Enter the resolution path below. |
| anything else | NOT DETERMINED | As the first row. |
The third state is the one that gets collapsed, and reaching for the exit code by itself is exactly how. An unresolvable ref, an unreachable remote, a fork head that was never fetched, a git older th
…(truncated)