返回 CodeWhale
check-contributor-credit.py
根目录 / scripts / check-contributor-credit.py
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
125 lines PYTHON