scripts-release-notes-generator
#!/usr/bin/env python3 """ Release Notes Generator - K.5.8
Generates comprehensive release notes from PRs, commits, and issues between two versions or tags.
Usage: python3 scripts/release-notes-generator.py --from v1.0.0 --to v1.1.0 python3 scripts/release-notes-generator.py --since-tag v1.0.0
Track: K (Workflow Automation) Agent: release-notes-generator Command: /release-notes """
import argparse import json import re import subprocess import sys from collections import defaultdict from datetime import datetime from pathlib import Path from typing import Any
def run_command(cmd: list[str]) -> tuple[int, str, str]: """Run a shell command and return (returncode, stdout, stderr).""" try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) return result.returncode, result.stdout, result.stderr except Exception as e: return 1, "", str(e)
def get_tags() -> list[str]: """Get sorted list of tags.""" code, stdout, stderr = run_command(["git", "tag", "--sort=-creatordate"]) return stdout.strip().split("\n") if code == 0 and stdout.strip() else []
def get_commits_between(from_ref: str, to_ref: str = "HEAD") -> list[dict[str, Any]]: """Get commits between two refs.""" cmd = ["git", "log", f"{from_ref}..{to_ref}", "--pretty=format:%H|%an|%ae|%s|%b|%ai", "--no-merges"] code, stdout, stderr = run_command(cmd)
if code != 0:
return []
commits = []
for entry in stdout.split("\n%H|"): # Split on commit boundaries
if not entry.strip():
continue
# Handle first entry which won't have the leading %H|
if not entry.startswith("%"):
entry = entry
parts = entry.split("|", 5)
if len(parts) >= 5:
commits.append({
"sha": parts[0].replace("%H", ""),
"author": parts[1],
"email": parts[2],
"subject": parts[3],
"body": parts[4] if len(parts) > 4 else "",
"date": parts[5] if len(parts) > 5 else ""
})
return commits
def get_prs_for_commits(commits: list[dict[str, Any]], repo: str | None = None) -> dict[str, dict[str, Any]]: """Get PR information for commits that came from PRs.""" # Extract PR numbers from commit messages pr_numbers = set() for commit in commits: # Match "Merge pull request #123" or "(#123)" patterns matches = re.findall(r"#(\d+)", commit["subject"] + commit.get("body", "")) pr_numbers.update(matches)
if not pr_numbers:
return {}
pr_info = {}
for pr_num in list(pr_numbers)[:50]: # Limit API calls
cmd = ["gh", "pr", "view", pr_num, "--json",
"number,title,author,labels,body,url"]
if repo:
cmd.extend(["--repo", repo])
code, stdout, stderr = run_command(cmd)
if code == 0 and stdout.strip():
try:
pr_data = json.loads(stdout)
pr_info[pr_num] = pr_data
except json.JSONDecodeError:
pass
return pr_info
def categorize_changes(commits: list[dict[str, Any]], prs: dict[str, dict[str, Any]]) -> dict[str, list[dict[str, Any]]]: """Categorize changes by type.""" categories = { "breaking": [], "features": [], "fixes": [], "performance": [], "documentation": [], "security": [], "dependencies": [], "other": [] }
# Conventional commit patterns
patterns = {
"breaking": [r"BREAKING CHANGE", r"!:"],
"features": [r"^feat(\(.+\))?:", r"^feature:", r"^add:"],
"fixes": [r"^fix(\(.+\))?:", r"^bugfix:", r"^hotfix:"],
"performance": [r"^perf(\(.+\))?:", r"^optimize:"],
"documentation": [r"^docs(\(.+\))?:", r"^doc:"],
"security": [r"^security:", r"\bCVE-\d+", r"vulnerability"],
"dependencies": [r"^deps?(\(.+\))?:", r"^build(\(.+\))?:", r"bump .+ from"],
}
for commit in commits:
subject = commit["subject"].lower()
body = commit.get("body", "").lower()
full_text = f"{subject} {body}"
categorized = False
for category, category_patterns in patterns.items():
for pattern in category_patterns:
if re.search(pattern, full_text, re.IGNORECASE):
categories[category].append(commit)
categorized = True
break
if categorized:
break
if not categorized:
categories["other"].append(commit)
return {k: v for k, v in categories.items() if v}
def format_commit_entry(commit: dict[str, Any], prs: dict[str, dict[str, Any]]) -> str: """Format a single commit entry for the release notes.""" subject = commit["subject"]
# Clean up conventional commit prefix for display
subject = re.sub(r"^(feat|fix|docs|perf|chore|build|ci|test|refactor)(\(.+\))?:\s*", "", subject)
# Check if this commit references a PR
pr_match = re.search(r"#(\d+)", commit["subject"])
if pr_match and pr_match.group(1) in prs:
pr = prs[pr_match.group(1)]
return f"- {subject} (#{pr['number']}) @{pr.get('author', {}).get('login', 'unknown')}"
return f"- {subject} ({commit['sha'][:7]})"
def get_contributors(commits: list[dict[str, Any]]) -> list[str]: """Get unique contributors.""" contributors = set() for commit in commits: contributors.add(commit.get("author", "Unknown")) return sorted(contributors)
def generate_release_notes( version: str, from_ref: str, to_ref: str, commits: list[dict[str, Any]], prs: dict[str, dict[str, Any]], repo: str | None = None ) -> str: """Generate the release notes markdown.""" categories = categorize_changes(commits, prs) contributors = get_contributors(commits)
lines = [
f"# Release Notes - {version}",
"",
f"**Release Date:** {datetime.now().strftime('%Y-%m-%d')}",
f"**Compare:** [{from_ref}...{to_ref}](https://github.com/{repo or 'owner/repo'}/compare/{from_ref}...{to_ref})",
"",
"---",
"",
]
# Summary stats
total_commits = len(commits)
total_prs = len(prs)
total_contributors = len(contributors)
lines.extend([
"## Summary",
"",
f"This release includes **{total_commits}** commits from **{total_contributors}** contributors.",
"",
])
# Breaking changes (if any)
if "breaking" in categories:
lines.extend([
"## Breaking Changes",
"",
])
for commit in categories["breaking"]:
lines.append(format_commit_entry(commit, prs))
lines.append("")
# Features
if "features" in categories:
lines.extend([
"## New Features",
"",
])
for commit in categories["features"]:
lines.append(format_commit_entry(commit, prs))
lines.append("")
# Bug fixes
if "fixes" in categories:
lines.extend([
"## Bug Fixes",
"",
])
for commit in categories["fixes"]:
lines.append(format_commit_entry(commit, prs))
lines.append("")
# Security
if "security" in categories:
lines.extend([
"## Security",
"",
])
for commit in categories["security"]:
lines.append(format_commit_entry(commit, prs))
lines.append("")
# Performance
if "performance" in categories:
lines.extend([
"## Performance Improvements",
"",
])
for commit in categories["performance"]:
lines.append(format_commit_entry(commit, prs))
lines.append("")
# Documentation
if "documentation" in categories:
lines.extend([
"## Documentation",
"",
])
for commit in categories["documentation"][:10]: # Limit docs entries
lines.append(format_commit_entry(commit, prs))
if len(categories["documentation"]) > 10:
lines.append(f"- ...and {len(categories['documentation']) - 10} more documentation updates")
lines.append("")
# Dependencies
if "dependencies" in categories:
lines.extend([
"## Dependencies",
"",
])
for commit in categories["dependencies"][:10]:
lines.append(format_commit_entry(commit, prs))
if len(categories["dependencies"]) > 10:
lines.append(f"- ...and {len(categories['dependencies']) - 10} more dependency updates")
lines.append("")
# Other changes
if "other" in categories and len(categories) == 1: # Only show if nothing else
lines.extend([
"## Other Changes",
"",
])
for commit in categories["other"][:15]:
lines.append(format_commit_entry(commit, prs))
if len(categories["other"]) > 15:
lines.append(f"- ...and {len(categories['other']) - 15} more changes")
lines.append("")
# Contributors
lines.extend([
"## Contributors",
"",
"Thanks to all contributors who made this release possible:",
"",
])
for contributor in contributors:
lines.append(f"- @{contributor}")
lines.append("")
lines.extend([
"---",
"*Generated by CODITECT Release Notes Generator*",
])
return "\n".join(lines)
def main(): parser = argparse.ArgumentParser( description="Generate release notes from commits and PRs" ) parser.add_argument( "--from", "-f", dest="from_ref", type=str, help="Starting ref (tag or commit)" ) parser.add_argument( "--to", "-t", dest="to_ref", type=str, default="HEAD", help="Ending ref (default: HEAD)" ) parser.add_argument( "--since-tag", type=str, help="Generate notes since this tag" ) parser.add_argument( "--version", "-v", type=str, help="Version string for the release" ) parser.add_argument( "--repo", "-r", type=str, default=None, help="GitHub repository (owner/repo format)" ) parser.add_argument( "--output", "-o", type=str, default=None, help="Output file path (default: stdout)" ) parser.add_argument( "--json", action="store_true", help="Output as JSON instead of Markdown" )
args = parser.parse_args()
# Determine from_ref
if args.since_tag:
from_ref = args.since_tag
elif args.from_ref:
from_ref = args.from_ref
else:
# Use latest tag
tags = get_tags()
if tags:
from_ref = tags[0]
else:
print("Error: No tags found. Specify --from or --since-tag", file=sys.stderr)
sys.exit(1)
to_ref = args.to_ref
version = args.version or to_ref
print(f"Generating release notes: {from_ref} -> {to_ref}", file=sys.stderr)
commits = get_commits_between(from_ref, to_ref)
if not commits:
print(f"No commits found between {from_ref} and {to_ref}", file=sys.stderr)
sys.exit(0)
print(f"Found {len(commits)} commits, fetching PR info...", file=sys.stderr)
prs = get_prs_for_commits(commits, args.repo)
if args.json:
output = json.dumps({
"version": version,
"from": from_ref,
"to": to_ref,
"commits": commits,
"prs": prs
}, indent=2, default=str)
else:
output = generate_release_notes(version, from_ref, to_ref, commits, prs, args.repo)
if args.output:
Path(args.output).write_text(output)
print(f"Release notes written to: {args.output}", file=sys.stderr)
else:
print(output)
if name == "main": main()