|
| 1 | +#!/usr/bin/env bash |
| 2 | +set -euo pipefail |
| 3 | + |
| 4 | +usage() { |
| 5 | + cat <<'EOF' |
| 6 | +Usage: scripts/preview-changelog.sh [--target next|hotfix|all] |
| 7 | +
|
| 8 | +Preview pending changelog fragments without modifying files. |
| 9 | +EOF |
| 10 | +} |
| 11 | + |
| 12 | +target="all" |
| 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" && "$target" != "all" ]]; then |
| 38 | + echo "--target must be 'next', 'hotfix', or 'all'" >&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_DIR = ROOT / "changelog.d" |
| 53 | +
|
| 54 | +CATEGORY_LABELS = { |
| 55 | + "added": "Added", |
| 56 | + "changed": "Changed", |
| 57 | + "deprecated": "Deprecated", |
| 58 | + "removed": "Removed", |
| 59 | + "fixed": "Fixed", |
| 60 | + "security": "Security", |
| 61 | +} |
| 62 | +CATEGORY_ORDER = list(CATEGORY_LABELS.values()) |
| 63 | +FRAGMENT_PATTERN = re.compile( |
| 64 | + r"^(?P<ref>[A-Za-z0-9][A-Za-z0-9._-]*)\.(?P<category>added|changed|deprecated|removed|fixed|security)\.md$" |
| 65 | +) |
| 66 | +
|
| 67 | +
|
| 68 | +def fail(message: str) -> None: |
| 69 | + raise SystemExit(f"preview-changelog: {message}") |
| 70 | +
|
| 71 | +
|
| 72 | +def fragment_entry(path: Path) -> tuple[str, str]: |
| 73 | + match = FRAGMENT_PATTERN.match(path.name) |
| 74 | + if not match: |
| 75 | + fail( |
| 76 | + f"invalid fragment name '{path.relative_to(ROOT)}'; " |
| 77 | + "expected <issue-or-pr>.<category>.md" |
| 78 | + ) |
| 79 | +
|
| 80 | + lines = [line.strip() for line in path.read_text().splitlines() if line.strip()] |
| 81 | + if not lines: |
| 82 | + fail(f"fragment '{path.relative_to(ROOT)}' is empty") |
| 83 | +
|
| 84 | + text = " ".join(lines) |
| 85 | + if text.startswith("-"): |
| 86 | + fail(f"fragment '{path.relative_to(ROOT)}' must not start with a bullet") |
| 87 | +
|
| 88 | + ref = match.group("ref") |
| 89 | + category = CATEGORY_LABELS[match.group("category")] |
| 90 | + suffix = f" #{ref}" if ref.isdigit() else "" |
| 91 | + return category, f"- {text}{suffix} ({path.relative_to(ROOT)})" |
| 92 | +
|
| 93 | +
|
| 94 | +def target_dirs() -> list[tuple[str, Path]]: |
| 95 | + targets = ["next", "hotfix"] if TARGET == "all" else [TARGET] |
| 96 | + return [(target, CHANGELOG_DIR / target) for target in targets] |
| 97 | +
|
| 98 | +
|
| 99 | +found_any = False |
| 100 | +
|
| 101 | +for target, directory in target_dirs(): |
| 102 | + if not directory.exists(): |
| 103 | + fail(f"fragment directory '{directory.relative_to(ROOT)}' does not exist") |
| 104 | +
|
| 105 | + fragments = sorted( |
| 106 | + path for path in directory.glob("*.md") if path.is_file() and path.name != ".gitkeep" |
| 107 | + ) |
| 108 | +
|
| 109 | + print(f"## {target}") |
| 110 | + if not fragments: |
| 111 | + print("No pending changelog fragments.\n") |
| 112 | + continue |
| 113 | +
|
| 114 | + found_any = True |
| 115 | + entries_by_category: dict[str, list[str]] = {category: [] for category in CATEGORY_ORDER} |
| 116 | + for fragment in fragments: |
| 117 | + category, entry = fragment_entry(fragment) |
| 118 | + entries_by_category[category].append(entry) |
| 119 | +
|
| 120 | + for category in CATEGORY_ORDER: |
| 121 | + entries = entries_by_category[category] |
| 122 | + if not entries: |
| 123 | + continue |
| 124 | +
|
| 125 | + print(f"\n### {category}") |
| 126 | + for entry in entries: |
| 127 | + print(entry) |
| 128 | +
|
| 129 | + print() |
| 130 | +
|
| 131 | +if not found_any: |
| 132 | + raise SystemExit(0) |
| 133 | +PY |
0 commit comments