/
/
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
10
11import pytest
12
13SCRIPT_PATH = (
14 Path(__file__).parent.parent
15 / ".github"
16 / "actions"
17 / "generate-release-notes"
18 / "generate_notes.py"
19)
20
21
22@pytest.fixture
23def generate_notes(monkeypatch: pytest.MonkeyPatch) -> types.ModuleType:
24 """Load the action script with its action-only dependencies stubbed."""
25 # The action script depends on PyGithub and PyYAML, which are installed ad hoc
26 # in the GitHub action and not part of the project's (test) dependencies.
27 github_stub = types.ModuleType("github")
28 github_stub.Github = object # type: ignore[attr-defined]
29 github_stub.GithubException = type("GithubException", (Exception,), {}) # type: ignore[attr-defined]
30 monkeypatch.setitem(sys.modules, "github", github_stub)
31 if importlib.util.find_spec("yaml") is None:
32 monkeypatch.setitem(sys.modules, "yaml", types.ModuleType("yaml"))
33 spec = importlib.util.spec_from_file_location("generate_notes", SCRIPT_PATH)
34 assert spec is not None
35 assert spec.loader is not None
36 module = importlib.util.module_from_spec(spec)
37 spec.loader.exec_module(module)
38 return module
39
40
41class FakeCommit:
42 """Mimics PyGithub Commit (.sha and .commit.message/.commit.committer.date)."""
43
44 def __init__(self, sha: str, message: str, date: datetime | None = None) -> None:
45 """Initialize fake commit."""
46 self.sha = sha
47 self.commit = types.SimpleNamespace(
48 message=message,
49 committer=types.SimpleNamespace(date=date),
50 )
51
52
53class FakeComparison:
54 """Mimics PyGithub Comparison."""
55
56 def __init__(
57 self,
58 commits: list[FakeCommit],
59 behind_by: int = 0,
60 merge_base_commit: FakeCommit | None = None,
61 ) -> None:
62 """Initialize fake comparison."""
63 self.commits = commits
64 self.total_commits = len(commits)
65 self.behind_by = behind_by
66 self.merge_base_commit = merge_base_commit
67
68
69class FakePR:
70 """Mimics PyGithub PullRequest."""
71
72 def __init__(self, number: int, merged_at: datetime) -> None:
73 """Initialize fake pull request."""
74 self.number = number
75 self.merged = True
76 self.merged_at = merged_at
77
78
79class FakeRepo:
80 """Mimics the PyGithub Repository calls used by get_prs_between_tags."""
81
82 def __init__(
83 self,
84 comparisons: dict[tuple[str, str], FakeComparison],
85 pulls: dict[int, FakePR],
86 tag_commits: dict[str, FakeCommit] | None = None,
87 ) -> None:
88 """Initialize fake repository."""
89 self.comparisons = comparisons
90 self.pulls = pulls
91 self.tag_commits = tag_commits or {}
92 self.fetched_pulls: list[int] = []
93
94 def compare(self, base: str, head: str) -> FakeComparison:
95 """Return the preset comparison for the given base/head refs."""
96 return self.comparisons[(base, head)]
97
98 def get_pull(self, number: int) -> FakePR:
99 """Return the preset pull request with the given number."""
100 self.fetched_pulls.append(number)
101 return self.pulls[number]
102
103 def get_git_ref(self, ref: str) -> types.SimpleNamespace:
104 """Return a lightweight tag ref pointing at the preset tag commit."""
105 tag_name = ref.removeprefix("tags/")
106 commit = self.tag_commits[tag_name]
107 return types.SimpleNamespace(object=types.SimpleNamespace(type="commit", sha=commit.sha))
108
109 def get_commit(self, sha: str) -> FakeCommit:
110 """Return the preset commit with the given sha."""
111 for commit in self.tag_commits.values():
112 if commit.sha == sha:
113 return commit
114 raise KeyError(sha)
115
116
117def test_linear_release_filters_prs_merged_before_previous_tag(
118 generate_notes: types.ModuleType,
119) -> None:
120 """Beta/nightly/patch releases: previous tag is an ancestor of the branch."""
121 tag_commit = FakeCommit("tagsha", "2.9.0b1 release", datetime(2026, 6, 1, tzinfo=UTC))
122 comparison = FakeComparison(
123 commits=[
124 FakeCommit("aaa", "Add feature (#200)"),
125 FakeCommit("bbb", "Improve thing\n\nfixes #150"),
126 ],
127 )
128 repo = FakeRepo(
129 comparisons={("2.9.0b1", "headsha"): comparison},
130 pulls={
131 200: FakePR(200, datetime(2026, 6, 5, tzinfo=UTC)),
132 150: FakePR(150, datetime(2026, 1, 10, tzinfo=UTC)),
133 },
134 tag_commits={"2.9.0b1": tag_commit},
135 )
136
137 prs = generate_notes.get_prs_between_tags(repo, "2.9.0b1", "headsha")
138
139 assert [pr.number for pr in prs] == [200]
140
141
142def test_minor_release_with_diverged_previous_tag(
143 generate_notes: types.ModuleType,
144) -> None:
145 """
146 Generate notes for a minor release whose previous tag diverged.
147
148 For a minor release (e.g. 2.9.0) the previous stable tag (2.8.9) lives on the
149 old stable branch, which diverged from dev at the 2.8.0 branch point. The notes
150 must include everything merged to dev since the branch point, except PRs that
151 already shipped in the 2.8.x patch releases.
152 """
153 merge_base = FakeCommit("mbsha", "2.8.0 release", datetime(2026, 3, 25, tzinfo=UTC))
154 head_comparison = FakeComparison(
155 commits=[
156 # Merged to dev well before the 2.8.9 tag date: must be included
157 FakeCommit("aaa", "Add feature X (#100)"),
158 # Cherry-picked to stable and released as a 2.8.x patch: must be excluded
159 FakeCommit("bbb", "Fix bug Y (#50)"),
160 # Body references an old PR merged before the branch point: must be excluded
161 FakeCommit("ccc", "Improve Z (#120)\n\nfixes #10"),
162 ],
163 behind_by=3,
164 merge_base_commit=merge_base,
165 )
166 base_comparison = FakeComparison(
167 commits=[
168 # Body mentions #120 but only the first line identifies the released PR
169 FakeCommit("ddd", "Fix bug Y (#50)\n\nRelates to #120"),
170 ],
171 )
172 # 2.8.9 was tagged long after most of the 2.9.0 content was merged to dev
173 tag_commit = FakeCommit("tagsha", "2.8.9 release", datetime(2026, 6, 3, tzinfo=UTC))
174 repo = FakeRepo(
175 comparisons={
176 ("2.8.9", "headsha"): head_comparison,
177 ("mbsha", "2.8.9"): base_comparison,
178 },
179 pulls={
180 100: FakePR(100, datetime(2026, 4, 20, tzinfo=UTC)),
181 50: FakePR(50, datetime(2026, 5, 1, tzinfo=UTC)),
182 120: FakePR(120, datetime(2026, 6, 5, tzinfo=UTC)),
183 10: FakePR(10, datetime(2026, 1, 1, tzinfo=UTC)),
184 },
185 tag_commits={"2.8.9": tag_commit},
186 )
187
188 prs = generate_notes.get_prs_between_tags(repo, "2.8.9", "headsha")
189
190 assert [pr.number for pr in prs] == [100, 120]
191