feat: implement optimization #7 - release summary auto-generation
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>
This commit is contained in:
@@ -12,6 +12,7 @@ from typing import Optional
|
||||
from ..core.manager import ReleaseManager
|
||||
from ..utils.version import VersionManager
|
||||
from ..changelog.editor import ChangelogEditor
|
||||
from ..summary.generator import SummaryGenerator
|
||||
|
||||
|
||||
@click.group(invoke_without_command=True)
|
||||
@@ -326,5 +327,36 @@ def prepare(ctx, version: str, date: Optional[str]):
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@main.command('summary')
|
||||
@click.argument('version')
|
||||
@click.option('--output', '-o', default=None, type=click.Path(path_type=Path),
|
||||
help='Output file path (defaults to RELEASE_SUMMARY_vX.Y.Z.md)')
|
||||
@click.pass_context
|
||||
def summary(ctx, version: str, output: Optional[Path]):
|
||||
"""Generate release summary document.
|
||||
|
||||
Extracts CHANGELOG content, git statistics, build artifacts, and
|
||||
validation results to create a comprehensive release summary.
|
||||
"""
|
||||
project_root = ctx.obj['project_root'] or Path.cwd()
|
||||
|
||||
# Default output path
|
||||
if output is None:
|
||||
version_clean = version.lstrip('v')
|
||||
output = project_root / f"RELEASE_SUMMARY_v{version_clean}.md"
|
||||
elif not output.is_absolute():
|
||||
output = project_root / output
|
||||
|
||||
generator = SummaryGenerator(project_root)
|
||||
|
||||
try:
|
||||
content = generator.generate(version, output_path=output)
|
||||
print(f"\n✅ Release summary generated successfully")
|
||||
print(f"📄 Summary saved to: {output}")
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to generate release summary: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
Release summary generation tools.
|
||||
|
||||
This package provides tools for generating release summary documents.
|
||||
"""
|
||||
|
||||
from .generator import SummaryGenerator
|
||||
|
||||
__all__ = ['SummaryGenerator']
|
||||
@@ -0,0 +1,305 @@
|
||||
"""
|
||||
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"
|
||||
Reference in New Issue
Block a user