/
/
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 ) -> None:
81 """Initialize fake pull request."""
82 self.number = number
83 self.merged = True
84 self.merged_at = merged_at
85 self.title = title
86 self.user = types.SimpleNamespace(login=author)
87 self.labels = [types.SimpleNamespace(name=label) for label in labels]
88 self.html_url = f"https://github.com/music-assistant/server/pull/{number}"
89
90
91class FakeRepo:
92 """Mimics the PyGithub Repository calls used by get_prs_between_tags."""
93
94 def __init__(
95 self,
96 comparisons: dict[tuple[str, str], FakeComparison],
97 pulls: dict[int, FakePR],
98 tag_commits: dict[str, FakeCommit] | None = None,
99 ) -> None:
100 """Initialize fake repository."""
101 self.comparisons = comparisons
102 self.pulls = pulls
103 self.tag_commits = tag_commits or {}
104 self.fetched_pulls: list[int] = []
105
106 def compare(self, base: str, head: str) -> FakeComparison:
107 """Return the preset comparison for the given base/head refs."""
108 return self.comparisons[(base, head)]
109
110 def get_pull(self, number: int) -> FakePR:
111 """Return the preset pull request with the given number."""
112 self.fetched_pulls.append(number)
113 return self.pulls[number]
114
115 def get_git_ref(self, ref: str) -> types.SimpleNamespace:
116 """Return a lightweight tag ref pointing at the preset tag commit."""
117 tag_name = ref.removeprefix("tags/")
118 commit = self.tag_commits[tag_name]
119 return types.SimpleNamespace(object=types.SimpleNamespace(type="commit", sha=commit.sha))
120
121 def get_commit(self, sha: str) -> FakeCommit:
122 """Return the preset commit with the given sha."""
123 for commit in self.tag_commits.values():
124 if commit.sha == sha:
125 return commit
126 raise KeyError(sha)
127
128
129def test_linear_release_filters_prs_merged_before_previous_tag(
130 generate_notes: types.ModuleType,
131) -> None:
132 """Beta/nightly/patch releases: previous tag is an ancestor of the branch."""
133 tag_commit = FakeCommit("tagsha", "2.9.0b1 release", datetime(2026, 6, 1, tzinfo=UTC))
134 comparison = FakeComparison(
135 commits=[
136 FakeCommit("aaa", "Add feature (#200)"),
137 FakeCommit("bbb", "Improve thing\n\nfixes #150"),
138 ],
139 )
140 repo = FakeRepo(
141 comparisons={("2.9.0b1", "headsha"): comparison},
142 pulls={
143 200: FakePR(200, datetime(2026, 6, 5, tzinfo=UTC)),
144 150: FakePR(150, datetime(2026, 1, 10, tzinfo=UTC)),
145 },
146 tag_commits={"2.9.0b1": tag_commit},
147 )
148
149 prs = generate_notes.get_prs_between_tags(repo, "2.9.0b1", "headsha")
150
151 assert [pr.number for pr in prs] == [200]
152
153
154def test_minor_release_with_diverged_previous_tag(
155 generate_notes: types.ModuleType,
156) -> None:
157 """
158 Generate notes for a minor release whose previous tag diverged.
159
160 For a minor release (e.g. 2.9.0) the previous stable tag (2.8.9) lives on the
161 old stable branch, which diverged from dev at the 2.8.0 branch point. The notes
162 must include everything merged to dev since the branch point, except PRs that
163 already shipped in the 2.8.x patch releases.
164 """
165 merge_base = FakeCommit("mbsha", "2.8.0 release", datetime(2026, 3, 25, tzinfo=UTC))
166 head_comparison = FakeComparison(
167 commits=[
168 # Merged to dev well before the 2.8.9 tag date: must be included
169 FakeCommit("aaa", "Add feature X (#100)"),
170 # Cherry-picked to stable and released as a 2.8.x patch: must be excluded
171 FakeCommit("bbb", "Fix bug Y (#50)"),
172 # Body references an old PR merged before the branch point: must be excluded
173 FakeCommit("ccc", "Improve Z (#120)\n\nfixes #10"),
174 ],
175 behind_by=3,
176 merge_base_commit=merge_base,
177 )
178 base_comparison = FakeComparison(
179 commits=[
180 # Body mentions #120 but only the first line identifies the released PR
181 FakeCommit("ddd", "Fix bug Y (#50)\n\nRelates to #120"),
182 ],
183 )
184 # 2.8.9 was tagged long after most of the 2.9.0 content was merged to dev
185 tag_commit = FakeCommit("tagsha", "2.8.9 release", datetime(2026, 6, 3, tzinfo=UTC))
186 repo = FakeRepo(
187 comparisons={
188 ("2.8.9", "headsha"): head_comparison,
189 ("mbsha", "2.8.9"): base_comparison,
190 },
191 pulls={
192 100: FakePR(100, datetime(2026, 4, 20, tzinfo=UTC)),
193 50: FakePR(50, datetime(2026, 5, 1, tzinfo=UTC)),
194 120: FakePR(120, datetime(2026, 6, 5, tzinfo=UTC)),
195 10: FakePR(10, datetime(2026, 1, 1, tzinfo=UTC)),
196 },
197 tag_commits={"2.8.9": tag_commit},
198 )
199
200 prs = generate_notes.get_prs_between_tags(repo, "2.8.9", "headsha")
201
202 assert [pr.number for pr in prs] == [100, 120]
203
204
205def test_filter_dependency_bumps(generate_notes: types.ModuleType) -> None:
206 """Inlined bumps are always dropped; other bumps keep only the latest one."""
207 merged_at = datetime(2026, 6, 1, tzinfo=UTC)
208 deps = ("dependencies",)
209 prs = [
210 FakePR(1, merged_at, "â¬ï¸ Update music-assistant-frontend to 2.17.1", labels=deps),
211 FakePR(2, merged_at, "Fix a bug"),
212 FakePR(3, merged_at, "Bump aiohttp from 3.11.0 to 3.12.0", labels=deps),
213 FakePR(4, merged_at, "â¬ï¸ Update music-assistant-models to 1.1.100", labels=deps),
214 FakePR(5, merged_at, "Bump aiohttp from 3.12.0 to 3.13.0", labels=deps),
215 FakePR(6, merged_at, "â¬ï¸ Update music-assistant-frontend to 2.17.2", labels=deps),
216 FakePR(7, merged_at, "Add a feature"),
217 ]
218
219 filtered = generate_notes.filter_dependency_bumps(prs)
220
221 assert [pr.number for pr in filtered] == [2, 5, 7]
222
223
224def test_filter_dependency_bumps_drop_all(generate_notes: types.ModuleType) -> None:
225 """With drop_all every labeled dependency bump is dropped, unlabeled PRs never."""
226 merged_at = datetime(2026, 6, 1, tzinfo=UTC)
227 deps = ("dependencies",)
228 prs = [
229 FakePR(1, merged_at, "Bump pytest from 9.0.3 to 9.1.1", labels=deps),
230 FakePR(2, merged_at, "Fix a bug"),
231 FakePR(3, merged_at, "â¬ï¸ Update music-assistant-frontend to 2.17.2", labels=deps),
232 FakePR(4, merged_at, "Bump stages for various providers"),
233 FakePR(5, merged_at, "Bump `aiosendspin` to 9.1.1", labels=deps),
234 FakePR(6, merged_at, "Bump the music-assistant-libs group with 2 updates", labels=deps),
235 ]
236
237 filtered = generate_notes.filter_dependency_bumps(prs, drop_all=True)
238
239 assert [pr.number for pr in filtered] == [2, 4]
240
241
242def test_write_outputs_hands_notes_over_via_file(
243 generate_notes: types.ModuleType,
244 monkeypatch: pytest.MonkeyPatch,
245 tmp_path: Path,
246) -> None:
247 """The notes land in the file; the step output only carries the file path."""
248 notes_file = tmp_path / "release-notes.md"
249 output_file = tmp_path / "github-output"
250 monkeypatch.setenv("RELEASE_NOTES_FILE", str(notes_file))
251 monkeypatch.setenv("GITHUB_OUTPUT", str(output_file))
252 notes = "# Notes\n" + ("- a change\n" * 10_000)
253
254 generate_notes.write_outputs(notes, ["alice", "bob"])
255
256 assert notes_file.read_text() == notes
257 output = output_file.read_text()
258 assert f"release-notes-file={notes_file}" in output
259 assert "- a change" not in output
260 assert "contributors<<EOF\nalice,bob\nEOF" in output
261
262
263def test_notes_are_shrunk_to_fit_body_limit(
264 generate_notes: types.ModuleType, monkeypatch: pytest.MonkeyPatch
265) -> None:
266 """Oversized notes lose maintenance entries first and gain a full-changelog link."""
267 monkeypatch.setenv("GITHUB_REPOSITORY", "music-assistant/server")
268 merged_at = datetime(2026, 6, 1, tzinfo=UTC)
269 config = {
270 "categories": [
271 {"title": "ð Bugfixes", "labels": ["bugfix"]},
272 {
273 "title": "ð§° Maintenance",
274 "labels": ["maintenance"],
275 "after-other": True,
276 "collapse-after": 3,
277 },
278 ],
279 }
280 categories = {
281 "ð Bugfixes": [
282 FakePR(number, merged_at, f"Fix issue number {number}") for number in range(1, 4)
283 ],
284 "ð§° Maintenance": [
285 FakePR(number, merged_at, f"Maintenance chore number {number}")
286 for number in range(100, 140)
287 ],
288 }
289 uncategorized: list[FakePR] = []
290 maintenance = categories["ð§° Maintenance"]
291
292 def render() -> str:
293 return cast(
294 "str",
295 generate_notes.generate_release_notes(
296 config, categories, uncategorized, [], "2.9.13", None, None
297 ),
298 )
299
300 limit = len(render()) - 500
301 monkeypatch.setattr(generate_notes, "MAX_BODY_CHARS", limit)
302
303 notes = generate_notes.shrink_notes_to_limit(
304 render, config, categories, uncategorized, "2.9.13", "2.10.0"
305 )
306
307 assert len(notes) <= limit
308 # All bugfixes survive; only the maintenance tail was dropped
309 for number in range(1, 4):
310 assert f"#{number})" in notes
311 assert 0 < len(maintenance) < 40
312 assert "compare/2.9.13...2.10.0" in notes
313