| 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 |