Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:46:13 +08:00
commit 6005591708
7 changed files with 1125 additions and 0 deletions

View File

@@ -0,0 +1,243 @@
#!/usr/bin/env python3
"""
Generate CHANGELOG.md from Conventional Commits in git history.
"""
import argparse
import re
import subprocess
from collections import defaultdict
from datetime import datetime
from pathlib import Path
COMMIT_PATTERN = re.compile(
r'^(?P<type>\w+)(?:\((?P<scope>[\w-]+)\))?(?P<breaking>!)?: (?P<description>.+)$'
)
TYPE_HEADERS = {
'feat': 'Added',
'fix': 'Fixed',
'docs': 'Documentation',
'style': 'Style',
'refactor': 'Changed',
'perf': 'Performance',
'test': 'Tests',
'chore': 'Maintenance',
'ci': 'CI/CD',
'build': 'Build',
'revert': 'Reverted',
}
def get_commits(since=None, until='HEAD'):
"""Get git commits in range."""
cmd = ['git', 'log', '--pretty=format:%H|%s|%b|%an|%ae|%ad', '--date=short']
if since:
cmd.append(f'{since}..{until}')
else:
cmd.append(until)
result = subprocess.run(cmd, capture_output=True, text=True)
commits = []
for line in result.stdout.strip().split('\n'):
if not line:
continue
parts = line.split('|')
if len(parts) >= 6:
commits.append({
'hash': parts[0],
'subject': parts[1],
'body': parts[2],
'author': parts[3],
'email': parts[4],
'date': parts[5],
})
return commits
def parse_commit(commit):
"""Parse conventional commit message."""
match = COMMIT_PATTERN.match(commit['subject'])
if not match:
return None
parsed = {
'type': match.group('type'),
'scope': match.group('scope'),
'breaking': bool(match.group('breaking')),
'description': match.group('description'),
'hash': commit['hash'][:7],
'full_hash': commit['hash'],
'body': commit['body'],
}
# Check for BREAKING CHANGE in body
if 'BREAKING CHANGE' in commit['body']:
parsed['breaking'] = True
# Extract breaking change description
breaking_match = re.search(r'BREAKING CHANGE:\s*(.+)', commit['body'])
if breaking_match:
parsed['breaking_description'] = breaking_match.group(1)
return parsed
def group_commits(commits):
"""Group commits by type and breaking changes."""
groups = defaultdict(list)
breaking = []
for commit in commits:
parsed = parse_commit(commit)
if not parsed:
continue
if parsed['breaking']:
breaking.append(parsed)
commit_type = parsed['type']
groups[commit_type].append(parsed)
return groups, breaking
def format_commit(commit):
"""Format a single commit for changelog."""
scope = f"**{commit['scope']}**: " if commit['scope'] else ""
return f"- {scope}{commit['description']} ([{commit['hash']}](../../commit/{commit['full_hash']}))"
def generate_changelog_section(version, date, groups, breaking):
"""Generate a changelog section for a version."""
lines = [
f"## [{version}] - {date}",
""
]
# Breaking changes first
if breaking:
lines.append("### BREAKING CHANGES")
lines.append("")
for commit in breaking:
if 'breaking_description' in commit:
lines.append(f"- {commit['breaking_description']}")
else:
lines.append(format_commit(commit))
lines.append("")
# Regular changes by type
for commit_type, header in TYPE_HEADERS.items():
if commit_type in groups and groups[commit_type]:
lines.append(f"### {header}")
lines.append("")
for commit in groups[commit_type]:
lines.append(format_commit(commit))
lines.append("")
return '\n'.join(lines)
def get_current_version():
"""Get current version from package.json."""
try:
import json
with open('package.json') as f:
data = json.load(f)
return data.get('version', '0.0.0')
except:
return '0.0.0'
def get_last_tag():
"""Get the most recent git tag."""
result = subprocess.run(
['git', 'describe', '--tags', '--abbrev=0'],
capture_output=True,
text=True
)
if result.returncode == 0:
return result.stdout.strip()
return None
def update_changelog(content, output_path):
"""Update or create CHANGELOG.md file."""
changelog_path = Path(output_path)
if changelog_path.exists():
with open(changelog_path) as f:
existing = f.read()
# Insert new content after header
if '## [' in existing:
parts = existing.split('## [', 1)
updated = parts[0] + content + '\n## [' + parts[1]
else:
updated = existing + '\n\n' + content
else:
# Create new changelog with header
header = """# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
"""
updated = header + content
with open(changelog_path, 'w') as f:
f.write(updated)
print(f"Updated: {changelog_path}")
def main():
parser = argparse.ArgumentParser(description='Generate changelog from git commits')
parser.add_argument('--since', help='Start from this tag/commit')
parser.add_argument('--until', default='HEAD', help='End at this tag/commit')
parser.add_argument('--version', help='Version number for this release')
parser.add_argument('--output', default='CHANGELOG.md', help='Output file path')
parser.add_argument('--date', help='Release date (YYYY-MM-DD)')
args = parser.parse_args()
# Determine version and since
version = args.version or get_current_version()
since = args.since or get_last_tag()
if not since:
print("Warning: No previous tag found. Generating changelog for all commits.")
# Get and parse commits
commits = get_commits(since=since, until=args.until)
if not commits:
print("No commits found in range.")
return
groups, breaking = group_commits(commits)
# Generate changelog section
date = args.date or datetime.now().strftime('%Y-%m-%d')
changelog_content = generate_changelog_section(version, date, groups, breaking)
# Update changelog file
update_changelog(changelog_content, args.output)
# Print summary
print(f"\nGenerated changelog for version {version}")
print(f" Commits processed: {len(commits)}")
print(f" Breaking changes: {len(breaking)}")
print(f" Features: {len(groups.get('feat', []))}")
print(f" Bug fixes: {len(groups.get('fix', []))}")
if __name__ == '__main__':
main()