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