""" 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"