chore: sync workflows, scripts, and configurations from MokoStandards
This commit is contained in:
319
scripts/maintenance/update_changelog.py
Executable file
319
scripts/maintenance/update_changelog.py
Executable file
@@ -0,0 +1,319 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user