Files
CleanArchitecture-template/.brain/.agent/skills/engineering-advanced-skills/release-manager/version_bumper.py
2026-03-12 15:17:52 +07:00

645 lines
23 KiB
Python

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