From 5fea98b0687ddf07b126f1ba4bf938ffe352c0fc Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 6 Jan 2026 21:28:46 +0100 Subject: [PATCH] feat: implement optimization #5 - CHANGELOG section generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../release_management/changelog/__init__.py | 9 + .../release_management/changelog/editor.py | 207 ++++++++++++++++++ .../src/release_management/cli/main.py | 40 ++++ 3 files changed, 256 insertions(+) create mode 100644 capabilities/release-management/src/release_management/changelog/__init__.py create mode 100644 capabilities/release-management/src/release_management/changelog/editor.py diff --git a/capabilities/release-management/src/release_management/changelog/__init__.py b/capabilities/release-management/src/release_management/changelog/__init__.py new file mode 100644 index 00000000..367a4246 --- /dev/null +++ b/capabilities/release-management/src/release_management/changelog/__init__.py @@ -0,0 +1,9 @@ +""" +CHANGELOG management tools. + +This package provides tools for working with CHANGELOG.md files. +""" + +from .editor import ChangelogEditor + +__all__ = ['ChangelogEditor'] diff --git a/capabilities/release-management/src/release_management/changelog/editor.py b/capabilities/release-management/src/release_management/changelog/editor.py new file mode 100644 index 00000000..09afdfa9 --- /dev/null +++ b/capabilities/release-management/src/release_management/changelog/editor.py @@ -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 diff --git a/capabilities/release-management/src/release_management/cli/main.py b/capabilities/release-management/src/release_management/cli/main.py index 87387e16..28a2b2cc 100644 --- a/capabilities/release-management/src/release_management/cli/main.py +++ b/capabilities/release-management/src/release_management/cli/main.py @@ -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() \ No newline at end of file