feat: implement optimization #9 - release notes from CHANGELOG

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 <noreply@anthropic.com>
This commit is contained in:
2026-01-06 21:49:09 +01:00
parent 7515b9c0e5
commit 843f579305
3 changed files with 235 additions and 1 deletions

View File

@@ -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']

View File

@@ -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'<h3>\1</h3>', html, flags=re.MULTILINE)
html = re.sub(r'^## (.+)$', r'<h2>\1</h2>', html, flags=re.MULTILINE)
# Bold/italic
html = re.sub(r'\*\*([^*]+)\*\*', r'<strong>\1</strong>', html)
html = re.sub(r'\*([^*]+)\*', r'<em>\1</em>', html)
# Links
html = re.sub(r'\[([^\]]+)\]\(([^\)]+)\)', r'<a href="\2">\1</a>', html)
# Code
html = re.sub(r'`([^`]+)`', r'<code>\1</code>', html)
# Lists
html = re.sub(r'^- (.+)$', r'<li>\1</li>', html, flags=re.MULTILINE)
html = re.sub(r'(<li>.*</li>)', r'<ul>\1</ul>', html, flags=re.DOTALL)
# Paragraphs
html = re.sub(r'\n\n', '</p><p>', html)
html = f'<p>{html}</p>'
return html

View File

@@ -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()