Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -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 ``<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())
|
||||
Reference in New Issue
Block a user