File size: 5,262 Bytes
f76c374 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 | #!/usr/bin/env python3
"""Audit (and auto-fix) contributor email mappings for a PR branch.
Mirrors the CI gate in .github/workflows/contributor-check.yml so salvage
branches never bounce off the check-attribution job. Run it from the branch
you are about to push:
python3 scripts/audit_pr_attribution.py # report only
python3 scripts/audit_pr_attribution.py --fix # create mapping files
Logic (kept in sync with contributor-check.yml):
- scans ``git log $(git merge-base origin/main HEAD)..HEAD --format=%ae``
- skips teknium/bot emails and ``<id>+<login>@users.noreply.github.com``
(CI auto-resolves those)
- everything else must have ``contributors/emails/<email>`` or a legacy
AUTHOR_MAP entry in scripts/release.py
``--fix`` resolution order for an unmapped email:
1. bare ``<login>@users.noreply.github.com`` → ``<login>``, verified via
``gh api users/<login>``. A warning is printed: the local part is
*usually* the GitHub login but is user-controlled (the historical
``bryan@…`` → ``hydraxman`` case) — eyeball it against the PR author.
2. ``gh api 'search/users?q=<email>+in:email'``
3. otherwise: prints the manual ``add_contributor.py`` command and exits 1.
"""
import argparse
import json
import re
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
SKIP_SUBSTRINGS = (
"teknium",
"noreply@github.com",
"dependabot",
"github-actions",
"anthropic.com",
"cursor.com",
)
ID_NOREPLY_RE = re.compile(r"\d+\+.+@users\.noreply\.github\.com$")
BARE_NOREPLY_RE = re.compile(r"^([A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38})@users\.noreply\.github\.com$")
def run(*args: str, check: bool = True) -> str:
result = subprocess.run(
list(args), capture_output=True, text=True, encoding="utf-8",
errors="replace", cwd=str(REPO_ROOT),
)
if check and result.returncode != 0:
raise RuntimeError(f"{' '.join(args)}: {result.stderr.strip()}")
return result.stdout.strip()
def new_emails() -> list[str]:
base = run("git", "merge-base", "origin/main", "HEAD")
log = run("git", "log", f"{base}..HEAD", "--format=%ae", "--no-merges", check=False)
return sorted({e for e in log.splitlines() if e.strip()})
def is_mapped(email: str) -> bool:
if any(s in email for s in SKIP_SUBSTRINGS):
return True
if ID_NOREPLY_RE.search(email):
return True
if (REPO_ROOT / "contributors" / "emails" / email).is_file():
return True
release_py = REPO_ROOT / "scripts" / "release.py"
try:
if f'"{email}"' in release_py.read_text(encoding="utf-8", errors="replace"):
return True
except OSError:
pass
return False
def gh_json(*args: str):
try:
out = run("gh", "api", *args, check=False)
return json.loads(out) if out else None
except (RuntimeError, json.JSONDecodeError, FileNotFoundError):
return None
def resolve_login(email: str) -> tuple[str, str] | None:
"""Return (login, how) or None."""
m = BARE_NOREPLY_RE.match(email)
if m:
login = m.group(1)
user = gh_json(f"users/{login}")
if user and user.get("login"):
return user["login"], "bare-noreply local part (verified user exists)"
found = gh_json(f"search/users?q={email}+in:email")
if found and found.get("items"):
return found["items"][0]["login"], "GitHub email search"
return None
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--fix", action="store_true",
help="auto-create contributors/emails/ mapping files")
args = parser.parse_args()
unmapped = [e for e in new_emails() if not is_mapped(e)]
if not unmapped:
print("✅ All contributor emails on this branch are mapped.")
return 0
failed = []
for email in unmapped:
author = run("git", "log", f"--author={email}", "--format=%an", "-1", check=False)
if not args.fix:
print(f"⚠️ unmapped: {email} ({author})")
continue
resolved = resolve_login(email)
if resolved:
login, how = resolved
run("python3", "scripts/add_contributor.py", email, login)
print(f"✔ mapped {email} -> {login} [{how}]")
if BARE_NOREPLY_RE.match(email):
print(f" ⚠ local part is user-controlled — confirm @{login} really is "
f"the contributor (git name: {author!r}) before pushing.")
else:
failed.append((email, author))
if not args.fix:
print("\nRun with --fix to auto-create mapping files, or manually:")
for email in unmapped:
print(f" python3 scripts/add_contributor.py {email} <github-username>")
return 1
if failed:
print("\nCould not auto-resolve; map manually:")
for email, author in failed:
print(f" python3 scripts/add_contributor.py {email} <github-username> # {author}")
return 1
print("\nDone — remember to `git add contributors && git commit`.")
return 0
if __name__ == "__main__":
sys.exit(main())
|