#!/usr/bin/env python3 """ Automated Changelog Generator for TimeTracker Generates changelogs from git commits and GitHub issues/PRs """ import os import sys import subprocess import argparse import re from datetime import datetime from typing import List, Dict, Tuple, Optional import requests class ChangelogGenerator: def __init__(self, repo_path: str = "."): self.repo_path = repo_path self.github_token = os.getenv('GITHUB_TOKEN') self.repo_url = self._get_repo_url() def _get_repo_url(self) -> Optional[str]: """Get GitHub repository URL from git remote""" try: result = subprocess.run( ['git', 'remote', 'get-url', 'origin'], cwd=self.repo_path, capture_output=True, text=True ) if result.returncode == 0: url = result.stdout.strip() # Convert SSH URL to HTTPS if needed if url.startswith('git@github.com:'): url = url.replace('git@github.com:', 'https://github.com/') if url.endswith('.git'): url = url[:-4] return url except Exception as e: print(f"Warning: Could not get repository URL: {e}") return None def _run_git_command(self, command: List[str]) -> str: """Run a git command and return output""" try: result = subprocess.run( ['git'] + command, cwd=self.repo_path, capture_output=True, text=True, check=True ) return result.stdout.strip() except subprocess.CalledProcessError as e: print(f"Git command failed: {e}") return "" def get_latest_tag(self) -> str: """Get the latest git tag""" output = self._run_git_command(['describe', '--tags', '--abbrev=0']) return output if output else "HEAD~50" # Fallback to last 50 commits def get_commits_since_tag(self, since_tag: str) -> List[Dict[str, str]]: """Get commits since the specified tag""" if since_tag == "HEAD~50": command = ['log', '--pretty=format:%H|%s|%an|%ad|%b', '--date=short', '-50'] else: command = ['log', f'{since_tag}..HEAD', '--pretty=format:%H|%s|%an|%ad|%b', '--date=short'] output = self._run_git_command(command) commits = [] if output: for line in output.split('\n'): if '|' in line: parts = line.split('|', 4) if len(parts) >= 4: commits.append({ 'hash': parts[0][:8], 'subject': parts[1], 'author': parts[2], 'date': parts[3], 'body': parts[4] if len(parts) > 4 else '' }) return commits def categorize_commits(self, commits: List[Dict[str, str]]) -> Dict[str, List[Dict[str, str]]]: """Categorize commits by type""" categories = { 'Features': [], 'Bug Fixes': [], 'Improvements': [], 'Documentation': [], 'Refactoring': [], 'Dependencies': [], 'Database': [], 'Docker': [], 'CI/CD': [], 'Other': [] } # Patterns for categorization patterns = { 'Features': [r'^feat(\(.+\))?:', r'^add:', r'^implement:', r'^new:'], 'Bug Fixes': [r'^fix(\(.+\))?:', r'^bug:', r'^hotfix:', r'^patch:'], 'Improvements': [r'^improve:', r'^enhance:', r'^update:', r'^upgrade:'], 'Documentation': [r'^docs?(\(.+\))?:', r'^readme:', r'^doc:'], 'Refactoring': [r'^refactor(\(.+\))?:', r'^cleanup:', r'^reorganize:'], 'Dependencies': [r'^deps?(\(.+\))?:', r'^bump:', r'^requirements:'], 'Database': [r'^db:', r'^migration:', r'^schema:', r'^alembic:'], 'Docker': [r'^docker:', r'^dockerfile:', r'^compose:'], 'CI/CD': [r'^ci:', r'^cd:', r'^workflow:', r'^action:', r'^build:'] } for commit in commits: subject = commit['subject'].lower() categorized = False for category, category_patterns in patterns.items(): for pattern in category_patterns: if re.match(pattern, subject): categories[category].append(commit) categorized = True break if categorized: break if not categorized: categories['Other'].append(commit) return categories def extract_breaking_changes(self, commits: List[Dict[str, str]]) -> List[Dict[str, str]]: """Extract breaking changes from commits""" breaking_changes = [] for commit in commits: # Look for BREAKING CHANGE in commit message full_message = f"{commit['subject']} {commit['body']}" if 'BREAKING CHANGE' in full_message or 'breaking:' in commit['subject'].lower(): breaking_changes.append(commit) return breaking_changes def get_github_prs_and_issues(self, commits: List[Dict[str, str]]) -> Dict[str, List[Dict]]: """Get GitHub PRs and issues mentioned in commits""" prs = [] issues = [] if not self.github_token or not self.repo_url: return {'prs': prs, 'issues': issues} # Extract PR/issue numbers from commit messages pr_pattern = r'#(\d+)' mentioned_numbers = set() for commit in commits: matches = re.findall(pr_pattern, f"{commit['subject']} {commit['body']}") mentioned_numbers.update(matches) # Fetch details from GitHub API repo_parts = self.repo_url.replace('https://github.com/', '').split('/') if len(repo_parts) >= 2: owner, repo = repo_parts[0], repo_parts[1] headers = {'Authorization': f'token {self.github_token}'} for number in mentioned_numbers: try: # Try to fetch as PR first pr_url = f'https://api.github.com/repos/{owner}/{repo}/pulls/{number}' response = requests.get(pr_url, headers=headers) if response.status_code == 200: pr_data = response.json() prs.append({ 'number': number, 'title': pr_data['title'], 'url': pr_data['html_url'], 'author': pr_data['user']['login'] }) else: # Try as issue issue_url = f'https://api.github.com/repos/{owner}/{repo}/issues/{number}' response = requests.get(issue_url, headers=headers) if response.status_code == 200: issue_data = response.json() issues.append({ 'number': number, 'title': issue_data['title'], 'url': issue_data['html_url'], 'author': issue_data['user']['login'] }) except Exception as e: print(f"Warning: Could not fetch GitHub data for #{number}: {e}") return {'prs': prs, 'issues': issues} def generate_changelog(self, version: str, since_tag: str = None) -> str: """Generate complete changelog""" if not since_tag: since_tag = self.get_latest_tag() print(f"Generating changelog for version {version} since {since_tag}...") # Get commits commits = self.get_commits_since_tag(since_tag) print(f"Found {len(commits)} commits") if not commits: return f"# {version}\n\n*No changes since {since_tag}*\n" # Categorize commits categorized = self.categorize_commits(commits) # Extract breaking changes breaking_changes = self.extract_breaking_changes(commits) # Get GitHub data github_data = self.get_github_prs_and_issues(commits) # Generate changelog content changelog = self._format_changelog( version, since_tag, categorized, breaking_changes, github_data ) return changelog def _format_changelog( self, version: str, since_tag: str, categorized: Dict[str, List[Dict[str, str]]], breaking_changes: List[Dict[str, str]], github_data: Dict[str, List[Dict]] ) -> str: """Format the changelog content""" changelog = f"# {version}\n\n" changelog += f"*Released on {datetime.now().strftime('%Y-%m-%d')}*\n\n" # Summary total_commits = sum(len(commits) for commits in categorized.values()) changelog += f"**{total_commits} changes** since {since_tag}\n\n" # Breaking changes (if any) if breaking_changes: changelog += "## ⚠️ Breaking Changes\n\n" for commit in breaking_changes: changelog += f"- {commit['subject']} ([{commit['hash']}]" if self.repo_url: changelog += f"({self.repo_url}/commit/{commit['hash']}))\n" else: changelog += ")\n" changelog += "\n" # Features and improvements feature_categories = ['Features', 'Improvements', 'Bug Fixes'] for category in feature_categories: if categorized[category]: icon = {'Features': '✨', 'Improvements': '🚀', 'Bug Fixes': '🐛'}[category] changelog += f"## {icon} {category}\n\n" for commit in categorized[category]: # Clean up commit subject subject = re.sub(r'^(feat|fix|improve|enhance|update)(\(.+\))?:\s*', '', commit['subject'], flags=re.IGNORECASE) changelog += f"- {subject} ([{commit['hash']}]" if self.repo_url: changelog += f"({self.repo_url}/commit/{commit['hash']}))\n" else: changelog += ")\n" changelog += "\n" # Technical changes tech_categories = ['Database', 'Docker', 'CI/CD', 'Refactoring', 'Dependencies'] tech_changes = any(categorized[cat] for cat in tech_categories) if tech_changes: changelog += "## 🔧 Technical Changes\n\n" for category in tech_categories: if categorized[category]: changelog += f"### {category}\n" for commit in categorized[category]: subject = re.sub(r'^(db|docker|ci|cd|refactor|deps?)(\(.+\))?:\s*', '', commit['subject'], flags=re.IGNORECASE) changelog += f"- {subject} ([{commit['hash']}]" if self.repo_url: changelog += f"({self.repo_url}/commit/{commit['hash']}))\n" else: changelog += ")\n" changelog += "\n" # Documentation if categorized['Documentation']: changelog += "## 📚 Documentation\n\n" for commit in categorized['Documentation']: subject = re.sub(r'^docs?(\(.+\))?:\s*', '', commit['subject'], flags=re.IGNORECASE) changelog += f"- {subject} ([{commit['hash']}]" if self.repo_url: changelog += f"({self.repo_url}/commit/{commit['hash']}))\n" else: changelog += ")\n" changelog += "\n" # Other changes if categorized['Other']: changelog += "## 📋 Other Changes\n\n" for commit in categorized['Other']: changelog += f"- {commit['subject']} ([{commit['hash']}]" if self.repo_url: changelog += f"({self.repo_url}/commit/{commit['hash']}))\n" else: changelog += ")\n" changelog += "\n" # GitHub PRs and Issues if github_data['prs'] or github_data['issues']: changelog += "## 🔗 Related\n\n" if github_data['prs']: changelog += "**Pull Requests:**\n" for pr in github_data['prs']: changelog += f"- [{pr['title']}]({pr['url']}) by @{pr['author']}\n" changelog += "\n" if github_data['issues']: changelog += "**Issues:**\n" for issue in github_data['issues']: changelog += f"- [{issue['title']}]({issue['url']}) by @{issue['author']}\n" changelog += "\n" # Contributors contributors = set() for category_commits in categorized.values(): for commit in category_commits: contributors.add(commit['author']) if contributors: changelog += "## 👥 Contributors\n\n" changelog += f"Thanks to all contributors: {', '.join(f'@{c}' for c in sorted(contributors))}\n\n" return changelog def main(): parser = argparse.ArgumentParser(description='Generate changelog for TimeTracker') parser.add_argument('version', help='Version for the changelog (e.g., v1.2.3)') parser.add_argument('--since', help='Generate changelog since this tag/commit') parser.add_argument('--output', '-o', help='Output file (default: CHANGELOG.md)') parser.add_argument('--append', action='store_true', help='Append to existing changelog') parser.add_argument('--repo-path', default='.', help='Repository path') args = parser.parse_args() generator = ChangelogGenerator(args.repo_path) changelog = generator.generate_changelog(args.version, args.since) if args.output: output_file = args.output else: output_file = os.path.join(args.repo_path, 'CHANGELOG.md') # Write changelog mode = 'a' if args.append else 'w' with open(output_file, mode, encoding='utf-8') as f: if args.append and os.path.exists(output_file): f.write('\n\n---\n\n') f.write(changelog) print(f"Changelog written to {output_file}") # Also output to stdout for GitHub Actions print("\n" + "="*50) print("GENERATED CHANGELOG:") print("="*50) print(changelog) if __name__ == '__main__': main()