From 7f696582a91418a0dfc5b321849774b98264ee3b Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 6 Jan 2026 21:32:28 +0100 Subject: [PATCH] feat: implement optimization #7 - release summary auto-generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/release_management/cli/main.py | 32 ++ .../release_management/summary/__init__.py | 9 + .../release_management/summary/generator.py | 305 ++++++++++++++++++ 3 files changed, 346 insertions(+) create mode 100644 capabilities/release-management/src/release_management/summary/__init__.py create mode 100644 capabilities/release-management/src/release_management/summary/generator.py diff --git a/capabilities/release-management/src/release_management/cli/main.py b/capabilities/release-management/src/release_management/cli/main.py index 28a2b2cc..5cf2f469 100644 --- a/capabilities/release-management/src/release_management/cli/main.py +++ b/capabilities/release-management/src/release_management/cli/main.py @@ -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() \ No newline at end of file diff --git a/capabilities/release-management/src/release_management/summary/__init__.py b/capabilities/release-management/src/release_management/summary/__init__.py new file mode 100644 index 00000000..b86c18a0 --- /dev/null +++ b/capabilities/release-management/src/release_management/summary/__init__.py @@ -0,0 +1,9 @@ +""" +Release summary generation tools. + +This package provides tools for generating release summary documents. +""" + +from .generator import SummaryGenerator + +__all__ = ['SummaryGenerator'] diff --git a/capabilities/release-management/src/release_management/summary/generator.py b/capabilities/release-management/src/release_management/summary/generator.py new file mode 100644 index 00000000..955baa73 --- /dev/null +++ b/capabilities/release-management/src/release_management/summary/generator.py @@ -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"