返回 CodeWhale
check-coauthor-trailers.py
根目录 / scripts / check-coauthor-trailers.py
1 #!/usr/bin/env python3
2 """Validate that harvested contributor credit is GitHub-mappable.
3
4 The check is intentionally scoped to new commits. Historical commits may carry
5 raw or local emails, but new harvested commits should use GitHub's numeric
6 `id+login@users.noreply.github.com` address so co-author credit lands in the
7 contributor graph. Preserved integration commits may resolve a mapped identity
8 without a history rewrite only through an exact-SHA exception below.
9 """
10
11 from __future__ import annotations
12
13 import argparse
14 import re
15 import subprocess
16 import sys
17 from dataclasses import dataclass
18 from pathlib import Path
19
20
21 ROOT = Path(__file__).resolve().parents[1]
22 DEFAULT_AUTHOR_MAP = ROOT / ".github" / "AUTHOR_MAP"
23
24 IDENTITY_RE = re.compile(r"^\s*(?P<name>.+?)\s*<(?P<email>[^<>]+)>\s*$")
25 CANONICAL_NOREPLY_RE = re.compile(
26 r"^[0-9]+\+[^@\s]+@users\.noreply\.github\.com$", re.IGNORECASE
27 )
28 COAUTHOR_RE = re.compile(
29 r"^Co-authored-by:\s*(?P<name>.*?)\s*<(?P<email>[^<>]+)>\s*$",
30 re.IGNORECASE | re.MULTILINE,
31 )
32 HARVEST_RE = re.compile(r"Harvested from PR #[0-9]+ by @([A-Za-z0-9-]+)")
33
34 BOT_EMAILS = {
35 "codex@local",
36 "codex@example.com",
37 "cursoragent@cursor.com",
38 "noreply@anthropic.com",
39 }
40 BOT_NAMES = ("claude", "codex", "cursor")
41
42 # This commit is already immutable history on origin/main. Its trailer names a
43 # local Codewhale automation actor, not a human contributor. It escaped the
44 # existing gate and is now immutable on origin/main. Rewriting main would
45 # invalidate every descendant, while mapping the actor to a human would
46 # manufacture contributor credit. Exempt only the exact full SHA + exact actor
47 # identity; every other malformed trailer still fails.
48 LEGACY_AUTOMATION_TRAILER_EXCEPTIONS = {
49 (
50 "9a74825cd182a62465943bcbbcbcf591d1ce99ee",
51 "codewhale agent",
52 "codewhale-agent@hmbown.local",
53 ),
54 }
55
56 # These public-surface commits were merged into the v0.9.1 integration graph
57 # before the credit gate ran. Rewriting them would replace the original commits
58 # and every descendant merge. Resolve only their exact Hunter identities through
59 # AUTHOR_MAP; a changed SHA, role, name, or email remains a hard failure.
60 PRESERVED_MAPPED_IDENTITY_EXCEPTIONS = {
61 (
62 "5087269606fc8847487b0a8b51ef6adffa8eb2ca",
63 "author",
64 "hunter b",
65 "hmbown@gmail.com",
66 ),
67 (
68 "5087269606fc8847487b0a8b51ef6adffa8eb2ca",
69 "coauthor",
70 "hunter bown",
71 "hmbown@gmail.com",
72 ),
73 (
74 "e37df06caeb3064b2bb9263c1c98a903738f3a0a",
75 "coauthor",
76 "hunter bown",
77 "hmbown@gmail.com",
78 ),
79 (
80 "6d0ebc881a8bd2469c45b25f2a606fa63681e112",
81 "coauthor",
82 "fleitz",
83 "fleitzo@gmail.com",
84 ),
85 (
86 "338138eb546bcf8917b27395325f59af0d2e4f52",
87 "author",
88 "hunter b",
89 "hmbown@gmail.com",
90 ),
91 }
92
93
94 @dataclass(frozen=True)
95 class Identity:
96 name: str
97 email: str
98
99 def trailer(self) -> str:
100 return f"Co-authored-by: {self.name} <{self.email}>"
101
102 def author(self) -> str:
103 return f"{self.name} <{self.email}>"
104
105
106 @dataclass(frozen=True)
107 class Commit:
108 sha: str
109 parents: str
110 author_name: str
111 author_email: str
112 subject: str
113 body: str
114
115 def is_merge_commit(self) -> bool:
116 return len(self.parents.split()) > 1
117
118
119 def norm_key(value: str) -> str:
120 return value.strip().lower()
121
122
123 def github_login_from_noreply(email: str) -> str | None:
124 if not CANONICAL_NOREPLY_RE.match(email):
125 return None
126 local = email.split("@", 1)[0]
127 return local.split("+", 1)[1]
128
129
130 def parse_identity(raw: str, context: str) -> Identity:
131 match = IDENTITY_RE.match(raw)
132 if not match:
133 raise ValueError(f"{context}: expected 'Name <id+login@users.noreply.github.com>'")
134 identity = Identity(match.group("name").strip(), match.group("email").strip())
135 if not CANONICAL_NOREPLY_RE.match(identity.email):
136 raise ValueError(
137 f"{context}: right-hand email must be numeric GitHub noreply, got {identity.email}"
138 )
139 return identity
140
141
142 def load_author_map(path: Path) -> dict[str, Identity]:
143 aliases: dict[str, Identity] = {}
144 for lineno, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
145 line = raw_line.split("#", 1)[0].strip()
146 if not line:
147 continue
148 if "=" not in line:
149 raise ValueError(f"{path}:{lineno}: expected 'alias = Name <email>'")
150 alias, raw_identity = [part.strip() for part in line.split("=", 1)]
151 identity = parse_identity(raw_identity, f"{path}:{lineno}")
152 key = norm_key(alias)
153 if key in aliases and aliases[key] != identity:
154 raise ValueError(f"{path}:{lineno}: duplicate alias {alias!r}")
155 aliases[key] = identity
156 aliases.setdefault(norm_key(identity.email), identity)
157 aliases.setdefault(norm_key(identity.name), identity)
158 if login := github_login_from_noreply(identity.email):
159 aliases.setdefault(norm_key(login), identity)
160 return aliases
161
162
163 def git_log(commit_range: str) -> list[Commit]:
164 try:
165 raw = subprocess.check_output(
166 [
167 "git",
168 "log",
169 "--format=%H%x00%P%x00%an%x00%ae%x00%s%x00%B%x1e",
170 commit_range,
171 ],
172 cwd=ROOT,
173 text=True,
174 )
175 except subprocess.CalledProcessError as exc:
176 raise RuntimeError(f"failed to read git range {commit_range!r}: {exc}") from exc
177
178 commits: list[Commit] = []
179 for record in raw.split("\x1e"):
180 if not record.strip():
181 continue
182 # `git log` emits a newline after each record separator. Remove only
183 # that framing byte so the next record's full SHA remains exact while
184 # preserving commit-body whitespace.
185 record = record.lstrip("\n")
186 parts = record.split("\x00", 5)
187 if len(parts) != 6:
188 raise RuntimeError("failed to parse git log output")
189 commits.append(Commit(*parts))
190 return commits
191
192
193 def is_bot_identity(name: str, email: str) -> bool:
194 lowered_name = name.strip().lower()
195 lowered_email = email.strip().lower()
196 return lowered_email in BOT_EMAILS or any(
197 lowered_name == bot or lowered_name.startswith(f"{bot} ") for bot in BOT_NAMES
198 )
199
200
201 def lookup_identity(aliases: dict[str, Identity], *values: str) -> Identity | None:
202 for value in values:
203 identity = aliases.get(norm_key(value))
204 if identity is not None:
205 return identity
206 return None
207
208
209 def is_preserved_mapped_identity(commit: Commit, role: str, identity: Identity) -> bool:
210 return (
211 commit.sha.strip().lower(),
212 role,
213 norm_key(identity.name),
214 norm_key(identity.email),
215 ) in PRESERVED_MAPPED_IDENTITY_EXCEPTIONS
216
217
218 def validate(commits: list[Commit], aliases: dict[str, Identity], check_authors: bool) -> list[str]:
219 errors: list[str] = []
220 for commit in commits:
221 prefix = f"{commit.sha[:10]} {commit.subject}"
222 coauthors = [
223 Identity(match.group("name").strip(), match.group("email").strip())
224 for match in COAUTHOR_RE.finditer(commit.body)
225 ]
226 harvested_logins = HARVEST_RE.findall(commit.body)
227 is_harvested_commit = bool(harvested_logins)
228 mapped_author = lookup_identity(aliases, commit.author_email, commit.author_name)
229
230 if check_authors:
231 if is_harvested_commit and is_bot_identity(commit.author_name, commit.author_email):
232 errors.append(
233 f"{prefix}: author {commit.author_name} <{commit.author_email}> is a "
234 "bot/tool identity. Human harvested work should preserve the contributor "
235 "as author or use a human co-author trailer."
236 )
237 elif (
238 is_harvested_commit
239 and mapped_author
240 and norm_key(commit.author_email) != norm_key(mapped_author.email)
241 and not is_preserved_mapped_identity(
242 commit,
243 "author",
244 Identity(commit.author_name, commit.author_email),
245 )
246 ):
247 errors.append(
248 f"{prefix}: author {commit.author_name} <{commit.author_email}> "
249 f"matches AUTHOR_MAP but is not canonical. Use author {mapped_author.author()}."
250 )
251
252 for coauthor in coauthors:
253 if (
254 commit.sha.strip().lower(),
255 norm_key(coauthor.name),
256 norm_key(coauthor.email),
257 ) in LEGACY_AUTOMATION_TRAILER_EXCEPTIONS:
258 continue
259 if is_bot_identity(coauthor.name, coauthor.email):
260 if not commit.is_merge_commit():
261 errors.append(
262 f"{prefix}: remove bot/tool co-author trailer "
263 f"{coauthor.name} <{coauthor.email}>; contributor trailers are for humans."
264 )
265 continue
266 if CANONICAL_NOREPLY_RE.match(coauthor.email):
267 continue
268 expected = lookup_identity(aliases, coauthor.email, coauthor.name)
269 if expected:
270 if not is_preserved_mapped_identity(commit, "coauthor", coauthor):
271 errors.append(
272 f"{prefix}: co-author {coauthor.name} <{coauthor.email}> is not "
273 f"GitHub-mappable. Use `{expected.trailer()}`."
274 )
275 else:
276 errors.append(
277 f"{prefix}: co-author {coauthor.name} <{coauthor.email}> is not "
278 "numeric GitHub noreply and has no AUTHOR_MAP entry. Add an alias "
279 "or use `gh api users/<login> --jq '\"\\(.id)+\\(.login)@users.noreply.github.com\"'`."
280 )
281
282 coauthor_emails: set[str] = set()
283 for coauthor in coauthors:
284 coauthor_emails.add(norm_key(coauthor.email))
285 expected = lookup_identity(aliases, coauthor.email, coauthor.name)
286 if expected and is_preserved_mapped_identity(commit, "coauthor", coauthor):
287 coauthor_emails.add(norm_key(expected.email))
288 for login in harvested_logins:
289 expected = lookup_identity(aliases, login)
290 if expected is None:
291 errors.append(
292 f"{prefix}: harvested contributor @{login} is missing from .github/AUTHOR_MAP."
293 )
294 continue
295 if (
296 norm_key(commit.author_email) != norm_key(expected.email)
297 and norm_key(expected.email) not in coauthor_emails
298 ):
299 errors.append(
300 f"{prefix}: `Harvested from PR ... by @{login}` needs machine-readable "
301 f"credit. Add `{expected.trailer()}` or preserve the contributor as author."
302 )
303 return errors
304
305
306 def main(argv: list[str]) -> int:
307 parser = argparse.ArgumentParser(description=__doc__)
308 parser.add_argument("--author-map", type=Path, default=DEFAULT_AUTHOR_MAP)
309 parser.add_argument("--range", default="origin/main..HEAD", help="git commit range to check")
310 parser.add_argument(
311 "--check-authors",
312 action="store_true",
313 help="also reject commit author emails that match known AUTHOR_MAP aliases",
314 )
315 args = parser.parse_args(argv)
316
317 try:
318 aliases = load_author_map(args.author_map)
319 commits = git_log(args.range)
320 errors = validate(commits, aliases, args.check_authors)
321 except Exception as exc:
322 print(f"co-author credit check failed to run: {exc}", file=sys.stderr)
323 return 2
324
325 if errors:
326 print("Co-author credit check failed:", file=sys.stderr)
327 for error in errors:
328 print(f"- {error}", file=sys.stderr)
329 return 1
330
331 print(f"Co-author credit check passed for {len(commits)} commit(s).")
332 return 0
333
334
335 if __name__ == "__main__":
336 raise SystemExit(main(sys.argv[1:]))
337
337 lines PYTHON