mirror of
https://github.com/meshtastic/firmware.git
synced 2026-01-14 13:57:24 +00:00
277 lines
7.8 KiB
Python
Executable File
277 lines
7.8 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Generate release notes from merged PRs on develop and master branches.
|
|
Categorizes PRs into Enhancements and Bug Fixes/Maintenance sections.
|
|
"""
|
|
|
|
import subprocess
|
|
import re
|
|
import json
|
|
import sys
|
|
from datetime import datetime
|
|
|
|
|
|
def get_last_release_tag():
|
|
"""Get the most recent release tag."""
|
|
result = subprocess.run(
|
|
["git", "describe", "--tags", "--abbrev=0"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
)
|
|
return result.stdout.strip()
|
|
|
|
|
|
def get_merged_prs_since_tag(tag, branch):
|
|
"""Get all merged PRs since the given tag on the specified branch."""
|
|
# Get commits since tag on the branch - look for PR numbers in parentheses
|
|
result = subprocess.run(
|
|
[
|
|
"git",
|
|
"log",
|
|
f"{tag}..origin/{branch}",
|
|
"--oneline",
|
|
],
|
|
capture_output=True,
|
|
text=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 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
|
|
|
|
# Exclude automated dependency digest updates (chore(deps): update X digest to Y)
|
|
if re.match(r"^chore\(deps\):\s*update .+ digest to [a-f0-9]+$", title, re.IGNORECASE):
|
|
return True
|
|
|
|
# Exclude generic "Update X digest to Y" patterns
|
|
if re.match(r"^update .+ digest to [a-f0-9]+$", title, re.IGNORECASE):
|
|
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):
|
|
"""Find contributors who made their first contribution in this release."""
|
|
# Exclude bots from new contributors
|
|
bot_authors = {"github-actions", "renovate", "dependabot", "app/renovate", "app/github-actions", "app/dependabot"}
|
|
|
|
new_contributors = []
|
|
seen_authors = set()
|
|
|
|
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)
|
|
|
|
# Check if this author appears in git history before tag
|
|
author_check = subprocess.run(
|
|
["git", "log", f"{tag}", f"--author={author}", "--oneline", "-1"],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if not author_check.stdout.strip():
|
|
new_contributors.append((author, pr.get("url", "")))
|
|
|
|
return new_contributors
|
|
|
|
|
|
def main():
|
|
if len(sys.argv) < 2:
|
|
print("Usage: generate_release_notes.py <new_version>", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
new_version = sys.argv[1]
|
|
|
|
# Get last release tag
|
|
try:
|
|
last_tag = get_last_release_tag()
|
|
except subprocess.CalledProcessError:
|
|
print("Error: Could not find last release tag", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Collect PRs from both branches
|
|
all_pr_numbers = set()
|
|
|
|
for branch in ["develop", "master"]:
|
|
try:
|
|
prs = get_merged_prs_since_tag(last_tag, branch)
|
|
all_pr_numbers.update(prs)
|
|
except Exception as e:
|
|
print(f"Warning: Could not get PRs from {branch}: {e}", file=sys.stderr)
|
|
|
|
# Get details for all PRs
|
|
enhancements = []
|
|
bug_fixes = []
|
|
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_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("")
|
|
|
|
# Find new contributors
|
|
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()
|