From 85767bfe7ad3f0473349da8cf6e5f1c6f9daff8e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:22:57 +0000 Subject: [PATCH 1/4] Initial plan From 29aac050746dcc0bdb181c682988462fee14b73c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:27:58 +0000 Subject: [PATCH 2/4] 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> --- .../src/scverse_template_scripts/cruft_prs.py | 14 +++++++- scripts/tests/test_cruft.py | 35 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/scripts/src/scverse_template_scripts/cruft_prs.py b/scripts/src/scverse_template_scripts/cruft_prs.py index 5950e421..273de42d 100644 --- a/scripts/src/scverse_template_scripts/cruft_prs.py +++ b/scripts/src/scverse_template_scripts/cruft_prs.py @@ -8,6 +8,7 @@ import json import math import os +import re import sys from collections.abc import Iterable from dataclasses import KW_ONLY, InitVar, dataclass, field @@ -72,6 +73,16 @@ ] +def _escape_github_mentions(text: str) -> str: + """Escape GitHub @mentions with backticks to prevent notifications. + + Wraps ``@username`` patterns in backticks so that GitHub doesn't treat them + as real mentions when the text is used in PRs. + Already-escaped mentions and email addresses are left unchanged. + """ + return re.sub(r"(? str: @property def body(self) -> str: - return PR_BODY_TEMPLATE.format( + body = PR_BODY_TEMPLATE.format( release=self.release, template_usage="https://cookiecutter-scverse-instance.readthedocs.io/en/latest/template_usage.html", ) + return _escape_github_mentions(body) def matches_prefix(self, pr: PullRequest) -> bool: """Check if `pr` is either a current or previous template update PR by matching the branch name""" diff --git a/scripts/tests/test_cruft.py b/scripts/tests/test_cruft.py index e76f84ec..be16de2c 100644 --- a/scripts/tests/test_cruft.py +++ b/scripts/tests/test_cruft.py @@ -13,6 +13,7 @@ _apply_update, _clone_and_prepare_repo, _commit_update, + _escape_github_mentions, _get_cruft_config_from_upstream, get_repo_urls, get_template_release, @@ -143,3 +144,37 @@ def test_commit_update(clone: Repo, exclude_files: list[str], expected_untracked def test_commit_update_no_files(clone: Repo) -> None: assert _commit_update(clone, commit_msg="foo", commit_author="scverse-bot") is False + + +@pytest.mark.parametrize( + ("input_text", "expected"), + [ + # Basic mention gets escaped + ("by @grst in", "by `@grst` in"), + # Multiple mentions get escaped + ("@alice and @bob", "`@alice` and `@bob`"), + # Already-escaped mention stays unchanged + ("`@grst`", "`@grst`"), + # Email address stays unchanged + ("user@example.com", "user@example.com"), + # Mention with hyphenated username + ("by @some-user in", "by `@some-user` in"), + # Mention at start of line + ("@grst made changes", "`@grst` made changes"), + # No mentions + ("no mentions here", "no mentions here"), + # Single char username + ("@a contributed", "`@a` contributed"), + # Realistic release notes + ( + "* Fix bug by @grst in https://github.com/scverse/cookiecutter-scverse/pull/1\n" + "* Add feature by @some-user in https://github.com/scverse/cookiecutter-scverse/pull/2", + "* Fix bug by `@grst` in https://github.com/scverse/cookiecutter-scverse/pull/1\n" + "* Add feature by `@some-user` in https://github.com/scverse/cookiecutter-scverse/pull/2", + ), + # Bot email should not be escaped + ("108668866+scverse-bot@users.noreply.github.com", "108668866+scverse-bot@users.noreply.github.com"), + ], +) +def test_escape_github_mentions(input_text: str, expected: str) -> None: + assert _escape_github_mentions(input_text) == expected From 383c0dd10400457a5676f95a0d6c5e8ac46eca1c Mon Sep 17 00:00:00 2001 From: Gregor Sturm Date: Mon, 8 Jun 2026 10:59:03 +0200 Subject: [PATCH 3/4] Update escape function --- .../src/scverse_template_scripts/cruft_prs.py | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/scripts/src/scverse_template_scripts/cruft_prs.py b/scripts/src/scverse_template_scripts/cruft_prs.py index 5950e421..60f5efb6 100644 --- a/scripts/src/scverse_template_scripts/cruft_prs.py +++ b/scripts/src/scverse_template_scripts/cruft_prs.py @@ -8,6 +8,7 @@ import json import math import os +import re import sys from collections.abc import Iterable from dataclasses import KW_ONLY, InitVar, dataclass, field @@ -72,6 +73,39 @@ ] +def _escape_github_mentions(text: str) -> str: + """Escape GitHub @mentions with backticks to prevent notifications. + + Wraps ``@username`` patterns in backticks so that GitHub doesn't treat them as + real mentions when the release notes are embedded in template-update PRs. + Otherwise every contributor named in the release notes would be subscribed to + the ~150 template-update PRs that are opened on every release. + + Already-escaped mentions and email addresses are left unchanged. + + Note + ---- + This is a simple regex that comes with certain limitations, + e.g., a mention that sits *inside* an inline code span but is preceded by whitespace + (e.g. ``\\`see @bar here\\```) would be re-escaped incorrectly. + This does not occur in GitHub's auto-generated release notes (a flat bullet list of `… by @user in `). + + At the time of writing, we couldn't identify a library providing a markdown parser + that reliably identifies github usernames. + """ + # A GitHub @mention, e.g. `@grst`. The username pattern matches GitHub's own rules: + # alphanumeric or single non-leading/non-trailing/non-consecutive hyphens, max 39 chars. + # See https://github.com/shinnn/github-username-regex. + # The negative lookbehind skips email addresses (e.g. `bot@example.com`) and + # already-escaped mentions (e.g. `` `@grst` ``). + github_username_regex = re.compile( + r"(? str: @property def body(self) -> str: - return PR_BODY_TEMPLATE.format( + body = PR_BODY_TEMPLATE.format( release=self.release, template_usage="https://cookiecutter-scverse-instance.readthedocs.io/en/latest/template_usage.html", ) + return _escape_github_mentions(body) def matches_prefix(self, pr: PullRequest) -> bool: """Check if `pr` is either a current or previous template update PR by matching the branch name""" From 34397cd1657602e600f38a38d29bebd461c5c62c Mon Sep 17 00:00:00 2001 From: Gregor Sturm Date: Mon, 8 Jun 2026 11:30:45 +0200 Subject: [PATCH 4/4] update test cases --- scripts/tests/test_cruft.py | 54 +++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/scripts/tests/test_cruft.py b/scripts/tests/test_cruft.py index e76f84ec..c5126a87 100644 --- a/scripts/tests/test_cruft.py +++ b/scripts/tests/test_cruft.py @@ -13,6 +13,7 @@ _apply_update, _clone_and_prepare_repo, _commit_update, + _escape_github_mentions, _get_cruft_config_from_upstream, get_repo_urls, get_template_release, @@ -143,3 +144,56 @@ def test_commit_update(clone: Repo, exclude_files: list[str], expected_untracked def test_commit_update_no_files(clone: Repo) -> None: assert _commit_update(clone, commit_msg="foo", commit_author="scverse-bot") is False + + +@pytest.mark.parametrize( + ("input_text", "expected"), + [ + # Basic mention gets escaped + ("by @grst in", "by `@grst` in"), + # Multiple mentions get escaped + ("@alice and @bob", "`@alice` and `@bob`"), + # Already-escaped mention stays unchanged + ("`@grst`", "`@grst`"), + # Email address stays unchanged + ("user@example.com", "user@example.com"), + # Mention with hyphenated username + ("by @some-user in", "by `@some-user` in"), + # Mention at start of line + ("@grst made changes", "`@grst` made changes"), + # No mentions + ("no mentions here", "no mentions here"), + # Single char username + ("@a contributed", "`@a` contributed"), + # Realistic release notes + ( + "* Fix bug by @grst in https://github.com/scverse/cookiecutter-scverse/pull/1\n" + "* Add feature by @some-user in https://github.com/scverse/cookiecutter-scverse/pull/2", + "* Fix bug by `@grst` in https://github.com/scverse/cookiecutter-scverse/pull/1\n" + "* Add feature by `@some-user` in https://github.com/scverse/cookiecutter-scverse/pull/2", + ), + # Bot email should not be escaped + ("108668866+scverse-bot@users.noreply.github.com", "108668866+scverse-bot@users.noreply.github.com"), + # Trailing hyphen is not part of a valid username + ("ping @user- now", "ping `@user`- now"), + # Consecutive hyphens are not allowed: only the valid prefix is matched + ("@a--b", "`@a`--b"), + # Username is capped at 39 characters; the 40th char is left outside the mention + (f"@{'a' * 40}", f"`@{'a' * 39}`a"), + ], +) +def test_escape_github_mentions(input_text: str, expected: str) -> None: + assert _escape_github_mentions(input_text) == expected + + +@pytest.mark.xfail( + reason="regex approach has no GFM context; mentions inside code spans are wrongly escaped", + strict=True, +) +def test_escape_github_mentions_inside_code_span() -> None: + """A mention inside an inline code span should be left unchanged. + + This is not handled by the regex approach (no full GFM parse), but it does not occur + in GitHub's auto-generated release notes. See ``_escape_github_mentions``. + """ + assert _escape_github_mentions("`see @bar here`") == "`see @bar here`"