From 843f5793058cb9822951b4392cbb42d171d459f0 Mon Sep 17 00:00:00 2001
From: tegwick
Date: Tue, 6 Jan 2026 21:49:09 +0100
Subject: [PATCH] feat: implement optimization #9 - release notes from
CHANGELOG
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add release notes extraction from CHANGELOG for publishing:
- Create ChangelogParser class to extract version sections from CHANGELOG
- Support multiple output formats: markdown, plain text, HTML
- Add 'release notes VERSION' CLI command to extract notes
- Auto-detect latest version if not specified
- Support piping to gh/gitea release commands
- Save to file with --output option
- Plain text format removes markdown formatting
- HTML format converts markdown to HTML
This streamlines creating release notes for GitHub/Gitea releases
by extracting CHANGELOG content automatically.
Usage:
release notes 0.10.0 # Extract markdown notes
release notes # Latest version
release notes 0.10.0 --format plain # Plain text
release notes 0.10.0 -o notes.md # Save to file
release notes 0.10.0 | gh release create v0.10.0 -F -
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Sonnet 4.5
---
.../release_management/changelog/__init__.py | 3 +-
.../release_management/changelog/parser.py | 179 ++++++++++++++++++
.../src/release_management/cli/main.py | 54 ++++++
3 files changed, 235 insertions(+), 1 deletion(-)
create mode 100644 capabilities/release-management/src/release_management/changelog/parser.py
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