chore(tools): make_changelog missed and resurrected PRs (#6108)
diff --git a/tools/make_changelog.py b/tools/make_changelog.py
index bb49de6..f872546 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<content>.*?)                       # Lazily capture content until...
     (?=                                    # Lookahead for one of the following:
@@ -26,33 +28,57 @@
     """,
     re.DOTALL | re.VERBOSE | re.MULTILINE,
 )
+# Conventional commit prefix, such as "fix(cmake)!:"
+TITLE_CAT = re.compile(r"(?P<cat>\w+)(?:\((?P<sub>[^)]+)\))?!?:")
+
 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", per_page=100
 )
-issues = (issue for page in issues_pages for issue in page)
+# 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",
-    "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:
@@ -79,22 +105,26 @@
         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)
+    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, msgs in cats.items():
-    if msgs:
-        desc = cats_descr[cat]
-        print(f"[bold]{desc}:" if desc else f"<!-- {cat} -->")
+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"<!-- {cat}({sub}) -->")
+                print()
+            for msg in subs[sub]:
+                print(Syntax(msg, "md", theme="ansi_light", word_wrap=True))
+                print()
             print()
-        print()
 
 if missing:
     print()