#!/usr/bin/env python3 import sys import re import json import requests import subprocess import argparse from collections import defaultdict from git import Repo def get_commits_since_tag(repo, tag): try: tag_commit = repo.commit(tag) commits = list(repo.iter_commits(f"{tag_commit.hexsha}..HEAD")) return [ (commit.hexsha, commit.message.splitlines()[0], commit.message) for commit in commits ] except Exception as e: sys.stderr.write(f"Error retrieving commits: {e}\n") sys.exit(1) def check_pr_number(first_line): match = re.search(r"\(\#(\d+)\)$", first_line) if match: return int(match.group(1)) return None def fetch_pr_labels(pr_number): try: # Use gh CLI to fetch PR details result = subprocess.run([ "gh", "api", f"repos/leanprover/lean4/pulls/{pr_number}" ], capture_output=True, text=True, check=True) pr_data = result.stdout pr_json = json.loads(pr_data) return [label["name"] for label in pr_json.get("labels", [])] except subprocess.CalledProcessError as e: sys.stderr.write(f"Failed to fetch PR #{pr_number} using gh: {e.stderr}\n") return [] def format_section_title(label): title = label.replace("changelog-", "").capitalize() if title == "Doc": return "Documentation" elif title == "Pp": return "Pretty Printing" return title def sort_sections_order(): return [ "Language", "Library", "Tactics", "Compiler", "Pretty Printing", "Documentation", "Server", "Lake", "Other", "Uncategorised" ] def format_markdown_description(pr_number, description): link = f"[#{pr_number}](https://github.com/leanprover/lean4/pull/{pr_number})" return f"{link} {description}" def commit_types(): # see doc/dev/commit_convention.md return ['feat', 'fix', 'doc', 'style', 'refactor', 'test', 'chore', 'perf'] def count_commit_types(commits): counts = { 'total': len(commits), } for commit_type in commit_types(): counts[commit_type] = 0 for _, first_line, _ in commits: for commit_type in commit_types(): if first_line.startswith(f'{commit_type}:'): counts[commit_type] += 1 break return counts def main(): parser = argparse.ArgumentParser(description='Generate release notes from Git commits') parser.add_argument('--since', required=True, help='Git tag to generate release notes since') args = parser.parse_args() try: repo = Repo(".") except Exception as e: sys.stderr.write(f"Error opening Git repository: {e}\n") sys.exit(1) commits = get_commits_since_tag(repo, args.since) sys.stderr.write(f"Found {len(commits)} commits since tag {args.since}:\n") for commit_hash, first_line, _ in commits: sys.stderr.write(f"- {commit_hash}: {first_line}\n") changelog = defaultdict(list) for commit_hash, first_line, full_message in commits: # Skip commits with the specific first lines if first_line == "chore: update stage0" or first_line.startswith("chore: CI: bump "): continue pr_number = check_pr_number(first_line) if not pr_number: sys.stderr.write(f"No PR number found in commit:\n{commit_hash}\n{first_line}\n") continue # Remove the first line from the full_message for further processing body = full_message[len(first_line):].strip() paragraphs = body.split('\n\n') description = paragraphs[0] if len(paragraphs) > 0 else "" # If there's a third paragraph and second ends with colon, include it if len(paragraphs) > 1 and description.endswith(':'): description = description + '\n\n' + paragraphs[1] labels = fetch_pr_labels(pr_number) # Skip entries with the "changelog-no" label if "changelog-no" in labels: continue report_errors = first_line.startswith("feat:") or first_line.startswith("fix:") if not description.startswith("This PR "): if report_errors: sys.stderr.write(f"No PR description found in commit:\n{commit_hash}\n{first_line}\n{body}\n\n") fallback_description = re.sub(r":$", "", first_line.split(" ", 1)[1]).rsplit(" (#", 1)[0] markdown_description = format_markdown_description(pr_number, fallback_description) else: continue else: markdown_description = format_markdown_description(pr_number, description.replace("This PR ", "")) changelog_labels = [label for label in labels if label.startswith("changelog-")] if len(changelog_labels) > 1: sys.stderr.write(f"Warning: Multiple changelog-* labels found for PR #{pr_number}: {changelog_labels}\n") if not changelog_labels: if report_errors: sys.stderr.write(f"Warning: No changelog-* label found for PR #{pr_number}\n") else: continue for label in changelog_labels: changelog[label].append((pr_number, markdown_description)) # Add commit type counting counts = count_commit_types(commits) print(f"For this release, {counts['total']} changes landed. " f"In addition to the {counts['feat']} feature additions and {counts['fix']} fixes listed below " f"there were {counts['refactor']} refactoring changes, {counts['doc']} documentation improvements, " f"{counts['perf']} performance improvements, {counts['test']} improvements to the test suite " f"and {counts['style'] + counts['chore']} other changes.\n") section_order = sort_sections_order() sorted_changelog = sorted(changelog.items(), key=lambda item: section_order.index(format_section_title(item[0])) if format_section_title(item[0]) in section_order else len(section_order)) for label, entries in sorted_changelog: section_title = format_section_title(label) if label != "Uncategorised" else "Uncategorised" print(f"## {section_title}\n") for _, entry in sorted(entries, key=lambda x: x[0]): # Split entry into lines and indent all lines after the first lines = entry.splitlines() print(f"* {lines[0]}") for line in lines[1:]: print(f" {line}") print() # Empty line after each entry if __name__ == "__main__": main()