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:
@@ -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']
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user