← All posts

The Outdated Comment That Wouldn't Die

All four checks green, zero approvals needed, and my PR still sat BLOCKED. The culprit was a stale bot comment GitHub still counted.

  • github
  • automation
  • ci
  • agents
  • graphql

One of my agents was trying to merge a pull request — a proposed code change waiting to be folded into the main version of one of my apps, a Flask portal — and it just… couldn’t. All four required checks were green. Zero approvals required. And GitHub still showed the PR as BLOCKED, with the maddening reason “base branch policy prohibits.”

If you don’t live in GitHub: think of a pull request as a suggested edit to a shared document, and “branch protection” as the office rule that says nobody merges an edit until every open comment is settled. My edit had no failing tests and nobody demanding changes. And yet the door stayed locked.

I burned real time on this. My first assumption was the checks — maybe one was silently pending, maybe a flaky test. Nope, all four green. Then I figured maybe a human review was secretly required. Nope, zero approvals needed. I was staring at a PR that met every visible condition and refused to move.

The rule doing it was require_conversation_resolution. When that’s on, GitHub blocks the merge if any review thread is unresolved — and the sneaky part is that “any” includes threads marked outdated. An outdated thread is a comment attached to a line of code that has since changed, so the comment no longer points at anything real. It looks dead. GitHub still counts it.

The unresolved thread was from CodeRabbit, an AI review bot that leaves inline notes on PRs. It had commented on code I’d already rewritten. The thread was flagged isOutdated: true. Visually irrelevant. Mechanically, still a locked door.

Here’s where I’d shot myself in the foot. My agent auto-resolves review threads to unblock merges — it walks the threads and marks them resolved. But I’d scoped it to only resolve threads that were both unresolved and not outdated, on the theory that outdated comments would fall off on their own. They don’t. So my resolver skipped the exact thread that was doing the blocking.

The fix was one deleted condition: resolve every thread where isResolved == false, and ignore the isOutdated flag entirely. Don’t be clever about which unresolved threads “count.” To the branch-protection rule, they all count.

Enumerating and resolving every unresolved thread via GraphQL give me the detail

The trap is filtering the wrong field. Enumerate threads, then resolve on isResolved == false alone — never add && !isOutdated.

query($owner:String!,$repo:String!,$pr:Int!){
  repository(owner:$owner,name:$repo){
    pullRequest(number:$pr){
      reviewThreads(first:100){
        nodes{ id isResolved isOutdated }
      }
    }
  }
}
# resolve EVERY thread where isResolved==false — ignore isOutdated
for id in $(gh api graphql -f query="$Q" \
  --jq '.data.repository.pullRequest.reviewThreads.nodes[]
        | select(.isResolved==false) | .id'); do
  gh api graphql -f query='mutation($t:ID!){
    resolveReviewThread(input:{threadId:$t}){ thread{ isResolved } }
  }' -f t="$id"
done

Test it: leave one outdated bot comment, run the resolver scoped to fresh threads only, and watch the PR stay BLOCKED. Drop the isOutdated guard; it clears.

The general lesson is smaller than the hour it cost me: when a system says “resolve everything,” believe the everything. Don’t teach your automation to guess which stale items are safe to skip. If the gate counts a thing, your resolver has to touch that thing — even when it looks like it’s already dead.