|
| 1 | +#!/usr/bin/env bash |
| 2 | +set -euo pipefail |
| 3 | + |
| 4 | +usage() { |
| 5 | + cat <<'EOF' |
| 6 | +Usage: scripts/collect-changelog.sh --target next|hotfix |
| 7 | +
|
| 8 | +Collect changelog fragments from changelog.d/<target>/ into CHANGELOG.md. |
| 9 | +EOF |
| 10 | +} |
| 11 | + |
| 12 | +target="" |
| 13 | + |
| 14 | +while [[ $# -gt 0 ]]; do |
| 15 | + case "$1" in |
| 16 | + --target) |
| 17 | + if [[ $# -lt 2 ]]; then |
| 18 | + echo "--target requires a value" >&2 |
| 19 | + usage >&2 |
| 20 | + exit 1 |
| 21 | + fi |
| 22 | + target="$2" |
| 23 | + shift 2 |
| 24 | + ;; |
| 25 | + -h|--help) |
| 26 | + usage |
| 27 | + exit 0 |
| 28 | + ;; |
| 29 | + *) |
| 30 | + echo "Unknown argument: $1" >&2 |
| 31 | + usage >&2 |
| 32 | + exit 1 |
| 33 | + ;; |
| 34 | + esac |
| 35 | +done |
| 36 | + |
| 37 | +if [[ "$target" != "next" && "$target" != "hotfix" ]]; then |
| 38 | + echo "--target must be either 'next' or 'hotfix'" >&2 |
| 39 | + usage >&2 |
| 40 | + exit 1 |
| 41 | +fi |
| 42 | + |
| 43 | +python3 - "$target" <<'PY' |
| 44 | +from __future__ import annotations |
| 45 | +
|
| 46 | +import re |
| 47 | +import sys |
| 48 | +from pathlib import Path |
| 49 | +
|
| 50 | +TARGET = sys.argv[1] |
| 51 | +ROOT = Path.cwd() |
| 52 | +CHANGELOG = ROOT / "CHANGELOG.md" |
| 53 | +FRAGMENT_DIR = ROOT / "changelog.d" / TARGET |
| 54 | +
|
| 55 | +CATEGORY_LABELS = { |
| 56 | + "added": "Added", |
| 57 | + "changed": "Changed", |
| 58 | + "deprecated": "Deprecated", |
| 59 | + "removed": "Removed", |
| 60 | + "fixed": "Fixed", |
| 61 | + "security": "Security", |
| 62 | +} |
| 63 | +CATEGORY_ORDER = list(CATEGORY_LABELS.values()) |
| 64 | +FRAGMENT_PATTERN = re.compile( |
| 65 | + r"^(?P<ref>[A-Za-z0-9][A-Za-z0-9._-]*)\.(?P<category>added|changed|deprecated|removed|fixed|security)\.md$" |
| 66 | +) |
| 67 | +
|
| 68 | +
|
| 69 | +def fail(message: str) -> None: |
| 70 | + raise SystemExit(f"collect-changelog: {message}") |
| 71 | +
|
| 72 | +
|
| 73 | +def fragment_entry(path: Path) -> tuple[str, str]: |
| 74 | + match = FRAGMENT_PATTERN.match(path.name) |
| 75 | + if not match: |
| 76 | + fail( |
| 77 | + f"invalid fragment name '{path.relative_to(ROOT)}'; " |
| 78 | + "expected <issue-or-pr>.<category>.md" |
| 79 | + ) |
| 80 | +
|
| 81 | + lines = [line.strip() for line in path.read_text().splitlines() if line.strip()] |
| 82 | + if not lines: |
| 83 | + fail(f"fragment '{path.relative_to(ROOT)}' is empty") |
| 84 | +
|
| 85 | + text = " ".join(lines) |
| 86 | + if text.startswith("-"): |
| 87 | + fail(f"fragment '{path.relative_to(ROOT)}' must not start with a bullet") |
| 88 | +
|
| 89 | + ref = match.group("ref") |
| 90 | + category = CATEGORY_LABELS[match.group("category")] |
| 91 | + suffix = f" #{ref}" if ref.isdigit() else "" |
| 92 | + return category, f"- {text}{suffix}" |
| 93 | +
|
| 94 | +
|
| 95 | +def unreleased_bounds(changelog: str) -> tuple[int, int]: |
| 96 | + header = re.search(r"^## \[Unreleased\]\n", changelog, flags=re.MULTILINE) |
| 97 | + if not header: |
| 98 | + fail("could not find '## [Unreleased]' in CHANGELOG.md") |
| 99 | +
|
| 100 | + next_release = re.search(r"^## \[[^\]]+\].*$", changelog[header.end() :], flags=re.MULTILINE) |
| 101 | + start = header.end() |
| 102 | + end = start + next_release.start() if next_release else len(changelog) |
| 103 | + return start, end |
| 104 | +
|
| 105 | +
|
| 106 | +def category_heading(category: str) -> re.Pattern[str]: |
| 107 | + return re.compile(rf"^### {re.escape(category)}\n", flags=re.MULTILINE) |
| 108 | +
|
| 109 | +
|
| 110 | +def insert_entries(body: str, category: str, entries: list[str]) -> str: |
| 111 | + heading = category_heading(category).search(body) |
| 112 | + entries_text = "\n".join(entries) + "\n" |
| 113 | +
|
| 114 | + if heading: |
| 115 | + insert_at = heading.end() |
| 116 | + return body[:insert_at] + entries_text + body[insert_at:] |
| 117 | +
|
| 118 | + block = f"### {category}\n{entries_text}\n" |
| 119 | + category_index = CATEGORY_ORDER.index(category) |
| 120 | +
|
| 121 | + for later_category in CATEGORY_ORDER[category_index + 1 :]: |
| 122 | + later_heading = category_heading(later_category).search(body) |
| 123 | + if later_heading: |
| 124 | + return body[: later_heading.start()] + block + body[later_heading.start() :] |
| 125 | +
|
| 126 | + stripped_body = body.rstrip() |
| 127 | + if not stripped_body: |
| 128 | + return f"\n{block}" |
| 129 | +
|
| 130 | + trailing = body[len(stripped_body) :] |
| 131 | + return f"{stripped_body}\n\n{block}{trailing}" |
| 132 | +
|
| 133 | +
|
| 134 | +if not CHANGELOG.exists(): |
| 135 | + fail("CHANGELOG.md does not exist") |
| 136 | +
|
| 137 | +if not FRAGMENT_DIR.exists(): |
| 138 | + fail(f"fragment directory '{FRAGMENT_DIR.relative_to(ROOT)}' does not exist") |
| 139 | +
|
| 140 | +fragments = sorted( |
| 141 | + path for path in FRAGMENT_DIR.glob("*.md") if path.is_file() and path.name != ".gitkeep" |
| 142 | +) |
| 143 | +
|
| 144 | +if not fragments: |
| 145 | + print(f"No changelog fragments found in {FRAGMENT_DIR.relative_to(ROOT)}.") |
| 146 | + raise SystemExit(0) |
| 147 | +
|
| 148 | +entries_by_category: dict[str, list[str]] = {category: [] for category in CATEGORY_ORDER} |
| 149 | +for fragment in fragments: |
| 150 | + category, entry = fragment_entry(fragment) |
| 151 | + entries_by_category[category].append(entry) |
| 152 | +
|
| 153 | +changelog = CHANGELOG.read_text() |
| 154 | +start, end = unreleased_bounds(changelog) |
| 155 | +body = changelog[start:end] |
| 156 | +existing_entries = set(body.splitlines()) |
| 157 | +
|
| 158 | +inserted = 0 |
| 159 | +for category in CATEGORY_ORDER: |
| 160 | + entries = [entry for entry in entries_by_category[category] if entry not in existing_entries] |
| 161 | + if not entries: |
| 162 | + continue |
| 163 | +
|
| 164 | + body = insert_entries(body, category, entries) |
| 165 | + existing_entries.update(entries) |
| 166 | + inserted += len(entries) |
| 167 | +
|
| 168 | +CHANGELOG.write_text(changelog[:start] + body + changelog[end:]) |
| 169 | +
|
| 170 | +for fragment in fragments: |
| 171 | + fragment.unlink() |
| 172 | +
|
| 173 | +print(f"Collected {inserted} changelog entr{'y' if inserted == 1 else 'ies'} from changelog.d/{TARGET}.") |
| 174 | +PY |
0 commit comments