Skip to content

Commit b8e063f

Browse files
Merge pull request #1082 from RocketPy-Team/enh/llm-changelog-automation
CI: make changelog automation LLM-based (Gemini) and race-safe
1 parent 9ccc804 commit b8e063f

6 files changed

Lines changed: 591 additions & 58 deletions

File tree

.github/pull_request_template.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
- [ ] Docs have been reviewed and added / updated
1818
- [ ] Lint (`black rocketpy/ tests/`) has passed locally
1919
- [ ] All tests (`pytest tests -m slow --runslow`) have passed locally
20-
- [ ] `CHANGELOG.md` has been updated (if relevant)
20+
- `CHANGELOG.md` — no action needed; an LLM workflow auto-updates it after merge
2121

2222
## Current behavior
2323
<!-- Describe current behavior or link to an issue. -->
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
#!/usr/bin/env python3
2+
"""Self-contained tests for update_changelog.py.
3+
4+
Runs without network or the ``google-genai`` package (the LLM import in the
5+
script is lazy). Exercises the deterministic pieces that keep the changelog
6+
safe: section splitting, duplicate detection, prefix handling, the fallback
7+
classifier, and the validator that guards LLM output.
8+
9+
Run locally with either:
10+
python .github/scripts/test_update_changelog.py
11+
pytest .github/scripts/test_update_changelog.py
12+
"""
13+
14+
import os
15+
import sys
16+
17+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
18+
19+
import update_changelog as uc # noqa: E402
20+
21+
SAMPLE = """\
22+
# RocketPy Change Log
23+
24+
## [Unreleased] - yyyy-mm-dd
25+
26+
### Added
27+
28+
### Changed
29+
30+
### Fixed
31+
32+
## [v1.13.0] - 2026-07-21
33+
34+
### Added
35+
36+
- ENH: something old [#100](https://github.com/RocketPy-Team/RocketPy/pull/100)
37+
"""
38+
39+
40+
def test_split_roundtrips():
41+
before, block, after = uc.split_changelog(SAMPLE)
42+
assert before + block + after == SAMPLE
43+
assert block.startswith("## [Unreleased]")
44+
assert "## [v1.13.0]" not in block
45+
assert "## [v1.13.0]" in after
46+
47+
48+
def test_already_present():
49+
_, block, after = uc.split_changelog(SAMPLE)
50+
assert uc.already_present(after, 100) is True
51+
assert uc.already_present(block, 100) is False
52+
assert uc.already_present(block, 999) is False
53+
54+
55+
def test_detect_prefix():
56+
assert uc.detect_prefix("BUG: fix a thing") == "BUG"
57+
assert uc.detect_prefix("BUG/MNT: pre-release review fixes") == "BUG/MNT"
58+
assert uc.detect_prefix("Add a shiny feature") is None
59+
60+
61+
def test_no_double_prefix():
62+
# The historical bug: a title already carrying a prefix must not gain another.
63+
entry = uc.build_entry("BUG/MNT: pre-release fixes", 1074, "ENH", "BUG/MNT")
64+
assert entry.startswith("- BUG/MNT: pre-release fixes ")
65+
assert "ENH:" not in entry
66+
assert "[#1074](https://github.com/RocketPy-Team/RocketPy/pull/1074)" in entry
67+
68+
69+
def test_build_entry_adds_prefix_when_missing():
70+
entry = uc.build_entry("Add a shiny feature", 200, "ENH", None)
71+
assert entry.startswith("- ENH: Add a shiny feature ")
72+
73+
74+
def test_fallback_routing():
75+
# Bug label -> Fixed
76+
section, prefix, _ = uc.fallback_section_and_prefix("Fix crash", "Bug,Flight")
77+
assert section == "### Fixed" and prefix == "BUG"
78+
# Refactor label -> Changed
79+
section, _, _ = uc.fallback_section_and_prefix("Tidy internals", "Refactor")
80+
assert section == "### Changed"
81+
# Existing prefix wins the routing even without a matching label
82+
section, prefix, existing = uc.fallback_section_and_prefix("BUG: oops", "")
83+
assert section == "### Fixed" and existing == "BUG"
84+
# Default
85+
section, prefix, _ = uc.fallback_section_and_prefix("New capability", "Enhancement")
86+
assert section == "### Added" and prefix == "ENH"
87+
88+
89+
def test_fallback_update_inserts_once_under_right_section():
90+
_, block, _ = uc.split_changelog(SAMPLE)
91+
updated = uc.fallback_update(block, "BUG: fix a thing", 321, "Bug")
92+
assert updated.count("[#321]") == 1
93+
fixed = updated.split("### Fixed", 1)[1]
94+
assert "- BUG: fix a thing" in fixed
95+
# Not misplaced under Added.
96+
added = updated.split("### Added", 1)[1].split("### Changed", 1)[0]
97+
assert "[#321]" not in added
98+
99+
100+
def test_ensure_section_inserts_in_canonical_order():
101+
_, block, _ = uc.split_changelog(SAMPLE)
102+
# SAMPLE has Added/Changed/Fixed but no Removed subsection.
103+
lines = uc.ensure_section(block.splitlines(keepends=True), "### Removed")
104+
joined = "".join(lines)
105+
# Removed must sit after Changed and before Fixed.
106+
assert (
107+
joined.index("### Changed")
108+
< joined.index("### Removed")
109+
< joined.index("### Fixed")
110+
)
111+
112+
113+
def test_validator_accepts_good_output():
114+
_, block, _ = uc.split_changelog(SAMPLE)
115+
good = block.replace(
116+
"### Fixed\n",
117+
"### Fixed\n\n- BUG: fix it [#500](https://github.com/RocketPy-Team/RocketPy/pull/500)\n",
118+
)
119+
assert uc.validate_llm_block(block, good, 500) is None
120+
121+
122+
def test_validator_rejects_dropped_entry():
123+
old = block_with_entry()
124+
# New block loses the pre-existing entry.
125+
new = "## [Unreleased] - yyyy-mm-dd\n\n### Fixed\n\n- BUG: new [#500](https://github.com/RocketPy-Team/RocketPy/pull/500)\n\n"
126+
assert uc.validate_llm_block(old, new, 500) is not None
127+
128+
129+
def test_validator_rejects_missing_new_ref():
130+
_, block, _ = uc.split_changelog(SAMPLE)
131+
assert uc.validate_llm_block(block, block, 500) is not None # no new ref at all
132+
133+
134+
def test_validator_rejects_leaked_version_header():
135+
_, block, _ = uc.split_changelog(SAMPLE)
136+
leaked = (
137+
"## [Unreleased] - yyyy-mm-dd\n\n### Fixed\n\n"
138+
"- BUG: x [#500](https://github.com/RocketPy-Team/RocketPy/pull/500)\n\n"
139+
"## [v1.13.0] - 2026-07-21\n"
140+
)
141+
assert uc.validate_llm_block(block, leaked, 500) is not None
142+
143+
144+
def test_validator_rejects_runaway_growth():
145+
_, block, _ = uc.split_changelog(SAMPLE)
146+
huge = (
147+
"## [Unreleased] - yyyy-mm-dd\n\n### Fixed\n\n"
148+
"- BUG: x [#500](https://github.com/RocketPy-Team/RocketPy/pull/500)\n"
149+
+ ("x" * (uc.MAX_BLOCK_GROWTH + 50))
150+
)
151+
assert uc.validate_llm_block(block, huge, 500) is not None
152+
153+
154+
def block_with_entry():
155+
return (
156+
"## [Unreleased] - yyyy-mm-dd\n\n### Added\n\n"
157+
"- ENH: keep me [#200](https://github.com/RocketPy-Team/RocketPy/pull/200)\n\n"
158+
"### Fixed\n\n"
159+
)
160+
161+
162+
def _run_all():
163+
tests = [
164+
v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)
165+
]
166+
failures = 0
167+
for test in tests:
168+
try:
169+
test()
170+
print(f" ok {test.__name__}")
171+
except AssertionError as exc:
172+
failures += 1
173+
print(f" FAIL {test.__name__}: {exc}")
174+
print(f"\n{len(tests) - failures}/{len(tests)} passed")
175+
return 1 if failures else 0
176+
177+
178+
if __name__ == "__main__":
179+
sys.exit(_run_all())

0 commit comments

Comments
 (0)