From 0661646961f03a786ebb57fdebf34d2fc80aae7b Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Fri, 24 Jul 2026 21:58:11 -0400 Subject: [PATCH 1/3] fix(tools): make_changelog missed and resurrected PRs Three unrelated causes made the generated changelog wrong: - Requests were unauthenticated, so GitHub returned "Cache-Control: public" and a shared CDN cache supplied stale PR bodies. Send a token from GITHUB_TOKEN, GH_TOKEN, or `gh auth token`. - The heading regex required a trailing colon, so a PR that wrote "## Suggested changelog entry" without one was reported as missing. - The server-side label filter lags behind label removals by many minutes, so PRs that no longer have the label came back. Re-check the label on each issue. Also update to ghapi>=2 and its sync API. Assisted-by: ClaudeCode:claude-opus-5 --- tools/make_changelog.py | 40 ++++++++++++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/tools/make_changelog.py b/tools/make_changelog.py index bb49de64d2..2ecbb78599 100755 --- a/tools/make_changelog.py +++ b/tools/make_changelog.py @@ -1,12 +1,14 @@ #!/usr/bin/env -S uv run # /// script -# dependencies = ["ghapi", "rich"] +# dependencies = ["ghapi>=2", "rich"] # /// from __future__ import annotations +import os import re +import subprocess import ghapi.all from rich import print @@ -14,7 +16,7 @@ MD_ENTRY = re.compile( r""" - \#\#\ Suggested\ changelog\ entry: # Match the heading exactly + ^\#+\ Suggested\ changelog\ entry:?\s*$ # Match the heading, colon optional (?:\s*)? # Optionally match one comment (?P.*?) # Lazily capture content until... (?= # Lookahead for one of the following: @@ -29,12 +31,38 @@ print() -api = ghapi.all.GhApi(owner="pybind", repo="pybind11") +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() -issues_pages = ghapi.page.paged( - api.issues.list_for_repo, labels="needs changelog", state="closed" + +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" +) +# 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) ) -issues = (issue for page in issues_pages for issue in page) missing = [] old = [] cats_descr = { From d67ec42e23aee76485f1c15ee5ae6378ccc503c4 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Fri, 24 Jul 2026 22:04:12 -0400 Subject: [PATCH 2/3] perf(tools): request 100 issues per page in make_changelog Assisted-by: ClaudeCode:claude-opus-5 --- tools/make_changelog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/make_changelog.py b/tools/make_changelog.py index 2ecbb78599..4d87b41393 100755 --- a/tools/make_changelog.py +++ b/tools/make_changelog.py @@ -54,7 +54,7 @@ def get_token() -> str | None: 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" + 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 = ( From b2e4d9990562bc56c8a058cb7332635da938af9c Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Fri, 24 Jul 2026 22:34:55 -0400 Subject: [PATCH 3/3] feat(tools): group changelog subcategories automatically Only the main categories are listed now. A conventional commit scope on the title, such as "fix(cmake):", becomes a group inside its main category, sorted alphabetically after the entries that have no scope. The main heading also prints when a category holds only scoped entries. Assisted-by: ClaudeCode:claude-opus-5 --- tools/make_changelog.py | 44 +++++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/tools/make_changelog.py b/tools/make_changelog.py index 4d87b41393..f872546294 100755 --- a/tools/make_changelog.py +++ b/tools/make_changelog.py @@ -28,6 +28,9 @@ """, re.DOTALL | re.VERBOSE | re.MULTILINE, ) +# Conventional commit prefix, such as "fix(cmake)!:" +TITLE_CAT = re.compile(r"(?P\w+)(?:\((?P[^)]+)\))?!?:") + print() @@ -67,20 +70,15 @@ def get_token() -> str | None: old = [] cats_descr = { "feat": "New Features", - "feat(types)": "", - "feat(cmake)": "", "fix": "Bug fixes", - "fix(types)": "", - "fix(cmake)": "", - "fix(free-threading)": "", "docs": "Documentation", "tests": "Tests", "ci": "CI", "chore": "Other", - "chore(cmake)": "", "unknown": "Uncategorised", } -cats: dict[str, list[str]] = {c: [] for c in cats_descr} +# 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: @@ -107,22 +105,26 @@ def get_token() -> str | None: continue msg += f"\n [#{issue.number}]({issue.html_url})" - for cat, cat_list in cats.items(): - if issue.title.lower().startswith(f"{cat}:"): - cat_list.append(msg) - break - else: - cats["unknown"].append(msg) - -for cat, msgs in cats.items(): - if msgs: - desc = cats_descr[cat] - print(f"[bold]{desc}:" if desc else f"") + 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() - for msg in msgs: - print(Syntax(msg, "md", theme="ansi_light", word_wrap=True)) + # An empty subcategory sorts first, so plain entries lead the section + for sub in sorted(subs): + if sub: + print(f"") + print() + for msg in subs[sub]: + print(Syntax(msg, "md", theme="ansi_light", word_wrap=True)) + print() print() - print() if missing: print()