diff --git a/capabilities/release-management/src/release_management/changelog/__init__.py b/capabilities/release-management/src/release_management/changelog/__init__.py index 367a4246..de01f398 100644 --- a/capabilities/release-management/src/release_management/changelog/__init__.py +++ b/capabilities/release-management/src/release_management/changelog/__init__.py @@ -5,5 +5,6 @@ This package provides tools for working with CHANGELOG.md files. """ from .editor import ChangelogEditor +from .parser import ChangelogParser -__all__ = ['ChangelogEditor'] +__all__ = ['ChangelogEditor', 'ChangelogParser'] diff --git a/capabilities/release-management/src/release_management/changelog/parser.py b/capabilities/release-management/src/release_management/changelog/parser.py new file mode 100644 index 00000000..ff066262 --- /dev/null +++ b/capabilities/release-management/src/release_management/changelog/parser.py @@ -0,0 +1,179 @@ +""" +CHANGELOG.md parser for extracting release notes. + +This module provides tools for parsing CHANGELOG.md files and extracting +version-specific content for release notes. +""" + +import re +from pathlib import Path +from typing import Optional + + +class ChangelogParser: + """Parse CHANGELOG.md files and extract release information.""" + + def __init__(self, changelog_path: Optional[Path] = None): + """Initialize changelog parser. + + Args: + changelog_path: Path to CHANGELOG.md file + """ + self.changelog_path = changelog_path or Path.cwd() / 'CHANGELOG.md' + + def extract_version_section(self, version: str, format: str = 'markdown') -> str: + """Extract CHANGELOG section for a specific version. + + Args: + version: Version to extract (e.g., "0.10.0") + format: Output format ('markdown', 'plain', 'html') + + Returns: + Formatted content of the version section + """ + if not self.changelog_path.exists(): + return f"Error: CHANGELOG.md not found at {self.changelog_path}" + + try: + version_clean = version.lstrip('v') + + with open(self.changelog_path) as f: + content = f.read() + + # Find the version section using regex + # Match: ## [VERSION] - DATE followed by content until next ## [ + pattern = rf"## \[{re.escape(version_clean)}\].*?\n\n(.*?)(?=\n## \[|\Z)" + match = re.search(pattern, content, re.DOTALL) + + if not match: + return f"Error: No section found for version {version_clean} in CHANGELOG.md" + + section_content = match.group(1).strip() + + if not section_content: + return f"Warning: Section for version {version_clean} exists but is empty" + + # Format based on requested format + if format == 'plain': + return self._to_plain(section_content) + elif format == 'html': + return self._to_html(section_content) + else: + return section_content # markdown (default) + + except Exception as e: + return f"Error reading CHANGELOG: {e}" + + def get_latest_version(self) -> Optional[str]: + """Get the latest version number from CHANGELOG. + + Returns: + Latest version string or None if not found + """ + if not self.changelog_path.exists(): + return None + + try: + with open(self.changelog_path) as f: + content = f.read() + + # Find first version section (skip Unreleased) + pattern = r"## \[(\d+\.\d+\.\d+[^\]]*)\]" + match = re.search(pattern, content) + + return match.group(1) if match else None + + except Exception: + return None + + def list_versions(self) -> list: + """List all versions in CHANGELOG. + + Returns: + List of version strings + """ + if not self.changelog_path.exists(): + return [] + + try: + with open(self.changelog_path) as f: + content = f.read() + + # Find all version sections (excluding Unreleased) + pattern = r"## \[(\d+\.\d+\.\d+[^\]]*)\]" + matches = re.findall(pattern, content) + + return matches + + except Exception: + return [] + + def _to_plain(self, markdown_content: str) -> str: + """Convert markdown content to plain text. + + Args: + markdown_content: Markdown formatted content + + Returns: + Plain text content + """ + # Remove markdown formatting + plain = markdown_content + + # Remove bold/italic + plain = re.sub(r'\*\*([^*]+)\*\*', r'\1', plain) # bold + plain = re.sub(r'\*([^*]+)\*', r'\1', plain) # italic + plain = re.sub(r'__([^_]+)__', r'\1', plain) # bold (underscores) + plain = re.sub(r'_([^_]+)_', r'\1', plain) # italic (underscores) + + # Remove links but keep text + plain = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', plain) + + # Remove inline code backticks + plain = re.sub(r'`([^`]+)`', r'\1', plain) + + # Convert headers to plain text with spacing + plain = re.sub(r'^### (.+)$', r'\n\1:', plain, flags=re.MULTILINE) + plain = re.sub(r'^## (.+)$', r'\n\1\n' + '=' * 40, plain, flags=re.MULTILINE) + + return plain.strip() + + def _to_html(self, markdown_content: str) -> str: + """Convert markdown content to HTML. + + Args: + markdown_content: Markdown formatted content + + Returns: + HTML formatted content + """ + try: + import markdown + return markdown.markdown(markdown_content) + except ImportError: + # Fallback to basic HTML conversion if markdown package not available + html = markdown_content + + # Headers + html = re.sub(r'^### (.+)$', r'

\1

', html, flags=re.MULTILINE) + html = re.sub(r'^## (.+)$', r'

\1

', html, flags=re.MULTILINE) + + # Bold/italic + html = re.sub(r'\*\*([^*]+)\*\*', r'\1', html) + html = re.sub(r'\*([^*]+)\*', r'\1', html) + + # Links + html = re.sub(r'\[([^\]]+)\]\(([^\)]+)\)', r'\1', html) + + # Code + html = re.sub(r'`([^`]+)`', r'\1', html) + + # Lists + html = re.sub(r'^- (.+)$', r'
  • \1
  • ', html, flags=re.MULTILINE) + html = re.sub(r'(
  • .*
  • )', r'', html, flags=re.DOTALL) + + # Paragraphs + html = re.sub(r'\n\n', '

    ', html) + html = f'

    {html}

    ' + + return html diff --git a/capabilities/release-management/src/release_management/cli/main.py b/capabilities/release-management/src/release_management/cli/main.py index 5cf2f469..cfa8f59e 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 ..changelog.parser import ChangelogParser from ..summary.generator import SummaryGenerator @@ -358,5 +359,58 @@ def summary(ctx, version: str, output: Optional[Path]): sys.exit(1) +@main.command('notes') +@click.argument('version', required=False) +@click.option('--format', 'output_format', type=click.Choice(['markdown', 'plain', 'html']), + default='markdown', help='Output format (default: markdown)') +@click.option('--output', '-o', default=None, type=click.Path(path_type=Path), + help='Save to file instead of stdout') +@click.pass_context +def notes(ctx, version: Optional[str], output_format: str, output: Optional[Path]): + """Extract release notes from CHANGELOG.md. + + Extracts the CHANGELOG section for a specific version and outputs it + in various formats. Useful for creating GitHub/Gitea release notes. + + If no version is specified, uses the latest version from CHANGELOG. + + Examples: + release notes 0.10.0 # Extract v0.10.0 notes (markdown) + release notes # Extract latest version notes + release notes 0.10.0 --format plain # Plain text format + release notes 0.10.0 -o notes.md # Save to file + release notes 0.10.0 | gh release create v0.10.0 -F - # Pipe to gh + """ + project_root = ctx.obj['project_root'] or Path.cwd() + changelog_path = project_root / 'CHANGELOG.md' + + parser = ChangelogParser(changelog_path) + + # Get version (use latest if not specified) + if version is None: + version = parser.get_latest_version() + if version is None: + print("❌ Could not determine version from CHANGELOG.md") + sys.exit(1) + print(f"Using latest version: {version}", file=sys.stderr) + + # Extract content + content = parser.extract_version_section(version, format=output_format) + + # Check for errors + if content.startswith("Error:") or content.startswith("Warning:"): + print(content) + sys.exit(1 if content.startswith("Error:") else 0) + + # Output + if output: + if not output.is_absolute(): + output = project_root / output + output.write_text(content) + print(f"✅ Release notes written to {output}", file=sys.stderr) + else: + print(content) + + if __name__ == '__main__': main() \ No newline at end of file