Something went wrong. Try again.
The agent that grows with you
Something went wrong. Try again.
16 kB · 473 lines
Python
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474#!/usr/bin/env python3"""Contributor Audit Script
Cross-references git authors, Co-authored-by trailers, and salvaged PRdescriptions to find any contributors missing from the release notes.
Usage: # Basic audit since a tag python scripts/contributor_audit.py --since-tag v2026.4.8
# Audit with a custom endpoint python scripts/contributor_audit.py --since-tag v2026.4.8 --until v2026.4.13
# Compare against a release notes file python scripts/contributor_audit.py --since-tag v2026.4.8 --release-file RELEASE_v0.9.0.md"""
import argparseimport jsonimport osimport reimport subprocessimport sysfrom collections import defaultdictfrom pathlib import Path
# ---------------------------------------------------------------------------# Import AUTHOR_MAP and resolve_author from the sibling release.py module# ---------------------------------------------------------------------------SCRIPT_DIR = Path(__file__).resolve().parentsys.path.insert(0, str(SCRIPT_DIR))
from release import AUTHOR_MAP, resolve_author # noqa: E402
REPO_ROOT = SCRIPT_DIR.parent
# ---------------------------------------------------------------------------# AI assistants, bots, and machine accounts to exclude from contributor lists# ---------------------------------------------------------------------------IGNORED_PATTERNS = [ re.compile(r"^Claude", re.IGNORECASE), re.compile(r"^Copilot$", re.IGNORECASE), re.compile(r"^Cursor\s+Agent$", re.IGNORECASE), re.compile(r"^GitHub\s*Actions?$", re.IGNORECASE), re.compile(r"^dependabot", re.IGNORECASE), re.compile(r"^renovate", re.IGNORECASE), re.compile(r"^Hermes\s+(Agent|Audit)$", re.IGNORECASE), re.compile(r"^Ubuntu$", re.IGNORECASE),]
IGNORED_EMAILS = { "noreply@anthropic.com", "noreply@github.com", "cursoragent@cursor.com", "hermes@nousresearch.com", "hermes-audit@example.com", "hermes@habibilabs.dev",}
def is_ignored(handle: str, email: str = "") -> bool: """Return True if this contributor is a bot/AI/machine account.""" if email in IGNORED_EMAILS: return True for pattern in IGNORED_PATTERNS: if pattern.search(handle): return True return False
# ---------------------------------------------------------------------------# Helpers# ---------------------------------------------------------------------------
def git(*args, cwd=None): """Run a git command and return stdout.""" result = subprocess.run( ["git"] + list(args), capture_output=True, text=True, cwd=cwd or str(REPO_ROOT), ) if result.returncode != 0: print(f" [warn] git {' '.join(args)} failed: {result.stderr.strip()}", file=sys.stderr) return "" return result.stdout.strip()
def gh_pr_list(): """Fetch merged PRs from GitHub using the gh CLI.
Returns a list of dicts with keys: number, title, body, author. Returns an empty list if gh is not available or the call fails. """ try: result = subprocess.run( [ "gh", "pr", "list", "--repo", "NousResearch/hermes-agent", "--state", "merged", "--json", "number,title,body,author,mergedAt", "--limit", "300", ], capture_output=True, text=True, timeout=60, ) if result.returncode != 0: print(f" [warn] gh pr list failed: {result.stderr.strip()}", file=sys.stderr) return [] return json.loads(result.stdout) except FileNotFoundError: print(" [warn] 'gh' CLI not found — skipping salvaged PR scan.", file=sys.stderr) return [] except subprocess.TimeoutExpired: print(" [warn] gh pr list timed out — skipping salvaged PR scan.", file=sys.stderr) return [] except json.JSONDecodeError: print(" [warn] gh pr list returned invalid JSON — skipping salvaged PR scan.", file=sys.stderr) return []
# ---------------------------------------------------------------------------# Contributor collection# ---------------------------------------------------------------------------
# Patterns that indicate salvaged/cherry-picked/co-authored work in PR bodiesSALVAGE_PATTERNS = [ # "Salvaged from @username" or "Salvaged from #123" re.compile(r"[Ss]alvaged\s+from\s+@(\w[\w-]*)"), re.compile(r"[Ss]alvaged\s+from\s+#(\d+)"), # "Cherry-picked from @username" re.compile(r"[Cc]herry[- ]?picked\s+from\s+@(\w[\w-]*)"), # "Based on work by @username" re.compile(r"[Bb]ased\s+on\s+work\s+by\s+@(\w[\w-]*)"), # "Original PR by @username" re.compile(r"[Oo]riginal\s+PR\s+by\s+@(\w[\w-]*)"), # "Co-authored with @username" re.compile(r"[Cc]o[- ]?authored\s+with\s+@(\w[\w-]*)"),]
# Pattern for Co-authored-by trailers in commit messagesCO_AUTHORED_RE = re.compile( r"Co-authored-by:\s*(.+?)\s*<([^>]+)>", re.IGNORECASE,)
def collect_commit_authors(since_tag, until="HEAD"): """Collect contributors from git commit authors.
Returns: contributors: dict mapping github_handle -> set of source labels unknown_emails: dict mapping email -> git name (for emails not in AUTHOR_MAP) """ range_spec = f"{since_tag}..{until}" log = git( "log", range_spec, "--format=%H|%an|%ae|%s", "--no-merges", )
contributors = defaultdict(set) unknown_emails = {}
if not log: return contributors, unknown_emails
for line in log.split("\n"): if not line.strip(): continue parts = line.split("|", 3) if len(parts) != 4: continue _sha, name, email, _subject = parts
handle = resolve_author(name, email) # resolve_author returns "@handle" or plain name if handle.startswith("@"): contributors[handle.lstrip("@")].add("commit") else: # Could not resolve — record as unknown contributors[handle].add("commit") unknown_emails[email] = name
return contributors, unknown_emails
def collect_co_authors(since_tag, until="HEAD"): """Collect contributors from Co-authored-by trailers in commit messages.
Returns: contributors: dict mapping github_handle -> set of source labels unknown_emails: dict mapping email -> git name """ range_spec = f"{since_tag}..{until}" # Get full commit messages to scan for trailers log = git( "log", range_spec, "--format=__COMMIT__%H%n%b", "--no-merges", )
contributors = defaultdict(set) unknown_emails = {}
if not log: return contributors, unknown_emails
for line in log.split("\n"): match = CO_AUTHORED_RE.search(line) if match: name = match.group(1).strip() email = match.group(2).strip() handle = resolve_author(name, email) if handle.startswith("@"): contributors[handle.lstrip("@")].add("co-author") else: contributors[handle].add("co-author") unknown_emails[email] = name
return contributors, unknown_emails
def collect_salvaged_contributors(since_tag, until="HEAD"): """Scan merged PR bodies for salvage/cherry-pick/co-author attribution.
Uses the gh CLI to fetch PRs, then filters to the date range defined by since_tag..until and scans bodies for salvage patterns.
Returns: contributors: dict mapping github_handle -> set of source labels pr_refs: dict mapping github_handle -> list of PR numbers where found """ contributors = defaultdict(set) pr_refs = defaultdict(list)
# Determine the date range from git tags/refs since_date = git("log", "-1", "--format=%aI", since_tag) if until == "HEAD": until_date = git("log", "-1", "--format=%aI", "HEAD") else: until_date = git("log", "-1", "--format=%aI", until)
if not since_date: print(f" [warn] Could not resolve date for {since_tag}", file=sys.stderr) return contributors, pr_refs
prs = gh_pr_list() if not prs: return contributors, pr_refs
for pr in prs: # Filter by merge date if available merged_at = pr.get("mergedAt", "") if merged_at and since_date: if merged_at < since_date: continue if until_date and merged_at > until_date: continue
body = pr.get("body") or "" pr_number = pr.get("number", "?")
# Also credit the PR author pr_author = pr.get("author", {}) pr_author_login = pr_author.get("login", "") if isinstance(pr_author, dict) else ""
for pattern in SALVAGE_PATTERNS: for match in pattern.finditer(body): value = match.group(1) # If it's a number, it's a PR reference — skip for now # (would need another API call to resolve PR author) if value.isdigit(): continue contributors[value].add("salvage") pr_refs[value].append(pr_number)
return contributors, pr_refs
# ---------------------------------------------------------------------------# Release file comparison# ---------------------------------------------------------------------------
def check_release_file(release_file, all_contributors): """Check which contributors are mentioned in the release file.
Returns: mentioned: set of handles found in the file missing: set of handles NOT found in the file """ try: content = Path(release_file).read_text() except FileNotFoundError: print(f" [error] Release file not found: {release_file}", file=sys.stderr) return set(), set(all_contributors)
mentioned = set() missing = set() content_lower = content.lower()
for handle in all_contributors: # Check for @handle or just handle (case-insensitive) if f"@{handle.lower()}" in content_lower or handle.lower() in content_lower: mentioned.add(handle) else: missing.add(handle)
return mentioned, missing
# ---------------------------------------------------------------------------# Main# ---------------------------------------------------------------------------
def main(): parser = argparse.ArgumentParser( description="Audit contributors across git history, co-author trailers, and salvaged PRs.", ) parser.add_argument( "--since-tag", required=True, help="Git tag to start from (e.g., v2026.4.8)", ) parser.add_argument( "--until", default="HEAD", help="Git ref to end at (default: HEAD)", ) parser.add_argument( "--release-file", default=None, help="Path to a release notes file to check for missing contributors", ) parser.add_argument( "--strict", action="store_true", help="Exit with code 1 if new unmapped emails are found (for CI)", ) parser.add_argument( "--diff-base", default=None, help="Git ref to diff against (only flag emails from commits after this ref)", ) args = parser.parse_args()
print(f"=== Contributor Audit: {args.since_tag}..{args.until} ===") print()
# ---- 1. Git commit authors ---- print("[1/3] Scanning git commit authors...") commit_contribs, commit_unknowns = collect_commit_authors(args.since_tag, args.until) print(f" Found {len(commit_contribs)} contributor(s) from commits.")
# ---- 2. Co-authored-by trailers ---- print("[2/3] Scanning Co-authored-by trailers...") coauthor_contribs, coauthor_unknowns = collect_co_authors(args.since_tag, args.until) print(f" Found {len(coauthor_contribs)} contributor(s) from co-author trailers.")
# ---- 3. Salvaged PRs ---- print("[3/3] Scanning salvaged/cherry-picked PR descriptions...") salvage_contribs, salvage_pr_refs = collect_salvaged_contributors(args.since_tag, args.until) print(f" Found {len(salvage_contribs)} contributor(s) from salvaged PRs.")
# ---- Merge all contributors ---- all_contributors = defaultdict(set) for handle, sources in commit_contribs.items(): all_contributors[handle].update(sources) for handle, sources in coauthor_contribs.items(): all_contributors[handle].update(sources) for handle, sources in salvage_contribs.items(): all_contributors[handle].update(sources)
# Merge unknown emails all_unknowns = {} all_unknowns.update(commit_unknowns) all_unknowns.update(coauthor_unknowns)
# Filter out AI assistants, bots, and machine accounts ignored = {h for h in all_contributors if is_ignored(h)} for h in ignored: del all_contributors[h] # Also filter unknowns by email all_unknowns = {e: n for e, n in all_unknowns.items() if not is_ignored(n, e)}
# ---- Output ---- print() print(f"=== All Contributors ({len(all_contributors)}) ===") print()
# Sort by handle, case-insensitive for handle in sorted(all_contributors.keys(), key=str.lower): sources = sorted(all_contributors[handle]) source_str = ", ".join(sources) extra = "" if handle in salvage_pr_refs: pr_nums = salvage_pr_refs[handle] extra = f" (PRs: {', '.join(f'#{n}' for n in pr_nums)})" print(f" @{handle} [{source_str}]{extra}")
# ---- Unknown emails ---- if all_unknowns: print() print(f"=== Unknown Emails ({len(all_unknowns)}) ===") print("These emails are not in AUTHOR_MAP and should be added:") print() for email, name in sorted(all_unknowns.items()): print(f' "{email}": "{name}",')
# ---- Strict mode: fail CI if new unmapped emails are introduced ---- if args.strict and all_unknowns: # In strict mode, check if ANY unknown emails come from commits in this # PR's diff range (new unmapped emails that weren't there before). # This is the CI gate: existing unknowns are grandfathered, but new # commits must have their author email in AUTHOR_MAP. new_unknowns = {} if args.diff_base: # Only flag emails from commits after diff_base new_commits_output = git( "log", f"{args.diff_base}..HEAD", "--format=%ae", "--no-merges", ) new_emails = set(new_commits_output.splitlines()) if new_commits_output else set() for email, name in all_unknowns.items(): if email in new_emails: new_unknowns[email] = name else: new_unknowns = all_unknowns
if new_unknowns: print() print(f"=== STRICT MODE FAILURE: {len(new_unknowns)} new unmapped email(s) ===") print("Add these to AUTHOR_MAP in scripts/release.py before merging:") print() for email, name in sorted(new_unknowns.items()): print(f' "{email}": "<github-username>",') print() print("To find the GitHub username:") print(" gh api 'search/users?q=EMAIL+in:email' --jq '.items[0].login'") strict_failed = True else: strict_failed = False else: strict_failed = False
# ---- Release file comparison ---- if args.release_file: print() print(f"=== Release File Check: {args.release_file} ===") print() mentioned, missing = check_release_file(args.release_file, all_contributors.keys()) print(f" Mentioned in release notes: {len(mentioned)}") print(f" Missing from release notes: {len(missing)}") if missing: print() print(" Contributors NOT mentioned in the release file:") for handle in sorted(missing, key=str.lower): sources = sorted(all_contributors[handle]) print(f" @{handle} [{', '.join(sources)}]") else: print() print(" All contributors are mentioned in the release file!")
print() print("Done.")
if strict_failed: sys.exit(1)
if __name__ == "__main__": main()