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