Skip to content

Commit 617cb45

Browse files
julian-rischclaude
andauthored
ci: enforce double backticks in release notes via pre-commit hook (#11562)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b7985d6 commit 617cb45

3 files changed

Lines changed: 157 additions & 0 deletions

File tree

.pre-commit-config.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@ repos:
3535
- ruff
3636
- add-trailing-comma
3737

38+
- id: release-note-backticks
39+
name: release-note-backticks
40+
language: python
41+
entry: python scripts/release_note_backticks.py
42+
files: ^releasenotes/notes/.*\.yaml$
43+
types: [text]
44+
3845
- repo: https://github.com/codespell-project/codespell
3946
rev: v2.4.1
4047
hooks:

scripts/release_note_backticks.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
"""
6+
Pre-commit hook that enforces reStructuredText inline-code syntax in reno release notes.
7+
8+
Release notes under ``releasenotes/notes/*.yaml`` are rendered as reStructuredText, where inline
9+
code must use double backticks (``like_this``). A common mistake is to use Markdown-style single
10+
backticks (`like_this`), which RST renders as italic "interpreted text" instead of code.
11+
12+
By default the hook rewrites single backticks to double backticks and exits non-zero when it
13+
changes a file, so the rewrite can be reviewed before re-staging; ``--check`` only reports.
14+
It leaves existing double backticks ``code`` untouched and skips RST roles (:func:`x`) and
15+
hyperlink references (`text <url>`_).
16+
"""
17+
18+
import argparse
19+
import re
20+
import sys
21+
22+
# A single-backtick inline-code span that should use double backticks. The look-arounds skip
23+
# valid RST: ``...`` pairs (the backtick-adjacent guards), roles like :func:`x` (no ':' before
24+
# the opening backtick), and hyperlink refs like `text <url>`_ (no '_' after the closing one).
25+
SINGLE_BACKTICK_RE = re.compile(r"(?<![`:])`(?!`)([^`\n]+?)`(?![`_])")
26+
27+
28+
def fix_text(text: str) -> str:
29+
"""Return ``text`` with single-backtick inline code rewritten to double backticks."""
30+
return SINGLE_BACKTICK_RE.sub(r"``\1``", text)
31+
32+
33+
def main() -> int:
34+
"""Entry point for the pre-commit hook."""
35+
parser = argparse.ArgumentParser(description="Enforce double backticks in reno release notes.")
36+
parser.add_argument("--check", action="store_true", help="Report problems without modifying files.")
37+
parser.add_argument("files", nargs="*")
38+
args = parser.parse_args()
39+
40+
ret = 0
41+
for path in args.files:
42+
with open(path, encoding="utf-8") as f:
43+
original = f.read()
44+
fixed = fix_text(original)
45+
if fixed == original:
46+
continue
47+
ret = 1
48+
print(f"{'single backtick in' if args.check else 'fixed'}: {path}", file=sys.stderr)
49+
if not args.check:
50+
with open(path, "w", encoding="utf-8") as f:
51+
f.write(fixed)
52+
53+
if ret:
54+
hint = "Run without --check to fix automatically." if args.check else "Review and re-stage the changes."
55+
print(f"\nrelease notes need double backticks (``like_this``) for inline code. {hint}", file=sys.stderr)
56+
return ret
57+
58+
59+
if __name__ == "__main__":
60+
raise SystemExit(main())
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
import importlib.util
6+
import subprocess
7+
import sys
8+
from pathlib import Path
9+
10+
import pytest
11+
12+
_SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "release_note_backticks.py"
13+
_spec = importlib.util.spec_from_file_location("release_note_backticks", _SCRIPT)
14+
_module = importlib.util.module_from_spec(_spec)
15+
_spec.loader.exec_module(_module)
16+
fix_text = _module.fix_text
17+
18+
19+
class TestFixText:
20+
@pytest.mark.parametrize(
21+
"text, expected",
22+
[
23+
("Use `OpenAIChatGenerator` now.", "Use ``OpenAIChatGenerator`` now."),
24+
("Set `api_key` and `azure_endpoint`.", "Set ``api_key`` and ``azure_endpoint``."),
25+
('Call `Secret.from_env_var("X")`.', 'Call ``Secret.from_env_var("X")``.'),
26+
("Leading `code` token.", "Leading ``code`` token."),
27+
],
28+
)
29+
def test_converts_single_to_double(self, text, expected):
30+
assert fix_text(text) == expected
31+
32+
@pytest.mark.parametrize(
33+
"text",
34+
[
35+
"Already correct: ``OpenAIChatGenerator``.",
36+
"Two literals ``Secret`` and ``api_key``.",
37+
"No inline code at all here.",
38+
"RST role :func:`do_thing` must stay single.",
39+
"Link `Haystack <https://haystack.deepset.ai>`_ must stay single.",
40+
],
41+
)
42+
def test_leaves_valid_rst_untouched(self, text):
43+
assert fix_text(text) == text
44+
45+
def test_only_single_backticks_in_mixed_text_are_converted(self):
46+
text = "Use ``Secret`` for `api_key` and `azure_endpoint`."
47+
expected = "Use ``Secret`` for ``api_key`` and ``azure_endpoint``."
48+
assert fix_text(text) == expected
49+
50+
def test_is_idempotent(self):
51+
once = fix_text("Set `x` and `y`.")
52+
assert fix_text(once) == once
53+
54+
def test_unbalanced_single_backtick_is_left_untouched(self):
55+
# A stray, unpaired backtick is ambiguous, so we never rewrite it.
56+
text = "An unbalanced `backtick stays as is.\n"
57+
assert fix_text(text) == text
58+
59+
60+
class TestCli:
61+
def test_fix_rewrites_file_and_is_idempotent(self, tmp_path):
62+
note = tmp_path / "note.yaml"
63+
note.write_text("enhancements:\n - |\n Use `Foo` and `Bar` now.\n", encoding="utf-8")
64+
65+
first = subprocess.run([sys.executable, str(_SCRIPT), str(note)], capture_output=True, text=True, check=False)
66+
assert first.returncode == 1
67+
assert "``Foo``" in note.read_text(encoding="utf-8")
68+
assert "``Bar``" in note.read_text(encoding="utf-8")
69+
70+
# Running again on the now-fixed file is a no-op and succeeds.
71+
second = subprocess.run([sys.executable, str(_SCRIPT), str(note)], capture_output=True, text=True, check=False)
72+
assert second.returncode == 0
73+
74+
def test_check_mode_reports_without_modifying(self, tmp_path):
75+
note = tmp_path / "note.yaml"
76+
content = "enhancements:\n - |\n Use `Foo` now.\n"
77+
note.write_text(content, encoding="utf-8")
78+
79+
result = subprocess.run(
80+
[sys.executable, str(_SCRIPT), "--check", str(note)], capture_output=True, text=True, check=False
81+
)
82+
assert result.returncode == 1
83+
assert note.read_text(encoding="utf-8") == content
84+
85+
def test_clean_file_passes(self, tmp_path):
86+
note = tmp_path / "note.yaml"
87+
note.write_text("enhancements:\n - |\n Use ``Foo`` now.\n", encoding="utf-8")
88+
89+
result = subprocess.run([sys.executable, str(_SCRIPT), str(note)], capture_output=True, text=True, check=False)
90+
assert result.returncode == 0

0 commit comments

Comments
 (0)