blob: 8d2c7b08efa906ea4b67ff161d3bf8fd8e322405 [file]
#!/usr/bin/env -S uv run
# /// script
# dependencies = ["ghapi>=2", "rich"]
# ///
from __future__ import annotations
import os
import re
import subprocess
import ghapi.all
from rich import print
from rich.syntax import Syntax
MD_ENTRY = re.compile(
r"""
^\#+\ Suggested\ changelog\ entry:?\s*$ # Match the heading, colon optional
(?:\s*<!--.*?-->)? # Optionally match one comment
(?P<content>.*?) # Lazily capture content until...
(?= # Lookahead for one of the following:
^-{3,}\s*$ # A line with 3 or more dashes
| ^<!--\s*readthedocs # A comment starting with 'readthedocs'
| ^\#\# # A new heading
| \Z # End of string
)
""",
re.DOTALL | re.VERBOSE | re.MULTILINE,
)
# Conventional commit prefix, such as "fix(cmake)!:"
TITLE_CAT = re.compile(r"(?P<cat>\w+)(?:\((?P<sub>[^)]+)\))?!?:")
print()
def get_token() -> str | None:
"""
Unauthenticated requests get a shared CDN cache ("Cache-Control: public"),
which returns stale bodies and labels. A token makes the cache private.
"""
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
if token:
return token
try:
result = subprocess.run(
["gh", "auth", "token"], capture_output=True, text=True, check=True
)
except (OSError, subprocess.CalledProcessError):
print("[yellow]No GitHub token found; results may be stale (cached).")
return None
return result.stdout.strip()
LABEL = "needs changelog"
api = ghapi.all.GhApi(owner="pybind", repo="pybind11", token=get_token(), sync=True)
issues_pages = ghapi.page.sync_paged(
api.issues.list_for_repo, labels=LABEL, state="closed", per_page=100
)
# The server-side label filter lags behind label removals, so re-check each issue
issues = (
issue
for page in issues_pages
for issue in page
if any(label.name == LABEL for label in issue.labels)
)
missing = []
old = []
cats_descr = {
"feat": "New Features",
"fix": "Bug fixes",
"docs": "Documentation",
"tests": "Tests",
"ci": "CI",
"chore": "Other",
"unknown": "Uncategorised",
}
# Each main category maps its subcategories ("" if there is none) to entries
cats: dict[str, dict[str, list[str]]] = {c: {} for c in cats_descr}
for issue in issues:
if "```rst" in issue.body:
old.append(issue)
continue
changelog = MD_ENTRY.search(issue.body or "")
if not changelog:
missing.append(issue)
continue
msg = changelog.group("content").strip()
if not msg:
missing.append(issue)
continue
msg = msg.removeprefix("* ")
if not msg.startswith("- "):
msg = "- " + msg
if not msg.endswith("."):
msg += "."
if msg == "- Placeholder.":
missing.append(issue)
continue
msg += f"\n [#{issue.number}]({issue.html_url})"
title = TITLE_CAT.match(issue.title)
cat = title.group("cat").lower() if title else "unknown"
sub = (title.group("sub") or "").lower() if title else ""
if cat not in cats:
cat, sub = "unknown", ""
cats[cat].setdefault(sub, []).append(msg)
for cat, subs in cats.items():
if subs:
print(f"[bold]{cats_descr[cat]}:")
print()
# An empty subcategory sorts first, so plain entries lead the section
for sub in sorted(subs):
if sub:
print(f"<!-- {cat}({sub}) -->")
print()
for msg in subs[sub]:
print(Syntax(msg, "md", theme="ansi_light", word_wrap=True))
print()
print()
if missing:
print()
print("[blue]" + "-" * 30)
print()
for issue in missing:
print(f"[red bold]Missing:[/red bold][red] {issue.title}")
print(f"[red] {issue.html_url}\n")
print("[bold]Template:\n")
msg = "## Suggested changelog entry:"
print(Syntax(msg, "md", theme="ansi_light"))
if old:
print()
print("[red]" + "-" * 30)
print()
for issue in old:
print(f"[red bold]Old:[/red bold][red] {issue.title}")
print(f"[red] {issue.html_url}\n")
print()