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