296 lines
9.7 KiB
Python
Executable File
296 lines
9.7 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Changelog generation script for Text Texture Generator.
|
|
Generates changelog from git commits between tags.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import json
|
|
import subprocess
|
|
import argparse
|
|
from pathlib import Path
|
|
from typing import List, Dict, Optional, Tuple
|
|
from datetime import datetime
|
|
import re
|
|
|
|
def get_project_root() -> Path:
|
|
"""Get the project root directory."""
|
|
return Path(__file__).parent.parent
|
|
|
|
def load_config() -> Dict:
|
|
"""Load build configuration."""
|
|
config_path = get_project_root() / "build" / "config.json"
|
|
with open(config_path, 'r') as f:
|
|
return json.load(f)
|
|
|
|
def run_git_command(command: List[str]) -> Optional[str]:
|
|
"""Run a git command and return output."""
|
|
try:
|
|
result = subprocess.run(
|
|
['git'] + command,
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
cwd=get_project_root()
|
|
)
|
|
return result.stdout.strip()
|
|
except subprocess.CalledProcessError as e:
|
|
print(f"Git command failed: {' '.join(['git'] + command)}")
|
|
print(f"Error: {e.stderr}")
|
|
return None
|
|
|
|
def get_git_tags() -> List[str]:
|
|
"""Get all git tags sorted by version."""
|
|
output = run_git_command(['tag', '-l', '--sort=-version:refname'])
|
|
if output:
|
|
return [tag for tag in output.split('\n') if tag.strip()]
|
|
return []
|
|
|
|
def get_commits_between(from_ref: Optional[str], to_ref: str) -> List[Dict]:
|
|
"""Get commits between two references."""
|
|
if from_ref:
|
|
commit_range = f"{from_ref}..{to_ref}"
|
|
else:
|
|
commit_range = to_ref
|
|
|
|
# Get commit info in a structured format
|
|
format_str = "%H|%an|%ae|%ad|%s"
|
|
output = run_git_command([
|
|
'log',
|
|
'--pretty=format:' + format_str,
|
|
'--date=iso',
|
|
commit_range
|
|
])
|
|
|
|
if not output:
|
|
return []
|
|
|
|
commits = []
|
|
for line in output.split('\n'):
|
|
if '|' in line:
|
|
parts = line.split('|', 4)
|
|
if len(parts) == 5:
|
|
commits.append({
|
|
'hash': parts[0],
|
|
'author': parts[1],
|
|
'email': parts[2],
|
|
'date': parts[3],
|
|
'message': parts[4]
|
|
})
|
|
|
|
return commits
|
|
|
|
def categorize_commit(commit_message: str) -> Tuple[str, str]:
|
|
"""Categorize a commit based on its message."""
|
|
message_lower = commit_message.lower()
|
|
|
|
# Define categories and their patterns
|
|
categories = {
|
|
'Features': [
|
|
r'^feat(\(.+\))?:', r'^add:', r'^implement:',
|
|
r'\bfeature\b', r'\badd\b.*\bfeature\b', r'\bnew\b.*\bfeature\b'
|
|
],
|
|
'Bug Fixes': [
|
|
r'^fix(\(.+\))?:', r'^bug:', r'^hotfix:',
|
|
r'\bfix\b', r'\bbug\b.*\bfix\b', r'\bresolve\b.*\bissue\b'
|
|
],
|
|
'Improvements': [
|
|
r'^improve(\(.+\))?:', r'^enhance(\(.+\))?:', r'^refactor(\(.+\))?:',
|
|
r'\bimprove\b', r'\benhance\b', r'\boptimize\b', r'\brefactor\b'
|
|
],
|
|
'Documentation': [
|
|
r'^docs?(\(.+\))?:', r'^documentation:',
|
|
r'\bdocs?\b', r'\bdocumentation\b', r'\breadme\b'
|
|
],
|
|
'Build System': [
|
|
r'^build(\(.+\))?:', r'^ci(\(.+\))?:', r'^chore(\(.+\))?:',
|
|
r'\bbuild\b', r'\bci\b', r'\bchore\b', r'\bscript\b'
|
|
],
|
|
'Tests': [
|
|
r'^test(\(.+\))?:', r'\btest\b', r'\btesting\b'
|
|
]
|
|
}
|
|
|
|
# Check each category
|
|
for category, patterns in categories.items():
|
|
for pattern in patterns:
|
|
if re.search(pattern, message_lower):
|
|
# Clean up the message
|
|
cleaned_message = commit_message
|
|
# Remove conventional commit prefixes
|
|
cleaned_message = re.sub(r'^(feat|fix|docs?|style|refactor|test|chore|build|ci|perf|improve|enhance|add|implement|bug|hotfix)(\(.+\))?:\s*', '', cleaned_message, flags=re.IGNORECASE)
|
|
return category, cleaned_message
|
|
|
|
# Default category
|
|
return 'Other Changes', commit_message
|
|
|
|
def generate_changelog_content(version: str, commits: List[Dict], previous_version: Optional[str] = None) -> str:
|
|
"""Generate changelog content for a version."""
|
|
lines = []
|
|
|
|
# Header
|
|
lines.append(f"## [{version}] - {datetime.now().strftime('%Y-%m-%d')}")
|
|
lines.append("")
|
|
|
|
if not commits:
|
|
lines.append("No changes recorded.")
|
|
lines.append("")
|
|
return "\n".join(lines)
|
|
|
|
# Categorize commits
|
|
categorized = {}
|
|
for commit in commits:
|
|
category, message = categorize_commit(commit['message'])
|
|
if category not in categorized:
|
|
categorized[category] = []
|
|
categorized[category].append({
|
|
'message': message,
|
|
'hash': commit['hash'][:8],
|
|
'author': commit['author']
|
|
})
|
|
|
|
# Order categories by importance
|
|
category_order = [
|
|
'Features',
|
|
'Bug Fixes',
|
|
'Improvements',
|
|
'Documentation',
|
|
'Build System',
|
|
'Tests',
|
|
'Other Changes'
|
|
]
|
|
|
|
# Generate sections
|
|
for category in category_order:
|
|
if category in categorized:
|
|
lines.append(f"### {category}")
|
|
lines.append("")
|
|
for item in categorized[category]:
|
|
# Format: - Message (hash)
|
|
lines.append(f"- {item['message']} ({item['hash']})")
|
|
lines.append("")
|
|
|
|
# Add comparison link if we have a previous version
|
|
if previous_version:
|
|
repo_url = "https://github.com/marcmintel/text-texture-generator" # Adjust as needed
|
|
lines.append(f"**Full Changelog**: {repo_url}/compare/{previous_version}...{version}")
|
|
lines.append("")
|
|
|
|
return "\n".join(lines)
|
|
|
|
def generate_full_changelog() -> str:
|
|
"""Generate complete changelog from all tags."""
|
|
lines = []
|
|
|
|
# Header
|
|
lines.append("# Changelog")
|
|
lines.append("")
|
|
lines.append("All notable changes to this project will be documented in this file.")
|
|
lines.append("")
|
|
lines.append("The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),")
|
|
lines.append("and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).")
|
|
lines.append("")
|
|
|
|
# Get tags
|
|
tags = get_git_tags()
|
|
|
|
if not tags:
|
|
lines.append("## [Unreleased]")
|
|
lines.append("")
|
|
# Get all commits
|
|
commits = get_commits_between(None, "HEAD")
|
|
version_content = generate_changelog_content("Unreleased", commits)
|
|
lines.append(version_content)
|
|
else:
|
|
# Process each version
|
|
for i, tag in enumerate(tags):
|
|
# Clean version (remove 'v' prefix if present)
|
|
clean_version = tag.lstrip('v')
|
|
|
|
# Get previous tag
|
|
previous_tag = tags[i + 1] if i + 1 < len(tags) else None
|
|
|
|
# Get commits for this version
|
|
commits = get_commits_between(previous_tag, tag)
|
|
|
|
# Generate content
|
|
version_content = generate_changelog_content(clean_version, commits, previous_tag)
|
|
lines.append(version_content)
|
|
|
|
return "\n".join(lines)
|
|
|
|
def generate_changelog_for_version(version: str, output_file: Optional[Path] = None) -> str:
|
|
"""Generate changelog for a specific version."""
|
|
tags = get_git_tags()
|
|
|
|
# Find the version tag
|
|
version_tag = None
|
|
previous_tag = None
|
|
|
|
for i, tag in enumerate(tags):
|
|
if tag == version or tag == f"v{version}":
|
|
version_tag = tag
|
|
previous_tag = tags[i + 1] if i + 1 < len(tags) else None
|
|
break
|
|
|
|
if not version_tag:
|
|
print(f"Warning: Version tag '{version}' not found. Using current HEAD.")
|
|
version_tag = "HEAD"
|
|
previous_tag = tags[0] if tags else None
|
|
|
|
# Get commits
|
|
commits = get_commits_between(previous_tag, version_tag)
|
|
|
|
# Generate content
|
|
content = generate_changelog_content(version, commits, previous_tag)
|
|
|
|
# Write to file if specified
|
|
if output_file:
|
|
with open(output_file, 'w') as f:
|
|
f.write(content)
|
|
print(f"Changelog written to: {output_file}")
|
|
|
|
return content
|
|
|
|
def main():
|
|
"""Main entry point."""
|
|
parser = argparse.ArgumentParser(description="Generate changelog from git commits")
|
|
parser.add_argument("--version", help="Generate changelog for specific version")
|
|
parser.add_argument("--output", type=Path, help="Output file path")
|
|
parser.add_argument("--full", action="store_true", help="Generate full changelog")
|
|
|
|
args = parser.parse_args()
|
|
|
|
try:
|
|
if args.full:
|
|
content = generate_full_changelog()
|
|
output_file = args.output or get_project_root() / "CHANGELOG.md"
|
|
elif args.version:
|
|
content = generate_changelog_for_version(args.version, args.output)
|
|
output_file = args.output
|
|
else:
|
|
# Default: generate for latest version
|
|
tags = get_git_tags()
|
|
if tags:
|
|
latest_version = tags[0].lstrip('v')
|
|
content = generate_changelog_for_version(latest_version, args.output)
|
|
output_file = args.output
|
|
else:
|
|
print("No git tags found. Use --full to generate unreleased changelog.")
|
|
sys.exit(1)
|
|
|
|
# Write to file or print
|
|
if output_file and not args.version:
|
|
with open(output_file, 'w') as f:
|
|
f.write(content)
|
|
print(f"Changelog written to: {output_file}")
|
|
elif not args.version:
|
|
print(content)
|
|
|
|
except Exception as e:
|
|
print(f"❌ Changelog generation failed: {e}")
|
|
sys.exit(1)
|
|
|
|
if __name__ == "__main__":
|
|
main() |