From a0babe26e4c0aa979a64caa3ba1100ee86667314 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 4 Jan 2026 02:03:47 +0000 Subject: [PATCH] Add migrate_unreleased.sh script to automate changelog migrations Co-authored-by: jmiller-moko <230051081+jmiller-moko@users.noreply.github.com> --- scripts/run/migrate_unreleased.sh | 292 ++++++++++++++++++++++++++++++ 1 file changed, 292 insertions(+) create mode 100755 scripts/run/migrate_unreleased.sh diff --git a/scripts/run/migrate_unreleased.sh b/scripts/run/migrate_unreleased.sh new file mode 100755 index 0000000..f17eb19 --- /dev/null +++ b/scripts/run/migrate_unreleased.sh @@ -0,0 +1,292 @@ +#!/usr/bin/env bash + +# ============================================================================ +# Copyright (C) 2025 Moko Consulting +# +# 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 +# along with this program (./LICENSE.md). +# ============================================================================ + +# ============================================================================ +# FILE INFORMATION +# ============================================================================ +# DEFGROUP: Script.Run +# INGROUP: Repository.Release +# REPO: https://github.com/mokoconsulting-tech/moko-cassiopeia +# PATH: /scripts/run/migrate_unreleased.sh +# VERSION: 01.00.00 +# BRIEF: Migrate unreleased changelog entries to a versioned section +# NOTE: Moves content from [Unreleased] section to a specified version heading +# ============================================================================ + +set -euo pipefail + +# ---------------------------------------------------------------------------- +# Usage +# ---------------------------------------------------------------------------- + +usage() { +cat <<-USAGE +Usage: $0 [OPTIONS] + +Migrate unreleased changelog entries to a versioned section. + +Arguments: + VERSION Version number in format NN.NN.NN (e.g., 03.05.00) + +Options: + -h, --help Show this help message + -d, --date Date to use for version entry (default: today, format: YYYY-MM-DD) + -n, --dry-run Show what would be done without making changes + -k, --keep Keep the [Unreleased] section after migration (default: empty it) + +Examples: + $0 03.05.00 # Migrate unreleased to version 03.05.00 + $0 03.05.00 --date 2026-01-04 # Use specific date + $0 03.05.00 --dry-run # Preview changes without applying + $0 03.05.00 --keep # Keep unreleased section after migration + +USAGE +exit 0 +} + +# ---------------------------------------------------------------------------- +# Argument parsing +# ---------------------------------------------------------------------------- + +VERSION="" +DATE="" +DRY_RUN=false +KEEP_UNRELEASED=false + +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + usage + ;; + -d|--date) + DATE="$2" + shift 2 + ;; + -n|--dry-run) + DRY_RUN=true + shift + ;; + -k|--keep) + KEEP_UNRELEASED=true + shift + ;; + *) + if [[ -z "$VERSION" ]]; then + VERSION="$1" + shift + else + echo "ERROR: Unknown argument: $1" >&2 + usage + fi + ;; + esac +done + +# ---------------------------------------------------------------------------- +# Validation +# ---------------------------------------------------------------------------- + +if [[ -z "$VERSION" ]]; then + echo "ERROR: VERSION is required" >&2 + usage +fi + +if ! [[ "$VERSION" =~ ^[0-9]{2}\.[0-9]{2}\.[0-9]{2}$ ]]; then + echo "ERROR: Invalid version format: $VERSION" >&2 + echo "Expected format: NN.NN.NN (e.g., 03.05.00)" >&2 + exit 1 +fi + +if [[ -z "$DATE" ]]; then + DATE=$(date '+%Y-%m-%d') +fi + +if ! [[ "$DATE" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then + echo "ERROR: Invalid date format: $DATE" >&2 + echo "Expected format: YYYY-MM-DD" >&2 + exit 1 +fi + +# ---------------------------------------------------------------------------- +# Source common utilities +# ---------------------------------------------------------------------------- + +SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +. "${SCRIPT_DIR}/lib/common.sh" + +# ---------------------------------------------------------------------------- +# Main logic +# ---------------------------------------------------------------------------- + +CHANGELOG_FILE="CHANGELOG.md" + +if [[ ! -f "$CHANGELOG_FILE" ]]; then + log_error "CHANGELOG.md not found in repository root" + exit 1 +fi + +log_info "Migrating unreleased changelog entries to version $VERSION" +log_info "Date: $DATE" +log_info "Dry run: $DRY_RUN" +log_info "Keep unreleased section: $KEEP_UNRELEASED" + +# Use Python to process the changelog +python3 - < bool: + return line.lstrip().startswith("## ") + +def norm(line: str) -> str: + return line.strip().lower() + +def find_idx(predicate): + for i, ln in enumerate(lines): + if predicate(ln): + return i + return None + +unreleased_idx = find_idx(lambda ln: norm(ln) == "## [unreleased]") +version_idx = find_idx(lambda ln: ln.lstrip().startswith(f"## [{version}]")) + +def version_header() -> list: + return ["\\n", f"## [{version}] {stamp}\\n", "\\n"] + +if unreleased_idx is None: + print(f"INFO: No [Unreleased] section found in {changelog_path}") + if version_idx is None: + print(f"INFO: Version section [{version}] does not exist") + print(f"INFO: Creating new version section with placeholder content") + if not dry_run: + # Find insertion point after main heading + insert_at = 0 + for i, ln in enumerate(lines): + if ln.lstrip().startswith("# "): + insert_at = i + 1 + while insert_at < len(lines) and lines[insert_at].strip() == "": + insert_at += 1 + break + entry = version_header() + ["- No changes recorded.\\n", "\\n"] + lines[insert_at:insert_at] = entry + changelog_path.write_text("".join(lines), encoding="utf-8") + print(f"SUCCESS: Created version section [{version}]") + else: + print(f"DRY-RUN: Would create version section [{version}]") + else: + print(f"INFO: Version section [{version}] already exists") + sys.exit(0) + +# Extract unreleased content +u_start = unreleased_idx + 1 +u_end = len(lines) +for j in range(u_start, len(lines)): + if is_h2(lines[j]): + u_end = j + break + +unreleased_body = "".join(lines[u_start:u_end]).strip() + +if not unreleased_body: + print(f"INFO: [Unreleased] section is empty, nothing to migrate") + sys.exit(0) + +print(f"INFO: Found unreleased content ({len(unreleased_body)} chars)") + +# Create or find version section +if version_idx is None: + print(f"INFO: Creating version section [{version}]") + if not dry_run: + lines[u_end:u_end] = version_header() + else: + print(f"DRY-RUN: Would create version section [{version}]") + +version_idx = find_idx(lambda ln: ln.lstrip().startswith(f"## [{version}]")) +if version_idx is None and not dry_run: + print("ERROR: Failed to locate version header after insertion", file=sys.stderr) + sys.exit(1) + +# Move unreleased content to version section +if unreleased_body: + if not dry_run: + insert_at = version_idx + 1 + while insert_at < len(lines) and lines[insert_at].strip() == "": + insert_at += 1 + + moved = ["\\n"] + [ln + "\\n" for ln in unreleased_body.split("\\n") if ln != ""] + ["\\n"] + lines[insert_at:insert_at] = moved + print(f"INFO: Moved {len([ln for ln in unreleased_body.split('\\n') if ln])} lines to [{version}]") + else: + line_count = len([ln for ln in unreleased_body.split('\\n') if ln]) + print(f"DRY-RUN: Would move {line_count} lines to [{version}]") + print(f"DRY-RUN: Content preview:") + for line in unreleased_body.split('\\n')[:5]: + if line: + print(f" {line}") + +# Handle unreleased section +if not keep_unreleased: + unreleased_idx = find_idx(lambda ln: norm(ln) == "## [unreleased]") + if unreleased_idx is not None: + if not dry_run: + u_start = unreleased_idx + 1 + u_end = len(lines) + for j in range(u_start, len(lines)): + if is_h2(lines[j]): + u_end = j + break + lines[u_start:u_end] = ["\\n"] + print(f"INFO: Emptied [Unreleased] section") + else: + print(f"DRY-RUN: Would empty [Unreleased] section") +else: + print(f"INFO: Keeping [Unreleased] section as requested") + +if not dry_run: + changelog_path.write_text("".join(lines), encoding="utf-8") + print(f"SUCCESS: Migrated unreleased content to [{version}]") +else: + print(f"DRY-RUN: Changes not applied (use without --dry-run to apply)") + +PY + +if [[ $? -eq 0 ]]; then + if [[ "$DRY_RUN" == "false" ]]; then + log_info "✓ Migration completed successfully" + log_info "✓ Changelog updated: $CHANGELOG_FILE" + else + log_info "✓ Dry run completed" + fi +else + log_error "Migration failed" + exit 1 +fi