My merge-readiness gate printed “3 green / 0 unresolved threads / ready to merge.” The PR was one click from shipping. Then GitHub refused to let me click — the merge button stayed gray, status BLOCKED, with every check passing.
Here’s the setup in plain terms. CodeRabbit is an AI reviewer that leaves comments on a pull request (a proposed batch of code changes). Each comment is a “thread” you’re supposed to resolve before merging. I’d written a little gate that counted the unresolved ones and only gave the green light when the count hit zero. Think of it as a bouncer checking that every objection has been answered before the code goes out the door.
My bouncer was counting wrong.
I filtered the threads with isResolved == false AND isOutdated == false. That returned zero. Sounds right — why count outdated stuff? But “outdated” doesn’t mean what I assumed. I read it as “stale, already dealt with.” What it actually means is: the line numbers this comment points at have shifted because a later commit added or removed lines above it. The comment is pinned to a spot on a page, and someone inserted paragraphs higher up, so the pin no longer lands where it used to.
The finding underneath was a 🔴 CRITICAL — a fail-open bug, the kind where a security check silently passes when it should block. Still completely valid against the current code. My filter had hidden it for the dumbest possible reason: the lines moved.
The thing that saved me was the coarser, dumber gate I almost overrode. GitHub branch protection has a setting called require_conversation_resolution that counts every unresolved thread, outdated or not, and refuses the merge until they’re all closed. It doesn’t try to be clever. It just keeps mergeStateStatus at BLOCKED. My hand-rolled filter and GitHub’s structural gate disagreed — and my first instinct was that GitHub was being annoying.
It wasn’t. It was right because it had no opinion. My filter encoded an assumption — “outdated implies addressed” — and so it inherited my blind spot exactly. That’s the trap with a verification tool you wrote yourself: it can only check the things you already thought to doubt. The bug I missed was in the checker.
Counting threads the fail-closed way give me the detail
The fix was deleting one clause. GraphQL over the GitHub API gives you both signals per thread:
reviewThreads(first: 100) {
nodes { isResolved isOutdated }
}Wrong (my version) vs. right:
// WRONG: shares the author's blind spot
const blocking = threads.filter(t => !t.isResolved && !t.isOutdated);
// RIGHT: outdated is about line drift, not resolution
const blocking = threads.filter(t => !t.isResolved);Then treat the structural gate as source of truth, not your count:
gh pr view "$PR" --json mergeStateStatus -q .mergeStateStatus
# only ship on CLEAN; BLOCKED with green checks means look hardermergeStateStatus == CLEAN is fail-closed — it’s red until proven green. Your custom filter is fail-open — green until you remember to check something.
So the rule I’d give you: when two checks disagree and one of them is the dumb structural one you can’t easily fool, don’t override it — go find out why it’s unhappy. And be most suspicious of the tool that agrees with you, because you built it to.