Add automated release summary document generation: - Create SummaryGenerator class to generate comprehensive release summaries - Extract CHANGELOG sections for specific versions automatically - Calculate git statistics (commits, files changed, insertions, deletions) - List build artifacts from dist/ directory with sizes - Include validation results in summary - Add 'release summary VERSION' CLI command to generate summaries - Support custom output paths with --output option - Auto-detect project name from pyproject.toml - Include contributor information from git log This automates the manual task of creating release documentation, ensuring consistent and comprehensive release summaries. Usage: release summary 0.10.0 # Generates RELEASE_SUMMARY_v0.10.0.md release summary 0.10.0 --output docs/v0.10.0.md # Custom output path 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
306 lines
10 KiB
Python
306 lines
10 KiB
Python
"""
|
|
Release summary generator.
|
|
|
|
This module generates comprehensive release summary documents from
|
|
CHANGELOG content and git metadata.
|
|
"""
|
|
|
|
import subprocess
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Optional, Dict, Any, List
|
|
import re
|
|
|
|
|
|
class SummaryGenerator:
|
|
"""Generate release summary documents."""
|
|
|
|
def __init__(self, project_root: Optional[Path] = None):
|
|
"""Initialize summary generator.
|
|
|
|
Args:
|
|
project_root: Root directory of the project
|
|
"""
|
|
self.project_root = project_root or Path.cwd()
|
|
self.changelog_path = self.project_root / 'CHANGELOG.md'
|
|
self.dist_dir = self.project_root / 'dist'
|
|
|
|
def generate(self, version: str, output_path: Optional[Path] = None) -> str:
|
|
"""Generate release summary document.
|
|
|
|
Args:
|
|
version: Version to generate summary for (e.g., "0.10.0")
|
|
output_path: Optional path to write summary to
|
|
|
|
Returns:
|
|
Generated summary content
|
|
"""
|
|
version_clean = version.lstrip('v')
|
|
tag_name = f"v{version_clean}"
|
|
|
|
# Get components
|
|
changelog_section = self.extract_changelog_section(version_clean)
|
|
git_stats = self.get_git_statistics(tag_name)
|
|
build_artifacts = self.list_build_artifacts()
|
|
validation_results = self.get_validation_results()
|
|
|
|
# Determine project name
|
|
project_name = self._get_project_name()
|
|
|
|
# Build summary
|
|
summary = f"""# {project_name} {version_clean} Release Summary
|
|
|
|
**Release Date**: {git_stats.get('release_date', 'Unknown')}
|
|
**Git Tag**: {tag_name}
|
|
**Commit**: {git_stats.get('commit_hash', 'Unknown')}
|
|
|
|
---
|
|
|
|
## Changes
|
|
|
|
{changelog_section}
|
|
|
|
---
|
|
|
|
## Git Statistics
|
|
|
|
- **Commits**: {git_stats.get('commit_count', 0)} commit(s) since last release
|
|
- **Files Changed**: {git_stats.get('files_changed', 0)} file(s)
|
|
- **Insertions**: +{git_stats.get('insertions', 0)} lines
|
|
- **Deletions**: -{git_stats.get('deletions', 0)} lines
|
|
- **Contributors**: {git_stats.get('contributors', 'Unknown')}
|
|
|
|
---
|
|
|
|
## Build Artifacts
|
|
|
|
{build_artifacts}
|
|
|
|
---
|
|
|
|
## Validation
|
|
|
|
{validation_results}
|
|
|
|
---
|
|
|
|
**Generated**: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
|
|
"""
|
|
|
|
if output_path:
|
|
output_path.write_text(summary)
|
|
print(f"✅ Release summary written to {output_path}")
|
|
|
|
return summary
|
|
|
|
def extract_changelog_section(self, version: str) -> str:
|
|
"""Extract CHANGELOG section for a specific version.
|
|
|
|
Args:
|
|
version: Version to extract (e.g., "0.10.0")
|
|
|
|
Returns:
|
|
Markdown content of the version section
|
|
"""
|
|
if not self.changelog_path.exists():
|
|
return "⚠️ CHANGELOG.md not found"
|
|
|
|
try:
|
|
with open(self.changelog_path) as f:
|
|
content = f.read()
|
|
|
|
# Find the version section
|
|
pattern = rf"## \[{re.escape(version)}\].*?\n\n(.*?)(?=\n## \[|\Z)"
|
|
match = re.search(pattern, content, re.DOTALL)
|
|
|
|
if match:
|
|
section_content = match.group(1).strip()
|
|
return section_content if section_content else "No changes documented"
|
|
else:
|
|
return f"⚠️ No section found for version {version} in CHANGELOG.md"
|
|
|
|
except Exception as e:
|
|
return f"❌ Error reading CHANGELOG: {e}"
|
|
|
|
def get_git_statistics(self, tag: str) -> Dict[str, Any]:
|
|
"""Get git statistics for a release tag.
|
|
|
|
Args:
|
|
tag: Git tag name (e.g., "v0.10.0")
|
|
|
|
Returns:
|
|
Dictionary with git statistics
|
|
"""
|
|
stats = {}
|
|
|
|
try:
|
|
# Get tag date
|
|
try:
|
|
result = subprocess.run(
|
|
['git', 'log', '-1', '--format=%ci', tag],
|
|
capture_output=True, text=True, check=True, cwd=self.project_root
|
|
)
|
|
date_str = result.stdout.strip()
|
|
# Parse to get just the date
|
|
stats['release_date'] = date_str.split()[0] if date_str else 'Unknown'
|
|
except subprocess.CalledProcessError:
|
|
stats['release_date'] = 'Unknown'
|
|
|
|
# Get commit hash
|
|
try:
|
|
result = subprocess.run(
|
|
['git', 'rev-parse', tag],
|
|
capture_output=True, text=True, check=True, cwd=self.project_root
|
|
)
|
|
stats['commit_hash'] = result.stdout.strip()[:8]
|
|
except subprocess.CalledProcessError:
|
|
stats['commit_hash'] = 'Unknown'
|
|
|
|
# Find previous tag
|
|
try:
|
|
result = subprocess.run(
|
|
['git', 'describe', '--tags', '--abbrev=0', f'{tag}^'],
|
|
capture_output=True, text=True, check=True, cwd=self.project_root
|
|
)
|
|
previous_tag = result.stdout.strip()
|
|
except subprocess.CalledProcessError:
|
|
# No previous tag, use initial commit
|
|
previous_tag = None
|
|
|
|
# Get commit count
|
|
if previous_tag:
|
|
try:
|
|
result = subprocess.run(
|
|
['git', 'rev-list', '--count', f'{previous_tag}..{tag}'],
|
|
capture_output=True, text=True, check=True, cwd=self.project_root
|
|
)
|
|
stats['commit_count'] = int(result.stdout.strip())
|
|
except subprocess.CalledProcessError:
|
|
stats['commit_count'] = 0
|
|
else:
|
|
try:
|
|
result = subprocess.run(
|
|
['git', 'rev-list', '--count', tag],
|
|
capture_output=True, text=True, check=True, cwd=self.project_root
|
|
)
|
|
stats['commit_count'] = int(result.stdout.strip())
|
|
except subprocess.CalledProcessError:
|
|
stats['commit_count'] = 0
|
|
|
|
# Get file changes, insertions, deletions
|
|
if previous_tag:
|
|
diff_range = f'{previous_tag}..{tag}'
|
|
else:
|
|
diff_range = tag
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
['git', 'diff', '--shortstat', diff_range],
|
|
capture_output=True, text=True, check=True, cwd=self.project_root
|
|
)
|
|
shortstat = result.stdout.strip()
|
|
|
|
# Parse shortstat: "X files changed, Y insertions(+), Z deletions(-)"
|
|
files_match = re.search(r'(\d+) files? changed', shortstat)
|
|
insert_match = re.search(r'(\d+) insertions?', shortstat)
|
|
delete_match = re.search(r'(\d+) deletions?', shortstat)
|
|
|
|
stats['files_changed'] = int(files_match.group(1)) if files_match else 0
|
|
stats['insertions'] = int(insert_match.group(1)) if insert_match else 0
|
|
stats['deletions'] = int(delete_match.group(1)) if delete_match else 0
|
|
except subprocess.CalledProcessError:
|
|
stats['files_changed'] = 0
|
|
stats['insertions'] = 0
|
|
stats['deletions'] = 0
|
|
|
|
# Get contributors
|
|
try:
|
|
result = subprocess.run(
|
|
['git', 'log', '--format=%an', f'{previous_tag}..{tag}' if previous_tag else tag],
|
|
capture_output=True, text=True, check=True, cwd=self.project_root
|
|
)
|
|
contributors = list(set(result.stdout.strip().split('\n')))
|
|
stats['contributors'] = ', '.join(contributors) if contributors and contributors[0] else 'Unknown'
|
|
except subprocess.CalledProcessError:
|
|
stats['contributors'] = 'Unknown'
|
|
|
|
except Exception as e:
|
|
print(f"⚠️ Error getting git statistics: {e}")
|
|
|
|
return stats
|
|
|
|
def list_build_artifacts(self) -> str:
|
|
"""List build artifacts in dist/ directory.
|
|
|
|
Returns:
|
|
Formatted markdown list of build artifacts
|
|
"""
|
|
if not self.dist_dir.exists():
|
|
return "No build artifacts found (dist/ directory does not exist)"
|
|
|
|
artifacts = list(self.dist_dir.glob('*'))
|
|
if not artifacts:
|
|
return "No build artifacts found in dist/"
|
|
|
|
lines = []
|
|
for artifact in sorted(artifacts):
|
|
if artifact.is_file():
|
|
size = artifact.stat().st_size
|
|
size_kb = size / 1024
|
|
size_mb = size / (1024 * 1024)
|
|
|
|
if size_mb >= 1:
|
|
size_str = f"{size_mb:.2f} MB"
|
|
else:
|
|
size_str = f"{size_kb:.2f} KB"
|
|
|
|
lines.append(f"- **{artifact.name}** ({size_str})")
|
|
|
|
return '\n'.join(lines) if lines else "No build artifacts found"
|
|
|
|
def get_validation_results(self) -> str:
|
|
"""Get validation results summary.
|
|
|
|
Returns:
|
|
Formatted validation results
|
|
"""
|
|
# Import here to avoid circular dependency
|
|
from ..utils.validation import ReleaseValidator
|
|
|
|
validator = ReleaseValidator(self.project_root)
|
|
is_valid, issues = validator.validate_release_state(force=True) # Force to get all issues
|
|
|
|
if is_valid:
|
|
return "✅ All validation checks passed"
|
|
else:
|
|
lines = ["Validation Issues:"]
|
|
for issue in issues:
|
|
lines.append(f"- {issue}")
|
|
return '\n'.join(lines)
|
|
|
|
def _get_project_name(self) -> str:
|
|
"""Get project name from pyproject.toml.
|
|
|
|
Returns:
|
|
Project name or default
|
|
"""
|
|
pyproject_path = self.project_root / 'pyproject.toml'
|
|
|
|
if not pyproject_path.exists():
|
|
return "Project"
|
|
|
|
try:
|
|
import tomllib
|
|
except ImportError:
|
|
try:
|
|
import tomli as tomllib
|
|
except ImportError:
|
|
return "Project"
|
|
|
|
try:
|
|
with open(pyproject_path, 'rb') as f:
|
|
config = tomllib.load(f)
|
|
return config.get('project', {}).get('name', 'Project').title()
|
|
except Exception:
|
|
return "Project"
|