Skip to content

fix(2555): the sync PR title stops naming an epic it must not close - #345

Merged
LukasWodka merged 2 commits into
developfrom
fix/2555-sync-pr-title
Aug 26, 2026
Merged

fix(2555): the sync PR title stops naming an epic it must not close#345
LukasWodka merged 2 commits into
developfrom
fix/2555-sync-pr-title

Conversation

@LukasWodka

@LukasWodkaLukasWodka commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Closes tracebloc/backend#2555

Summary

create-prs opened every sync PR titled docs(claude): sync org-standards block (backend#1602). closing-ref-gate.py requires every ticket a title names to appear in that PR's closingIssuesReferences — and these PRs must not close #1602, because one is opened per repo and the first to merge would close the epic for all of them. The body says Part of tracebloc/backend#1602 on purpose.

So the remediation path generated PRs its own required gate refuses. Observed on today's dispatch, on every repo it touched:

##[error]Closing-ref gate: the title names a ticket this PR does not link.
backend#1602 -- named in the title, linked nowhere.

The title is the only defect — the body was already right. The commit-message template at standards-sync.py:271 keeps its (backend#1602); commit messages are not parsed by the gate, and there the reference is pure provenance.

Why it shipped

_ensure_pr() had no selftest coverage whatsoever. The offline selftest is thorough about the splice and its fail-closed paths and never looked at the PR being opened, so the title was free to contradict a required gate indefinitely.

Type

fix

Test plan

30 checks, 0 failed — 3 new, all in _ensure_pr:

  1. the generated title names no ticket
  2. the body keeps backend#1602without a closing keyword
  3. a mutation anchor: the pre-fix title is seen as naming a ticket

Check 1 derives its rule by importing parse_title from the realclosing-ref-gate.py rather than restating a regex, so the generator and the gate cannot drift apart — the failure mode this repo has hit twice by re-implementing a rule inline.

Check 3 exists because a vacuous assertion and a live one are indistinguishable in a green log. Mutation-proved both directions:

bug restored -> 30 checks, 1 failed
FAIL: _ensure_pr: the PR title names no ticket the PR does not close
fix restored -> 30 checks, 0 failed

Follow-up (not in this PR)

The ~19 PRs already opened need retitling by hand — _ensure_pr returns early when an open PR exists, so re-dispatching will not correct them. I am doing that separately.

Checklist

  • Targets develop
  • Reviewer requested
  • Mutation-proved, both directions
  • The new test calls the real gate, not a copy of its rule

Note

Low Risk
Automation-only change to PR title text and offline selftests; no runtime product or security paths affected.

Overview
Org-standards sync remediation was opening PRs whose titles included (backend#1602), which closing-ref-gate treats as naming a ticket that must appear in closingIssuesReferences. Those PRs intentionally do not close the shared epic—one PR per repo—so the gate blocked merge across the fleet.

standards-sync.py now creates PRs with title docs(claude): sync the org-standards block (no ticket in the title). The body still uses Part of tracebloc/backend#… for traceability without a closing link. Commit messages keep the (backend#…) reference; only PR titles changed.

standards-sync-selftest.py adds _ensure_pr coverage: imports the real gate’s parse_title, asserts the generated title names no ticket, asserts the body mentions the epic without close/fix/resolve keywords, and includes mutation anchors so regressions fail visibly.

Reviewed by Cursor Bugbot for commit 1661a49. Bugbot is set up for automated code reviews on this repo. Configure here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@LukasWodkaLukasWodka self-assigned this Aug 26, 2026

@saadqbalsaadqbal left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the right fix and the right root cause. _ensure_pr() having no coverage at all is the real answer to "why it shipped" — the selftest was thorough about the splice and never looked at the PR being opened, so the title was free to contradict a required gate indefinitely.

Importing parse_title from the real closing-ref-gate.py rather than restating a regex is the part I'd point at: the generator and the gate can't drift apart now, and the mutation anchor (the pre-fix title is seen as naming a ticket) makes check 1 non-vacuous rather than passing because parse_title went blind. Failing closed if the import can't be loaded is the right third leg.

One gap, in the check whose name is broader than its assertion. Check 2 is titled "the body keeps traceability WITHOUT a closing keyword" but asserts:

"backend#1602"inbodyand"Closes"notinbody

GitHub honours three keyword families, not one: close/closes/closed, fix/fixes/fixed, resolve/resolves/resolved. So a future edit to Fixes tracebloc/backend#1602 or Resolves tracebloc/backend#1602 passes this test and closes the epic on the first of nineteen merges — which is the exact disaster this PR exists to prevent, arriving through the door the test leaves open.

The fix is the same move you already made for the title: derive the keyword set rather than name one of it. Cheapest version is a tuple of the three stems checked case-insensitively; better, if the gate ever grows a CLOSING_KEYWORDS constant, import that the way you import parse_title. Worth noting the gate's own prose already discusses Closes as one keyword ("linked by a closing keyword"), so the concept exists — it just isn't exported yet.

Holding on bugbot / review, selftests and Cursor Bugbot, all pending. Ping me when they land.

…st Closes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@LukasWodka

Copy link
Copy Markdown
ContributorAuthor

You're right, and the gap was worse than "broader name than assertion" — it was the exact failure mode this PR exists to prevent, reachable in one word.

Fixed in 9c3a1f2. The check now scans all nine forms (close/closes/closed, fix/fixes/fixed, resolve/resolves/resolved), case-insensitively.

On importing it rather than writing it: I looked, and closing-ref-gate.py has no keyword constant to import — it delegates entirely to GitHub's computed closingIssuesReferences and never scans text, which is why its prose can discuss Closes as one keyword without ever enumerating the set. So the tuple lives in the selftest with that provenance recorded, and a note to import and delete it if the gate ever grows one. I'd rather not add a constant to the gate that the gate itself doesn't read — an unused "source of truth" is the thing that drifts while looking authoritative.

Your suggestion also caught a bug in my own anchor, which is worth naming because it changes what the check proves. My first version asserted closing_keyword_in("Fixes …") == "fix" and it failed — the probe is f"{k} ", so the trailing space means "Fixes " matches the inflected fixes and never the bare fix. The scan was correct; my expected value was wrong. It now asserts a stem prefix across all three families plus the negative case:

Fixes -> 'fixes', Resolves -> 'resolves', Closed -> 'closed', 'Part of' -> None

Mutation-proved against the real generator by rewriting its body line to each family in turn:

body says 'Fixes ...' -> body-check reddens: YES
body says 'Resolves ...' -> body-check reddens: YES
body says 'Closes ...' -> body-check reddens: YES

The pre-fix assertion would have caught only the third. 31 checks, 0 failed; reason-citations and caller-drift-selftest (197/0) clean.

One thing your review surfaced that I've filed separately rather than folded in here: backend#2556set-pr-status.yml's callers don't listen for edited, so the closing-ref gate never re-runs when the title changes. That makes it bypassable (green on a ticket-free title, then retitle to name an unlinked ticket), and it's why clearing the 20 already-opened sync PRs took 20 manual gh run rerun calls rather than a retitle. Same shape as backend#1945, third instance.

@LukasWodka

Copy link
Copy Markdown
ContributorAuthor

bugbot run

@cursorcursorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 1661a49. Configure here.

@LukasWodka
LukasWodka merged commit 5c227df into developAug 26, 2026
14 checks passed
@LukasWodka
LukasWodka deleted the fix/2555-sync-pr-title branch August 26, 2026 11:53

@saadqbalsaadqbal left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1661a499 covers all three families, and the reason you give for writing the tuple rather than importing it is the right one — closing-ref-gate.py genuinely has no such constant, because it delegates to GitHub's computed closingIssuesReferences and never scans text. Leaving "if it ever grows one, import it the way parse_title is imported and delete this tuple" is better than either alternative.

One more, same shape one layer in.closing_keyword_in matches on f"{k} " in low — keyword followed by a space:

returnnext((kforkinCLOSING_KEYWORDSiff"{k} "inlow), None)

So Fixes: tracebloc/backend#1602 doesn't match: low contains fixes:, not fixes . The colon form is one people write, and if GitHub honours it — which I can't test from here, but I wouldn't bet the epic on it not doing — the assertion passes and the first of nineteen merges closes #1602 anyway. That's the same door, still ajar by one character.

I'd match the separator rather than assume it: keyword followed by space or colon, e.g. re.search(rf"\b{k}\b\s*:?\s", low) or simply testing both f"{k} " and f"{k}:". Cheap, and it removes the need to be right about GitHub's parser — which is the better property for a check whose false negative costs an epic.

Worth saying that the check is otherwise the right shape now, and the failure mode it guards is worth this much fussing over precisely because nothing else would catch it: the epic closes, the other eighteen PRs lose their tracking ticket, and the only symptom is a closed issue nobody closed on purpose.

Held on one pending check. Ping me and I'll approve — this is the last thing I have.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@LukasWodka@saadqbal