Files
markitect-main/capabilities/release-management/src/release_management/summary/generator.py
tegwick 7f696582a9 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>
2026-01-06 21:32:28 +01:00

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"