mirror of
https://github.com/leanprover/lean4.git
synced 2026-03-17 18:34:06 +00:00
183 lines
6.5 KiB
Python
Executable File
183 lines
6.5 KiB
Python
Executable File
#!/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()
|