/
/
/
1"""Tests for the generate-release-notes GitHub action script."""
2
3from __future__ import annotations
4
5import importlib.util
6import sys
7import types
8from datetime import UTC, datetime
9from pathlib import Path
10from typing import cast
11
12import pytest
13
14SCRIPT_PATH = (
15 Path(__file__).parent.parent
16 / ".github"
17 / "actions"
18 / "generate-release-notes"
19 / "generate_notes.py"
20)
21
22
23@pytest.fixture
24def generate_notes(monkeypatch: pytest.MonkeyPatch) -> types.ModuleType:
25 """Load the action script with its action-only dependencies stubbed."""
26 # The action script depends on PyGithub and PyYAML, which are installed ad hoc
27 # in the GitHub action and not part of the project's (test) dependencies.
28 github_stub = types.ModuleType("github")
29 github_stub.Github = object # type: ignore[attr-defined]
30 github_stub.GithubException = type("GithubException", (Exception,), {}) # type: ignore[attr-defined]
31 monkeypatch.setitem(sys.modules, "github", github_stub)
32 if importlib.util.find_spec("yaml") is None:
33 monkeypatch.setitem(sys.modules, "yaml", types.ModuleType("yaml"))
34 spec = importlib.util.spec_from_file_location("generate_notes", SCRIPT_PATH)
35 assert spec is not None
36 assert spec.loader is not None
37 module = importlib.util.module_from_spec(spec)
38 spec.loader.exec_module(module)
39 return module
40
41
42class FakeCommit:
43 """Mimics PyGithub Commit (.sha and .commit.message/.commit.committer.date)."""
44
45 def __init__(self, sha: str, message: str, date: datetime | None = None) -> None:
46 """Initialize fake commit."""
47 self.sha = sha
48 self.commit = types.SimpleNamespace(
49 message=message,
50 committer=types.SimpleNamespace(date=date),
51 )
52
53
54class FakeComparison:
55 """Mimics PyGithub Comparison."""
56
57 def __init__(
58 self,
59 commits: list[FakeCommit],
60 behind_by: int = 0,
61 merge_base_commit: FakeCommit | None = None,
62 ) -> None:
63 """Initialize fake comparison."""
64 self.commits = commits
65 self.total_commits = len(commits)
66 self.behind_by = behind_by
67 self.merge_base_commit = merge_base_commit
68
69
70class FakePR:
71 """Mimics PyGithub PullRequest."""
72
73 def __init__(
74 self,
75 number: int,
76 merged_at: datetime,
77 title: str = "",
78 author: str = "someone",
79 labels: tuple[str, ...] = (),
80 body: str = "",
81 ) -> None:
82 """Initialize fake pull request."""
83 self.number = number
84 self.merged = True
85 self.merged_at = merged_at
86 self.title = title
87 self.user = types.SimpleNamespace(login=author)
88 self.labels = [types.SimpleNamespace(name=label) for label in labels]
89 self.body = body
90 self.html_url = f"https://github.com/music-assistant/server/pull/{number}"
91
92
93class FakeRepo:
94 """Mimics the PyGithub Repository calls used by get_prs_between_tags."""
95
96 def __init__(
97 self,
98 comparisons: dict[tuple[str, str], FakeComparison],
99 pulls: dict[int, FakePR],
100 tag_commits: dict[str, FakeCommit] | None = None,
101 ) -> None:
102 """Initialize fake repository."""
103 self.comparisons = comparisons
104 self.pulls = pulls
105 self.tag_commits = tag_commits or {}
106 self.fetched_pulls: list[int] = []
107
108 def compare(self, base: str, head: str) -> FakeComparison:
109 """Return the preset comparison for the given base/head refs."""
110 return self.comparisons[(base, head)]
111
112 def get_pull(self, number: int) -> FakePR:
113 """Return the preset pull request with the given number."""
114 self.fetched_pulls.append(number)
115 return self.pulls[number]
116
117 def get_git_ref(self, ref: str) -> types.SimpleNamespace:
118 """Return a lightweight tag ref pointing at the preset tag commit."""
119 tag_name = ref.removeprefix("tags/")
120 commit = self.tag_commits[tag_name]
121 return types.SimpleNamespace(object=types.SimpleNamespace(type="commit", sha=commit.sha))
122
123 def get_commit(self, sha: str) -> FakeCommit:
124 """Return the preset commit with the given sha."""
125 for commit in self.tag_commits.values():
126 if commit.sha == sha:
127 return commit
128 raise KeyError(sha)
129
130
131def test_linear_release_filters_prs_merged_before_previous_tag(
132 generate_notes: types.ModuleType,
133) -> None:
134 """Beta/nightly/patch releases: previous tag is an ancestor of the branch."""
135 tag_commit = FakeCommit("tagsha", "2.9.0b1 release", datetime(2026, 6, 1, tzinfo=UTC))
136 comparison = FakeComparison(
137 commits=[
138 FakeCommit("aaa", "Add feature (#200)"),
139 FakeCommit("bbb", "Improve thing\n\nfixes #150"),
140 ],
141 )
142 repo = FakeRepo(
143 comparisons={("2.9.0b1", "headsha"): comparison},
144 pulls={
145 200: FakePR(200, datetime(2026, 6, 5, tzinfo=UTC)),
146 150: FakePR(150, datetime(2026, 1, 10, tzinfo=UTC)),
147 },
148 tag_commits={"2.9.0b1": tag_commit},
149 )
150
151 prs = generate_notes.get_prs_between_tags(repo, "2.9.0b1", "headsha")
152
153 assert [pr.number for pr in prs] == [200]
154
155
156def test_minor_release_with_diverged_previous_tag(
157 generate_notes: types.ModuleType,
158) -> None:
159 """
160 Generate notes for a minor release whose previous tag diverged.
161
162 For a minor release (e.g. 2.9.0) the previous stable tag (2.8.9) lives on the
163 old stable branch, which diverged from dev at the 2.8.0 branch point. The notes
164 must include everything merged to dev since the branch point, except PRs that
165 already shipped in the 2.8.x patch releases.
166 """
167 merge_base = FakeCommit("mbsha", "2.8.0 release", datetime(2026, 3, 25, tzinfo=UTC))
168 head_comparison = FakeComparison(
169 commits=[
170 # Merged to dev well before the 2.8.9 tag date: must be included
171 FakeCommit("aaa", "Add feature X (#100)"),
172 # Cherry-picked to stable and released as a 2.8.x patch: must be excluded
173 FakeCommit("bbb", "Fix bug Y (#50)"),
174 # Body references an old PR merged before the branch point: must be excluded
175 FakeCommit("ccc", "Improve Z (#120)\n\nfixes #10"),
176 ],
177 behind_by=3,
178 merge_base_commit=merge_base,
179 )
180 base_comparison = FakeComparison(
181 commits=[
182 # Body mentions #120 but only the first line identifies the released PR
183 FakeCommit("ddd", "Fix bug Y (#50)\n\nRelates to #120"),
184 ],
185 )
186 # 2.8.9 was tagged long after most of the 2.9.0 content was merged to dev
187 tag_commit = FakeCommit("tagsha", "2.8.9 release", datetime(2026, 6, 3, tzinfo=UTC))
188 repo = FakeRepo(
189 comparisons={
190 ("2.8.9", "headsha"): head_comparison,
191 ("mbsha", "2.8.9"): base_comparison,
192 },
193 pulls={
194 100: FakePR(100, datetime(2026, 4, 20, tzinfo=UTC)),
195 50: FakePR(50, datetime(2026, 5, 1, tzinfo=UTC)),
196 120: FakePR(120, datetime(2026, 6, 5, tzinfo=UTC)),
197 10: FakePR(10, datetime(2026, 1, 1, tzinfo=UTC)),
198 },
199 tag_commits={"2.8.9": tag_commit},
200 )
201
202 prs = generate_notes.get_prs_between_tags(repo, "2.8.9", "headsha")
203
204 assert [pr.number for pr in prs] == [100, 120]
205
206
207def test_filter_dependency_bumps(generate_notes: types.ModuleType) -> None:
208 """Inlined bumps are always dropped; other bumps keep only the latest one."""
209 merged_at = datetime(2026, 6, 1, tzinfo=UTC)
210 deps = ("dependencies",)
211 prs = [
212 FakePR(1, merged_at, "â¬ï¸ Update music-assistant-frontend to 2.17.1", labels=deps),
213 FakePR(2, merged_at, "Fix a bug"),
214 FakePR(3, merged_at, "Bump aiohttp from 3.11.0 to 3.12.0", labels=deps),
215 FakePR(4, merged_at, "â¬ï¸ Update music-assistant-models to 1.1.100", labels=deps),
216 FakePR(5, merged_at, "Bump aiohttp from 3.12.0 to 3.13.0", labels=deps),
217 FakePR(6, merged_at, "â¬ï¸ Update music-assistant-frontend to 2.17.2", labels=deps),
218 FakePR(7, merged_at, "Add a feature"),
219 ]
220
221 filtered = generate_notes.filter_dependency_bumps(prs)
222
223 assert [pr.number for pr in filtered] == [2, 5, 7]
224
225
226def test_filter_dependency_bumps_drop_all(generate_notes: types.ModuleType) -> None:
227 """With drop_all every labeled dependency bump is dropped, unlabeled PRs never."""
228 merged_at = datetime(2026, 6, 1, tzinfo=UTC)
229 deps = ("dependencies",)
230 prs = [
231 FakePR(1, merged_at, "Bump pytest from 9.0.3 to 9.1.1", labels=deps),
232 FakePR(2, merged_at, "Fix a bug"),
233 FakePR(3, merged_at, "â¬ï¸ Update music-assistant-frontend to 2.17.2", labels=deps),
234 FakePR(4, merged_at, "Bump stages for various providers"),
235 FakePR(5, merged_at, "Bump `aiosendspin` to 9.1.1", labels=deps),
236 FakePR(6, merged_at, "Bump the music-assistant-libs group with 2 updates", labels=deps),
237 ]
238
239 filtered = generate_notes.filter_dependency_bumps(prs, drop_all=True)
240
241 assert [pr.number for pr in filtered] == [2, 4]
242
243
244def test_write_outputs_hands_notes_over_via_file(
245 generate_notes: types.ModuleType,
246 monkeypatch: pytest.MonkeyPatch,
247 tmp_path: Path,
248) -> None:
249 """The notes land in the file; the step output only carries the file path."""
250 notes_file = tmp_path / "release-notes.md"
251 output_file = tmp_path / "github-output"
252 monkeypatch.setenv("RELEASE_NOTES_FILE", str(notes_file))
253 monkeypatch.setenv("GITHUB_OUTPUT", str(output_file))
254 notes = "# Notes\n" + ("- a change\n" * 10_000)
255
256 generate_notes.write_outputs(notes, ["alice", "bob"])
257
258 assert notes_file.read_text() == notes
259 output = output_file.read_text()
260 assert f"release-notes-file={notes_file}" in output
261 assert "- a change" not in output
262 assert "contributors<<EOF\nalice,bob\nEOF" in output
263
264
265def test_notes_are_shrunk_to_fit_body_limit(
266 generate_notes: types.ModuleType, monkeypatch: pytest.MonkeyPatch
267) -> None:
268 """Oversized notes lose maintenance entries first and gain a full-changelog link."""
269 monkeypatch.setenv("GITHUB_REPOSITORY", "music-assistant/server")
270 merged_at = datetime(2026, 6, 1, tzinfo=UTC)
271 config = {
272 "categories": [
273 {"title": "ð Bugfixes", "labels": ["bugfix"]},
274 {
275 "title": "ð§° Maintenance",
276 "labels": ["maintenance"],
277 "after-other": True,
278 "collapse-after": 3,
279 },
280 ],
281 }
282 categories = {
283 "ð Bugfixes": [
284 FakePR(number, merged_at, f"Fix issue number {number}") for number in range(1, 4)
285 ],
286 "ð§° Maintenance": [
287 FakePR(number, merged_at, f"Maintenance chore number {number}")
288 for number in range(100, 140)
289 ],
290 }
291 uncategorized: list[FakePR] = []
292 maintenance = categories["ð§° Maintenance"]
293
294 def render() -> str:
295 return cast(
296 "str",
297 generate_notes.generate_release_notes(
298 config, categories, uncategorized, [], "2.9.13", None, None
299 ),
300 )
301
302 limit = len(render()) - 500
303 monkeypatch.setattr(generate_notes, "MAX_BODY_CHARS", limit)
304
305 notes = generate_notes.shrink_notes_to_limit(
306 render, config, categories, uncategorized, "2.9.13", "2.10.0"
307 )
308
309 assert len(notes) <= limit
310 # All bugfixes survive; only the maintenance tail was dropped
311 for number in range(1, 4):
312 assert f"#{number})" in notes
313 assert 0 < len(maintenance) < 40
314 assert "compare/2.9.13...2.10.0" in notes
315
316
317def test_categorize_prs_excludes_title_prefixes(generate_notes: types.ModuleType) -> None:
318 """PRs matching an excluded title prefix never appear in the notes."""
319 merged_at = datetime(2026, 6, 1, tzinfo=UTC)
320 config = {
321 "categories": [{"title": "ð Bugfixes", "labels": ["bugfix"]}],
322 "exclude-title-prefixes": ["Lokalise", "[Backport to stable]"],
323 }
324 prs = [
325 FakePR(1, merged_at, "Fix a bug", labels=("bugfix",)),
326 FakePR(2, merged_at, "Lokalise translations update"),
327 FakePR(3, merged_at, "Lokalise: Translations update"),
328 FakePR(4, merged_at, "[Backport to stable] 2.9.4"),
329 FakePR(5, merged_at, "Add a feature"),
330 ]
331
332 categories, uncategorized = generate_notes.categorize_prs(prs, config)
333
334 assert [pr.number for pr in categories["ð Bugfixes"]] == [1]
335 assert [pr.number for pr in uncategorized] == [5]
336
337
338def test_extract_frontend_changes_skips_lokalise_bullets(
339 generate_notes: types.ModuleType,
340) -> None:
341 """Lokalise translation-sync bullets in frontend PR bodies are skipped."""
342 merged_at = datetime(2026, 6, 1, tzinfo=UTC)
343 prs = [
344 FakePR(
345 1,
346 merged_at,
347 "â¬ï¸ Update music-assistant-frontend to 2.17.294",
348 body=(
349 "## What's changed\n"
350 "- Fix the player card layout (by @someone in #100)\n"
351 "- Lokalise translations update (by @github-actions[bot] in #101)\n"
352 "- Lokalise: Translations update (by @marcelveldt in #102)\n"
353 ),
354 ),
355 ]
356
357 changes, contributors = generate_notes.extract_frontend_changes(prs)
358
359 assert changes == ["- Fix the player card layout (by @someone in #100)"]
360 assert contributors == {"someone"}
361