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 ..core.manager import ReleaseManager
|
||||||
from ..utils.version import VersionManager
|
from ..utils.version import VersionManager
|
||||||
|
from ..changelog.editor import ChangelogEditor
|
||||||
|
|
||||||
|
|
||||||
@click.group(invoke_without_command=True)
|
@click.group(invoke_without_command=True)
|
||||||
@@ -286,5 +287,44 @@ def check_consistency(ctx, version: str):
|
|||||||
sys.exit(1)
|
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__':
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
Reference in New Issue
Block a user