Skip to content

Commit 8a09762

Browse files
committed
fix(buds): tighten BUD-NNN matcher + guard relink to title-only edits
Code review of the post-merge fixes flagged two real regressions and one auto-transition risk: * The relaxed regex ``(?<![A-Za-z])(?<![A-Za-z]-)`` accepted ``1bud-3``, ``sha7bud-5``, ``v2bud-5``, and ``auth1-bud-7`` — digit-glued mid-word and prefix-with-trailing-digit shapes that are not real BUD references. Revert to the strict ``(?<![A-Za-z0-9-])`` prefix that uniformly rejects letters, digits, and hyphens on the left. Multi-BUD release branches are now handled by a SEPARATE chain matcher: ``extract_all_bud_numbers`` finds each strict ``bud-NNN`` match and then walks forward through any ``-bud-NNN-bud-NNN`` tail. Six new parametrize-reject cases pin the digit-glued and trailing-digit-prefix rejections. * ``_handle_pr_edited`` previously called ``resolve_bud_from_pr`` on every invocation. Now that the dispatcher also fires on base edits, unconditional re-resolution risked unlinking a PR whose title carried a once-accepted-now-rejected reference (the regex tightening is exactly that kind of contract change). Gate the relink to actual title changes; base / head edits still sync ``pr.base_branch`` and ``pr.head_branch`` but do not touch ``pr.bud_id``. Auto-transition audit confirmed clean: * ``check_all_repos_have_prs`` / ``check_all_prs_merged`` key off ``pr.bud_id``, not the text content — the matcher tightening only affects NEW PR-open / title-edit links, not existing rows. * The development → code_review threshold is unaffected by base edits because the gate above blocks the only path that could have unlinked an existing link on a base-only edit. Full backend suite green: 1887 passed. Signed-off-by: Arun Rajkumar <mickyarunr@gmail.com>
1 parent 5e19eca commit 8a09762

4 files changed

Lines changed: 78 additions & 30 deletions

File tree

backend/app/api/v1/bud_prs.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -245,9 +245,7 @@ async def get_bud_release_stage(
245245
# and BUD-004 tabs, while an unrelated PR to ``main`` does not
246246
# leak through. Directly-linked PRs (``bud_id == bud_id``)
247247
# short-circuit so the user-edited link wins over text matching.
248-
if pr.bud_id != bud_id and not pr_references_bud(
249-
bud.bud_number, pr.head_branch, pr.title
250-
):
248+
if pr.bud_id != bud_id and not pr_references_bud(bud.bud_number, pr.head_branch, pr.title):
251249
continue
252250
seen_pr_ids.add(pr.github_pr_id)
253251
open_prs.append(

backend/app/services/github_webhook_handler.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,10 +443,25 @@ async def _handle_pr_edited(
443443
if pr is None:
444444
return
445445

446+
# Title / base / head are always synced — the dispatcher only fires
447+
# this handler when at least one of them changed, so the writes are
448+
# never wasted.
449+
title_changed = pr.title != pr_data.title
446450
pr.title = pr_data.title
447451
pr.base_branch = pr_data.base.ref
448452
pr.head_branch = pr_data.head.ref
449453

454+
# Only re-resolve the BUD link when the title actually changed. A
455+
# base-only edit (the BUD-004 / PR-1997 case) must NOT trigger a
456+
# re-resolution that could unlink a PR whose old title once carried
457+
# a now-rejected reference shape (``auth1-bud-7``-style strings the
458+
# previous, looser regex used to accept). The auto-transition checks
459+
# downstream key off ``pr.bud_id``; leaving the link intact keeps
460+
# the development → code_review threshold stable across base edits.
461+
if not title_changed:
462+
await db.commit()
463+
return
464+
450465
new_bud_id, new_bud = await resolve_bud_from_pr(db, org_id, pr_data.head.ref, pr_data.title)
451466
if new_bud_id == pr.bud_id:
452467
await db.commit()

backend/app/services/pr_auto_transition.py

Lines changed: 50 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -36,36 +36,41 @@
3636

3737
logger = structlog.get_logger(__name__)
3838

39-
# Match ``bud-NNN`` anywhere in a string. Two stacked negative
40-
# lookbehinds reject the mid-word cases while keeping multi-BUD release
41-
# branches matchable:
39+
# Strict ``bud-NNN`` matcher. The negative lookbehind rejects every
40+
# letter, digit, or hyphen immediately before ``bud``, which covers all
41+
# the mid-word and run-on cases with one rule:
4242
#
43-
# ``(?<![A-Za-z])`` — char before ``bud`` is not a letter
44-
# (rejects ``abcbud-5``, ``prebud-2``).
45-
# ``(?<![A-Za-z]-)`` — char before ``bud`` is not "letter then -"
46-
# (rejects ``auth-bud-7``, ``token-bud-3``).
43+
# ``auth-bud-7`` — rejected (``-`` before).
44+
# ``abcbud-5`` — rejected (``c`` before).
45+
# ``1bud-3`` — rejected (``1`` before).
46+
# ``sha7bud-5`` — rejected (``7`` before).
47+
# ``v2-bud-5`` — rejected (``-`` before; the leading digit doesn't
48+
# rescue it because ``-`` is in the rejection class).
49+
# ``bud2name`` — rejected (no ``\b`` between digit and following
50+
# word char, trailing boundary fails).
4751
#
48-
# A leading hyphen preceded by a digit IS allowed, so multi-BUD release
49-
# branches surface every BUD they reference:
50-
#
51-
# ``bud-008``, ``BUD-8``, ``bud8`` (case-insensitive, optional dash,
52-
# leading zeros stripped) — accepted.
52+
# ``bud-008``, ``BUD-8``, ``bud8`` — accepted.
5353
# ``feat/BUD-7-rename``, ``[BUD-12]``, ``(bud-77)``, ``BUD-8 fix x``,
5454
# ``Closes #BUD-4``, ``fix,BUD-3``, ``.BUD-2`` — accepted.
55-
# ``release/bud-001-bud-004-bud-007`` — accepted (all three) because
56-
# the digit-then-hyphen prefix bypasses both lookbehinds.
57-
# ``auth-bud-7`` — rejected (``h-`` matches lookbehind 2).
58-
# ``abcbud-5`` — rejected (``c`` matches lookbehind 1).
59-
# ``bud2name`` — rejected (no ``\b`` between digit and following
60-
# word char, so the trailing boundary fails).
6155
#
62-
# Used to drive PR → BUD linking (both head branch and PR title), the
63-
# multi-BUD content guard in the release-stage tabs, and the Claude-Code
64-
# dev-activity hook below.
65-
_BUD_RE = re.compile(
66-
r"(?<![A-Za-z])(?<![A-Za-z]-)bud-?0*(\d+)\b",
67-
re.IGNORECASE,
68-
)
56+
# Multi-BUD release branches like ``release/bud-001-bud-004-bud-007``
57+
# only have one strict match (the first ``bud-001``); the rest are
58+
# discovered as a CHAIN appended to that first match — see
59+
# ``_BUD_CHAIN_RE`` and ``extract_all_bud_numbers`` below.
60+
#
61+
# Used to drive PR → BUD linking (head branch and title) and the
62+
# Claude-Code dev-activity hook.
63+
_BUD_RE = re.compile(r"(?<![A-Za-z0-9-])bud-?0*(\d+)\b", re.IGNORECASE)
64+
65+
66+
# Anchored variant for chain extraction. After a strict ``bud-NNN``
67+
# match, any number of trailing ``-bud-NNN`` segments belong to the
68+
# same chain (multi-BUD release branch). The chain regex is anchored
69+
# to start-of-string because it is applied to the captured chain tail,
70+
# not the full input; ``finditer`` then walks each ``bud-NNN`` token
71+
# inside that tail.
72+
_BUD_CHAIN_TAIL_RE = re.compile(r"(?:-bud-?0*\d+\b)+", re.IGNORECASE)
73+
_BUD_IN_CHAIN_RE = re.compile(r"bud-?0*(\d+)\b", re.IGNORECASE)
6974

7075

7176
def extract_bud_number(text: str | None) -> int | None:
@@ -86,11 +91,29 @@ def extract_all_bud_numbers(text: str | None) -> set[int]:
8691
Release branches like ``release/bud-001-bud-004-bud-007`` carry
8792
several BUDs at once; the release-stage tabs need to know all of
8893
them so the same PR shows up on each BUD's tab when it genuinely
89-
relates to that BUD. Returns an empty set on falsy / no-match input.
94+
relates to that BUD. Two passes:
95+
96+
1. Strict regex finds every ``bud-NNN`` token that is not glued to
97+
a letter / digit / hyphen on the left.
98+
2. For each match, look at the immediately-following text for a
99+
``-bud-NNN-bud-NNN...`` chain and add every BUD number in it.
100+
The chain step intentionally allows the hyphen-prefix that
101+
Pass 1 rejects — within a chain, the preceding ``-`` came from
102+
the previous BUD reference, not from a leading word.
103+
104+
Returns an empty set on falsy / no-match input.
90105
"""
91106
if not text:
92107
return set()
93-
return {int(m.group(1)) for m in _BUD_RE.finditer(text)}
108+
result: set[int] = set()
109+
for match in _BUD_RE.finditer(text):
110+
result.add(int(match.group(1)))
111+
tail = _BUD_CHAIN_TAIL_RE.match(text, match.end())
112+
if tail is None:
113+
continue
114+
for chain_match in _BUD_IN_CHAIN_RE.finditer(tail.group(0)):
115+
result.add(int(chain_match.group(1)))
116+
return result
94117

95118

96119
def pr_references_bud(

backend/tests/services/test_pr_auto_transition.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,18 @@ def test_extract_bud_number_accepts_real_world_forms(text: str, expected: int) -
7474
# Glued to other digits.
7575
"bud2name",
7676
"abcbud-5",
77+
# Run-on with a leading digit / sha-like prefix — the prior relaxed
78+
# regex accepted these; the strict prefix now rejects them.
79+
"1bud-3",
80+
"sha7bud-5",
81+
"v2bud-5",
82+
# Hyphen-separated label with a trailing digit: ``a1-bud-7``-style
83+
# patterns are NOT valid BUD chains (multi-BUD chains start with
84+
# a fresh ``bud-NNN``, not a non-BUD token). Locked in so the
85+
# next regex tweak doesn't widen here.
86+
"auth1-bud-7",
87+
"repo2-bud-3",
88+
"team42-bud-9",
7789
# No number.
7890
"feature/bud-x",
7991
"release/uat",

0 commit comments

Comments
 (0)