Make the staging auto-close parser accept the PR template's own form - #158
Conversation
The auto-close Action exists because native Closes-#N only fires on the
default branch. But its parser skipped only [:\s]* between the keyword
and the reference, so the PR template's own line --
Fixes / Implements: #N
-- matched NOTHING: the "/" stopped it cold. Every PR that followed the
template left its issue open, and every issue closed by a staging merge
in the last week was closed by hand. Verified against the parser's exact
regex: 'Fixes / Implements: #94' -> [] , 'Fixes #94' -> ['94'].
The skip now allows an optional "/ Implements" (or / Closes / Fixes /
Resolves) between keyword and reference. Ran the workflow's real parse
step, extracted from the YAML and executed with PR_BODY set, against the
template form: "Parsed closing-keyword issues: ['94']", GITHUB_OUTPUT
issues=94. Refs #N, cross-repo refs and prose ("a fix for #12") still do
not close, matching native GitHub behaviour.
The template gains a comment saying which forms close and that
"Implements: #N" alone does not (it is not a closing keyword, natively
or here), so authors can choose deliberately between closing and
referencing.
Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
Self-reviewNo defects found. The one thing that could make this PR worse than the bug it fixes is over-matching — closing an issue someone meant to keep open — so that's where I spent the review. Adversarial cases — the widened skip must not newly catch theseAll correct. Why the path-like cases are safe: after the Scope decisions, stated
The real verification is the first staging merge after thisThis is a One caution for the reviewerAny currently-open PR carrying |
mwiget
left a comment
There was a problem hiding this comment.
The regex change itself is correct — I re-ran your matrix against the parser and got your results exactly, including every negative case that must not close (Implements: #7, Refs #41, Fixes other/repo#5, a fix for #12 landed, fixed in #3, and the unfilled Fixes / Implements: #[Issue Number] template line). No objection to the skip pattern.
But merging this PR will wrongly close two open issues — its own documentation examples parse as closing references.
I extracted the parse step body from this branch's YAML and executed it with PR_BODY set to this PR's own description:
Parsed closing-keyword issues: ['94', '128', '7']
GITHUB_OUTPUT: issues=94 128 7
Those come from the examples in the description and the matrix table — Fixes / Implements: #94, Fixes / Implements: #94, #128, Closes / Implements: #7 — all inside backticks, all intended as documentation. #7 is already closed so the idempotency check skips it, but #94 and #128 are open, and their actual fixes are in #157 and #156, which have not merged. They'd be closed by #158 with the comment "Auto-closed by PR #158" — wrong issue, wrong PR, misleading trail, and the real fixes then merge against already-closed issues.
The old parser has the same blind spot (it reads ['94'] from this body) — so this isn't a regression you introduced conceptually. But this PR triples the blast radius on its own body, and a parser PR is the right place to close it.
The underlying bug: the parser doesn't skip code spans or fenced blocks. Any PR that documents closing keywords — this one, and any future doc/template change — closes the issues it's talking about. Stripping ``` fences and ` spans from `body` before `kw_pat.finditer` is a few lines and fixes the class of problem, not just this instance:
body = re.sub(r'```.*?```', '', body, flags=re.S)
body = re.sub(r'`[^`]*`', '', body)That also keeps your matrix honest — every example in your table is inside backticks, so they'd all stop counting while the real Fixes #N lines outside code spans keep working. Worth re-running the matrix after, since the negative cases in the table are then exercising the stripped path rather than the regex.
If you'd rather not touch the parser here, the minimum is rewriting this PR's own examples so they don't parse (e.g. Fixes / Implements: #NNN) before merge — but I'd take the fence-stripping, since the next doc PR hits it again.
Everything else — the template annotation, the environment-validation note that the real test is the next staging merge, the choice to keep the existing template line so open PRs keep working — is good, and I'd approve on a re-push.
…ywords Review finding (mwiget): merging #158 as it stood would have closed #94 and #128 -- not because of the regex change, but because the PR's own description DOCUMENTS closing keywords in backticks, and the parser reads body text raw. Reproduced against this branch's real parse step with this PR's body as PR_BODY: Parsed closing-keyword issues: ['94', '128', '7'] #94 and #128 are open with their real fixes unmerged in #157/#156; they would have closed with "Auto-closed by PR #158", wrong issue and wrong PR. The old parser had the same blind spot (it read ['94'] from this body); widening the skip tripled the blast radius on a body that talks about the very forms it now accepts. A parser PR is the right place to close the class, not the instance. Fenced blocks are stripped first (they may contain backticks), then inline spans. Re-ran the full matrix through the real step: every real closing line in plain text still closes; every example in backticks or a fence no longer does; a body with both a real "Fixes #94" and a documented "`Fixes #999`" closes only 94. This PR's own body now yields no issues. Then ran EVERY open PR's actual body through the patched step -- the check I should have done the first time: #156 -> 128 #157 -> 94 #159 -> 154 #160 -> 99 #158 -> (none) #161 -> (none, deliberate Refs #79) #135 -> (none) Each PR closes exactly its own issue and nothing else. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
|
@mwiget — thank you, this was exactly right, and it caught a real gap in my verification: I ran the parser against synthetic cases but never against this PR's own body. You did, and it would have closed 94 and 128 out from under #157/#156. Reproduced first ( Pushed in
And one more catch that proves your point twice. After stripping, I updated this PR's description to explain the fix — and the updated body parsed to PR body updated to describe both changes; the description is now itself a test case. Ready for re-review. |
mwiget
left a comment
There was a problem hiding this comment.
Re-verified the fix independently — extracted the parse step from this branch's YAML and ran it, rather than re-implementing it.
Every open PR body through the patched step, matching your numbers exactly:
#156 -> 128 #157 -> 94 #159 -> 154 #160 -> 99
#158 -> (none) #161 -> (none) #135 -> (none)
And the matrix: inline-span examples, fenced examples, and a table row of backticked forms all go inert; a real Fixes #94 alongside a documented example still closes only 94; the plain-text and negative cases are unchanged. Stripping fences before spans is the right order and I checked it holds — a fence containing backticks survives correctly (```\nuse `Fixes #7` here\n```\n\nFixes #42 yields 42, not 7).
Running every open PR's body through the patched step, rather than just this one's, is the check that turns this from "the instance is fixed" into "the class is fixed" — good call.
Two residual edge cases below. Neither blocks — I'm raising them so they're on the record rather than rediscovered later, and I'd merge with both left as-is. Waiting on CI to go green on the new head, then I'll approve.
Review edge case (mwiget): only ``` fences were stripped, so a ~~~ block still closed what it documented (~~~\nFixes #55\n~~~ -> 55). That is the unsafe direction, and ~~~ is what an author reaches for precisely when the example itself contains backticks -- the case this stripping exists for. Cheap to close. Matched like-to-like via a backreference, (```|~~~).*?\1, rather than the alternation (?:```|~~~).*?(?:```|~~~): the latter lets a stray ~~~ inside a ``` block terminate it early and over-strip. Verified both mixed cases -- a ``` fence containing a stray ~~~, and a ~~~ fence containing ``` -- strip correctly and a real closing line after them still closes. Left as-is, per review and agreed: 4-space indented blocks (need a line-based pass; nobody here writes them) and the unbalanced-backtick false negative (safe direction -- the issue stays open, the pre-Action status quo). Both recorded in the comment. Original 15-case matrix and every open PR body re-cleared, unchanged. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
|
Second round addressed in
Re-cleared the original 15-case matrix and every open PR body — unchanged ( Ready for your re-look. |
|
Correction to my comment just above: it said the PR body parsed to nothing after the update — it didn't, it parsed to
Reworded to "tilde fence … backtick fence" — no bare markers in prose. Body now parses to The lesson is sharper than "strip code": any prose that mentions fence markers or closing keywords near an issue number is a hazard, and the only reliable check is running the real step against the final body — which I've now done, and will do before every future edit to this PR. Nothing in the code changed; this was entirely my description. |
mwiget
left a comment
There was a problem hiding this comment.
Not blocking the intent — one correctness gap in the new code-stripping.
The added comment states the stripping "never wrongly closes", but replacing code spans/fences with the empty string can glue a closing keyword directly onto a reference and close an issue the pre-PR parser left alone:
"Fixes `the parser` #94" -> strip -> "Fixes #94" -> closes #94
"This resolves `race` #7" -> strip -> "resolves #7" -> closes #7
"Fixes\n```\ncode\n```\n#94" -> strip -> "Fixes\n\n#94" -> closes #94
Verified against the actual parse step: pre-PR "Fixes \x` #94"→[], post-PR → ['94']`. So this is a new false-close, in the exact direction the comment says is impossible ("Known safe-direction gap ... never wrongly closes") and the same bug class this PR exists to fix.
Trigger is narrow (keyword → whitespace-only → span/fence → #N), but PR bodies in this repo routinely place inline code next to issue numbers, so it's reachable.
Fix options:
- Real fix: replace stripped code with a non-whitespace sentinel instead of
'', so the barrier between keyword and reference survives. A plain space won't work — the separator[:\s]*swallows whitespace. - Minimal: correct the comment to acknowledge this direction rather than claiming it can't happen.
Everything else checks out empirically: template Fixes / Implements: #N closes, Implements:/Refs/cross-repo/prose correctly don't, this PR's own body parses to (none), and the ~~~ like-to-like backreference doesn't over-strip.
Review finding (mwiget): replacing code spans/fences with '' GLUES a
closing keyword onto a following reference and closes an issue the
pre-PR parser left alone:
"Fixes `the parser` #94" -> "Fixes #94" -> closes 94
"Fixes\n```\ncode\n```\n#94" -> "Fixes\n\n#94" -> closes 94
The [:\s]* separator swallows the gap the deletion leaves. That is a NEW
false-close, in the exact direction the code comment claimed was
impossible ("never wrongly closes"), and the same bug class this PR
exists to fix. Reproduced all three of the reviewer's cases against the
real step; also a ~~~ variant and a "Fixes: `x` #94" variant.
Code is now replaced with U+2400 (SYMBOL FOR NULL) -- non-whitespace,
never part of a keyword or reference -- so neither the separator nor the
"/ Implements" skip can consume it and the barrier survives. A real
closer in prose contains no code, so it is untouched; a real closer that
merely FOLLOWS a span or fence still closes (tested).
Verified property, against the original parser extracted from
origin/staging: across an adversarial set (code inside the skip region,
code where the skip word would be, the whole skip in code, empty spans,
Closes`#5`), NEW and OLD agree on every case except the one intended
widening, "Fixes / Implements: #N". No new closer was introduced.
Full 21-case matrix passes; every open PR body parses as before. The
"never wrongly closes" claim is removed from the comment; the remaining
known gap (unbalanced backtick) is stated as what it is: a false
negative.
Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
|
@mwiget — that's a real bug and a sharper one than the previous rounds, because it's a new false-close I introduced, in exactly the direction my comment said couldn't happen. Thank you for verifying it against the pre-PR parser; that comparison is what made it undeniable. Took the real fix, in Three verifications beyond the glue cases, since a sentinel could plausibly break something that worked:
Comment corrected: the "never wrongly closes" claim is gone; the unbalanced-backtick gap is now stated as what it is — a false negative only. Every open PR body re-cleared ( |
mwiget
left a comment
There was a problem hiding this comment.
Approving — the glue false-close is fixed. cfaf2c1d replaces stripped code with U+2400 (non-whitespace sentinel) instead of '', so the barrier between a keyword and a following reference survives.
Verified against the patched parser:
- All three glue cases I reported now stay open:
Fixes \the parser` #94,This resolves `race` #7, and the fence-then-#94case → **(none)**. PlusFixes: `x` #94andFixes / `x` / Implements: #94` → (none). - Real closers that merely follow code still close:
See \foo`. Fixes #94and a fence-then-Fixes #94→94`. The sentinel only sits where code was; prose closers contain no code, so they're unaffected. - Template form + matrix intact:
Fixes / Implements: #Ncloses,Implements:/Refs/cross-repo don't, and #158's own body parses to (none).
The sentinel can't be consumed by [:\s]* or the / Implements skip (which only matches implements/closes/fixes/resolves), so the mechanism is sound, and the inaccurate "never wrongly closes" claim is gone from the comment. Nice fix.
…163) CI on #158 -- a PR that touches only .github/ -- failed in the frontend unit job: 292 files / 2568 tests passed and one unhandled rejection killed the run: ReferenceError: ProgressEvent is not defined createEvent @mswjs/interceptors XMLHttpRequest respondWith ... originated in src/components/__tests__/Login.test.tsx "shows loading state during login" mocked /api/auth/login with a 200 ms setTimeout, asserted the loading text, and returned without awaiting the response. vitest tore jsdom down; ~200 ms later msw delivered the response by constructing a ProgressEvent that no longer existed. The same class as the SSOAuthDialog fix in #152 and the #153 sweep -- async completion racing environment teardown -- with a shorter fuse, so it loses the race less often and surfaces as an intermittent red on unrelated PRs. A never-settling promise holds the request open for the life of the test and leaves nothing to fire after it ends. Swept the suite for the same shape (a delay INSIDE an msw handler whose response the test never awaits): every other delayed handler is followed by a waitFor on a post-response outcome, so this was the only reachable instance. The in-test-body awaits (await new Promise(setTimeout)) are not this class. Refs #153 Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4 Co-authored-by: John Gruber <john.t.gruber@gmail.com>
Description
The auto-close Action exists because native
Closes #Nonly fires on the default branch. But its parser skipped only[:\s]*between the keyword and the reference, so the PR template's own line —— matched nothing: the
/stopped it cold. Every PR that followed the template left its issue open, and every issue closed by a staging merge in the last week was closed by hand. Verified against the parser's exact regex:The skip now allows an optional
/ Implements(or/ Closes// Fixes// Resolves) between keyword and reference.And (from review) code spans and fenced blocks are stripped before scanning
mwiget found that merging this PR as first written would have auto-closed issues 94 and 128 — not via the regex change, but because this very description documents closing keywords in backticks and the parser read body text raw. Reproduced against the real step:
issues=94 128 7, two of them open with their fixes unmerged in #157/#156. The old parser had the same blind spot (it read['94']from this body); the wider skip tripled it. So the class is fixed, not the instance: fences are stripped first (they may contain backticks), then inline spans, beforekw_patruns.This PR's own body is now the test case: it parses to no issues, while a plain-text
Fixes #94line outside code still closes 94.Second review round added tilde fences (neutralised like-to-like via backreference, so a stray tilde fence inside a backtick fence cannot over-strip).
Third round: code is replaced with a non-whitespace sentinel (U+2400), not deleted. Deleting it glued a keyword onto a following reference — a span or fence between them vanished and the whitespace separator swallowed the gap — producing a new false-close the pre-PR parser never had. Verified against the original parser: the sentinel version agrees with it on every adversarial case except the one intended widening. Two edge cases are deliberately left and recorded in the code comment: 4-space indented blocks (need a line-based pass; unused in this repo) and an unbalanced backtick in prose swallowing a real closing line (safe direction — the issue just stays open).
No issue for this; it surfaced while closing issues for #149–#155 and was asked for directly.
Verified on the real step, not a re-implementation
Extracted the workflow's actual
parsestep body from the YAML and executed it withPR_BODYset:And the full matrix against the new skip:
Fixes / Implements: #94(template)['94']Fixes / Implements: #94, #128['94','128']Fixes #94/Fixes: #94Closes / Implements: #7Implements: #7aloneRefs #41Fixes other/repo#5a fix for #12 landed/fixed in #3The last four are the ones that must not close, and still don't. All positive rows above are exercised as plain text (not inside backticks) — since the stripping change, an example inside code never closes anything, which is the point.
Ran every open PR's actual body through the patched step to see exactly what merges will close:
Each closes precisely its own issue.
Template annotated
PULL_REQUEST_TEMPLATE.mdgains a comment under theFixes / Implements:line stating which forms close, thatRefs #Nleaves an issue open for a partial fix, and thatImplements: #Nalone does not close — so authors choose deliberately. I kept the existing line rather than changing it, so PRs already written against the template keep working.Architectural Decision Record (ADR)
Type of Change
Behaviour change worth naming: PRs that already carry
Fixes / Implements: #Nand merge after this lands will now auto-close their issues. That is the intent — but if someone wrote that line on a partial fix expecting it to be inert, the issue will close. I checked the currently-open PRs (#156, #157) — both intend to close, so none are affected.Verification & Testing
parsestep executed as above.yaml.safe_loadround-trip on the workflow file.Environment validation needed
The only real test is the next staging merge. This touches a
pull_request: closedworkflow, which can't be exercised on a PR branch — it runs when a PR into staging merges, using the workflow definition on staging. So: after this merges, the next PR to merge with aFixes / Implements: #Nline should auto-close its issue. #156 or #157 would be a natural first check.Checklist