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:
2026-01-06 21:28:46 +01:00
parent 0b5098370a
commit 5fea98b068
3 changed files with 256 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
"""
CHANGELOG management tools.
This package provides tools for working with CHANGELOG.md files.
"""
from .editor import ChangelogEditor
__all__ = ['ChangelogEditor']

View File

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

View File

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