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