Skip to content

Commit fb162fc

Browse files
committed
chore: add preview changelog script
1 parent 92dbd5a commit fb162fc

1 file changed

Lines changed: 133 additions & 0 deletions

File tree

scripts/preview-changelog.sh

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
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

Comments
 (0)