Initial commit
This commit is contained in:
251
skills/git-commit/scripts/changelog.py
Executable file
251
skills/git-commit/scripts/changelog.py
Executable file
@@ -0,0 +1,251 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate changelog from conventional commits.
|
||||
|
||||
Usage:
|
||||
python changelog.py # Since last tag
|
||||
python changelog.py --from v1.0.0 # Since specific version
|
||||
python changelog.py --version 2.0.0 # Add version header
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import re
|
||||
from typing import List, Dict, Optional
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class Commit:
|
||||
"""Parsed commit."""
|
||||
def __init__(self, hash: str, message: str):
|
||||
self.hash = hash
|
||||
self.message = message
|
||||
self.type = None
|
||||
self.scope = None
|
||||
self.breaking = False
|
||||
self.description = None
|
||||
|
||||
self._parse()
|
||||
|
||||
def _parse(self):
|
||||
"""Parse conventional commit message."""
|
||||
header = self.message.split('\n')[0]
|
||||
|
||||
# Match: type(scope)!: description
|
||||
pattern = r'^(?P<type>\w+)(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?:\s*(?P<desc>.+)$'
|
||||
match = re.match(pattern, header)
|
||||
|
||||
if match:
|
||||
self.type = match.group('type')
|
||||
self.scope = match.group('scope')
|
||||
self.breaking = bool(match.group('breaking'))
|
||||
self.description = match.group('desc')
|
||||
|
||||
# Check for BREAKING CHANGE in body
|
||||
if 'BREAKING CHANGE:' in self.message:
|
||||
self.breaking = True
|
||||
|
||||
@property
|
||||
def is_valid(self):
|
||||
"""Check if this is a valid conventional commit."""
|
||||
return self.type is not None
|
||||
|
||||
|
||||
class ChangelogGenerator:
|
||||
"""Generate formatted changelog."""
|
||||
|
||||
TYPE_HEADERS = {
|
||||
'feat': '### ✨ Features',
|
||||
'fix': '### 🐛 Bug Fixes',
|
||||
'perf': '### ⚡ Performance',
|
||||
'refactor': '### ♻️ Refactoring',
|
||||
'docs': '### 📚 Documentation',
|
||||
'style': '### 💄 Styling',
|
||||
'test': '### ✅ Tests',
|
||||
'build': '### 📦 Build',
|
||||
'ops': '### 🔧 Operations',
|
||||
'chore': '### 🏗️ Chores',
|
||||
}
|
||||
|
||||
TYPE_ORDER = [
|
||||
'feat', 'fix', 'perf', 'refactor',
|
||||
'docs', 'style', 'test', 'build', 'ops', 'chore'
|
||||
]
|
||||
|
||||
def __init__(self, include_hash: bool = False):
|
||||
self.include_hash = include_hash
|
||||
|
||||
def get_commits(self, from_ref: Optional[str] = None) -> List[Commit]:
|
||||
"""Get commits from git log."""
|
||||
cmd = ['git', 'log', '--format=%H%n%B%n---END---']
|
||||
|
||||
if from_ref:
|
||||
cmd.insert(2, f'{from_ref}..HEAD')
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||
except subprocess.CalledProcessError:
|
||||
return []
|
||||
|
||||
commits = []
|
||||
lines = result.stdout.split('\n')
|
||||
|
||||
current_hash = None
|
||||
current_message = []
|
||||
|
||||
for line in lines:
|
||||
if not current_hash:
|
||||
current_hash = line.strip()
|
||||
elif line == '---END---':
|
||||
if current_hash and current_message:
|
||||
message = '\n'.join(current_message)
|
||||
commits.append(Commit(current_hash, message))
|
||||
current_hash = None
|
||||
current_message = []
|
||||
else:
|
||||
current_message.append(line)
|
||||
|
||||
return commits
|
||||
|
||||
def group_commits(self, commits: List[Commit]) -> Dict[str, List[Commit]]:
|
||||
"""Group commits by type."""
|
||||
breaking = []
|
||||
by_type = defaultdict(list)
|
||||
|
||||
for commit in commits:
|
||||
if not commit.is_valid:
|
||||
continue
|
||||
|
||||
if commit.breaking:
|
||||
breaking.append(commit)
|
||||
|
||||
by_type[commit.type].append(commit)
|
||||
|
||||
return {
|
||||
'breaking': breaking,
|
||||
'by_type': by_type
|
||||
}
|
||||
|
||||
def format_commit(self, commit: Commit) -> str:
|
||||
"""Format a single commit line."""
|
||||
parts = []
|
||||
|
||||
if commit.scope:
|
||||
parts.append(f"**{commit.scope}**:")
|
||||
|
||||
parts.append(commit.description)
|
||||
|
||||
if self.include_hash:
|
||||
parts.append(f"([`{commit.hash[:7]}`])")
|
||||
|
||||
return '- ' + ' '.join(parts)
|
||||
|
||||
def generate(
|
||||
self,
|
||||
from_ref: Optional[str] = None,
|
||||
version: Optional[str] = None,
|
||||
date: Optional[str] = None
|
||||
) -> str:
|
||||
"""Generate complete changelog."""
|
||||
commits = self.get_commits(from_ref)
|
||||
|
||||
if not commits:
|
||||
return "No commits found."
|
||||
|
||||
grouped = self.group_commits(commits)
|
||||
lines = []
|
||||
|
||||
# Version header
|
||||
if version:
|
||||
header = f"## [{version}]"
|
||||
if date:
|
||||
header += f" - {date}"
|
||||
lines.append(header)
|
||||
lines.append("")
|
||||
|
||||
# Breaking changes first
|
||||
if grouped['breaking']:
|
||||
lines.append("### ⚠️ BREAKING CHANGES")
|
||||
lines.append("")
|
||||
for commit in grouped['breaking']:
|
||||
lines.append(self.format_commit(commit))
|
||||
# Add BREAKING CHANGE description if available
|
||||
for line in commit.message.split('\n'):
|
||||
if line.startswith('BREAKING CHANGE:'):
|
||||
detail = line.replace('BREAKING CHANGE:', '').strip()
|
||||
if detail:
|
||||
lines.append(f" - {detail}")
|
||||
lines.append("")
|
||||
|
||||
# Group by type
|
||||
for commit_type in self.TYPE_ORDER:
|
||||
if commit_type not in grouped['by_type']:
|
||||
continue
|
||||
|
||||
type_commits = grouped['by_type'][commit_type]
|
||||
if not type_commits:
|
||||
continue
|
||||
|
||||
lines.append(self.TYPE_HEADERS[commit_type])
|
||||
lines.append("")
|
||||
|
||||
for commit in type_commits:
|
||||
lines.append(self.format_commit(commit))
|
||||
|
||||
lines.append("")
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def get_latest_tag() -> Optional[str]:
|
||||
"""Get latest git tag."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['git', 'describe', '--tags', '--abbrev=0'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True
|
||||
)
|
||||
return result.stdout.strip()
|
||||
except subprocess.CalledProcessError:
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description='Generate changelog')
|
||||
parser.add_argument('--from', dest='from_ref', help='Start from this ref')
|
||||
parser.add_argument('--version', help='Version for header')
|
||||
parser.add_argument('--date', help='Date for header (default: today)')
|
||||
parser.add_argument('--include-hash', action='store_true', help='Include commit hashes')
|
||||
parser.add_argument('--output', help='Output file (default: stdout)')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Get from ref
|
||||
from_ref = args.from_ref
|
||||
if not from_ref:
|
||||
from_ref = get_latest_tag()
|
||||
if from_ref:
|
||||
print(f"# Generating changelog since {from_ref}", file=sys.stderr)
|
||||
|
||||
# Generate
|
||||
generator = ChangelogGenerator(include_hash=args.include_hash)
|
||||
|
||||
date = args.date or datetime.now().strftime('%Y-%m-%d')
|
||||
changelog = generator.generate(from_ref, args.version, date)
|
||||
|
||||
# Output
|
||||
if args.output:
|
||||
with open(args.output, 'w') as f:
|
||||
f.write(changelog)
|
||||
print(f"Changelog written to {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(changelog)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user