Files
craft-common-actions/tests/test_contributor_generator.py
T
FredAppsandClaude Opus 5.5 e09a018c65 Build model titles only from each provider's known words
Anyone can commit under an agent email, so a free-form Git name such as
"Private Person <noreply@anthropic.com>" became a published model title,
which the apps would ship in their About windows. Each provider now lists
the words a model title may use. A name with any other word credits the
agent's default model and repeats none of it; versions are bounded too.

Across the co-author trailers in the 13 apps' history, 18,099 keep their
model row and 7 unusual names now fall back to the default. Also pin that
the PR lookup reads the merge commit rather than the PR head.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:49:44 +01:00

592 lines
35 KiB
Python

"""Exercise contributor attribution, name consent and atomic release snapshots."""
import importlib.util
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
SCRIPT = Path(__file__).resolve().parents[1] / "actions/refresh-contributors/refresh_contributors.py"
SPEC = importlib.util.spec_from_file_location("refresh_contributors", SCRIPT)
credits = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(credits)
class ContributorRefreshTests(unittest.TestCase):
"""Use real temporary Git history and fixture GitHub responses."""
def setUp(self):
"""Create a repository whose Git author name must never become a public name."""
self.directory = tempfile.TemporaryDirectory(prefix="caller checkout ")
self.addCleanup(self.directory.cleanup)
self.root = Path(self.directory.name)
self.git("init", "--initial-branch=main")
self.git("config", "user.name", "Private Git Author")
self.git("config", "user.email", "private@example.test")
(self.root / "contributors").mkdir()
self.people = self.root / "contributors/people.toml"
self.people.write_text('[people.alice]\nsync_github_name = true\n\n'
'[people.bob]\ndisplay_name = "Unconsented name"\nsync_github_name = true\n')
self.output = self.root / "contributors/contributors.json"
self.output.write_text('{"old": true}\n')
(self.root / "text.txt").write_text("first\n")
(self.root / "old.bin").write_bytes(b"\0old")
self.identities = {}
self.coauthors = {}
self.pulls = []
self.associated = {}
self.associated_lookups = []
self.linked = {}
self.permissions = {}
self.api_calls = []
self.commit("Initial fixture", "alice")
def git(self, *args):
"""Run Git in the fixture, failing the test on a setup error."""
return subprocess.check_output(["git", *args], cwd=self.root, stderr=subprocess.PIPE).decode().strip()
def commit(self, message, login, author=None):
"""Record the GitHub identity separately from untrusted Git author text."""
self.git("add", ".")
self.git(*(["-c", f"user.name={author[0]}", "-c", f"user.email={author[1]}"] if author else []),
"commit", "-m", message)
sha = self.git("rev-parse", "HEAD")
self.identities[sha] = login
return sha
def api(self, root, endpoint, query, paginate=False):
"""Supply projected API records without a network connection."""
self.api_calls.append((endpoint, query, paginate))
if "/pulls?" in endpoint:
self.assertTrue(paginate)
return self.pulls
if "/collaborators/" in endpoint:
login = endpoint.split("/")[-2]
return [dict(value=self.permissions.get(login, "read"))]
if endpoint.casefold() == "users/alice":
return [dict(value="Alice Public")]
self.fail(f"Unexpected API request: {endpoint}")
def pull_openers(self, root, repo, shas, reachable):
"""Answer commit-to-PR lookups from the fixture's merged-PR openers."""
self.associated_lookups.append(set(shas))
return {sha: login for sha, login in self.associated.items() if sha in shas}
def collect(self):
"""Collect through the real Git parser and fixture API."""
authors = {sha: self.coauthors.get(sha, {login.casefold(): login} if login else {})
for sha, login in self.identities.items()}
linked = {sha: self.linked.get(sha, login) for sha, login in self.identities.items()}
with patch.object(credits, "commit_authors", return_value=(self.identities, authors, linked)), \
patch.object(credits, "pull_openers", side_effect=self.pull_openers), \
patch.object(credits, "api", side_effect=self.api):
return credits.collect(self.root, "owner/app")
def history_page(self, *nodes):
"""One GraphQL history page holding the given commit nodes."""
return json.dumps(dict(data=dict(repository=dict(object=dict(history=dict(
nodes=list(nodes), pageInfo=dict(hasNextPage=False, endCursor=None)))))))
def test_agent_and_bot_accounts_are_never_people(self):
"""GitHub links agent trailers to accounts such as claude; only people get rows."""
node = dict(oid="a" * 40,
author=dict(email="198982749+Copilot@users.noreply.github.com", user=dict(login="Copilot")),
signature=dict(state="VALID", signer=dict(login="Copilot")),
authors=dict(nodes=[dict(email="noreply@anthropic.com", user=dict(login="claude")),
dict(email="codex@openai.com", user=dict(login="codex")),
dict(email="1+dependabot[bot]@users.noreply.github.com",
user=dict(login="dependabot[bot]")),
dict(email="alice@anthropic.com", user=dict(login="alice"))],
pageInfo=dict(hasNextPage=False)))
with patch.object(credits, "run", return_value=self.history_page(node)):
identities, authors, linked = credits.commit_authors(self.root, "owner/app", "a" * 40)
self.assertEqual(authors["a" * 40], {"alice": "alice"})
self.assertIsNone(identities["a" * 40])
self.assertIsNone(linked["a" * 40])
def test_agent_authored_commit_credits_its_model_and_the_pr_opener(self):
"""An agent's own commit counts for its model and for the person whose PR merged it."""
(self.root / "text.txt").write_text("agent change\n")
sha = self.commit("Agent change", None, author=("Claude", "noreply@anthropic.com"))
(self.root / "text.txt").write_text("cursor change\n")
cursor = self.commit("Cursor change\n\nCo-authored-by: Claude Opus 5.5 <noreply@anthropic.com>", None,
author=("Cursor Agent", "cursoragent@cursor.com"))
self.associated = {sha: "bob", cursor: "Copilot"}
result = self.collect()
rows = {row["login"]: row for row in result["contributors"]}
self.assertEqual(set(rows), {"alice", "bob"})
self.assertEqual(rows["bob"]["commits"], 1)
self.assertEqual(result["unattributed_commits"], 1)
self.assertEqual({row["id"]: row["commits"] for row in result["models"]},
{"anthropic/claude": 1, "anysphere/cursor": 1, "anthropic/claude-opus/5.5": 1})
self.assertNotIn("Claude <", json.dumps(result))
def test_merge_commit_pr_opener_verifies_their_own_names(self):
"""An unsigned self-added entry merged with a merge commit is verified through its PR."""
self.people.write_text('[people.alice]\nsync_github_name = true\n\n'
'[people.bob]\ndisplay_name = "Bob chose this"\n')
sha = self.commit("Add my name", "bob")
self.identities[sha] = None
self.coauthors[sha] = {"bob": "bob"}
for opener, expected in ((None, False), ("mallory", False), ("bob", True)):
with self.subTest(opener=opener):
self.associated = {sha: opener} if opener else {}
rows = {row["login"]: row for row in self.collect()["contributors"]}
self.assertEqual(rows["bob"]["people_entry"], expected)
self.assertIn(sha, self.associated_lookups[-1])
def test_writer_carrying_the_persons_own_commit_verifies_their_names(self):
"""An integration PR keeps the contributor's authorship; its writer-opener vouches for it."""
self.people.write_text('[people.alice]\nsync_github_name = true\n\n'
'[people.bob]\ndisplay_name = "Bob chose this"\n')
sha = self.commit("Add my name", "bob")
self.identities[sha] = None
self.coauthors[sha] = {"bob": "bob"}
self.associated = {sha: "maintainer"}
for author, permission, expected in (("bob", "write", True), ("bob", "read", False),
("mallory", "write", False)):
with self.subTest(author=author, permission=permission):
self.linked = {sha: author}
self.permissions["maintainer"] = permission
rows = {row["login"]: row for row in self.collect()["contributors"]}
self.assertEqual(rows["bob"]["people_entry"], expected)
def test_pull_openers_trust_only_one_merged_reachable_opener(self):
"""Ignore unmerged, unreachable, bot-opened and truncated PR lists; disagreeing openers stay unknown."""
shas = [c * 40 for c in "abcdef"] + ["f" * 39 + "e"]
merged = lambda login, oid="1" * 40, kind="User": dict(
merged=True, author=dict(__typename=kind, login=login), mergeCommit=dict(oid=oid))
repository = dict(
c0=dict(associatedPullRequests=dict(totalCount=2, nodes=[merged("alice"), merged("alice")])),
c1=dict(associatedPullRequests=dict(totalCount=2, nodes=[merged("alice"), merged("mallory")])),
c2=dict(associatedPullRequests=dict(totalCount=1, nodes=[dict(merged=False, author=dict(login="bob"),
mergeCommit=None)])),
c3=dict(associatedPullRequests=dict(totalCount=1, nodes=[merged("bob", "9" * 40)])),
c4=dict(associatedPullRequests=dict(totalCount=11, nodes=[merged("bob")])),
c5=dict(associatedPullRequests=dict(totalCount=1, nodes=[merged("dependabot", kind="Bot")])),
c6=None)
with patch.object(credits, "run", return_value=json.dumps(dict(data=dict(repository=repository)))) as mocked:
openers = credits.pull_openers(self.root, "owner/app", shas, {"1" * 40})
self.assertEqual(openers, {shas[0]: "alice"})
body = json.loads(mocked.call_args.kwargs["input_text"])
self.assertIn(f'object(oid: "{shas[0]}")', body["query"])
self.assertEqual(body["variables"], dict(owner="owner", name="app"))
with self.assertRaisesRegex(ValueError, "Invalid commit identity"):
credits.pull_openers(self.root, "owner/app", ['") { evil }'], set())
def test_pulls_opened_by_agents_or_bots_add_no_people(self):
"""A merged PR opened by an agent or bot account never creates a contributor row."""
sha = self.git("rev-parse", "HEAD")
self.pulls = [dict(number=1, sha=sha, login="Copilot"), dict(number=2, sha=sha, login="dependabot[bot]")]
self.assertEqual([row["login"] for row in self.collect()["contributors"]], ["alice"])
def test_verified_human_coauthors_are_credited_once(self):
"""Credit API-linked co-authors without interpreting human names or email text."""
sha = self.git("rev-parse", "HEAD")
self.coauthors[sha] = {"alice": "alice", "bob": "bob"}
rows = {row["login"]: row for row in self.collect()["contributors"]}
self.assertEqual(rows["alice"]["commits"], 1)
self.assertEqual(rows["bob"]["commits"], 1)
self.assertFalse(rows["bob"]["people_entry"])
def test_name_preferences_stay_bound_to_the_captured_commit(self):
"""Concurrent working-tree edits cannot change the release's consent preferences."""
original_api = self.api
original_head = self.git("rev-parse", "HEAD")
def edit_during_collection(root, endpoint, query, paginate=False):
if "/pulls?" in endpoint:
self.people.write_text('[people.alice]\nsync_github_name = false\n')
return original_api(root, endpoint, query, paginate)
with patch.object(self, "api", side_effect=edit_during_collection):
result = self.collect()
row = result["contributors"][0]
self.assertEqual(result["head"], original_head)
self.assertTrue(row["people_entry"])
self.assertEqual(row["github_name"], "Alice Public")
self.assertIn("false", self.people.read_text())
def test_author_history_pagination_preserves_primary_and_coauthor_identities(self):
"""Follow every cursor and retain GitHub-linked co-authors separately from editors."""
pages = []
for sha, login, following in (("a" * 40, "alice", "next"), ("b" * 40, "bob", None)):
node = dict(oid=sha, author=dict(user=dict(login=login)),
signature=dict(state="VALID", signer=dict(login=login)),
authors=dict(nodes=[dict(user=dict(login="alice")), dict(user=dict(login="bob"))],
pageInfo=dict(hasNextPage=False)))
pages.append(json.dumps(dict(data=dict(repository=dict(object=dict(history=dict(
nodes=[node], pageInfo=dict(hasNextPage=bool(following), endCursor=following))))))))
with patch.object(credits, "run", side_effect=pages) as mocked:
primary, authors, linked = credits.commit_authors(self.root, "owner/app", "a" * 40)
self.assertEqual(linked, {"a" * 40: "alice", "b" * 40: "bob"})
self.assertEqual(primary, {"a" * 40: "alice", "b" * 40: "bob"})
self.assertEqual(authors["b" * 40], {"alice": "alice", "bob": "bob"})
bodies = [json.loads(call.kwargs["input_text"]) for call in mocked.call_args_list]
self.assertIn("signature { state signer { login } }", bodies[0]["query"])
self.assertIsNone(bodies[0]["variables"]["cursor"])
self.assertEqual(bodies[1]["variables"]["cursor"], "next")
def test_consent_identity_requires_a_valid_matching_signer(self):
"""Spoofable email attribution cannot authorize names; signed authors or PR openers can."""
sha = self.git("rev-parse", "HEAD")
signatures = [
(None, False),
(dict(state="INVALID", signer=dict(login="alice")), False),
(dict(state="VALID", signer=None), False),
(dict(state="VALID", signer=dict(login="mallory")), False),
(dict(state="VALID", signer=dict(login="ALICE")), True),
]
real_run = credits.run
for signature, expected in signatures:
with self.subTest(signature=signature):
node = dict(oid=sha, author=dict(user=dict(login="alice")), signature=signature,
authors=dict(nodes=[dict(user=dict(login="alice"))],
pageInfo=dict(hasNextPage=False)))
page = json.dumps(dict(data=dict(repository=dict(object=dict(history=dict(
nodes=[node], pageInfo=dict(hasNextPage=False, endCursor=None)))))))
def response(args, root, input_text=None):
if args[:3] == ["gh", "api", "graphql"]:
return page
return real_run(args, root, input_text)
for opener in (None, "alice", "mallory"):
via_pull = opener == "alice"
self.pulls = [dict(number=1, sha=sha, login=opener)] if opener else []
self.api_calls.clear()
with patch.object(credits, "run", side_effect=response), \
patch.object(credits, "api", side_effect=self.api):
result = credits.collect(self.root, "owner/app")
row = result["contributors"][0]
self.assertEqual(row["login"], "alice")
self.assertEqual(row["commits"], 1)
self.assertEqual(row["people_entry"], expected or via_pull)
self.assertEqual(row["github_name"], "Alice Public" if expected or via_pull else None)
self.assertEqual(any(endpoint == "users/alice" for endpoint, _, _ in self.api_calls),
expected or via_pull)
def test_invalid_people_field_is_an_actionable_error(self):
"""Reject a malformed opt-in instead of guessing its intended value."""
self.people.write_text('[people.alice]\nsync_github_name = "false"\n')
self.commit("Malformed preference", "alice")
with self.assertRaisesRegex(ValueError, "must be a boolean"):
credits.load_people(self.root)
def test_stalled_history_pagination_is_rejected(self):
"""Do not loop forever or publish an incomplete history if a cursor repeats."""
page = json.dumps(dict(data=dict(repository=dict(object=dict(history=dict(
nodes=[], pageInfo=dict(hasNextPage=True, endCursor="same")))))))
with patch.object(credits, "run", return_value=page) as mocked:
with self.assertRaisesRegex(ValueError, "pagination did not advance"):
credits.commit_authors(self.root, "owner/app", "a" * 40)
self.assertEqual(mocked.call_count, 2)
def test_truncated_coauthor_list_is_rejected(self):
"""Fail clearly rather than silently omitting authors beyond the page limit."""
node = dict(oid="a" * 40, author=dict(user=dict(login="alice")),
authors=dict(nodes=[], pageInfo=dict(hasNextPage=True)))
page = json.dumps(dict(data=dict(repository=dict(object=dict(history=dict(
nodes=[node], pageInfo=dict(hasNextPage=False, endCursor=None)))))))
with patch.object(credits, "run", return_value=page):
with self.assertRaisesRegex(ValueError, "refusing incomplete credits"):
credits.commit_authors(self.root, "owner/app", "a" * 40)
def test_empty_scalar_api_response_is_an_actionable_error(self):
"""Do not index an absent profile or repository-permission response."""
with patch.object(credits, "api", return_value=[]):
with self.assertRaisesRegex(ValueError, "Expected one API value"):
credits.single_value(self.root, "users/alice", ".name")
def test_username_casing_matches_the_ui_registry_lookup(self):
"""Preserve the table key so the UI can find the verified entry by row login."""
self.people.write_text('[people.AlIcE]\nsync_github_name = true\n')
self.commit("Keep my chosen table casing", "alice")
row = self.collect()["contributors"][0]
self.assertEqual(row["login"], "AlIcE")
self.assertTrue(row["people_entry"])
self.assertEqual(row["github_name"], "Alice Public")
def test_duplicate_account_casing_is_rejected(self):
"""Do not silently choose between conflicting preferences for one account."""
self.people.write_text('[people.alice]\ndisplay_name = "One"\n'
'[people.ALICE]\ndisplay_name = "Two"\n')
self.commit("Conflicting preferences", "alice")
with self.assertRaisesRegex(ValueError, "duplicate account casing"):
credits.load_people(self.root)
def test_head_change_during_collection_aborts(self):
"""Reject a moved checkout rather than describing it with stale commit metadata."""
original_api = self.api
original_snapshot = self.output.read_bytes()
def commit_during_collection(root, endpoint, query, paginate=False):
if "/pulls?" in endpoint:
(self.root / "text.txt").write_text("concurrent commit\n")
self.commit("Concurrent change", "alice")
return original_api(root, endpoint, query, paginate)
with patch.object(self, "api", side_effect=commit_during_collection):
with self.assertRaisesRegex(ValueError, "HEAD changed"):
self.collect()
self.assertEqual(self.output.read_bytes(), original_snapshot)
def test_statistics_models_and_no_inferred_human_names(self):
"""Attribute text/binary changes and deduplicate explicit model co-authors."""
(self.root / "old.bin").unlink()
(self.root / "new.bin").write_bytes(b"\0new")
(self.root / "text.txt").write_text("first\nsecond\n")
(self.root / "tab\tname.txt").write_text("quoted path\n")
self.commit("Change fixture\n\nCo-authored-by: Claude Opus 5.5 <noreply@anthropic.com>\n"
"Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>\n"
"Co-authored-by: OpenAI Codex <noreply@openai.com>", "bob")
result = self.collect()
rows = {row["login"]: row for row in result["contributors"]}
self.assertEqual(result["head"], self.git("rev-parse", "HEAD"))
self.assertEqual(result["commits"], 2)
self.assertEqual(result["unattributed_commits"], 0)
self.assertEqual(rows["bob"]["lines_added"], 2)
self.assertEqual(rows["bob"]["lines_deleted"], 0)
self.assertEqual(rows["bob"]["binary_added"], 1)
self.assertEqual(rows["bob"]["binary_deleted"], 1)
self.assertEqual({row["id"]: row["commits"] for row in result["models"]},
{"anthropic/claude-opus/5.5": 1, "openai/codex": 1})
self.assertNotIn("Private Git Author", json.dumps(result))
self.assertNotIn("private@example.test", json.dumps(result))
def test_supported_model_formats_and_unknown_providers(self):
"""Keep the documented model families aligned with accepted explicit trailers."""
accepted = {
"Claude Sonnet 5.5 <noreply@anthropic.com>": ("anthropic/claude-sonnet/5.5", "Claude Sonnet", "5.5"),
"Claude Mythos 5.1 <noreply@anthropic.com>": ("anthropic/claude-mythos/5.1", "Claude Mythos", "5.1"),
"Claude <noreply@anthropic.com>": ("anthropic/claude", "Claude", ""),
"Codex <codex@openai.com>": ("openai/codex", "Codex", ""),
"OpenAI Codex <noreply@openai.com>": ("openai/codex", "Codex", ""),
"ChatGPT <noreply@openai.com>": ("openai/chatgpt", "ChatGPT", ""),
"GPT-5.1 <noreply@openai.com>": ("openai/gpt/5.1", "GPT", "5.1"),
"GPT-6.1 Sol <noreply@openai.com>": ("openai/gpt-sol/6.1", "GPT Sol", "6.1"),
"DeepSeek V4.1 Flash <noreply@deepseek.com>": ("deepseek/deepseek-flash/4.1", "DeepSeek Flash", "4.1"),
"GLM-5.3-Flash <noreply@z.ai>": ("zhipu/glm-flash/5.3", "GLM Flash", "5.3"),
"Qwen 3.8 Flash <noreply@qwen.ai>": ("alibaba/qwen-flash/3.8", "Qwen Flash", "3.8"),
"Codex (GPT-6) <noreply@openai.com>": ("openai/gpt/6", "GPT", "6"),
"Claude Opus 5.5 (1M context) <noreply@anthropic.com>": ("anthropic/claude-opus/5.5", "Claude Opus", "5.5"),
"Cursor Agent <cursoragent@cursor.com>": ("anysphere/cursor", "Cursor", ""),
"Copilot App <223556219+Copilot@users.noreply.github.com>": ("github/copilot", "Copilot", ""),
"Devin <1+devin-ai-integration[bot]@users.noreply.github.com>": ("cognition/devin", "Devin", ""),
}
for trailer, (expected, title, version) in accepted.items():
with self.subTest(trailer=trailer):
identity = credits.model(trailer)
self.assertEqual((identity["id"], identity["model"], identity["version"]), (expected, title, version))
for trailer in ("Another Model <noreply@other.example>", "Claude Opus 5.5 <unrelated@example.test>",
"A Person <someone@anthropic.com>", "Bob <1+bob@users.noreply.github.com>", "not a trailer"):
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 <noreply@anthropic.com>": claude,
"Claude Buy This 5.5 <noreply@anthropic.com>": claude,
"Claude 123456789 <noreply@anthropic.com>": claude,
"Mallory <codex@openai.com>": codex,
"GPT Mallory 6 <noreply@openai.com>": codex,
"Mallory (nothing here) <noreply@openai.com>": codex,
"Mallory (GPT-6) <noreply@openai.com>": ("openai/gpt/6", "GPT", "6"),
"Mallory <noreply@deepseek.com>": ("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 <noreply@openai.com>", 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()
rows = {row["login"]: row for row in result["contributors"]}
self.assertTrue(rows["alice"]["people_entry"])
self.assertEqual(rows["alice"]["github_name"], "Alice Public")
self.assertFalse(credits.verified_people(
self.root, "owner/app", {"bob": {"sync_github_name": True}}, self.identities, {})["bob"])
self.assertEqual([call[0] for call in self.api_calls if call[0].startswith("users/")],
["users/alice"])
def test_self_authored_name_replaces_an_unverified_entry(self):
"""Verify actual name lines after the contributor changes an existing table."""
self.people.write_text('[people.alice]\nsync_github_name = true\n\n'
'[people.bob]\ndisplay_name = "Bob chose this"\n')
self.commit("Choose my display name", "bob")
rows = {row["login"]: row for row in self.collect()["contributors"]}
self.assertTrue(rows["bob"]["people_entry"])
def test_recorded_consent_requires_repository_write_permission(self):
"""A consent_source alone cannot authorize an unrelated contributor's names."""
self.people.write_text('[people.alice]\nsync_github_name = true\n\n'
'[people.bob]\ndisplay_name = "Requested name"\n'
'consent_source = "https://github.com/owner/app/issues/1#issuecomment-2"\n')
self.commit("Record requested names", "alice")
for permission, expected in (("read", False), ("write", True), ("admin", True)):
with self.subTest(permission=permission):
self.permissions["alice"] = permission
with patch.object(credits, "api", side_effect=self.api):
verified = credits.verified_people(
self.root, "owner/app", {"bob": {"consent_source": "recorded request"}},
self.identities, {})
self.assertEqual(verified["bob"], expected)
def test_only_merged_pulls_reachable_from_head_count(self):
"""Exclude future/other-branch merges and deduplicate PR records."""
sha = self.git("rev-parse", "HEAD")
self.pulls = [dict(number=1, sha=sha, login="alice"),
dict(number=1, sha=sha, login="alice"),
dict(number=2, sha="f" * 40, login="outsider")]
result = self.collect()
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")
self.commit("No GitHub identity", None)
result = self.collect()
self.assertEqual(result["unattributed_commits"], 1)
self.assertEqual(result["contributors"][0]["commits"], 1)
def test_missing_identity_records_fail_instead_of_publishing_partial_data(self):
"""Require the complete API history, including explicitly unmapped identities."""
self.identities.clear()
with self.assertRaisesRegex(ValueError, "missing 1 reachable commits"):
self.collect()
def test_uncommitted_preferences_fail_clearly_before_api_reads(self):
"""Explain how to resolve dirty preferences without logging their private values."""
original = self.output.read_bytes()
self.people.write_text('[people.alice]\ndisplay_name = "Private local preference"\n')
with self.assertRaisesRegex(ValueError, "commit or restore") as error:
self.collect()
self.assertNotIn("Private local preference", str(error.exception))
self.assertEqual(self.api_calls, [])
self.assertEqual(self.output.read_bytes(), original)
def test_missing_or_untracked_preferences_fail_before_api_access(self):
"""An absent committed preference file must not trigger collection or leak local names."""
self.git("rm", "contributors/people.toml")
self.git("commit", "-m", "Remove preference file")
original = self.output.read_bytes()
for untracked in (False, True):
with self.subTest(untracked=untracked):
if untracked:
self.people.write_text('[people.alice]\ndisplay_name = "Private untracked name"\n')
with patch.object(credits, "commit_authors") as mocked_history, \
patch.object(credits, "api") as mocked_api:
with self.assertRaisesRegex(ValueError, "commit or restore") as error:
credits.collect(self.root, "owner/app")
self.assertNotIn("Private untracked name", str(error.exception))
mocked_history.assert_not_called()
mocked_api.assert_not_called()
self.assertEqual(self.output.read_bytes(), original)
def test_atomic_write_retains_previous_snapshot_on_replace_failure(self):
"""Preserve the last good file when disk replacement fails."""
original = self.output.read_bytes()
with patch.object(credits.os, "replace", side_effect=OSError("disk full")):
with self.assertRaisesRegex(OSError, "disk full"):
credits.write_snapshot(self.output, {"new": True})
self.assertEqual(self.output.read_bytes(), original)
self.assertEqual(list(self.root.glob("contributors/.contributors-*")), [])
def test_cli_writes_deterministic_snapshot_and_preserves_it_on_api_failure(self):
"""Run the real CLI twice, then fail gh before a replacement can be written."""
tools = self.root / "tools"
tools.mkdir()
records = self.root / "api.json"
records.write_text(json.dumps(dict(
commits=[dict(sha=sha, login=login) for sha, login in self.identities.items()])))
gh = tools / "gh"
gh.write_text(f"#!{sys.executable}\n" + '''import json, os, sys
from pathlib import Path
if os.environ.get("FAIL_GH") == "yes":
print("fixture API unavailable", file=sys.stderr)
raise SystemExit(23)
endpoint = sys.argv[2]
if endpoint == "graphql":
commits = json.loads(Path(os.environ["API_FIXTURE"]).read_text())["commits"]
nodes = [dict(oid=row["sha"], author=dict(user=dict(login=row["login"])),
signature=dict(state="VALID", signer=dict(login=row["login"])),
authors=dict(nodes=[dict(user=dict(login=row["login"]))],
pageInfo=dict(hasNextPage=False))) for row in commits]
print(json.dumps(dict(data=dict(repository=dict(object=dict(history=dict(
nodes=nodes, pageInfo=dict(hasNextPage=False, endCursor=None))))))))
raise SystemExit(0)
elif "/pulls?" in endpoint:
rows = []
elif endpoint == "users/alice":
rows = [dict(value="Alice Public")]
else:
raise SystemExit("Unexpected endpoint: " + endpoint)
for row in rows:
print(json.dumps(row))
''')
gh.chmod(0o755)
env = dict(os.environ, PATH=str(tools) + os.pathsep + os.environ["PATH"],
API_FIXTURE=str(records))
self.assertFalse((self.root / "scripts/refresh-contributors.py").exists())
command = [sys.executable, str(SCRIPT), "--repo", "owner/app", "--directory", str(self.root)]
first = subprocess.run(command, env=env, capture_output=True, text=True, timeout=30)
self.assertEqual(first.returncode, 0, first.stderr)
snapshot = self.output.read_bytes()
self.assertEqual(json.loads(snapshot)["head"], self.git("rev-parse", "HEAD"))
again = subprocess.run(command, env=env, capture_output=True, text=True, timeout=30)
self.assertEqual(again.returncode, 0, again.stderr)
self.assertEqual(self.output.read_bytes(), snapshot)
failed = subprocess.run(command, env={**env, "FAIL_GH": "yes"},
capture_output=True, text=True, timeout=30)
self.assertEqual(failed.returncode, 1)
self.assertIn("fixture API unavailable", failed.stderr)
self.assertEqual(self.output.read_bytes(), snapshot)
def test_shallow_checkout_is_rejected_before_api_access(self):
"""Require full history even when a truncated checkout can resolve HEAD."""
clone = self.root / "shallow"
self.git("clone", "--depth=1", "--no-local", self.root.as_uri(), str(clone))
with patch.object(credits, "api") as mocked_api:
with self.assertRaisesRegex(ValueError, "full Git history"):
credits.collect(clone, "owner/app")
mocked_api.assert_not_called()
if __name__ == "__main__":
unittest.main()