From 1c39f1c9f984705e7b5be66f826ebecb9d381629 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:04:34 -0700 Subject: [PATCH] =?UTF-8?q?feat(ci):=20auto-fixable=20contributor=20attrib?= =?UTF-8?q?ution=20=E2=80=94=20audit=5Fpr=5Fattribution.py=20+=20gate=20po?= =?UTF-8?q?ints=20at=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The check-attribution CI gate kept bouncing salvage PRs because mapping contributor emails was a manual, easy-to-forget step (bare @users.noreply.github.com emails don't auto-resolve like the + form). - scripts/audit_pr_attribution.py: mirrors the CI gate's logic exactly (merge-base scan, same skip rules). Report mode for pre-push checks; --fix auto-resolves via the bare-noreply local part (verified against the GitHub users API) or GitHub email search, then writes contributors/emails/ files via add_contributor.py. Prints a confirm-the-human warning on bare-noreply resolution since the local part is user-controlled (the bryan->hydraxman case). - contributor-check.yml: failure output + review_status how_to_fix now lead with the one-command fix instead of hand-editing instructions (also drops the stale 'edit AUTHOR_MAP' guidance — AUTHOR_MAP is frozen). --- .github/workflows/contributor-check.yml | 4 +- scripts/audit_pr_attribution.py | 147 ++++++++++++++++++++++++ 2 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 scripts/audit_pr_attribution.py diff --git a/.github/workflows/contributor-check.yml b/.github/workflows/contributor-check.yml index 014e1e2ff9..791e630558 100644 --- a/.github/workflows/contributor-check.yml +++ b/.github/workflows/contributor-check.yml @@ -67,6 +67,8 @@ jobs: echo -e "$MISSING" echo "" echo "Add a mapping file (do NOT edit AUTHOR_MAP in release.py):" + echo " python3 scripts/audit_pr_attribution.py --fix # auto-resolve + create files" + echo "or manually:" echo -e "$MISSING" | while read -r line; do email=$(echo "$line" | sed 's/^ *//' | cut -d' ' -f1) [ -z "$email" ] && continue @@ -78,7 +80,7 @@ jobs: # Emit review_status for unmapped emails DETAIL=$(echo -e "$MISSING" | sed '/^$/d; s/^ //') - HOW_TO_FIX=$'Add mappings to scripts/release.py AUTHOR_MAP:\n```\n"": "",\n```\nTo find the GitHub username for an email:\n```\ngh api \'search/users?q=EMAIL+in:email\' --jq \'.items[0].login\'\n```\n' + HOW_TO_FIX=$'Run from the PR branch:\n```\npython3 scripts/audit_pr_attribution.py --fix\ngit add contributors && git commit -m "chore: map contributor emails" && git push\n```\nOr map one email manually (do NOT edit AUTHOR_MAP in release.py):\n```\npython3 scripts/add_contributor.py \n```\nTo find the GitHub username for an email:\n```\ngh api \'search/users?q=EMAIL+in:email\' --jq \'.items[0].login\'\n```\n' REVIEW_STATUS=$(jq -nc \ --arg detail "$DETAIL" \ --arg how_to_fix "$HOW_TO_FIX" \ diff --git a/scripts/audit_pr_attribution.py b/scripts/audit_pr_attribution.py new file mode 100644 index 0000000000..1c998d81fb --- /dev/null +++ b/scripts/audit_pr_attribution.py @@ -0,0 +1,147 @@ +#!/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 ``+@users.noreply.github.com`` + (CI auto-resolves those) + - everything else must have ``contributors/emails/`` or a legacy + AUTHOR_MAP entry in scripts/release.py + +``--fix`` resolution order for an unmapped email: + 1. bare ``@users.noreply.github.com`` → ````, verified via + ``gh api users/``. 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=+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} ") + return 1 + + if failed: + print("\nCould not auto-resolve; map manually:") + for email, author in failed: + print(f" python3 scripts/add_contributor.py {email} # {author}") + return 1 + + print("\nDone — remember to `git add contributors && git commit`.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())