| 1 | #!/usr/bin/env python3 |
| 2 | """Fail when someone whose work landed in this release is not credited. |
| 3 | |
| 4 | The three credit surfaces -- docs/CONTRIBUTORS.md, the CHANGELOG's Contributors |
| 5 | block, and web/lib/release-credits.ts -- were cross-checked only against |
| 6 | `requiredCandidateCredits` in docs/public-surface-facts.json, which is itself |
| 7 | hand-maintained. That gate proves the three files agree with each other; it |
| 8 | never asks whether the list is complete, so a contributor nobody remembered to |
| 9 | add was invisible to every check. Nine were missing from 0.10.0 when this was |
| 10 | written. |
| 11 | |
| 12 | This derives the expected set from git history instead of from a list a human |
| 13 | curates: commit authors, `Co-authored-by:` trailers, and `Harvested from PR #N |
| 14 | by @handle` lines in the release window. Offline by design -- harvested handles |
| 15 | live in the commit bodies, so no network call is needed. |
| 16 | |
| 17 | Usage: scripts/check-contributor-credit.py [<since-rev>] |
| 18 | """ |
| 19 | from __future__ import annotations |
| 20 | import re, subprocess, sys |
| 21 | from pathlib import Path |
| 22 | |
| 23 | ROOT = Path(__file__).resolve().parent.parent |
| 24 | |
| 25 | # Bots and the maintainer account: real authors of most commits, never |
| 26 | # "contributors" in the credit sense this file guards. |
| 27 | SKIP = { |
| 28 | "codewhale bot", "codewhalebot", "claude", "codex", "dependabot[bot]", |
| 29 | "codewhale-maint", |
| 30 | "hunter bown", "hunter b", "hmbown", "deepseek-v41-flash", "devin", |
| 31 | } |
| 32 | |
| 33 | |
| 34 | def sh(*args: str) -> str: |
| 35 | return subprocess.run(args, cwd=ROOT, capture_output=True, text=True, check=True).stdout |
| 36 | |
| 37 | |
| 38 | def author_map() -> dict[str, str]: |
| 39 | """alias -> github handle, from .github/AUTHOR_MAP.""" |
| 40 | out: dict[str, str] = {} |
| 41 | path = ROOT / ".github" / "AUTHOR_MAP" |
| 42 | if not path.exists(): |
| 43 | return out |
| 44 | for line in path.read_text().splitlines(): |
| 45 | line = line.split("#", 1)[0].strip() |
| 46 | if "=" not in line: |
| 47 | continue |
| 48 | alias, target = (part.strip() for part in line.split("=", 1)) |
| 49 | m = re.search(r"\d+\+([^@<>\s]+)@users\.noreply\.github\.com", target) |
| 50 | if m: |
| 51 | out[alias.lower()] = m.group(1) |
| 52 | return out |
| 53 | |
| 54 | |
| 55 | def expected(since: str) -> dict[str, str]: |
| 56 | """handle -> why we think they are owed credit.""" |
| 57 | amap = author_map() |
| 58 | found: dict[str, str] = {} |
| 59 | |
| 60 | def add(name: str, email: str, why: str) -> None: |
| 61 | low = name.strip().lower() |
| 62 | # Model co-authorship ("Claude Opus 5 (1M context)") and bot addresses |
| 63 | # are not people owed credit; match on prefix since the model name and |
| 64 | # its context window change release to release. |
| 65 | if any(low.startswith(skip) for skip in SKIP): |
| 66 | return |
| 67 | if "[bot]" in email.lower() or email.lower().endswith(("noreply@anthropic.com", "noreply@openai.com")): |
| 68 | return |
| 69 | handle = amap.get(email.lower()) or amap.get(name.strip().lower()) |
| 70 | if not handle: |
| 71 | m = re.search(r"\d+\+([^@<>\s]+)@users\.noreply\.github\.com", email) |
| 72 | handle = m.group(1) if m else name.strip() |
| 73 | if any(handle.lower().startswith(skip) for skip in SKIP): |
| 74 | return |
| 75 | found.setdefault(handle, why) |
| 76 | |
| 77 | for line in sh("git", "log", f"{since}..HEAD", "--format=%an\t%ae").splitlines(): |
| 78 | name, _, email = line.partition("\t") |
| 79 | add(name, email, "commit author") |
| 80 | |
| 81 | body = sh("git", "log", f"{since}..HEAD", "--format=%b") |
| 82 | for name, email in re.findall(r"(?im)^co-authored-by:\s*(.+?)\s*<([^>]+)>", body): |
| 83 | add(name, email, "co-author trailer") |
| 84 | for pr, handle in re.findall(r"(?i)harvested from PR #(\d+) by @([A-Za-z0-9-]+)", body): |
| 85 | if handle.lower() not in SKIP: |
| 86 | found.setdefault(handle, f"harvested PR #{pr}") |
| 87 | return found |
| 88 | |
| 89 | |
| 90 | def main() -> int: |
| 91 | since = sys.argv[1] if len(sys.argv) > 1 else sh("git", "describe", "--tags", "--abbrev=0").strip() |
| 92 | owed = expected(since) |
| 93 | if not owed: |
| 94 | print(f"No external contributors found since {since}.") |
| 95 | return 0 |
| 96 | |
| 97 | surfaces = { |
| 98 | "docs/CONTRIBUTORS.md": (ROOT / "docs/CONTRIBUTORS.md").read_text(), |
| 99 | "CHANGELOG.md": (ROOT / "CHANGELOG.md").read_text(), |
| 100 | "web/lib/release-credits.ts": (ROOT / "web/lib/release-credits.ts").read_text(), |
| 101 | } |
| 102 | |
| 103 | missing: list[str] = [] |
| 104 | for handle, why in sorted(owed.items(), key=lambda kv: kv[0].lower()): |
| 105 | absent = [name for name, text in surfaces.items() if handle not in text] |
| 106 | if absent: |
| 107 | missing.append(f" @{handle} ({why}) -- not in: {', '.join(absent)}") |
| 108 | |
| 109 | print(f"Contributor credit since {since}: {len(owed)} contributor(s) found.") |
| 110 | if missing: |
| 111 | print("\nUncredited work landed in this release:\n") |
| 112 | print("\n".join(missing)) |
| 113 | print( |
| 114 | "\nAdd each to docs/CONTRIBUTORS.md (canonical), the CHANGELOG's\n" |
| 115 | "Contributors block, and web/lib/release-credits.ts. Harvested work\n" |
| 116 | "is still their work -- see AGENTS.md 'Landing other people's work'." |
| 117 | ) |
| 118 | return 1 |
| 119 | print("Every contributor in the window is credited on all three surfaces.") |
| 120 | return 0 |
| 121 | |
| 122 | |
| 123 | if __name__ == "__main__": |
| 124 | raise SystemExit(main()) |
| 125 |