.mise/tasks/docs-generate-changelog
.mise/tasks/docs-generate-changelogBrowse 1970 files
2,022 tokens
8,109 bytes
Token encoding: o200k_base
Snapshot 21a254f
← Back to SKILL.md
1#!/usr/bin/env python32#MISE description="Generate docs/src/content/docs/changelog.md from GitHub releases"3"""Fetch GitHub releases and write a Starlight-ready changelog page.4 5Requires `gh` (authenticated for higher rate limits, public API works unauthenticated6with lower limits). Skips draft and prerelease releases.7 8Usage:9 mise run docs-generate-changelog10"""11 12from __future__ import annotations13 14import json15import re16import subprocess17import sys18from datetime import datetime, timedelta, timezone19from pathlib import Path20 21REPO = "railwayapp/railpack"22RELEASES_URL = f"https://github.com/{REPO}/releases"23OUTPUT = Path("docs/src/content/docs/changelog.md")24# Only include releases published within this window on the docs page.25CHANGELOG_YEARS = 126 27# Match full GitHub PR/issue URLs so we can shorten them in prose.28PR_URL_RE = re.compile(29 r"https://github\.com/railwayapp/railpack/(?:pull|issues)/(\d+)"30)31# Bare "Full Changelog" compare/commits links from release templates.32FULL_CHANGELOG_RE = re.compile(33 r"\*\*Full Changelog\*\*:\s+(https://github\.com/railwayapp/railpack/"34 r"(?:compare|commits)/[^\s]+)"35)36# Boilerplate footer lines from the release notes template.37INTERNAL_LINE_RE = re.compile(38 r"^\*?Internal (improvements|build maintenance|snapshot maintenance)\b",39 re.IGNORECASE,40)41# Demote ATX headings one level so each release can own the ## slot.42HEADING_RE = re.compile(r"^(#{1,5})\s", re.MULTILINE)43 44 45def fetch_releases() -> list[dict]:46 """Return non-draft, non-prerelease releases newest-first.47 48 Stream one JSON object per release so gh --paginate cannot drop pages49 the way an outer array expression sometimes does.50 """51 result = subprocess.run(52 [53 "gh",54 "api",55 f"repos/{REPO}/releases",56 "--paginate",57 "--jq",58 (59 ".[] | select(.draft == false and .prerelease == false) | "60 "{tag_name, name, published_at, html_url, body}"61 ),62 ],63 capture_output=True,64 text=True,65 check=False,66 )67 if result.returncode != 0:68 print(69 result.stderr.strip() or "failed to fetch releases via gh",70 file=sys.stderr,71 )72 sys.exit(1)73 74 text = result.stdout.strip()75 if not text:76 return []77 78 releases: list[dict] = []79 decoder = json.JSONDecoder()80 idx = 081 while idx < len(text):82 while idx < len(text) and text[idx].isspace():83 idx += 184 if idx >= len(text):85 break86 obj, offset = decoder.raw_decode(text, idx)87 idx = offset88 if isinstance(obj, dict):89 releases.append(obj)90 91 # API returns newest-first per page; re-sort to be safe92 releases.sort(key=lambda r: r.get("published_at") or "", reverse=True)93 return releases94 95 96def parse_published_at(published_at: str | None) -> datetime | None:97 if not published_at:98 return None99 try:100 return datetime.fromisoformat(published_at.replace("Z", "+00:00"))101 except ValueError:102 return None103 104 105def filter_recent_releases(106 releases: list[dict],107) -> tuple[list[dict], int]:108 """Keep releases from the last CHANGELOG_YEARS; return (kept, older_count)."""109 cutoff = datetime.now(timezone.utc) - timedelta(days=365 * CHANGELOG_YEARS)110 recent: list[dict] = []111 older = 0112 for release in releases:113 published = parse_published_at(release.get("published_at"))114 if published is None or published >= cutoff:115 recent.append(release)116 else:117 older += 1118 return recent, older119 120 121def format_date(published_at: str | None) -> str:122 if not published_at:123 return ""124 try:125 dt = datetime.fromisoformat(published_at.replace("Z", "+00:00"))126 except ValueError:127 return published_at[:10]128 return dt.strftime("%B %-d, %Y")129 130 131def shorten_pr_urls(text: str) -> str:132 """Turn full PR/issue URLs into [#N](url) markdown links."""133 134 def repl(match: re.Match[str]) -> str:135 num = match.group(1)136 url = match.group(0)137 return f"[#{num}]({url})"138 139 return PR_URL_RE.sub(repl, text)140 141 142def linkify_full_changelog(text: str) -> str:143 """Turn bare Full Changelog URLs into markdown links."""144 145 def repl(match: re.Match[str]) -> str:146 url = match.group(1)147 label = url.rsplit("/", 1)[-1]148 return f"**Full Changelog**: [{label}]({url})"149 150 return FULL_CHANGELOG_RE.sub(repl, text)151 152 153def demote_headings(text: str) -> str:154 """Shift ATX headings down one level (## → ###, etc.)."""155 return HEADING_RE.sub(lambda m: "#" + m.group(1) + " ", text)156 157 158def is_noise_line(line: str) -> bool:159 stripped = line.strip()160 if not stripped:161 return False162 if stripped == "---":163 return True164 if INTERNAL_LINE_RE.match(stripped):165 return True166 return False167 168 169def clean_body(body: str | None) -> str:170 if not body or not body.strip():171 return ""172 173 text = body.replace("\r\n", "\n").strip()174 text = demote_headings(text)175 text = shorten_pr_urls(text)176 text = linkify_full_changelog(text)177 178 cleaned: list[str] = []179 prev_blank = False180 for line in text.split("\n"):181 line = line.rstrip()182 if is_noise_line(line):183 continue184 blank = line == ""185 # Collapse runs of blank lines left by filtered noise186 if blank and (prev_blank or not cleaned):187 continue188 cleaned.append(line)189 prev_blank = blank190 191 return "\n".join(cleaned).strip()192 193 194def version_label(tag_name: str, name: str | None) -> str:195 tag = tag_name.strip()196 if tag.startswith("v"):197 return tag198 if name and name.strip():199 return name.strip()200 return tag201 202 203def render_release(release: dict) -> str:204 tag = release.get("tag_name") or ""205 label = version_label(tag, release.get("name"))206 date = format_date(release.get("published_at"))207 html_url = release.get("html_url") or f"https://github.com/{REPO}/releases/tag/{tag}"208 body = clean_body(release.get("body"))209 210 parts = [f"## {label}"]211 meta = []212 if date:213 meta.append(date)214 meta.append(f"[GitHub release]({html_url})")215 parts.append(" · ".join(meta))216 parts.append("")217 if body:218 parts.append(body)219 parts.append("")220 return "\n".join(parts)221 222 223def render_page(releases: list[dict], older_count: int) -> str:224 header = """\225---226title: Changelog227description: Release notes for each published version of Railpack.228editUrl: false229tableOfContents:230 minHeadingLevel: 2231 maxHeadingLevel: 2232---233 234"""235 sections = [render_release(r) for r in releases]236 body = "\n".join(sections).rstrip()237 if older_count > 0:238 window = "year" if CHANGELOG_YEARS == 1 else f"{CHANGELOG_YEARS} years"239 body += (240 f"\n\n## Older releases\n\n"241 f"This page covers the last {window}. "242 f"See all releases on "243 f"[GitHub]({RELEASES_URL}).\n"244 )245 # Single trailing newline at EOF246 return header + body + "\n"247 248 249def main() -> None:250 repo_root = subprocess.run(251 ["git", "rev-parse", "--show-toplevel"],252 capture_output=True,253 text=True,254 check=True,255 ).stdout.strip()256 257 root = Path(repo_root)258 output = root / OUTPUT259 260 print(f"Fetching releases from {REPO}…")261 releases = fetch_releases()262 if not releases:263 print("No published releases found", file=sys.stderr)264 sys.exit(1)265 266 recent, older_count = filter_recent_releases(releases)267 if not recent:268 print(269 f"No releases in the last {CHANGELOG_YEARS} years",270 file=sys.stderr,271 )272 sys.exit(1)273 274 print(275 f"Writing {len(recent)} release(s) to {OUTPUT}"276 f" (omitting {older_count} older)"277 )278 output.parent.mkdir(parents=True, exist_ok=True)279 output.write_text(render_page(recent, older_count), encoding="utf-8")280 print(f"Wrote {output.relative_to(root)}")281 282 283if __name__ == "__main__":284 main()285