diff --git a/README.md b/README.md index 658ee74..4d9a72f 100644 --- a/README.md +++ b/README.md @@ -157,8 +157,10 @@ Devin author commits themselves; both are credited in the snapshot's `models`, w under **About ▸ Models**. Agents are recognized only by their exact Git email or GitHub account (`AGENT_EMAILS` and `AGENT_LOGINS` in the generator), never by name, so a person's work address at an AI company is still a person. A recognized agent's model and version are read from its Git name -(`Claude Opus 5.5`, `GPT-6.1 Sol`, `DeepSeek V4.1 Flash`); unrecognized emails are skipped. Add a new -agent to those tables. Other `[bot]` accounts, such as dependabot, are left out. +(`Claude Opus 5.5`, `GPT-6.1 Sol`, `DeepSeek V4.1 Flash`), using only the words those tables list for +that provider: Git names are free text that ends up in the apps, so any other word credits the +agent's default model instead. Unrecognized emails are skipped. Add a new agent or model word to +those tables. Other `[bot]` accounts, such as dependabot, are left out. Name preferences are verified against the commits that introduced their values. A Git email-linked author is trusted for consent only when GitHub reports a valid signature whose signer diff --git a/actions/refresh-contributors/refresh_contributors.py b/actions/refresh-contributors/refresh_contributors.py index cef5bb7..5bdfccb 100644 --- a/actions/refresh-contributors/refresh_contributors.py +++ b/actions/refresh-contributors/refresh_contributors.py @@ -17,36 +17,38 @@ import tomllib # AI agents identified by their exact Git email or GitHub account: (company, id prefix, default -# model, read the model and version from the Git name). Agents are credited as models, never people. +# model, the words a Git name may use to name a model). Agents are credited as models, never people. +# Git names are free text that ends up in the apps, so a model title holds only these words. +ANTHROPIC = ("Anthropic", "anthropic", "Claude", ("Claude", "Opus", "Sonnet", "Haiku", "Fable", "Mythos", "Code")) +OPENAI = ("OpenAI", "openai", "Codex", ("Codex", "ChatGPT", "GPT", "Sol", "Luna", "Astra", "Terra")) +ZHIPU = ("Zhipu AI", "zhipu", "GLM", ("GLM", "Flash", "ZCode")) AGENT_EMAILS = { - "noreply@anthropic.com": ("Anthropic", "anthropic", "Claude", True), - "codex@openai.com": ("OpenAI", "openai", "Codex", True), - "noreply@openai.com": ("OpenAI", "openai", "Codex", True), - "cursoragent@cursor.com": ("Anysphere", "anysphere", "Cursor", False), - "noreply@deepseek.com": ("DeepSeek", "deepseek", "DeepSeek", True), - "noreply@z.ai": ("Zhipu AI", "zhipu", "GLM", True), - "noreply@zcode.ai": ("Zhipu AI", "zhipu", "GLM", True), - "noreply@qwen.ai": ("Alibaba", "alibaba", "Qwen", True), - "noreply@google.com": ("Google", "google", "Gemini", True), - "noreply@minimax.io": ("MiniMax", "minimax", "MiniMax", True), - "noreply@x.ai": ("xAI", "xai", "Grok", True), - "noreply@xiaomi.com": ("Xiaomi", "xiaomi", "MiMo", False), - "noreply@opencode.ai": ("OpenCode", "opencode", "OpenCode", True), - "noreply@codebuff.com": ("Codebuff", "codebuff", "Codebuff", False), - "noreply@paperclip.ing": ("Paperclip", "paperclip", "Paperclip", False), + "noreply@anthropic.com": ANTHROPIC, + "codex@openai.com": OPENAI, + "noreply@openai.com": OPENAI, + "cursoragent@cursor.com": ("Anysphere", "anysphere", "Cursor", ()), + "noreply@deepseek.com": ("DeepSeek", "deepseek", "DeepSeek", ("DeepSeek", "Flash", "Free")), + "noreply@z.ai": ZHIPU, + "noreply@zcode.ai": ZHIPU, + "noreply@qwen.ai": ("Alibaba", "alibaba", "Qwen", ("Qwen", "Flash")), + "noreply@google.com": ("Google", "google", "Gemini", ("Gemini", "Flash", "Antigravity")), + "noreply@minimax.io": ("MiniMax", "minimax", "MiniMax", ("MiniMax", "M3")), + "noreply@x.ai": ("xAI", "xai", "Grok", ("Grok",)), + "noreply@xiaomi.com": ("Xiaomi", "xiaomi", "MiMo", ()), + "noreply@opencode.ai": ("OpenCode", "opencode", "OpenCode", ("OpenCode", "Big", "Pickle")), + "noreply@codebuff.com": ("Codebuff", "codebuff", "Codebuff", ()), + "noreply@paperclip.ing": ("Paperclip", "paperclip", "Paperclip", ()), } AGENT_LOGINS = { "claude": AGENT_EMAILS["noreply@anthropic.com"], "codex": AGENT_EMAILS["codex@openai.com"], "cursoragent": AGENT_EMAILS["cursoragent@cursor.com"], - "copilot": ("GitHub", "github", "Copilot", False), - "copilot-swe-agent[bot]": ("GitHub", "github", "Copilot", False), - "devin-ai-integration[bot]": ("Cognition", "cognition", "Devin", False), - "coderabbitai[bot]": ("CodeRabbit", "coderabbit", "CodeRabbit", False), - "capy-ai[bot]": ("Capy", "capy", "Capy", False), + "copilot": ("GitHub", "github", "Copilot", ()), + "copilot-swe-agent[bot]": ("GitHub", "github", "Copilot", ()), + "devin-ai-integration[bot]": ("Cognition", "cognition", "Devin", ()), + "coderabbitai[bot]": ("CodeRabbit", "coderabbit", "CodeRabbit", ()), + "capy-ai[bot]": ("Capy", "capy", "Capy", ()), } -# Display casing for model words that Git names often write in lower case. -MODEL_WORDS = {word.casefold(): word for word in ("GPT", "ChatGPT", "GLM", "DeepSeek", "MiMo", "OpenCode", "MiniMax")} def agent(email, login=None): @@ -253,30 +255,33 @@ def model(trailer): known = agent(email) if known is None: return None - company, prefix, default, named = known + company, prefix, default, allowed = known + spelling = {word.casefold(): word for word in allowed} def parse(text): - """Split "GPT-6.1 Sol" into its model words and first version number.""" + """Split "GPT-6.1 Sol" into model words and a version, or None if any word is unknown.""" words = [word for word in re.split(r"[\s_-]+", text) if word] # Drop a bare company prefix ("OpenAI Codex"), not a brand that names the model ("DeepSeek V3.2"). if words and words[0].casefold() == company.casefold() != default.split()[0].casefold(): words = words[1:] family, version = [], "" for word in words: - if not version and re.fullmatch(r"[vV]?\d+(?:\.\d+)*", word): + if not version and re.fullmatch(r"[vV]?\d{1,3}(?:\.\d{1,3}){0,2}", word): version = word.lstrip("vV") + elif word.casefold() in spelling: + family.append(spelling[word.casefold()]) else: - family.append(MODEL_WORDS.get(word.casefold(), word[:1].upper() + word[1:])) + return None return family, version - family, version = [], "" - if named: - # "Claude Opus 5.5 (1M context)", "GPT-6.1 Sol", "OpenAI Codex", "DeepSeek V4.1 Flash"; - # "Codex (GPT-6)" names its model in brackets when the outer name has no version. - family, version = parse(re.sub(r"\([^)]*\)", " ", name)) - for inner in re.findall(r"\(([^)]*)\)", name): - if not version and parse(inner)[1]: - family, version = parse(inner) + # "Claude Opus 5.5 (1M context)", "GPT-6.1 Sol", "OpenAI Codex", "DeepSeek V4.1 Flash"; "Codex + # (GPT-6)" names its model in brackets when the outer name has no version. A name with any other + # word, such as a person's, credits the agent's default model and repeats none of it. + family, version = (parse(re.sub(r"\([^)]*\)", " ", name)) if allowed else None) or ([], "") + for inner in re.findall(r"\(([^)]*)\)", name) if allowed else []: + found = parse(inner) + if not version and found and found[1]: + family, version = found title = " ".join(family) or default return dict(id=f"{prefix}/" + title.casefold().replace(" ", "-") + (f"/{version}" if version else ""), company=company, model=title, version=version) diff --git a/tests/test_contributor_generator.py b/tests/test_contributor_generator.py index e4130b9..47cea05 100644 --- a/tests/test_contributor_generator.py +++ b/tests/test_contributor_generator.py @@ -3,6 +3,7 @@ import importlib.util import json import os from pathlib import Path +import shutil import subprocess import sys import tempfile @@ -386,6 +387,34 @@ class ContributorRefreshTests(unittest.TestCase): with self.subTest(trailer=trailer): self.assertIsNone(credits.model(trailer)) + def test_free_text_git_names_never_become_model_titles(self): + """Anyone can commit under an agent email; only known words reach the apps.""" + claude, codex = ("anthropic/claude", "Claude", ""), ("openai/codex", "Codex", "") + for trailer, expected in { + "Private Person ": claude, + "Claude Buy This 5.5 ": claude, + "Claude 123456789 ": claude, + "Mallory ": codex, + "GPT Mallory 6 ": codex, + "Mallory (nothing here) ": codex, + "Mallory (GPT-6) ": ("openai/gpt/6", "GPT", "6"), + "Mallory ": ("deepseek/deepseek", "DeepSeek", ""), + }.items(): + with self.subTest(trailer=trailer): + identity = credits.model(trailer) + self.assertEqual((identity["id"], identity["model"], identity["version"]), expected) + + def test_a_person_committing_under_an_agent_email_is_not_published(self): + """The commit credits the agent's default model and repeats nothing of the Git name.""" + (self.root / "text.txt").write_text("spoofed\n") + self.commit("Spoofed author", None, author=("Private Person", "noreply@anthropic.com")) + (self.root / "text.txt").write_text("spoofed trailer\n") + self.commit("Spoofed trailer\n\nCo-authored-by: Other Person ", None) + result = self.collect() + self.assertEqual({row["id"]: row["commits"] for row in result["models"]}, {"anthropic/claude": 1, "openai/codex": 1}) + self.assertNotIn("Private Person", json.dumps(result)) + self.assertNotIn("Other Person", json.dumps(result)) + def test_profiles_are_requested_only_for_verified_opt_in_entries(self): """A copied people entry never enables name synchronization.""" result = self.collect() @@ -430,9 +459,23 @@ class ContributorRefreshTests(unittest.TestCase): pull_queries = [query for endpoint, query, _ in self.api_calls if "/pulls?" in endpoint] self.assertEqual(len(pull_queries), 1) self.assertIn("select(.merged_at != null)", pull_queries[0]) + self.assertIn("sha: .merge_commit_sha", pull_queries[0]) self.assertEqual(len(result["contributors"]), 1) self.assertEqual(result["contributors"][0]["prs"], 1) + @unittest.skipUnless(shutil.which("jq"), "needs jq, the language of gh's --jq") + def test_pull_projection_selects_the_merge_commit_not_the_head(self): + """A squash merge's head commit is not on the base branch; only the merge commit is.""" + self.collect() + query = next(query for endpoint, query, _ in self.api_calls if "/pulls?" in endpoint) + pulls = [dict(number=7, merged_at="2026-11-01T00:00:00Z", merge_commit_sha="a" * 40, + head=dict(sha="b" * 40), user=dict(login="alice")), + dict(number=8, merged_at=None, merge_commit_sha="c" * 40, + head=dict(sha="d" * 40), user=dict(login="bob"))] + output = subprocess.check_output(["jq", "-c", query], input=json.dumps(pulls), text=True) + self.assertEqual([json.loads(line) for line in output.splitlines()], + [dict(number=7, sha="a" * 40, login="alice")]) + def test_unknown_identity_is_not_guessed_from_git_author(self): """Keep unmapped commits visible as unattributed statistics.""" (self.root / "text.txt").write_text("unmapped\n")