Files
MokoCassiopeia/scripts/maintenance/update_changelog.py

320 lines
10 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Copyright (C) 2025 Moko Consulting <hello@mokoconsulting.tech>
This file is part of a Moko Consulting project.
SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later
This program is free software; you can redistribute it and/or modify it under the terms
of the GNU General Public License as published by the Free Software Foundation; either
version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License (./LICENSE).
# FILE INFORMATION
DEFGROUP: MokoStandards
INGROUP: MokoStandards.Scripts
REPO: https://github.com/mokoconsulting-tech/MokoStandards/
VERSION: 05.00.00
PATH: ./scripts/update_changelog.py
BRIEF: Script to update CHANGELOG.md with entries to UNRELEASED section
NOTE: Follows Keep a Changelog format, supports Added/Changed/Deprecated/Removed/Fixed/Security
"""
import argparse
import os
import re
import sys
from datetime import datetime
from pathlib import Path
from typing import List, Optional
class ChangelogUpdater:
"""Updates CHANGELOG.md following Keep a Changelog format."""
VALID_CATEGORIES = ["Added", "Changed", "Deprecated", "Removed", "Fixed", "Security"]
UNRELEASED_PATTERN = r"## \[Unreleased\]" # Standard Keep a Changelog format
def __init__(self, changelog_path: Path):
"""
Initialize the changelog updater.
Args:
changelog_path: Path to CHANGELOG.md file
"""
self.changelog_path = changelog_path
self.lines: List[str] = []
def read_changelog(self) -> bool:
"""
Read the changelog file.
Returns:
True if successful, False otherwise
"""
try:
with open(self.changelog_path, "r", encoding="utf-8") as f:
self.lines = f.readlines()
return True
except FileNotFoundError:
print(f"Error: CHANGELOG.md not found at {self.changelog_path}", file=sys.stderr)
return False
except Exception as e:
print(f"Error reading CHANGELOG.md: {e}", file=sys.stderr)
return False
def find_unreleased_section(self) -> Optional[int]:
"""
Find the UNRELEASED section in the changelog.
Returns:
Line index of UNRELEASED section, or None if not found
"""
for i, line in enumerate(self.lines):
if re.match(self.UNRELEASED_PATTERN, line):
return i
return None
def find_next_version_section(self, start_index: int) -> Optional[int]:
"""
Find the next version section after UNRELEASED.
Args:
start_index: Index to start searching from
Returns:
Line index of next version section, or None if not found
"""
version_pattern = r"## \[\d+\.\d+\.\d+\]"
for i in range(start_index + 1, len(self.lines)):
if re.match(version_pattern, self.lines[i]):
return i
return None
def get_category_index(self, unreleased_index: int, next_version_index: Optional[int],
category: str) -> Optional[int]:
"""
Find the index of a specific category within UNRELEASED section.
Args:
unreleased_index: Index of UNRELEASED heading
next_version_index: Index of next version section (or None)
category: Category name (e.g., "Added", "Changed")
Returns:
Line index of category heading, or None if not found
"""
end_index = next_version_index if next_version_index else len(self.lines)
category_pattern = rf"### {category}"
for i in range(unreleased_index + 1, end_index):
if re.match(category_pattern, self.lines[i]):
return i
return None
def add_entry(self, category: str, entry: str, subcategory: Optional[str] = None) -> bool:
"""
Add an entry to the UNRELEASED section.
Args:
category: Category (Added/Changed/Deprecated/Removed/Fixed/Security)
entry: Entry text to add
subcategory: Optional subcategory/subheading
Returns:
True if successful, False otherwise
"""
if category not in self.VALID_CATEGORIES:
print(f"Error: Invalid category '{category}'. Must be one of: {', '.join(self.VALID_CATEGORIES)}",
file=sys.stderr)
return False
unreleased_index = self.find_unreleased_section()
if unreleased_index is None:
print("Error: UNRELEASED section not found in CHANGELOG.md", file=sys.stderr)
return False
next_version_index = self.find_next_version_section(unreleased_index)
category_index = self.get_category_index(unreleased_index, next_version_index, category)
# Format entry with proper indentation
if subcategory:
formatted_entry = f" - **{subcategory}**: {entry}\n"
else:
formatted_entry = f"- {entry}\n"
if category_index is None:
# Category doesn't exist, create it
# Find insertion point (after UNRELEASED heading, before next section)
insert_index = unreleased_index + 1
# Skip any blank lines after UNRELEASED
while insert_index < len(self.lines) and self.lines[insert_index].strip() == "":
insert_index += 1
# Insert category heading and entry
self.lines.insert(insert_index, f"### {category}\n")
self.lines.insert(insert_index + 1, formatted_entry)
self.lines.insert(insert_index + 2, "\n")
else:
# Category exists, add entry after the category heading
insert_index = category_index + 1
# Skip existing entries to add at the end of the category
while insert_index < len(self.lines):
line = self.lines[insert_index]
# Stop if we hit another category or version section
if line.startswith("###") or line.startswith("##"):
break
# Stop if we hit a blank line followed by non-entry content
if line.strip() == "" and insert_index + 1 < len(self.lines):
next_line = self.lines[insert_index + 1]
if next_line.startswith("###") or next_line.startswith("##"):
break
insert_index += 1
# Insert entry before any blank lines
while insert_index > category_index + 1 and self.lines[insert_index - 1].strip() == "":
insert_index -= 1
self.lines.insert(insert_index, formatted_entry)
return True
def write_changelog(self) -> bool:
"""
Write the updated changelog back to file.
Returns:
True if successful, False otherwise
"""
try:
with open(self.changelog_path, "w", encoding="utf-8") as f:
f.writelines(self.lines)
return True
except Exception as e:
print(f"Error writing CHANGELOG.md: {e}", file=sys.stderr)
return False
def display_unreleased(self) -> None:
"""Display the current UNRELEASED section."""
unreleased_index = self.find_unreleased_section()
if unreleased_index is None:
print("UNRELEASED section not found")
return
next_version_index = self.find_next_version_section(unreleased_index)
end_index = next_version_index if next_version_index else len(self.lines)
print("Current UNRELEASED section:")
print("=" * 60)
for i in range(unreleased_index, end_index):
print(self.lines[i], end="")
print("=" * 60)
def main() -> int:
"""
Main entry point for the changelog updater script.
Returns:
Exit code (0 for success, non-zero for error)
"""
parser = argparse.ArgumentParser(
description="Update CHANGELOG.md with entries to UNRELEASED section",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Add a simple entry
%(prog)s --category Added --entry "New feature X"
# Add an entry with subcategory
%(prog)s --category Changed --entry "Updated API endpoints" --subcategory "API"
# Display current UNRELEASED section
%(prog)s --show
Categories: Added, Changed, Deprecated, Removed, Fixed, Security
"""
)
parser.add_argument(
"--changelog",
type=Path,
default=Path("CHANGELOG.md"),
help="Path to CHANGELOG.md file (default: ./CHANGELOG.md)"
)
parser.add_argument(
"--category",
choices=ChangelogUpdater.VALID_CATEGORIES,
help="Category for the entry"
)
parser.add_argument(
"--entry",
type=str,
help="Entry text to add to the changelog"
)
parser.add_argument(
"--subcategory",
type=str,
help="Optional subcategory/subheading for the entry"
)
parser.add_argument(
"--show",
action="store_true",
help="Display the current UNRELEASED section"
)
args = parser.parse_args()
# Resolve changelog path
if not args.changelog.is_absolute():
# Try to find repository root
current_dir = Path.cwd()
repo_root = current_dir
while repo_root.parent != repo_root:
if (repo_root / ".git").exists():
break
repo_root = repo_root.parent
else:
repo_root = current_dir
changelog_path = repo_root / args.changelog
else:
changelog_path = args.changelog
updater = ChangelogUpdater(changelog_path)
if not updater.read_changelog():
return 1
if args.show:
updater.display_unreleased()
return 0
if not args.category or not args.entry:
parser.error("--category and --entry are required (or use --show)")
if updater.add_entry(args.category, args.entry, args.subcategory):
if updater.write_changelog():
print(f"Successfully added entry to UNRELEASED section: [{args.category}] {args.entry}")
return 0
else:
return 1
else:
return 1
if __name__ == "__main__":
sys.exit(main())