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()