Files
markitect-main/capabilities/release-management/src/release_management/changelog/editor.py
tegwick 5fea98b068 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>
2026-01-06 21:28:46 +01:00

208 lines
6.9 KiB
Python

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