Skip to main content

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()