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 ..core.manager import ReleaseManager
|
||||||
from ..utils.version import VersionManager
|
from ..utils.version import VersionManager
|
||||||
from ..changelog.editor import ChangelogEditor
|
from ..changelog.editor import ChangelogEditor
|
||||||
|
from ..summary.generator import SummaryGenerator
|
||||||
|
|
||||||
|
|
||||||
@click.group(invoke_without_command=True)
|
@click.group(invoke_without_command=True)
|
||||||
@@ -326,5 +327,36 @@ def prepare(ctx, version: str, date: Optional[str]):
|
|||||||
sys.exit(1)
|
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__':
|
if __name__ == '__main__':
|
||||||
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