feat: implement optimization #5 - CHANGELOG section generation
Add automated CHANGELOG section preparation for releases: - Create ChangelogEditor class for programmatic CHANGELOG.md editing - Implement create_version_section() to create new release sections - Automatically move [Unreleased] content to new version section - Add 'release prepare VERSION' CLI command to prepare CHANGELOG - Validate CHANGELOG after edit to ensure correctness - Support custom release dates with --date option - Provide helpful feedback about content movement This streamlines release preparation by automating the manual task of creating version sections and moving unreleased changes. Usage: release prepare 0.11.0 # Uses today's date release prepare 0.11.0 --date 2026-01-15 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
CHANGELOG management tools.
|
||||
|
||||
This package provides tools for working with CHANGELOG.md files.
|
||||
"""
|
||||
|
||||
from .editor import ChangelogEditor
|
||||
|
||||
__all__ = ['ChangelogEditor']
|
||||
@@ -0,0 +1,207 @@
|
||||
"""
|
||||
CHANGELOG.md editor for programmatic updates.
|
||||
|
||||
This module provides tools for editing CHANGELOG.md files following
|
||||
the Keep a Changelog format.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
class ChangelogEditor:
|
||||
"""Programmatic editor for CHANGELOG.md files."""
|
||||
|
||||
def __init__(self, changelog_path: Optional[Path] = None):
|
||||
"""Initialize changelog editor.
|
||||
|
||||
Args:
|
||||
changelog_path: Path to CHANGELOG.md file
|
||||
"""
|
||||
self.changelog_path = changelog_path or Path.cwd() / 'CHANGELOG.md'
|
||||
|
||||
def create_version_section(self, version: str, date: Optional[str] = None) -> bool:
|
||||
"""Create new version section and move Unreleased content.
|
||||
|
||||
Args:
|
||||
version: Version number (e.g., "0.11.0")
|
||||
date: Release date in YYYY-MM-DD format (defaults to today)
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
if date is None:
|
||||
date = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
# Validate version format
|
||||
version_clean = version.lstrip('v')
|
||||
|
||||
if not self.changelog_path.exists():
|
||||
print(f"❌ CHANGELOG.md not found at {self.changelog_path}")
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(self.changelog_path) as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# Find Unreleased section
|
||||
unreleased_idx = None
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip() == "## [Unreleased]":
|
||||
unreleased_idx = i
|
||||
break
|
||||
|
||||
if unreleased_idx is None:
|
||||
print("❌ No [Unreleased] section found in CHANGELOG.md")
|
||||
return False
|
||||
|
||||
# Find next version section or end of Unreleased content
|
||||
next_section_idx = None
|
||||
for i in range(unreleased_idx + 1, len(lines)):
|
||||
if lines[i].startswith("## [") and not lines[i].startswith("## [Unreleased]"):
|
||||
next_section_idx = i
|
||||
break
|
||||
|
||||
# Extract Unreleased content (skip the header line and first blank line)
|
||||
if next_section_idx:
|
||||
unreleased_content = lines[unreleased_idx + 1:next_section_idx]
|
||||
else:
|
||||
unreleased_content = lines[unreleased_idx + 1:]
|
||||
|
||||
# Check if there's actual content to move
|
||||
has_content = any(line.strip() and not line.strip().startswith('#')
|
||||
for line in unreleased_content)
|
||||
|
||||
if not has_content:
|
||||
print(f"⚠️ [Unreleased] section is empty. Add changes before creating release section.")
|
||||
print(f"💡 Tip: You can still create the section, but it will be empty.")
|
||||
|
||||
# Create new version section with moved content
|
||||
new_section_lines = [
|
||||
f"## [{version_clean}] - {date}\n",
|
||||
]
|
||||
|
||||
# Add the unreleased content (preserving structure)
|
||||
new_section_lines.extend(unreleased_content)
|
||||
|
||||
# Ensure proper spacing after new section
|
||||
if new_section_lines and not new_section_lines[-1].endswith('\n\n'):
|
||||
if not new_section_lines[-1].endswith('\n'):
|
||||
new_section_lines[-1] += '\n'
|
||||
new_section_lines.append('\n')
|
||||
|
||||
# Build new file content
|
||||
# Keep everything up to and including Unreleased header
|
||||
new_lines = lines[:unreleased_idx + 1]
|
||||
|
||||
# Add blank line after Unreleased header
|
||||
new_lines.append('\n')
|
||||
|
||||
# Add the new version section
|
||||
new_lines.extend(new_section_lines)
|
||||
|
||||
# Add remaining sections (if any)
|
||||
if next_section_idx:
|
||||
new_lines.extend(lines[next_section_idx:])
|
||||
|
||||
# Write back
|
||||
with open(self.changelog_path, 'w') as f:
|
||||
f.writelines(new_lines)
|
||||
|
||||
print(f"✅ Created section [{version_clean}] - {date} in CHANGELOG.md")
|
||||
if has_content:
|
||||
print(f"📝 Moved content from [Unreleased] to [{version_clean}]")
|
||||
print(f"💡 [Unreleased] section is now empty and ready for new changes")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error editing CHANGELOG.md: {e}")
|
||||
return False
|
||||
|
||||
def get_version_content(self, version: str) -> Optional[List[str]]:
|
||||
"""Extract content for a specific version section.
|
||||
|
||||
Args:
|
||||
version: Version number to extract (e.g., "0.10.0")
|
||||
|
||||
Returns:
|
||||
List of lines in the version section, or None if not found
|
||||
"""
|
||||
version_clean = version.lstrip('v')
|
||||
|
||||
if not self.changelog_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(self.changelog_path) as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# Find the version section
|
||||
version_idx = None
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip().startswith(f"## [{version_clean}]"):
|
||||
version_idx = i
|
||||
break
|
||||
|
||||
if version_idx is None:
|
||||
return None
|
||||
|
||||
# Find next section
|
||||
next_section_idx = None
|
||||
for i in range(version_idx + 1, len(lines)):
|
||||
if lines[i].startswith("## ["):
|
||||
next_section_idx = i
|
||||
break
|
||||
|
||||
# Extract content
|
||||
if next_section_idx:
|
||||
return lines[version_idx:next_section_idx]
|
||||
else:
|
||||
return lines[version_idx:]
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def has_unreleased_content(self) -> bool:
|
||||
"""Check if Unreleased section has any content.
|
||||
|
||||
Returns:
|
||||
True if Unreleased section has content, False otherwise
|
||||
"""
|
||||
if not self.changelog_path.exists():
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(self.changelog_path) as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# Find Unreleased section
|
||||
unreleased_idx = None
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip() == "## [Unreleased]":
|
||||
unreleased_idx = i
|
||||
break
|
||||
|
||||
if unreleased_idx is None:
|
||||
return False
|
||||
|
||||
# Find next section
|
||||
next_section_idx = None
|
||||
for i in range(unreleased_idx + 1, len(lines)):
|
||||
if lines[i].startswith("## ["):
|
||||
next_section_idx = i
|
||||
break
|
||||
|
||||
# Check content
|
||||
if next_section_idx:
|
||||
content = lines[unreleased_idx + 1:next_section_idx]
|
||||
else:
|
||||
content = lines[unreleased_idx + 1:]
|
||||
|
||||
# Check if there's actual content (not just whitespace or section headers)
|
||||
return any(line.strip() and not line.strip().startswith('#')
|
||||
for line in content)
|
||||
|
||||
except Exception:
|
||||
return False
|
||||
@@ -11,6 +11,7 @@ from typing import Optional
|
||||
|
||||
from ..core.manager import ReleaseManager
|
||||
from ..utils.version import VersionManager
|
||||
from ..changelog.editor import ChangelogEditor
|
||||
|
||||
|
||||
@click.group(invoke_without_command=True)
|
||||
@@ -286,5 +287,44 @@ def check_consistency(ctx, version: str):
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@main.command('prepare')
|
||||
@click.argument('version')
|
||||
@click.option('--date', default=None, help='Release date (YYYY-MM-DD, defaults to today)')
|
||||
@click.pass_context
|
||||
def prepare(ctx, version: str, date: Optional[str]):
|
||||
"""Prepare CHANGELOG for new version release.
|
||||
|
||||
Creates a new version section in CHANGELOG.md and moves all content
|
||||
from the [Unreleased] section to the new version section.
|
||||
"""
|
||||
project_root = ctx.obj['project_root'] or Path.cwd()
|
||||
changelog_path = project_root / 'CHANGELOG.md'
|
||||
|
||||
editor = ChangelogEditor(changelog_path)
|
||||
|
||||
# Create version section
|
||||
if editor.create_version_section(version, date):
|
||||
# Validate result
|
||||
manager = ReleaseManager(
|
||||
project_root=ctx.obj['project_root'],
|
||||
dry_run=ctx.obj['dry_run'],
|
||||
force=ctx.obj['force']
|
||||
)
|
||||
|
||||
# Check if CHANGELOG is valid after edit
|
||||
is_valid, issues = manager.validate_release_state()
|
||||
|
||||
if is_valid:
|
||||
print("\n✅ CHANGELOG validation passed")
|
||||
else:
|
||||
print("\n⚠️ CHANGELOG validation issues after edit:")
|
||||
for issue in issues:
|
||||
if 'CHANGELOG' in issue:
|
||||
print(f" - {issue}")
|
||||
else:
|
||||
print(f"❌ Failed to prepare CHANGELOG for version {version}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user