#!/usr/bin/env python3 """Generate release notes from the actual release commit range.""" import argparse import json import re import subprocess import sys def get_last_release_tag(compare_ref, exclude_tag=None): """Get the most recent version tag merged into compare_ref.""" result = subprocess.run( ["git", "tag", "--merged", compare_ref, "--sort=-version:refname", "v*"], capture_output=True, text=True, check=True, ) for line in result.stdout.splitlines(): candidate = line.strip() if not candidate: continue if exclude_tag and candidate == exclude_tag: continue return candidate raise subprocess.CalledProcessError(result.returncode, result.args, output=result.stdout, stderr=result.stderr) def get_tag_date(tag): """Get the commit date (ISO 8601) of the tag.""" result = subprocess.run( ["git", "show", "-s", "--format=%cI", tag], capture_output=True, text=True, check=True, ) return result.stdout.strip() def get_merged_prs_in_range(tag, compare_ref): """Get all merged PRs in the git range between tag and compare_ref.""" result = subprocess.run( [ "git", "log", f"{tag}..{compare_ref}", "--oneline", ], capture_output=True, text=True, check=True, ) prs = [] seen_pr_numbers = set() for line in result.stdout.strip().split("\n"): if not line: continue # Extract PR number from commit message - format: "Title (#1234)" pr_match = re.search(r"\(#(\d+)\)", line) if pr_match: pr_number = pr_match.group(1) if pr_number not in seen_pr_numbers: seen_pr_numbers.add(pr_number) prs.append(pr_number) return prs def parse_args(): """Parse CLI arguments.""" parser = argparse.ArgumentParser( description="Generate release notes from the actual release commit range." ) parser.add_argument("new_version", help="Version that will be tagged for this release") parser.add_argument( "--base-tag", dest="base_tag", help="Existing version tag to diff from. Defaults to the latest version tag merged into the compare ref.", ) parser.add_argument( "--compare-ref", default="HEAD", help="Git ref to diff to. Defaults to HEAD.", ) return parser.parse_args() def get_pr_details(pr_number): """Get PR details from GitHub API via gh CLI.""" try: result = subprocess.run( [ "gh", "pr", "view", pr_number, "--json", "title,author,labels,url", ], capture_output=True, text=True, check=True, ) return json.loads(result.stdout) except subprocess.CalledProcessError: return None def should_exclude_pr(pr_details): """Check if PR should be excluded from release notes.""" if not pr_details: return True title = pr_details.get("title", "").lower() # Exclude trunk update PRs if "upgrade trunk" in title or "update trunk" in title or "trunk update" in title: return True # Exclude protobuf update PRs if "update protobufs" in title or "update protobuf" in title: return True # Exclude automated version bump PRs if "bump release version" in title or "bump version" in title: return True return False def is_dependency_update(pr_details): """Check if PR is a dependency/chore update.""" if not pr_details: return False title = pr_details.get("title", "").lower() author = pr_details.get("author", {}).get("login", "").lower() labels = [label.get("name", "").lower() for label in pr_details.get("labels", [])] # Check for renovate or dependabot authors if "renovate" in author or "dependabot" in author: return True # Check for chore(deps) pattern if re.match(r"^chore\(deps\):", title): return True # Check for digest update patterns if re.match(r".*digest to [a-f0-9]+", title, re.IGNORECASE): return True # Check for dependency-related labels dependency_labels = ["dependencies", "deps", "renovate"] if any(dep in label for label in labels for dep in dependency_labels): return True return False def is_enhancement(pr_details): """Determine if PR is an enhancement based on labels and title.""" labels = [label.get("name", "").lower() for label in pr_details.get("labels", [])] # Check labels first enhancement_labels = ["enhancement", "feature", "feat", "new feature"] for label in labels: if any(enh in label for enh in enhancement_labels): return True # Check title prefixes title = pr_details.get("title", "") enhancement_prefixes = ["feat:", "feature:", "add:"] title_lower = title.lower() for prefix in enhancement_prefixes: if title_lower.startswith(prefix) or f" {prefix}" in title_lower: return True return False def clean_title(title): """Clean up PR title for release notes.""" # Remove common prefixes prefixes_to_remove = [ r"^fix:\s*", r"^feat:\s*", r"^feature:\s*", r"^bug:\s*", r"^bugfix:\s*", r"^chore:\s*", r"^chore\([^)]+\):\s*", r"^refactor:\s*", r"^docs:\s*", r"^ci:\s*", r"^build:\s*", r"^perf:\s*", r"^style:\s*", r"^test:\s*", ] cleaned = title for prefix in prefixes_to_remove: cleaned = re.sub(prefix, "", cleaned, flags=re.IGNORECASE) # Ensure first letter is capitalized if cleaned: cleaned = cleaned[0].upper() + cleaned[1:] return cleaned.strip() def format_pr_line(pr_details): """Format a PR as a markdown bullet point.""" title = clean_title(pr_details.get("title", "Unknown")) author = pr_details.get("author", {}).get("login", "unknown") url = pr_details.get("url", "") return f"- {title} by @{author} in {url}" def get_new_contributors(pr_details_list, tag, repo="meshtastic/firmware"): """Find contributors who made their first merged PR before this release. GitHub usernames do not necessarily match git commit authors, so we use the GitHub search API via `gh` to see if the user has any merged PRs before the tag date. This mirrors how GitHub's "Generate release notes" feature works. """ bot_authors = {"github-actions", "renovate", "dependabot", "app/renovate", "app/github-actions", "app/dependabot"} new_contributors = [] seen_authors = set() try: tag_date = get_tag_date(tag) except subprocess.CalledProcessError: print(f"Warning: Could not determine tag date for {tag}; skipping new contributor detection", file=sys.stderr) return [] for pr in pr_details_list: author = pr.get("author", {}).get("login", "") if not author or author in seen_authors: continue # Skip bots if author.lower() in bot_authors or author.startswith("app/"): continue seen_authors.add(author) try: # Search for merged PRs by this author created before the tag date search_query = f"is:pr author:{author} repo:{repo} closed:<=\"{tag_date}\"" search = subprocess.run( [ "gh", "search", "issues", "--json", "number,mergedAt,createdAt", "--state", "closed", "--limit", "200", search_query, ], capture_output=True, text=True, ) if search.returncode != 0: # If gh fails, be conservative and skip adding to new contributors print(f"Warning: gh search failed for author {author}: {search.stderr.strip()}", file=sys.stderr) continue results = json.loads(search.stdout or "[]") # If any merged PR exists before or on tag date, not a new contributor had_prior_pr = any(item.get("mergedAt") for item in results) if not had_prior_pr: new_contributors.append((author, pr.get("url", ""))) except Exception as e: print(f"Warning: Could not check contributor history for {author}: {e}", file=sys.stderr) continue return new_contributors def main(): args = parse_args() new_version = args.new_version compare_ref = args.compare_ref current_tag = f"v{new_version}" # Get last release tag try: last_tag = args.base_tag or get_last_release_tag(compare_ref, exclude_tag=current_tag) except subprocess.CalledProcessError: print("Error: Could not find last release tag", file=sys.stderr) sys.exit(1) print( f"Resolved release note range: {last_tag}..{compare_ref}", file=sys.stderr, ) try: all_pr_numbers = set(get_merged_prs_in_range(last_tag, compare_ref)) except subprocess.CalledProcessError as e: print(f"Error: Could not get PRs for range {last_tag}..{compare_ref}: {e}", file=sys.stderr) sys.exit(1) # Get details for all PRs enhancements = [] bug_fixes = [] dependencies = [] all_pr_details = [] for pr_number in sorted(all_pr_numbers, key=int): details = get_pr_details(pr_number) if details and not should_exclude_pr(details): all_pr_details.append(details) if is_dependency_update(details): dependencies.append(details) elif is_enhancement(details): enhancements.append(details) else: bug_fixes.append(details) # Generate release notes output = [] if enhancements: output.append("## 🚀 Enhancements\n") for pr in enhancements: output.append(format_pr_line(pr)) output.append("") if bug_fixes: output.append("## 🐛 Bug fixes and maintenance\n") for pr in bug_fixes: output.append(format_pr_line(pr)) output.append("") if dependencies: output.append("## ⚙️ Dependencies\n") for pr in dependencies: output.append(format_pr_line(pr)) output.append("") # Find new contributors (GitHub-accurate check using merged PRs before tag date) new_contributors = get_new_contributors(all_pr_details, last_tag) if new_contributors: output.append("## New Contributors\n") for author, url in new_contributors: # Find first PR URL for this contributor first_pr_url = url for pr in all_pr_details: if pr.get("author", {}).get("login") == author: first_pr_url = pr.get("url", url) break output.append(f"- @{author} made their first contribution in {first_pr_url}") output.append("") # Add full changelog link output.append( f"**Full Changelog**: https://github.com/meshtastic/firmware/compare/{last_tag}...v{new_version}" ) print("\n".join(output)) if __name__ == "__main__": main()