#!/usr/bin/env python3 """ Summarizes PRs since `latest` branch, grouping them based on their GitHub labels. If the result is not satisfactory, you can edit the original PR titles and labels. You can add the `exclude from changelog` label to minor PRs that are not of interest to our users. Finally, copy-paste the output into `CHANGELOG.md` and add a high-level summary to the top. """ from __future__ import annotations import argparse import json import multiprocessing import re import subprocess import sys from dataclasses import dataclass from typing import Any from git import Repo # pip install GitPython from tqdm import tqdm OWNER = "rerun-io" REPO = "rerun" INCLUDE_LABELS = False # It adds quite a bit of visual noise # Cache for contributor classification to avoid repeated API calls _external_contributor_cache: dict[str, bool] = {} # Coding-agent accounts that GitHub registers as ordinary users, not as a `Bot` and with no # `[bot]` suffix or `app/` prefix, so neither the account `type` nor the login itself flags them. # `claude` is Anthropic's account, committing via `noreply@anthropic.com`. `cursoragent` is Cursor's. _AGENT_USER_NAMES = {"claude", "cursoragent"} def is_bot_account(user_name: str, account_type: str | None = None) -> bool: """Return whether a GitHub account is a bot rather than a human contributor. The account `type` from the GitHub API is the reliable signal, and the only one that catches bots with a plain login like the `Copilot` agent, so callers pass it whenever they have it. Without a `type` we judge by the login alone, which still covers GitHub App bots in both formats we see. The REST API uses a `[bot]` suffix, as in `dependabot[bot]` or `claude[bot]`. The `gh` CLI uses an `app/` prefix, as in `app/copilot-swe-agent`. A few coding agents run as ordinary user accounts with no such marker, so we also match `claude` and `cursoragent` by name. """ if account_type == "Bot": return True name = user_name.lower() return name.endswith("[bot]") or name.startswith("app/") or name in _AGENT_USER_NAMES def eprint(*args: Any, **kwargs: Any) -> None: print(*args, file=sys.stderr, **kwargs) @dataclass class PrInfo: gh_user_names: list[str] pr_title: str labels: list[str] @dataclass class CommitInfo: hexsha: str title: str pr_number: int | None source_ref_hash: str | None def unique_user_names(user_names: list[str]) -> list[str]: """Deduplicate GitHub usernames while preserving order.""" seen = set() unique = [] for user_name in user_names: if user_name not in seen and not is_bot_account(user_name): seen.add(user_name) unique.append(user_name) return unique def is_external_contributor(user_name: str) -> bool: """Return whether a GitHub user looks external to Rerun. We intentionally use the repository permissions endpoint instead of the org members endpoint, since the latter only returns public org memberships. Bots are never external contributors. The permissions endpoint reports the account `type`, so a resolvable bot like `dependabot[bot]` is caught from its type without a name list. Bots whose login never resolves through the API are caught earlier by `is_bot_account`, including the Copilot agent's `app/…` login. """ if is_bot_account(user_name): return False if user_name in _external_contributor_cache: return _external_contributor_cache[user_name] try: result = subprocess.run( [ "gh", "api", f"/repos/{OWNER}/{REPO}/collaborators/{user_name}/permission", "--jq", "[.user.type, .permission] | @tsv", ], capture_output=True, text=True, check=True, ) account_type, _, permission = result.stdout.strip().partition("\t") is_external = not is_bot_account(user_name, account_type) and permission not in { "admin", "maintain", "write", "triage", } except subprocess.CalledProcessError as e: eprint( f"ERROR fetching repository permission for @{user_name}: {e.stderr.strip()}. Assuming external contributor." ) is_external = True _external_contributor_cache[user_name] = is_external return is_external # Slow def fetch_pr_commit_authors(repo: str, pr_number: int) -> list[str]: """Return the GitHub logins of every human who authored a commit in a PR. GitHub resolves each commit's author email to a login server-side, so this recovers the real contributors even when their `Co-authored-by` trailers use a private email we cannot map. We pass each author's account `type` to `is_bot_account` so bot commit authors get dropped too, such as the `Copilot` agent whose plain login alone would not look like a bot. """ try: result = subprocess.run( [ "gh", "api", f"/repos/{OWNER}/{repo}/pulls/{pr_number}/commits?per_page=100", "--jq", ".[] | select(.author != null) | [.author.login, .author.type] | @tsv", ], capture_output=True, text=True, check=True, ) except subprocess.CalledProcessError as e: eprint(f"ERROR fetching commits for {repo} PR #{pr_number}: {e.stderr.strip()}") return [] user_names = [] for line in result.stdout.splitlines(): login, _, account_type = line.partition("\t") if not is_bot_account(login, account_type): user_names.append(login) return unique_user_names(user_names) # Slow def fetch_reality_pr_info(commit_hash: str) -> PrInfo | None: """ Fetch PR info from rerun-io/reality repo using a commit hash. """ try: # Find PR associated with this commit result = subprocess.run( [ "gh", "api", f"/repos/{OWNER}/reality/commits/{commit_hash}/pulls", "--jq", ".[0] | {number: .number, title: .title, labels: [.labels[].name]}", ], capture_output=True, text=True, check=True, ) pr_data = json.loads(result.stdout) # Check if we got a PR (API returns empty object if no PR found) if not pr_data or "title" not in pr_data: return None pr_number = pr_data.get("number") # The PR's commit authors are the human contributors. We take them rather than the PR author # because synced PRs are opened by the sync bot, with the real people only on the commits. gh_user_names = fetch_pr_commit_authors("reality", pr_number) if pr_number is not None else [] return PrInfo( gh_user_names=gh_user_names, pr_title=pr_data["title"], labels=pr_data["labels"], ) except subprocess.CalledProcessError as e: # Commit doesn't exist in Reality repo, or API error eprint(f"Could not fetch Reality PR for commit {commit_hash[:8]}: {e.stderr.strip()}") return None except (json.JSONDecodeError, KeyError) as e: eprint(f"ERROR parsing Reality PR data for commit {commit_hash[:8]}: {e}") return None # Slow def fetch_pr_info_from_commit_info(commit_info: CommitInfo) -> PrInfo | None: """ Fetch PR info with Reality-first, Rerun-fallback strategy. Priority order: 1. Try the Reality repo using the Source-Ref commit hash, if present. 2. Fall back to the Rerun repo using the PR number. Either way the contributors come from the PR's commit authors, which the API resolves to real logins and tags with an account `type`, so private emails are recovered and bots are dropped. """ # Priority 1: Try Reality repo if Source-Ref is present if commit_info.source_ref_hash is not None: reality_pr_info = fetch_reality_pr_info(commit_info.source_ref_hash) if reality_pr_info is not None: return reality_pr_info # If Reality lookup fails, fall through to Rerun repo fallback # Priority 2: Fallback to Rerun repo using PR number if commit_info.pr_number is not None: pr_info = fetch_pr_info(commit_info.pr_number) if pr_info is not None: pr_info.gh_user_names = fetch_pr_commit_authors(REPO, commit_info.pr_number) return pr_info # No PR info available return None # Slow def fetch_pr_info(pr_number: int) -> PrInfo | None: try: # Use gh CLI to fetch PR info result = subprocess.run( [ "gh", "pr", "view", str(pr_number), "--repo", f"{OWNER}/{REPO}", "--json", "title,labels,author", ], capture_output=True, text=True, check=True, ) pr_data = json.loads(result.stdout) labels = [label["name"] for label in pr_data["labels"]] gh_user_name = pr_data["author"]["login"] return PrInfo(gh_user_names=unique_user_names([gh_user_name]), pr_title=pr_data["title"], labels=labels) except subprocess.CalledProcessError as e: eprint( f"ERROR fetching PR #{pr_number}: {e.stderr.strip()}. If none of these succeed, You need to install the GitHub CLI tools: https://cli.github.com/ and authenticate with github." ) return None except (json.JSONDecodeError, KeyError) as e: eprint( f"ERROR parsing PR #{pr_number} data: {e}. If none of these succeed, You need to install the GitHub CLI tools: https://cli.github.com/ and authenticate with github." ) return None def get_commit_info(commit: Any) -> CommitInfo: match = re.match(r"(.*) \(#(\d+)\)$", commit.summary) if match: title = str(match.group(1)) pr_number = int(match.group(2)) else: title = commit.summary pr_number = None # Extract Source-Ref from commit body source_ref_hash = None source_ref_match = re.search(r"^Source-Ref:\s+([0-9a-f]{40})$", commit.message, re.MULTILINE) if source_ref_match: source_ref_hash = source_ref_match.group(1) return CommitInfo( hexsha=commit.hexsha, title=title, pr_number=pr_number, source_ref_hash=source_ref_hash, ) def print_section(title: str, items: list[str]) -> None: if 0 < len(items): print(f"#### {title}") for line in items: print(f"- {line}") print() def commit_range(new_version: str) -> str: parts = new_version.split(".") assert len(parts) == 3, "Expected version to be on the format X.Y.Z" major = int(parts[0]) minor = int(parts[1]) patch = int(parts[2]) if 0 < patch: # A patch release. # Include changes since last patch release. # This assumes we've cherry-picked stuff for this release. diff_since_version = f"0.{minor}.{patch - 1}" elif 0 < minor: # A minor release # The diff should span everything since the last minor release. # The script later excludes duplicated automatically, so we don't include stuff that # was part of intervening patch releases. diff_since_version = f"{major}.{minor - 1}.0" else: # A major release # The diff should span everything since the last major release. # The script later excludes duplicated automatically, so we don't include stuff that # was part of intervening minor/patch releases. diff_since_version = f"{major - 1}.{minor}.0" return f"{diff_since_version}..HEAD" def main() -> None: parser = argparse.ArgumentParser(description="Generate a changelog.") parser.add_argument("--version", required=True, help="The version of the new release, e.g. 0.42.0") args = parser.parse_args() # Because how we branch, we sometimes get duplicate commits in the changelog unless we check for it previous_changelog = open("CHANGELOG.md", encoding="utf8").read() repo = Repo(".") commits = list(repo.iter_commits(commit_range(args.version))) commits.reverse() # Most recent last commit_infos = list(map(get_commit_info, commits)) pool = multiprocessing.Pool() pr_infos = list( tqdm( pool.imap(fetch_pr_info_from_commit_info, commit_infos), total=len(commit_infos), desc="Fetch PR info commits", ), ) chronological = [] # Sections: analytics = [] bugs = [] cpp = [] dependencies = [] dev_experience = [] docs = [] enhancement = [] examples = [] log_api = [] oss_server = [] mcap = [] misc = [] performance = [] python = [] refactor = [] renderer = [] rfc = [] rust = [] ui = [] viewer = [] web = [] for commit_info, pr_info in zip(commit_infos, pr_infos, strict=False): hexsha_full = commit_info.hexsha hexsha_short = hexsha_full[:7] title = commit_info.title pr_number = commit_info.pr_number if pr_number is None and pr_info is None: # Someone committed straight to main without PR: summary = f"{title} [{hexsha_short}](https://github.com/{OWNER}/{REPO}/commit/{hexsha_full})" if f"[{hexsha_short}]" in previous_changelog or f"[{hexsha_full}]" in previous_changelog: print(f"Ignoring dup: {summary}") continue chronological.append(summary) misc.append(summary) else: title = pr_info.pr_title if pr_info else title # We prefer the PR title if available title = title.rstrip(".").strip() # Some PR end with an unnecessary period labels = pr_info.labels if pr_info else [] if "include in changelog" not in labels and "include in OSS changelog" not in labels: continue # Generate summary - prefer PR number, fallback to commit hash if pr_number is not None: summary = f"{title} [#{pr_number}](https://github.com/{OWNER}/{REPO}/pull/{pr_number})" dup_checks = [f"[#{pr_number}]"] else: # No PR number in title, but we have Reality PR info - use hash of synced commit in Rerun repo summary = f"{title} [{hexsha_short}](https://github.com/{OWNER}/{REPO}/commit/{hexsha_full})" # The changelog records the short hash in brackets and the full hash in the URL, so check both. dup_checks = [f"[{hexsha_short}]", f"[{hexsha_full}]"] if any(dup_check in previous_changelog for dup_check in dup_checks): eprint(f"Ignoring dup: {summary}") continue chronological.append(f"{summary} {hexsha_full}") if INCLUDE_LABELS and 0 < len(labels): summary += f" ({', '.join(labels)})" if pr_info is not None: external_contributors = [ gh_user_name for gh_user_name in pr_info.gh_user_names if is_external_contributor(gh_user_name) ] if len(external_contributors) == 1: gh_user_name = external_contributors[0] summary += f" (thanks [@{gh_user_name}](https://github.com/{gh_user_name})!)" elif 1 < len(external_contributors): thanks = ", ".join( f"[@{gh_user_name}](https://github.com/{gh_user_name})" for gh_user_name in external_contributors ) summary += f" (thanks {thanks}!)" if labels == ["β›΄ release"]: continue # Ignore release PRs added = False # Some PRs can show up under multiple sections: if "πŸͺ΅ Log & send APIs" in labels: log_api.append(summary) added = True else: if "sdk-cpp" in labels: cpp.append(summary) added = True if "sdk-python" in labels: python.append(summary) added = True if "sdk-rust" in labels: rust.append(summary) added = True if "πŸ“Š analytics" in labels: analytics.append(summary) added = True if not added: if "examples" in labels: examples.append(summary) elif "πŸͺ³ bug" in labels or "πŸ’£ crash" in labels or "🦟 regression" in labels: bugs.append(summary) elif "πŸ“‰ performance" in labels: performance.append(summary) elif "πŸ“– documentation" in labels: docs.append(summary) elif "ui" in labels: ui.append(summary) elif "πŸ“Ί re_viewer" in labels: viewer.append(summary) elif "πŸ”Ί re_renderer" in labels: renderer.append(summary) elif "πŸ•ΈοΈ web" in labels: web.append(summary) elif "🧒 MCAP" in labels: mcap.append(summary) elif "OSS-server" in labels: oss_server.append(summary) elif "enhancement" in labels: enhancement.append(summary) elif "🚜 refactor" in labels: refactor.append(summary) elif "πŸ§‘β€πŸ’» dev experience" in labels: dev_experience.append(summary) elif "πŸ’¬ discussion" in labels: rfc.append(summary) elif "dependencies" in labels: dependencies.append(summary) elif not added: misc.append(summary) print() # NOTE: we intentionally add TODO:s with names below, which the CI will not be happy about. Hence the # NOLINT suffixes print("TODO: add link to release video") # NOLINT print() print("πŸ“– Release blogpost: TODO: add link") # NOLINT print() print("🧳 Migration guide: TODO: add link") # NOLINT print() print("### ✨ Overview & highlights") print("TODO: fill in") # NOLINT print() print("### ⚠️ Breaking changes") print("TODO: fill in") # NOLINT print("🧳 Migration guide: TODO: add link (yes, again)") # NOLINT print() print("### πŸ”Ž Details") print() # Most interesting first: print_section("πŸͺ΅ Log API", log_api) print_section("🌊 C++ API", cpp) print_section("🐍 Python API", python) print_section("πŸ¦€ Rust API", rust) print_section("πŸͺ³ Bug fixes", bugs) print_section("🌁 Viewer improvements", viewer) print_section("πŸ—„οΈ OSS server", oss_server) print_section("πŸš€ Performance improvements", performance) print_section("πŸ§‘β€πŸ« Examples", examples) print_section("πŸ“š Docs", docs) print_section("πŸ–Ό UI improvements", ui) print_section("πŸ•ΈοΈ Web", web) print_section("🎨 Renderer improvements", renderer) print_section("🧒 MCAP", mcap) print_section("✨ Other enhancement", enhancement) print_section("πŸ“ˆ Analytics", analytics) print_section("πŸ—£ Merged RFCs", rfc) print_section("πŸ§‘β€πŸ’» Dev-experience", dev_experience) print_section("πŸ—£ Refactors", refactor) print_section("πŸ“¦ Dependencies", dependencies) print_section("πŸ€·β€ Other", misc) print() print_section("Chronological changes (don't include these)", chronological) if __name__ == "__main__": main()