Skip to content

Commit 29aac05

Browse files
Copilotgrst
andcommitted
Escape GitHub @mentions in release PR bodies to prevent mass notifications
Agent-Logs-Url: https://github.com/scverse/cookiecutter-scverse/sessions/e4339c85-f7eb-448e-a159-c267faf3583c Co-authored-by: grst <7051479+grst@users.noreply.github.com>
1 parent 85767bf commit 29aac05

2 files changed

Lines changed: 48 additions & 1 deletion

File tree

scripts/src/scverse_template_scripts/cruft_prs.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import json
99
import math
1010
import os
11+
import re
1112
import sys
1213
from collections.abc import Iterable
1314
from dataclasses import KW_ONLY, InitVar, dataclass, field
@@ -72,6 +73,16 @@
7273
]
7374

7475

76+
def _escape_github_mentions(text: str) -> str:
77+
"""Escape GitHub @mentions with backticks to prevent notifications.
78+
79+
Wraps ``@username`` patterns in backticks so that GitHub doesn't treat them
80+
as real mentions when the text is used in PRs.
81+
Already-escaped mentions and email addresses are left unchanged.
82+
"""
83+
return re.sub(r"(?<![`\w])@([a-zA-Z\d](?:[a-zA-Z\d-]*[a-zA-Z\d])?)", r"`@\1`", text)
84+
85+
7586
@dataclass
7687
class GitHubConnection:
7788
"""API connection to a GitHub user (e.g. scverse-bot)"""
@@ -138,10 +149,11 @@ def namespaced_head(self) -> str:
138149

139150
@property
140151
def body(self) -> str:
141-
return PR_BODY_TEMPLATE.format(
152+
body = PR_BODY_TEMPLATE.format(
142153
release=self.release,
143154
template_usage="https://cookiecutter-scverse-instance.readthedocs.io/en/latest/template_usage.html",
144155
)
156+
return _escape_github_mentions(body)
145157

146158
def matches_prefix(self, pr: PullRequest) -> bool:
147159
"""Check if `pr` is either a current or previous template update PR by matching the branch name"""

scripts/tests/test_cruft.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
_apply_update,
1414
_clone_and_prepare_repo,
1515
_commit_update,
16+
_escape_github_mentions,
1617
_get_cruft_config_from_upstream,
1718
get_repo_urls,
1819
get_template_release,
@@ -143,3 +144,37 @@ def test_commit_update(clone: Repo, exclude_files: list[str], expected_untracked
143144

144145
def test_commit_update_no_files(clone: Repo) -> None:
145146
assert _commit_update(clone, commit_msg="foo", commit_author="scverse-bot") is False
147+
148+
149+
@pytest.mark.parametrize(
150+
("input_text", "expected"),
151+
[
152+
# Basic mention gets escaped
153+
("by @grst in", "by `@grst` in"),
154+
# Multiple mentions get escaped
155+
("@alice and @bob", "`@alice` and `@bob`"),
156+
# Already-escaped mention stays unchanged
157+
("`@grst`", "`@grst`"),
158+
# Email address stays unchanged
159+
("user@example.com", "user@example.com"),
160+
# Mention with hyphenated username
161+
("by @some-user in", "by `@some-user` in"),
162+
# Mention at start of line
163+
("@grst made changes", "`@grst` made changes"),
164+
# No mentions
165+
("no mentions here", "no mentions here"),
166+
# Single char username
167+
("@a contributed", "`@a` contributed"),
168+
# Realistic release notes
169+
(
170+
"* Fix bug by @grst in https://github.com/scverse/cookiecutter-scverse/pull/1\n"
171+
"* Add feature by @some-user in https://github.com/scverse/cookiecutter-scverse/pull/2",
172+
"* Fix bug by `@grst` in https://github.com/scverse/cookiecutter-scverse/pull/1\n"
173+
"* Add feature by `@some-user` in https://github.com/scverse/cookiecutter-scverse/pull/2",
174+
),
175+
# Bot email should not be escaped
176+
("108668866+scverse-bot@users.noreply.github.com", "108668866+scverse-bot@users.noreply.github.com"),
177+
],
178+
)
179+
def test_escape_github_mentions(input_text: str, expected: str) -> None:
180+
assert _escape_github_mentions(input_text) == expected

0 commit comments

Comments
 (0)