#!/usr/bin/env python3 """ Version Bumper Analyzes commits since last tag to determine the correct version bump (major/minor/patch) based on conventional commits. Handles pre-release versions (alpha, beta, rc) and generates version bump commands for various package files. Input: current version + commit list JSON or git log Output: recommended new version + bump commands + updated file snippets """ import argparse import json import re import sys from typing import Dict, List, Optional, Tuple, Union from enum import Enum from dataclasses import dataclass class BumpType(Enum): """Version bump types.""" NONE = "none" PATCH = "patch" MINOR = "minor" MAJOR = "major" class PreReleaseType(Enum): """Pre-release types.""" ALPHA = "alpha" BETA = "beta" RC = "rc" @dataclass class Version: """Semantic version representation.""" major: int minor: int patch: int prerelease_type: Optional[PreReleaseType] = None prerelease_number: Optional[int] = None @classmethod def parse(cls, version_str: str) -> 'Version': """Parse version string into Version object.""" # Remove 'v' prefix if present clean_version = version_str.lstrip('v') # Pattern for semantic versioning with optional pre-release pattern = r'^(\d+)\.(\d+)\.(\d+)(?:-(\w+)\.?(\d+)?)?$' match = re.match(pattern, clean_version) if not match: raise ValueError(f"Invalid version format: {version_str}") major, minor, patch = int(match.group(1)), int(match.group(2)), int(match.group(3)) prerelease_type = None prerelease_number = None if match.group(4): # Pre-release identifier prerelease_str = match.group(4).lower() try: prerelease_type = PreReleaseType(prerelease_str) except ValueError: # Handle variations like 'alpha1' -> 'alpha' if prerelease_str.startswith('alpha'): prerelease_type = PreReleaseType.ALPHA elif prerelease_str.startswith('beta'): prerelease_type = PreReleaseType.BETA elif prerelease_str.startswith('rc'): prerelease_type = PreReleaseType.RC else: raise ValueError(f"Unknown pre-release type: {prerelease_str}") if match.group(5): prerelease_number = int(match.group(5)) else: # Extract number from combined string like 'alpha1' number_match = re.search(r'(\d+)$', prerelease_str) if number_match: prerelease_number = int(number_match.group(1)) else: prerelease_number = 1 # Default to 1 return cls(major, minor, patch, prerelease_type, prerelease_number) def to_string(self, include_v_prefix: bool = False) -> str: """Convert version to string representation.""" base = f"{self.major}.{self.minor}.{self.patch}" if self.prerelease_type: if self.prerelease_number is not None: base += f"-{self.prerelease_type.value}.{self.prerelease_number}" else: base += f"-{self.prerelease_type.value}" return f"v{base}" if include_v_prefix else base def bump(self, bump_type: BumpType, prerelease_type: Optional[PreReleaseType] = None) -> 'Version': """Create new version with specified bump.""" if bump_type == BumpType.NONE: return Version(self.major, self.minor, self.patch, self.prerelease_type, self.prerelease_number) new_major = self.major new_minor = self.minor new_patch = self.patch new_prerelease_type = None new_prerelease_number = None # Handle pre-release versions if prerelease_type: if bump_type == BumpType.MAJOR: new_major += 1 new_minor = 0 new_patch = 0 elif bump_type == BumpType.MINOR: new_minor += 1 new_patch = 0 elif bump_type == BumpType.PATCH: new_patch += 1 new_prerelease_type = prerelease_type new_prerelease_number = 1 # Handle existing pre-release -> next pre-release elif self.prerelease_type: # If we're already in pre-release, increment or promote if prerelease_type is None: # Promote to stable release # Don't change version numbers, just remove pre-release pass else: # Move to next pre-release type or increment if prerelease_type == self.prerelease_type: # Same pre-release type, increment number new_prerelease_type = self.prerelease_type new_prerelease_number = (self.prerelease_number or 0) + 1 else: # Different pre-release type new_prerelease_type = prerelease_type new_prerelease_number = 1 # Handle stable version bumps else: if bump_type == BumpType.MAJOR: new_major += 1 new_minor = 0 new_patch = 0 elif bump_type == BumpType.MINOR: new_minor += 1 new_patch = 0 elif bump_type == BumpType.PATCH: new_patch += 1 return Version(new_major, new_minor, new_patch, new_prerelease_type, new_prerelease_number) @dataclass class ConventionalCommit: """Represents a parsed conventional commit for version analysis.""" type: str scope: str description: str is_breaking: bool breaking_description: str hash: str = "" author: str = "" date: str = "" @classmethod def parse_message(cls, message: str, commit_hash: str = "", author: str = "", date: str = "") -> 'ConventionalCommit': """Parse conventional commit message.""" lines = message.split('\n') header = lines[0] if lines else "" # Parse header: type(scope): description header_pattern = r'^(\w+)(\([^)]+\))?(!)?:\s*(.+)$' match = re.match(header_pattern, header) commit_type = "chore" scope = "" description = header is_breaking = False breaking_description = "" if match: commit_type = match.group(1).lower() scope_match = match.group(2) scope = scope_match[1:-1] if scope_match else "" is_breaking = bool(match.group(3)) # ! indicates breaking change description = match.group(4).strip() # Check for breaking change in body/footers if len(lines) > 1: body_text = '\n'.join(lines[1:]) if 'BREAKING CHANGE:' in body_text: is_breaking = True breaking_match = re.search(r'BREAKING CHANGE:\s*(.+)', body_text) if breaking_match: breaking_description = breaking_match.group(1).strip() return cls(commit_type, scope, description, is_breaking, breaking_description, commit_hash, author, date) class VersionBumper: """Main version bumping logic.""" def __init__(self): self.current_version: Optional[Version] = None self.commits: List[ConventionalCommit] = [] self.custom_rules: Dict[str, BumpType] = {} self.ignore_types: List[str] = ['test', 'ci', 'build', 'chore', 'docs', 'style'] def set_current_version(self, version_str: str): """Set the current version.""" self.current_version = Version.parse(version_str) def add_custom_rule(self, commit_type: str, bump_type: BumpType): """Add custom rule for commit type to bump type mapping.""" self.custom_rules[commit_type] = bump_type def parse_commits_from_json(self, json_data: Union[str, List[Dict]]): """Parse commits from JSON format.""" if isinstance(json_data, str): data = json.loads(json_data) else: data = json_data self.commits = [] for commit_data in data: commit = ConventionalCommit.parse_message( message=commit_data.get('message', ''), commit_hash=commit_data.get('hash', ''), author=commit_data.get('author', ''), date=commit_data.get('date', '') ) self.commits.append(commit) def parse_commits_from_git_log(self, git_log_text: str): """Parse commits from git log output.""" lines = git_log_text.strip().split('\n') if not lines or not lines[0]: return # Simple oneline format (hash message) oneline_pattern = r'^([a-f0-9]{7,40})\s+(.+)$' self.commits = [] for line in lines: line = line.strip() if not line: continue match = re.match(oneline_pattern, line) if match: commit_hash = match.group(1) message = match.group(2) commit = ConventionalCommit.parse_message(message, commit_hash) self.commits.append(commit) def determine_bump_type(self) -> BumpType: """Determine version bump type based on commits.""" if not self.commits: return BumpType.NONE has_breaking = False has_feature = False has_fix = False for commit in self.commits: # Check for breaking changes if commit.is_breaking: has_breaking = True continue # Apply custom rules first if commit.type in self.custom_rules: bump_type = self.custom_rules[commit.type] if bump_type == BumpType.MAJOR: has_breaking = True elif bump_type == BumpType.MINOR: has_feature = True elif bump_type == BumpType.PATCH: has_fix = True continue # Standard rules if commit.type in ['feat', 'add']: has_feature = True elif commit.type in ['fix', 'security', 'perf', 'bugfix']: has_fix = True # Ignore types in ignore_types list # Determine bump type by priority if has_breaking: return BumpType.MAJOR elif has_feature: return BumpType.MINOR elif has_fix: return BumpType.PATCH else: return BumpType.NONE def recommend_version(self, prerelease_type: Optional[PreReleaseType] = None) -> Version: """Recommend new version based on commits.""" if not self.current_version: raise ValueError("Current version not set") bump_type = self.determine_bump_type() return self.current_version.bump(bump_type, prerelease_type) def generate_bump_commands(self, new_version: Version) -> Dict[str, List[str]]: """Generate version bump commands for different package managers.""" version_str = new_version.to_string() version_with_v = new_version.to_string(include_v_prefix=True) commands = { 'npm': [ f"npm version {version_str} --no-git-tag-version", f"# Or manually edit package.json version field to '{version_str}'" ], 'python': [ f"# Update version in setup.py, __init__.py, or pyproject.toml", f"# setup.py: version='{version_str}'", f"# pyproject.toml: version = '{version_str}'", f"# __init__.py: __version__ = '{version_str}'" ], 'rust': [ f"# Update Cargo.toml", f"# [package]", f"# version = '{version_str}'" ], 'git': [ f"git tag -a {version_with_v} -m 'Release {version_with_v}'", f"git push origin {version_with_v}" ], 'docker': [ f"docker build -t myapp:{version_str} .", f"docker tag myapp:{version_str} myapp:latest" ] } return commands def generate_file_updates(self, new_version: Version) -> Dict[str, str]: """Generate file update snippets for common package files.""" version_str = new_version.to_string() updates = {} # package.json updates['package.json'] = json.dumps({ "name": "your-package", "version": version_str, "description": "Your package description", "main": "index.js" }, indent=2) # pyproject.toml updates['pyproject.toml'] = f'''[build-system] requires = ["setuptools>=61.0", "wheel"] build-backend = "setuptools.build_meta" [project] name = "your-package" version = "{version_str}" description = "Your package description" authors = [ {{name = "Your Name", email = "your.email@example.com"}}, ] ''' # setup.py updates['setup.py'] = f'''from setuptools import setup, find_packages setup( name="your-package", version="{version_str}", description="Your package description", packages=find_packages(), python_requires=">=3.8", ) ''' # Cargo.toml updates['Cargo.toml'] = f'''[package] name = "your-package" version = "{version_str}" edition = "2021" description = "Your package description" ''' # __init__.py updates['__init__.py'] = f'''"""Your package.""" __version__ = "{version_str}" __author__ = "Your Name" __email__ = "your.email@example.com" ''' return updates def analyze_commits(self) -> Dict: """Provide detailed analysis of commits for version bumping.""" if not self.commits: return { 'total_commits': 0, 'by_type': {}, 'breaking_changes': [], 'features': [], 'fixes': [], 'ignored': [] } analysis = { 'total_commits': len(self.commits), 'by_type': {}, 'breaking_changes': [], 'features': [], 'fixes': [], 'ignored': [] } type_counts = {} for commit in self.commits: type_counts[commit.type] = type_counts.get(commit.type, 0) + 1 if commit.is_breaking: analysis['breaking_changes'].append({ 'type': commit.type, 'scope': commit.scope, 'description': commit.description, 'breaking_description': commit.breaking_description, 'hash': commit.hash }) elif commit.type in ['feat', 'add']: analysis['features'].append({ 'scope': commit.scope, 'description': commit.description, 'hash': commit.hash }) elif commit.type in ['fix', 'security', 'perf', 'bugfix']: analysis['fixes'].append({ 'scope': commit.scope, 'description': commit.description, 'hash': commit.hash }) elif commit.type in self.ignore_types: analysis['ignored'].append({ 'type': commit.type, 'scope': commit.scope, 'description': commit.description, 'hash': commit.hash }) analysis['by_type'] = type_counts return analysis def main(): """Main CLI entry point.""" parser = argparse.ArgumentParser(description="Determine version bump based on conventional commits") parser.add_argument('--current-version', '-c', required=True, help='Current version (e.g., 1.2.3, v1.2.3)') parser.add_argument('--input', '-i', type=str, help='Input file with commits (default: stdin)') parser.add_argument('--input-format', choices=['git-log', 'json'], default='git-log', help='Input format') parser.add_argument('--prerelease', '-p', choices=['alpha', 'beta', 'rc'], help='Generate pre-release version') parser.add_argument('--output-format', '-f', choices=['text', 'json', 'commands'], default='text', help='Output format') parser.add_argument('--output', '-o', type=str, help='Output file (default: stdout)') parser.add_argument('--include-commands', action='store_true', help='Include bump commands in output') parser.add_argument('--include-files', action='store_true', help='Include file update snippets') parser.add_argument('--custom-rules', type=str, help='JSON string with custom type->bump rules') parser.add_argument('--ignore-types', type=str, help='Comma-separated list of types to ignore') parser.add_argument('--analysis', '-a', action='store_true', help='Include detailed commit analysis') args = parser.parse_args() # Read input if args.input: with open(args.input, 'r', encoding='utf-8') as f: input_data = f.read() else: input_data = sys.stdin.read() if not input_data.strip(): print("No input data provided", file=sys.stderr) sys.exit(1) # Initialize version bumper bumper = VersionBumper() try: bumper.set_current_version(args.current_version) except ValueError as e: print(f"Invalid current version: {e}", file=sys.stderr) sys.exit(1) # Apply custom rules if args.custom_rules: try: custom_rules = json.loads(args.custom_rules) for commit_type, bump_type_str in custom_rules.items(): bump_type = BumpType(bump_type_str.lower()) bumper.add_custom_rule(commit_type, bump_type) except Exception as e: print(f"Invalid custom rules: {e}", file=sys.stderr) sys.exit(1) # Set ignore types if args.ignore_types: bumper.ignore_types = [t.strip() for t in args.ignore_types.split(',')] # Parse commits try: if args.input_format == 'json': bumper.parse_commits_from_json(input_data) else: bumper.parse_commits_from_git_log(input_data) except Exception as e: print(f"Error parsing commits: {e}", file=sys.stderr) sys.exit(1) # Determine pre-release type prerelease_type = None if args.prerelease: prerelease_type = PreReleaseType(args.prerelease) # Generate recommendation try: recommended_version = bumper.recommend_version(prerelease_type) bump_type = bumper.determine_bump_type() except Exception as e: print(f"Error determining version: {e}", file=sys.stderr) sys.exit(1) # Generate output output_data = {} if args.output_format == 'json': output_data = { 'current_version': args.current_version, 'recommended_version': recommended_version.to_string(), 'recommended_version_with_v': recommended_version.to_string(include_v_prefix=True), 'bump_type': bump_type.value, 'prerelease': args.prerelease } if args.analysis: output_data['analysis'] = bumper.analyze_commits() if args.include_commands: output_data['commands'] = bumper.generate_bump_commands(recommended_version) if args.include_files: output_data['file_updates'] = bumper.generate_file_updates(recommended_version) output_text = json.dumps(output_data, indent=2) elif args.output_format == 'commands': commands = bumper.generate_bump_commands(recommended_version) output_lines = [ f"# Version Bump Commands", f"# Current: {args.current_version}", f"# New: {recommended_version.to_string()}", f"# Bump Type: {bump_type.value}", "" ] for category, cmd_list in commands.items(): output_lines.append(f"## {category.upper()}") for cmd in cmd_list: output_lines.append(cmd) output_lines.append("") output_text = '\n'.join(output_lines) else: # text format output_lines = [ f"Current Version: {args.current_version}", f"Recommended Version: {recommended_version.to_string()}", f"With v prefix: {recommended_version.to_string(include_v_prefix=True)}", f"Bump Type: {bump_type.value}", "" ] if args.analysis: analysis = bumper.analyze_commits() output_lines.extend([ "Commit Analysis:", f"- Total commits: {analysis['total_commits']}", f"- Breaking changes: {len(analysis['breaking_changes'])}", f"- New features: {len(analysis['features'])}", f"- Bug fixes: {len(analysis['fixes'])}", f"- Ignored commits: {len(analysis['ignored'])}", "" ]) if analysis['breaking_changes']: output_lines.append("Breaking Changes:") for change in analysis['breaking_changes']: scope = f"({change['scope']})" if change['scope'] else "" output_lines.append(f" - {change['type']}{scope}: {change['description']}") output_lines.append("") if args.include_commands: commands = bumper.generate_bump_commands(recommended_version) output_lines.append("Bump Commands:") for category, cmd_list in commands.items(): output_lines.append(f" {category}:") for cmd in cmd_list: if not cmd.startswith('#'): output_lines.append(f" {cmd}") output_lines.append("") output_text = '\n'.join(output_lines) # Write output if args.output: with open(args.output, 'w', encoding='utf-8') as f: f.write(output_text) else: print(output_text) if __name__ == '__main__': main()