music-assistant-server

13.8 KBPY
generate_notes.py
13.8 KB414 lines • python
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