/
/
1#!/usr/bin/env python3
2"""
3Generate release notes based on PRs between two tags.
4
5Reads configuration from .github/release-notes-config.yml for categorization and formatting.
6"""
7
8import os
9import re
10import sys
11from collections import defaultdict
12from datetime import datetime
13from pathlib import Path
14from typing import Any
15
16import yaml
17from github import Github, GithubException
18
19
20def load_config() -> dict[str, Any]:
21 """Load the release-notes-config.yml configuration."""
22 config_path = ".github/release-notes-config.yml"
23 if not Path(config_path).exists():
24 print(f"Error: {config_path} not found") # noqa: T201
25 sys.exit(1)
26
27 with open(config_path) as f:
28 return yaml.safe_load(f)
29
30
31def get_tag_date(repo, tag_name) -> datetime | None:
32 """Get the creation date of a tag (supports both annotated and lightweight tags)."""
33 try:
34 ref = repo.get_git_ref(f"tags/{tag_name}")
35 if ref.object.type == "tag":
36 tag_obj = repo.get_git_tag(ref.object.sha)
37 return tag_obj.tagger.date
38 # Lightweight tag - use the commit date
39 commit = repo.get_commit(ref.object.sha)
40 return commit.commit.committer.date
41 except GithubException as e:
42 print(f"Warning: Could not get date for tag {tag_name}: {e}") # noqa: T201
43 return None
44
45
46def get_released_pr_numbers(repo, merge_base_sha, previous_tag) -> set[int]:
47 """Get PR numbers that already shipped on the previous tag's (diverged) branch."""
48 merge_pattern = re.compile(r"Merge pull request #(\d+)")
49 squash_pattern = re.compile(r"\(#(\d+)\)\s*$")
50 released = set()
51 comparison = repo.compare(merge_base_sha, previous_tag)
52 for commit in comparison.commits:
53 # Only the first line (squash/merge commit title) identifies the released
54 # PR; the body may reference unrelated PRs/issues.
55 title = commit.commit.message.split("\n", 1)[0]
56 match = merge_pattern.search(title) or squash_pattern.search(title)
57 if match:
58 released.add(int(match.group(1)))
59 return released
60
61
62def get_prs_between_tags(repo, previous_tag, head_sha) -> list[Any]:
63 """Get all merged PRs between the previous tag and exact source commit."""
64 pr_pattern = re.compile(r"#(\d+)")
65 merge_pattern = re.compile(r"Merge pull request #(\d+)")
66
67 cutoff_date = None
68 released_pr_numbers = set()
69 if not previous_tag:
70 print("No previous tag specified, will include all PRs from branch history") # noqa: T201
71 # Get the first commit on the branch
72 commits = list(repo.get_commits(sha=head_sha))
73 # Limit to last 100 commits to avoid going too far back
74 commits = commits[:100]
75 else:
76 print(f"Finding PRs between {previous_tag} and {head_sha}") # noqa: T201
77 comparison = repo.compare(previous_tag, head_sha)
78 commits = comparison.commits
79 print(f"Found {comparison.total_commits} commits") # noqa: T201
80 if comparison.behind_by:
81 # The previous tag lives on a diverged branch: a minor release (e.g. 2.9.0)
82 # compares against the latest patch tag (2.8.9) on the old stable branch.
83 # That tag's date lies *after* most of this release's content was merged to
84 # dev, so cut off at the merge base (the old branch point) instead, and
85 # drop PRs that already shipped in the patch releases on the old branch.
86 merge_base = comparison.merge_base_commit
87 cutoff_date = merge_base.commit.committer.date
88 print( # noqa: T201
89 f"Previous tag {previous_tag} has diverged from {head_sha}, "
90 f"using merge base date {cutoff_date} as cutoff"
91 )
92 released_pr_numbers = get_released_pr_numbers(repo, merge_base.sha, previous_tag)
93 print( # noqa: T201
94 f"Found {len(released_pr_numbers)} PRs already released "
95 f"in patch releases up to {previous_tag}"
96 )
97 else:
98 cutoff_date = get_tag_date(repo, previous_tag)
99 if cutoff_date:
100 print(f"Previous tag date: {cutoff_date}") # noqa: T201
101
102 # Extract PR numbers from commit messages
103 pr_numbers = set()
104
105 for commit in commits:
106 message = commit.commit.message
107 # First check for merge commits
108 merge_match = merge_pattern.search(message)
109 if merge_match:
110 pr_numbers.add(int(merge_match.group(1)))
111 else:
112 # Look for PR references in the message
113 for match in pr_pattern.finditer(message):
114 pr_numbers.add(int(match.group(1)))
115
116 print(f"Found {len(pr_numbers)} unique PRs") # noqa: T201
117
118 # Fetch the actual PR objects, filtering out PRs merged before the cutoff date
119 # and PRs that already shipped in patch releases on the previous (stable) branch
120 prs = []
121 skipped = 0
122 for pr_num in sorted(pr_numbers):
123 if pr_num in released_pr_numbers:
124 skipped += 1
125 print( # noqa: T201
126 f" Skipping PR #{pr_num}: already released in a patch release"
127 )
128 continue
129 try:
130 pr = repo.get_pull(pr_num)
131 if pr.merged:
132 if cutoff_date and pr.merged_at and pr.merged_at <= cutoff_date:
133 skipped += 1
134 print( # noqa: T201
135 f" Skipping PR #{pr_num}: merged at {pr.merged_at}, before cutoff {cutoff_date}"
136 )
137 continue
138 prs.append(pr)
139 except GithubException as e:
140 print(f"Warning: Could not fetch PR #{pr_num}: {e}") # noqa: T201
141
142 if skipped:
143 print(f"Filtered out {skipped} PRs already released before/in {previous_tag}") # noqa: T201
144
145 return prs
146
147
148def categorize_prs(prs, config) -> tuple[dict[str, list[Any]], list[Any]]:
149 """Categorize PRs based on their labels using the config."""
150 categories = defaultdict(list)
151 uncategorized = []
152
153 # Get category definitions from config
154 category_configs = config.get("categories", [])
155
156 # Get excluded labels
157 exclude_labels = set(config.get("exclude-labels", []))
158 include_labels = config.get("include-labels")
159 if include_labels:
160 include_labels = set(include_labels)
161
162 for pr in prs:
163 # Check if PR should be excluded
164 pr_labels = {label.name for label in pr.labels}
165
166 if exclude_labels and pr_labels & exclude_labels:
167 continue
168
169 if include_labels and not (pr_labels & include_labels):
170 continue
171
172 # Try to categorize
173 categorized = False
174 for cat_config in category_configs:
175 cat_title = cat_config.get("title", "Other")
176 cat_labels = cat_config.get("labels", [])
177 if isinstance(cat_labels, str):
178 cat_labels = [cat_labels]
179
180 # Check if PR has any of the category labels
181 if pr_labels & set(cat_labels):
182 categories[cat_title].append(pr)
183 categorized = True
184 break
185
186 if not categorized:
187 uncategorized.append(pr)
188
189 return categories, uncategorized
190
191
192def get_contributors(prs, config) -> list[str]:
193 """Extract unique contributors from PRs."""
194 excluded = set(config.get("exclude-contributors", []))
195 contributors = set()
196
197 for pr in prs:
198 author = pr.user.login
199 if author not in excluded:
200 contributors.add(author)
201
202 return sorted(contributors)
203
204
205def format_change_line(pr, config) -> str:
206 """Format a single PR line using the change-template from config."""
207 template = config.get("change-template", "- $TITLE (by @$AUTHOR in #$NUMBER)")
208
209 # Get title and escape characters if specified
210 title = pr.title
211 escapes = config.get("change-title-escapes", "")
212 if escapes:
213 for char in escapes:
214 if char in title:
215 title = title.replace(char, "\\" + char)
216
217 # Replace template variables
218 result = template.replace("$TITLE", title)
219 result = result.replace("$AUTHOR", pr.user.login)
220 result = result.replace("$NUMBER", str(pr.number))
221 return result.replace("$URL", pr.html_url)
222
223
224def extract_frontend_changes(prs) -> tuple[list[str], set[str]]:
225 """
226 Extract frontend changes from frontend update PRs.
227
228 Returns tuple of (frontend_changes_list, frontend_contributors_set)
229 """
230 frontend_changes = []
231 frontend_contributors = set()
232
233 # Pattern to match frontend update PRs
234 frontend_pr_pattern = re.compile(r"^â¬ï¸ Update music-assistant-frontend to \d")
235
236 for pr in prs:
237 if not frontend_pr_pattern.match(pr.title):
238 continue
239
240 print(f"Processing frontend PR #{pr.number}: {pr.title}") # noqa: T201
241
242 if not pr.body:
243 continue
244
245 # Extract bullet points from PR body, excluding headers and dependabot lines
246 for body_line in pr.body.split("\n"):
247 stripped_line = body_line.strip()
248 # Check if it's a bullet point
249 if stripped_line.startswith(("- ", "* ", "⢠")):
250 # Skip thank you lines and dependency updates
251 if "ð" in stripped_line:
252 continue
253 if re.match(r"^[â¢\-\*]\s*Chore\(deps", stripped_line, re.IGNORECASE):
254 continue
255 # Skip "No changes" entries
256 if re.match(r"^[â¢\-\*]\s*No changes\s*$", stripped_line, re.IGNORECASE):
257 continue
258
259 # Add the change
260 frontend_changes.append(stripped_line)
261
262 # Extract contributors mentioned in this line
263 contributors_in_line = re.findall(r"@([a-zA-Z0-9_-]+)", stripped_line)
264 frontend_contributors.update(contributors_in_line)
265
266 # Limit to 20 changes per PR
267 if len(frontend_changes) >= 20:
268 break
269
270 return frontend_changes, frontend_contributors
271
272
273def generate_release_notes( # noqa: PLR0915
274 config,
275 categories,
276 uncategorized,
277 contributors,
278 previous_tag,
279 frontend_changes=None,
280 important_notes=None,
281) -> str:
282 """Generate the formatted release notes."""
283 lines = []
284
285 # Add important notes section first if provided
286 if important_notes and important_notes.strip():
287 lines.append("## â ï¸ Important Notes")
288 lines.append("")
289 # Convert literal \n to actual newlines and preserve existing newlines
290 formatted_notes = important_notes.strip().replace("\\n", "\n")
291 lines.append(formatted_notes)
292 lines.append("")
293 lines.append("---")
294 lines.append("")
295
296 # Add header if previous tag exists
297 if previous_tag:
298 repo_url = (
299 os.environ.get("GITHUB_SERVER_URL", "https://github.com")
300 + "/"
301 + os.environ["GITHUB_REPOSITORY"]
302 )
303 channel_raw = os.environ.get("CHANNEL", "")
304 channel = "RC" if channel_raw == "rc" else channel_raw.title()
305 if channel:
306 lines.append(f"## ð¦ {channel} Release")
307 lines.append("")
308 lines.append(f"_Changes since [{previous_tag}]({repo_url}/releases/tag/{previous_tag})_")
309 lines.append("")
310
311 # Add categorized PRs - first pass: categories without "after-other" flag
312 category_configs = config.get("categories", [])
313 deferred_categories = []
314
315 for cat_config in category_configs:
316 # Defer categories marked with after-other
317 if cat_config.get("after-other", False):
318 deferred_categories.append(cat_config)
319 continue
320
321 cat_title = cat_config.get("title", "Other")
322 if cat_title not in categories or not categories[cat_title]:
323 continue
324
325 prs = categories[cat_title]
326 lines.append(f"### {cat_title}")
327 lines.append("")
328
329 # Check if category should be collapsed
330 collapse_after = cat_config.get("collapse-after")
331 if collapse_after and len(prs) > collapse_after:
332 lines.append("<details>")
333 lines.append(f"<summary>{len(prs)} changes</summary>")
334 lines.append("")
335
336 for pr in prs:
337 lines.append(format_change_line(pr, config))
338
339 if collapse_after and len(prs) > collapse_after:
340 lines.append("")
341 lines.append("</details>")
342
343 lines.append("")
344
345 # Add frontend changes if any (before "Other Changes")
346 if frontend_changes and len(frontend_changes) > 0:
347 lines.append("### ð¨ Frontend Changes")
348 lines.append("")
349 lines.extend(frontend_changes)
350 lines.append("")
351
352 # Add uncategorized PRs if any
353 if uncategorized:
354 lines.append("### Other Changes")
355 lines.append("")
356 for pr in uncategorized:
357 lines.append(format_change_line(pr, config))
358 lines.append("")
359
360 # Add deferred categories (after "Other Changes")
361 for cat_config in deferred_categories:
362 cat_title = cat_config.get("title", "Other")
363 if cat_title not in categories or not categories[cat_title]:
364 continue
365
366 prs = categories[cat_title]
367 lines.append(f"### {cat_title}")
368 lines.append("")
369
370 # Check if category should be collapsed
371 collapse_after = cat_config.get("collapse-after")
372 if collapse_after and len(prs) > collapse_after:
373 lines.append("<details>")
374 lines.append(f"<summary>{len(prs)} changes</summary>")
375 lines.append("")
376
377 for pr in prs:
378 lines.append(format_change_line(pr, config))
379
380 if collapse_after and len(prs) > collapse_after:
381 lines.append("")
382 lines.append("</details>")
383
384 lines.append("")
385
386 # Add contributors section using template
387 if contributors:
388 template = config.get("template", "")
389 if "$CONTRIBUTORS" in template or not template:
390 lines.append("## :bow: Thanks to our contributors")
391 lines.append("")
392 lines.append(
393 "Special thanks to the following contributors who helped with this release:"
394 )
395 lines.append("")
396 lines.append(", ".join(f"@{c}" for c in contributors))
397
398 return "\n".join(lines)
399
400
401def main() -> None:
402 """Generate release notes for the target version."""
403 # Get environment variables
404 github_token = os.environ.get("GITHUB_TOKEN")
405 version = os.environ.get("VERSION")
406 previous_tag = os.environ.get("PREVIOUS_TAG", "")
407 head_sha = os.environ.get("HEAD_SHA")
408 channel = os.environ.get("CHANNEL")
409 repo_name = os.environ.get("GITHUB_REPOSITORY")
410 important_notes = os.environ.get("IMPORTANT_NOTES", "")
411
412 if not all([github_token, version, head_sha, channel, repo_name]):
413 print("Error: Missing required environment variables") # noqa: T201
414 sys.exit(1)
415
416 print(f"Generating release notes for {version} ({channel} channel)") # noqa: T201
417 print(f"Repository: {repo_name}") # noqa: T201
418 print(f"Source SHA: {head_sha}") # noqa: T201
419 print(f"Previous tag: {previous_tag or 'None (first release)'}") # noqa: T201
420
421 # Initialize GitHub API
422 g = Github(github_token)
423 repo = g.get_repo(repo_name)
424
425 # Load configuration
426 config = load_config()
427 print(f"Loaded config with {len(config.get('categories', []))} categories") # noqa: T201
428
429 # Get PRs between tags
430 prs = get_prs_between_tags(repo, previous_tag, head_sha)
431 print(f"Processing {len(prs)} merged PRs") # noqa: T201
432
433 if not prs:
434 print("No PRs found in range") # noqa: T201
435 no_changes = config.get("no-changes-template", "* No changes")
436 notes = no_changes
437 contributors_list = []
438 else:
439 # Categorize PRs
440 categories, uncategorized = categorize_prs(prs, config)
441 print(f"Categorized into {len(categories)} categories, {len(uncategorized)} uncategorized") # noqa: T201
442
443 # Extract frontend changes and contributors
444 frontend_changes_list, frontend_contributors_set = extract_frontend_changes(prs)
445 print( # noqa: T201
446 f"Found {len(frontend_changes_list)} frontend changes "
447 f"from {len(frontend_contributors_set)} contributors"
448 )
449
450 # Get server contributors
451 contributors_list = get_contributors(prs, config)
452
453 # Merge frontend contributors with server contributors
454 all_contributors = set(contributors_list) | frontend_contributors_set
455 contributors_list = sorted(all_contributors)
456 print( # noqa: T201
457 f"Total {len(contributors_list)} unique contributors (server + frontend)"
458 )
459
460 # Generate formatted notes
461 notes = generate_release_notes(
462 config,
463 categories,
464 uncategorized,
465 contributors_list,
466 previous_tag,
467 frontend_changes_list,
468 important_notes,
469 )
470
471 # Output to GitHub Actions
472 # Use multiline output format
473 output_file = os.environ.get("GITHUB_OUTPUT")
474 if output_file:
475 with open(output_file, "a") as f:
476 f.write("release-notes<<EOF\n")
477 f.write(notes)
478 f.write("\nEOF\n")
479 f.write("contributors<<EOF\n")
480 f.write(",".join(contributors_list))
481 f.write("\nEOF\n")
482 else:
483 print("\n=== Generated Release Notes ===\n") # noqa: T201
484 print(notes) # noqa: T201
485 print("\n=== Contributors ===\n") # noqa: T201
486 print(", ".join(contributors_list)) # noqa: T201
487
488
489if __name__ == "__main__":
490 main()
491