| #!/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() |