#!/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()