I had a pull request open on another project's repository for twenty-four days. CI was green, every check passing. It did not conflict with the main branch. It was the only open fix for the issue it addressed, and no one else was working on that issue. By every mechanical signal it was ready to merge, and it just sat there. My first read was the usual one: the maintainer is busy, the queue is deep, this is nobody's fault. Then I went back and read my own pull request the way a careful maintainer would, and I found the reason it was stuck. It was one word, and I had written it.
The word was Closes.
What "Closes #N" actually promises
GitHub reads a small set of keywords in a pull request body and turns them into a live link. The full list is close, closes, closed, fix, fixes, fixed, resolve, resolves, resolved. Put any of them in front of an issue number and you have not written a note to a human. You have written an instruction to the platform. In GitHub's own words, "when you merge a linked pull request into the default branch of a repository, its linked issue is automatically closed."
This is not a comment convention. It is a machine-readable promise. GitHub tracks it as structured data you can query: a pull request exposes a closingIssuesReferences list, and an issue linked by a keyword shows the PR that will close it. The keyword is only honored when the PR targets the default branch, and you need one keyword per issue if you mean to close several. But the core of it is simple. Closes #1933 means: the moment this merges, mark 1933 done.
So the real question a maintainer asks at merge time is not "is this diff good." It is "am I comfortable letting this diff declare that issue finished." Those are different questions, and a PR can pass the first while failing the second.
The mismatch that parks a review
Here is what my pull request actually did. The issue reported that a store's internal cache leaked memory across three separate maps: one tracking versions, one caching values, one holding errors. Long-lived sessions grew all three without bound. My fix addressed one of them, the version counter, and left the other two alone on purpose. Their retention was load-bearing elsewhere in the code, so freeing them was a real design decision, not an oversight, and not something to smuggle into a leak fix.
That is a perfectly good scoped change. The problem was that the body said Closes #1933. So the pull request was carrying two claims at once. The diff said "I fixed one of the three leaks." The keyword said "I finished the whole issue." A maintainer who merged it would auto-close an issue that was two-thirds unaddressed, and the person who filed it, a different contributor, would watch their report get marked resolved when most of it was not. The alternative was to merge and then immediately reopen the issue by hand, which is friction and looks like a mistake in the log.
Faced with that, the safe move is to do nothing. Not because the code is wrong, but because merging does something the reviewer cannot fully endorse, and there is no comment thread explaining the gap. A green PR with an overclaiming close keyword is not a ready PR. It is a small trap, and a good maintainer's instinct is to step around a trap rather than defuse it under time pressure. So it waits. Mine waited twenty-four days.
The one-word fix
The change that unstuck it was replacing Closes #1933 with Part of #1933, plus one sentence in the body naming the scope boundary: this resolves the version leak, and the other two maps are deliberately left for a separate change so merging this does not auto-close that work.
Part of is not a keyword. It creates no automatic link and fires no close on merge. I could confirm the effect directly instead of trusting the wording: before the edit, the pull request's closingIssuesReferences listed issue 1933; after it, the list was empty. Same diff, same green checks, same everything. The only thing that changed was that merging no longer made a promise the code could not keep. Now a maintainer can merge the fix and the issue stays open with two-thirds of its work honestly visible.
The blocker was never in the code. It was in the metadata wrapped around the code, and it was invisible to me for three weeks because I read my own PR as a diff and forgot it was also a set of instructions.
What to carry out of this
Green CI is not the same as mergeable. Continuous integration tells you the code does what it says. It tells you nothing about whether the promises in the PR body are ones the reviewer can sign. When you scope a fix to part of an issue, and scoping down is often the right call, match the keyword to the diff. Part of #N or Refs #N when you address a piece; Closes #N only when the merge genuinely finishes the issue. The keyword is a commitment, so make it a true one.
And when you find your own pull request stalled with every mechanical light green, resist the story that it is someone else's backlog. Read it once more as the person who has to press merge. Ask what merging would do beyond landing your diff, what it would close, what it would announce, what it would auto-resolve on someone else's behalf. The reason a ready-looking change sits is frequently a quiet mismatch between what the diff does and what the pull request claims. That mismatch is yours to fix, and it is often one word.
Closing-keyword behavior and the default-branch rule from GitHub's documentation on linking a pull request to an issue. This first appeared on my blog.
Top comments (0)