Coding agents show as themselves on commits and contributors: Claude, Codex, Copilot, Gemini, Jules, Cursor, Devin, Aider, Dependabot, Renovate and GitHub Actions are known by the address they write on their commits (never by name), shown with their maker's mark, their own name and an agent or bot label, and a card saying what each is. Anyone else not on g1t gets a card too, saying so and how to claim the commits by adding the address. Contributor lists are worked out again (stats version 2) so an agent counts once.
16 files+543−290/16 viewed
| 68 | 68 | | --- | --- | | |
| 69 | 69 | | A person | By an address they confirmed on their account, or their noreply address. Several addresses of one account count as one. | | |
| 70 | 70 | | g1t | Its own commits, by its address. | | |
| 71 | − | | Anyone else | By the name on their commits. | | |
| 71 | + | | A coding agent or bot | Claude, Codex, Copilot, Gemini, Jules, Cursor, Devin, Aider, Dependabot, Renovate and GitHub Actions, by the address they write on their commits (as author or `Co-authored-by`), never by name. Each shows its maker's mark and a card saying what it is; none is an account on g1t. | | |
| 72 | + | | Anyone else | By the name on their commits, with a card that says they aren't on g1t and how to add the address to your account if the commits are yours. | | |
| 72 | 73 | ||
| 73 | 74 | The newest 3,000 commits are counted. | |
| 74 | 75 |
| 1 | + | import { Bot, ExternalLink, User } from "lucide-react"; | |
| 2 | + | import type { ReactElement } from "react"; | |
| 3 | + | import { siClaude, siCursor, siDependabot, siGithubactions, siGithubcopilot, siGooglegemini, siRenovate } from "simple-icons"; | |
| 4 | + | ||
| 5 | + | import { type CodingAgent, type CodingAgentId, codingAgentById } from "@g1t/contracts/coding-agents"; | |
| 6 | + | ||
| 7 | + | import { cn } from "../lib/cn"; | |
| 8 | + | ||
| 9 | + | import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card"; | |
| 10 | + | ||
| 11 | + | /** Each agent's mark (from Simple Icons) where its maker publishes one there; the rest show a bot. */ | |
| 12 | + | const MARKS: Partial<Record<CodingAgentId, string>> = { | |
| 13 | + | claude: siClaude.path, | |
| 14 | + | copilot: siGithubcopilot.path, | |
| 15 | + | gemini: siGooglegemini.path, | |
| 16 | + | jules: siGooglegemini.path, | |
| 17 | + | cursor: siCursor.path, | |
| 18 | + | dependabot: siDependabot.path, | |
| 19 | + | renovate: siRenovate.path, | |
| 20 | + | "github-actions": siGithubactions.path, | |
| 21 | + | }; | |
| 22 | + | ||
| 23 | + | /** | |
| 24 | + | * A coding agent's avatar: its maker's mark on its colour, or on the | |
| 25 | + | * page's own (light on dark, dark on light) when its mark is black or | |
| 26 | + | * it has none. | |
| 27 | + | */ | |
| 28 | + | export function AgentMark({ agent, size = 20, className }: { agent: CodingAgent; size?: number; className?: string }) { | |
| 29 | + | const path = MARKS[agent.id]; | |
| 30 | + | const glyph = Math.round(size * 0.58); | |
| 31 | + | return ( | |
| 32 | + | <span | |
| 33 | + | aria-hidden="true" | |
| 34 | + | className={cn("inline-flex shrink-0 items-center justify-center rounded-full", agent.color ? "text-white" : "bg-fg text-bg", className)} | |
| 35 | + | style={{ width: size, height: size, ...(agent.color ? { backgroundColor: `#${agent.color}` } : {}) }} | |
| 36 | + | > | |
| 37 | + | {path ? ( | |
| 38 | + | <svg viewBox="0 0 24 24" fill="currentColor" width={glyph} height={glyph}> | |
| 39 | + | <path d={path} /> | |
| 40 | + | </svg> | |
| 41 | + | ) : ( | |
| 42 | + | <Bot size={glyph} strokeWidth={2.25} /> | |
| 43 | + | )} | |
| 44 | + | </span> | |
| 45 | + | ); | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | /** "agent" or "bot", beside an agent's name. */ | |
| 49 | + | export function AgentBadge({ agent }: { agent: CodingAgent }) { | |
| 50 | + | return <span className="rounded border border-line px-1 text-[0.625rem] leading-[1.35] font-medium text-muted">{agent.kind}</span>; | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | /** | |
| 54 | + | * A coding agent's card, over its name or avatar (`children`, one element | |
| 55 | + | * that takes a ref): what it is, who makes it, and that g1t knows it by | |
| 56 | + | * the address on its commits rather than as an account. | |
| 57 | + | */ | |
| 58 | + | export function AgentCard({ agentId, children }: { agentId: string | null | undefined; children: ReactElement }) { | |
| 59 | + | const agent = codingAgentById(agentId); | |
| 60 | + | if (!agent) return children; | |
| 61 | + | return ( | |
| 62 | + | <HoverCard> | |
| 63 | + | <HoverCardTrigger asChild>{children}</HoverCardTrigger> | |
| 64 | + | <HoverCardContent aria-label={`About ${agent.name}`}> | |
| 65 | + | <div className="space-y-3"> | |
| 66 | + | <div className="flex items-start gap-3"> | |
| 67 | + | <AgentMark agent={agent} size={48} /> | |
| 68 | + | <div className="min-w-0 pt-0.5"> | |
| 69 | + | <p className="flex items-baseline gap-1.5 leading-tight"> | |
| 70 | + | <span className="font-semibold">{agent.name}</span> | |
| 71 | + | <AgentBadge agent={agent} /> | |
| 72 | + | </p> | |
| 73 | + | <p className="mt-0.5 text-xs text-faint">by {agent.maker}</p> | |
| 74 | + | </div> | |
| 75 | + | </div> | |
| 76 | + | <p className="leading-relaxed text-fg/90">{agent.about}</p> | |
| 77 | + | <p className="text-[0.8125rem] text-muted">Not an account on g1t: known by the address on its commits.</p> | |
| 78 | + | <a href={agent.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-[0.8125rem] font-medium text-accent hover:underline"> | |
| 79 | + | About {agent.name} | |
| 80 | + | <ExternalLink size={12} /> | |
| 81 | + | </a> | |
| 82 | + | </div> | |
| 83 | + | </HoverCardContent> | |
| 84 | + | </HoverCard> | |
| 85 | + | ); | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | /** | |
| 89 | + | * The card of an author nobody on g1t owns: the name on their commits, and | |
| 90 | + | * how to have them shown as you when they are yours. | |
| 91 | + | */ | |
| 92 | + | export function AuthorCard({ name, children }: { name: string; children: ReactElement }) { | |
| 93 | + | return ( | |
| 94 | + | <HoverCard> | |
| 95 | + | <HoverCardTrigger asChild>{children}</HoverCardTrigger> | |
| 96 | + | <HoverCardContent aria-label={`About ${name}`}> | |
| 97 | + | <div className="space-y-3"> | |
| 98 | + | <div className="flex items-start gap-3"> | |
| 99 | + | <span aria-hidden="true" className="inline-flex size-12 shrink-0 items-center justify-center rounded-full bg-line text-faint"> | |
| 100 | + | <User size={28} strokeWidth={2.25} /> | |
| 101 | + | </span> | |
| 102 | + | <div className="min-w-0 pt-0.5"> | |
| 103 | + | <p className="leading-tight font-semibold wrap-anywhere">{name}</p> | |
| 104 | + | <p className="mt-0.5 text-xs text-faint">Not on g1t</p> | |
| 105 | + | </div> | |
| 106 | + | </div> | |
| 107 | + | <p className="text-[0.8125rem] leading-relaxed text-muted"> | |
| 108 | + | The name on these commits. Their address isn't one any account has confirmed. If they're yours, add that address under{" "} | |
| 109 | + | <a href="/settings/emails" className="font-medium text-fg hover:underline"> | |
| 110 | + | Settings → Emails | |
| 111 | + | </a>{" "} | |
| 112 | + | and they'll show as you. | |
| 113 | + | </p> | |
| 114 | + | </div> | |
| 115 | + | </HoverCardContent> | |
| 116 | + | </HoverCard> | |
| 117 | + | ); | |
| 118 | + | } |
| 1 | 1 | import { User } from "lucide-react"; | |
| 2 | 2 | import { Link } from "react-router"; | |
| 3 | 3 | ||
| 4 | + | import { codingAgentById } from "@g1t/contracts/coding-agents"; | |
| 5 | + | ||
| 4 | 6 | import { type CommitPerson, type ShownCommit, profileHref, shownName } from "../lib/commit-people"; | |
| 5 | 7 | import { cn } from "../lib/cn"; | |
| 6 | 8 | ||
| 9 | + | import { AgentBadge, AgentCard, AgentMark, AuthorCard } from "./coding-agent"; | |
| 7 | 10 | import { UserCard } from "./user-card"; | |
| 8 | 11 | import { Avatar } from "./ui/avatar"; | |
| 9 | 12 | ||
| 10 | 13 | /** | |
| 11 | 14 | * A person on a commit, as an avatar: theirs and their card when the | |
| 12 | − | * commit is matched to an account (or is g1t's), else a plain one. | |
| 15 | + | * commit is matched to an account (or is g1t's); a coding agent's mark | |
| 16 | + | * and card; else a silhouette with a card that says they're not on g1t. | |
| 13 | 17 | */ | |
| 14 | 18 | export function CommitAvatar({ person, size = 20 }: { person: CommitPerson; size?: number }) { | |
| 15 | 19 | if (person.kind === "user" || person.kind === "g1t") { | |
| ⋯ | |||
| 28 | 32 | </UserCard> | |
| 29 | 33 | ); | |
| 30 | 34 | } | |
| 31 | − | // Nobody on g1t, or a deleted account: a plain silhouette, no card. | |
| 32 | − | return ( | |
| 35 | + | const agent = person.kind === "agent" ? codingAgentById(person.agent) : null; | |
| 36 | + | if (agent) { | |
| 37 | + | return ( | |
| 38 | + | <AgentCard agentId={agent.id}> | |
| 39 | + | <span className="relative z-10 inline-flex shrink-0 rounded-full"> | |
| 40 | + | <AgentMark agent={agent} size={size} /> | |
| 41 | + | </span> | |
| 42 | + | </AgentCard> | |
| 43 | + | ); | |
| 44 | + | } | |
| 45 | + | const silhouette = ( | |
| 33 | 46 | <span | |
| 34 | 47 | aria-hidden="true" | |
| 35 | 48 | className="inline-flex shrink-0 items-center justify-center rounded-full bg-line text-faint" | |
| ⋯ | |||
| 38 | 51 | <User size={Math.round(size * 0.62)} strokeWidth={2.25} /> | |
| 39 | 52 | </span> | |
| 40 | 53 | ); | |
| 54 | + | // A deleted account: a plain silhouette, no card. | |
| 55 | + | if (person.kind === "ghost") return silhouette; | |
| 56 | + | // Nobody on g1t: the silhouette, with a card that says so. | |
| 57 | + | return ( | |
| 58 | + | <AuthorCard name={person.name}> | |
| 59 | + | <span className="relative z-10 inline-flex shrink-0 rounded-full">{silhouette}</span> | |
| 60 | + | </AuthorCard> | |
| 61 | + | ); | |
| 41 | 62 | } | |
| 42 | 63 | ||
| 43 | 64 | /** | |
| ⋯ | |||
| 78 | 99 | </UserCard> | |
| 79 | 100 | ); | |
| 80 | 101 | } | |
| 102 | + | const agent = person.kind === "agent" ? codingAgentById(person.agent) : null; | |
| 103 | + | if (agent) { | |
| 104 | + | return ( | |
| 105 | + | <AgentCard agentId={agent.id}> | |
| 106 | + | <span tabIndex={0} className="relative z-10 inline-flex items-baseline gap-1 rounded outline-none focus-visible:ring-2 focus-visible:ring-accent"> | |
| 107 | + | <span className={className}>{agent.name}</span> | |
| 108 | + | <AgentBadge agent={agent} /> | |
| 109 | + | </span> | |
| 110 | + | </AgentCard> | |
| 111 | + | ); | |
| 112 | + | } | |
| 81 | 113 | const href = profileHref(person); | |
| 82 | − | if (!href) return <span className={className}>{shownName(person)}</span>; | |
| 114 | + | if (!href) { | |
| 115 | + | if (person.kind === "ghost") return <span className={className}>{shownName(person)}</span>; | |
| 116 | + | return ( | |
| 117 | + | <AuthorCard name={person.name}> | |
| 118 | + | <span tabIndex={0} className={cn("relative z-10 rounded outline-none focus-visible:ring-2 focus-visible:ring-accent", className)}> | |
| 119 | + | {shownName(person)} | |
| 120 | + | </span> | |
| 121 | + | </AuthorCard> | |
| 122 | + | ); | |
| 123 | + | } | |
| 83 | 124 | return ( | |
| 84 | 125 | <UserCard username={person.username}> | |
| 85 | 126 | <Link to={href} className={cn("relative z-10 hover:underline", className)}> | |
| 35 | 35 | import { TimeAgo } from "./ui"; | |
| 36 | 36 | import { Avatar } from "./ui/avatar"; | |
| 37 | 37 | import { Badge } from "./ui/badge"; | |
| 38 | − | import { Hint } from "./ui/hint"; | |
| 39 | 38 | import { SkeletonLine } from "./ui/skeleton"; | |
| 40 | 39 | ||
| 41 | 40 | /** How many contributors' avatars the About shows. */ | |
| ⋯ | |||
| 246 | 245 | )} | |
| 247 | 246 | </UserCard> | |
| 248 | 247 | ) : ( | |
| 249 | − | // Nobody on g1t: no card, only the name on their commits. | |
| 250 | − | <Hint label={label}> | |
| 251 | − | <span tabIndex={0} aria-label={label} className="block rounded-full"> | |
| 252 | − | <CommitAvatar person={person} size={30} /> | |
| 253 | − | </span> | |
| 254 | − | </Hint> | |
| 248 | + | // A coding agent, or someone not on g1t: its own card, opened from the avatar. | |
| 249 | + | <span tabIndex={0} aria-label={label} className="block rounded-full outline-none focus-visible:ring-2 focus-visible:ring-accent"> | |
| 250 | + | <CommitAvatar person={person} size={30} /> | |
| 251 | + | </span> | |
| 255 | 252 | )} | |
| 256 | 253 | </li> | |
| 257 | 254 | ); | |
| 108 | 108 | assert.equal(contributorPerson({ ...base, kind: "user", username: "ghost" }).kind, "ghost"); | |
| 109 | 109 | assert.deepEqual(contributorPerson({ ...base, kind: "author", name: "Sam" }), { kind: "author", name: "Sam", username: null, avatar: null }); | |
| 110 | 110 | }); | |
| 111 | + | ||
| 112 | + | test("a coding agent is shown as itself by the address on its commit, never by its name", () => { | |
| 113 | + | const claude = personFor("Claude", "noreply@anthropic.com", {}); | |
| 114 | + | assert.deepEqual(claude, { kind: "agent", name: "Claude", username: null, avatar: null, agent: "claude" }); | |
| 115 | + | assert.equal(shownName(claude), "Claude"); | |
| 116 | + | assert.equal(profileHref(claude), null); | |
| 117 | + | const bot = personFor("dependabot[bot]", "49699333+dependabot[bot]@users.noreply.github.com", {}); | |
| 118 | + | assert.equal(bot.kind, "agent"); | |
| 119 | + | assert.equal(shownName(bot), "Dependabot", "its own name, not the login on the commit"); | |
| 120 | + | assert.equal(personFor("Claude", "claude@example.com", {}).kind, "author", "a person called Claude stays a person"); | |
| 121 | + | const commit = showCommit( | |
| 122 | + | { hash: "a".repeat(40), message: "Fix it\n\nCo-Authored-By: Claude <noreply@anthropic.com>", author: { name: "Ada", email: "ada@example.com" } } as never, | |
| 123 | + | {}, | |
| 124 | + | ); | |
| 125 | + | assert.equal(commit.coAuthors[0]?.kind, "agent"); | |
| 126 | + | assert.deepEqual(contributorPerson({ kind: "agent", name: "Claude", agent: "claude" }), { kind: "agent", name: "Claude", username: null, avatar: null, agent: "claude" }); | |
| 127 | + | assert.equal(contributorPerson({ kind: "agent", name: "Robo", agent: "unknown-agent" }).kind, "author", "an id this site doesn't know is an author"); | |
| 128 | + | }); |
| 9 | 9 | * person, not the address, so a private address stays private. | |
| 10 | 10 | */ | |
| 11 | 11 | import type { Blame, Commit, Contributor, EmailOwner } from "@g1t/contracts"; | |
| 12 | + | import { codingAgentById, codingAgentFor } from "@g1t/contracts/coding-agents"; | |
| 12 | 13 | ||
| 13 | 14 | /** | |
| 14 | 15 | * One person on a commit. `user` is an account; `g1t` is g1t itself; | |
| 15 | − | * `ghost` is an account that was deleted; `author` is nobody on g1t, | |
| 16 | − | * shown by the name on the commit, with no link and no card. | |
| 16 | + | * `ghost` is an account that was deleted; `agent` is a known coding agent | |
| 17 | + | * or bot, by the address on the commit (@g1t/contracts coding-agents); | |
| 18 | + | * `author` is nobody on g1t, shown by the name on the commit, with a card | |
| 19 | + | * that says so. | |
| 17 | 20 | */ | |
| 18 | 21 | export type CommitPerson = { | |
| 19 | − | kind: "user" | "g1t" | "ghost" | "author"; | |
| 22 | + | kind: "user" | "g1t" | "ghost" | "agent" | "author"; | |
| 20 | 23 | /** The name written on the commit. Beside an account it is a hint. */ | |
| 21 | 24 | name: string; | |
| 22 | 25 | /** The account's username; null for `author`. */ | |
| 23 | 26 | username: string | null; | |
| 24 | 27 | /** The account's uploaded avatar, by hash. */ | |
| 25 | 28 | avatar: string | null; | |
| 29 | + | /** The coding agent's id, for an `agent`. */ | |
| 30 | + | agent?: string | null; | |
| 26 | 31 | }; | |
| 27 | 32 | ||
| 28 | 33 | /** A commit as the page gets it: its people, never their addresses. */ | |
| ⋯ | |||
| 97 | 102 | const owner = address ? owners[address] : undefined; | |
| 98 | 103 | if (owner?.username === "ghost") return { kind: "ghost", name: written, username: "ghost", avatar: null }; | |
| 99 | 104 | if (owner) return { kind: "user", name: written, username: owner.username, avatar: owner.avatar }; | |
| 105 | + | const agent = codingAgentFor(address); | |
| 106 | + | if (agent) return { kind: "agent", name: written, username: null, avatar: null, agent: agent.id }; | |
| 100 | 107 | return { kind: "author", name: written, username: null, avatar: null }; | |
| 101 | 108 | } | |
| 102 | 109 | ||
| ⋯ | |||
| 116 | 123 | * `email_owners`, then g1t's addresses, then the name), so the About, the | |
| 117 | 124 | * Contributors page and every commit show one person the same way. | |
| 118 | 125 | */ | |
| 119 | − | export function contributorPerson(contributor: Pick<Contributor, "kind" | "name" | "username" | "avatar">): CommitPerson { | |
| 126 | + | export function contributorPerson(contributor: Pick<Contributor, "kind" | "name" | "username" | "avatar" | "agent">): CommitPerson { | |
| 120 | 127 | if (contributor.kind === "g1t") return { kind: "g1t", name: contributor.name, username: "g1t", avatar: null }; | |
| 121 | 128 | if (contributor.kind === "user" && contributor.username === "ghost") return { kind: "ghost", name: contributor.name, username: "ghost", avatar: null }; | |
| 122 | 129 | if (contributor.kind === "user" && contributor.username) { | |
| 123 | 130 | return { kind: "user", name: contributor.name, username: contributor.username, avatar: contributor.avatar ?? null }; | |
| 124 | 131 | } | |
| 132 | + | if (contributor.kind === "agent" && codingAgentById(contributor.agent)) { | |
| 133 | + | return { kind: "agent", name: contributor.name, username: null, avatar: null, agent: contributor.agent ?? null }; | |
| 134 | + | } | |
| 125 | 135 | return { kind: "author", name: contributor.name, username: null, avatar: null }; | |
| 126 | 136 | } | |
| 127 | 137 | ||
| 128 | − | /** What a person on a commit is called on the page. */ | |
| 138 | + | /** What a person on a commit is called on the page: an agent by its own name, whatever the commit wrote. */ | |
| 129 | 139 | export function shownName(person: CommitPerson): string { | |
| 140 | + | if (person.kind === "agent") return codingAgentById(person.agent)?.name ?? person.name; | |
| 130 | 141 | return person.username ?? person.name; | |
| 131 | 142 | } | |
| 132 | 143 | ||
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | 42 | /// Who a contributor is: a person with an account (their commits' address | |
| 43 | − | /// is one they confirmed), g1t itself, or an author g1t cannot match to an | |
| 44 | − | /// account. | |
| 43 | + | /// is one they confirmed), g1t itself, a known coding agent or bot by the | |
| 44 | + | /// address on its commits (`coding_agents`), or an author g1t cannot match | |
| 45 | + | /// to anyone. | |
| 45 | 46 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 46 | 47 | #[serde(rename_all = "snake_case")] | |
| 47 | 48 | pub enum ContributorKind { | |
| 48 | 49 | User, | |
| 49 | 50 | G1t, | |
| 51 | + | Agent, | |
| 50 | 52 | Author, | |
| 51 | 53 | } | |
| 52 | 54 | ||
| ⋯ | |||
| 71 | 73 | /// The uploaded avatar's hash, for a `user` who has one. | |
| 72 | 74 | #[serde(default)] | |
| 73 | 75 | pub avatar: Option<String>, | |
| 76 | + | /// The coding agent's id (`coding_agents`), for an `agent`. | |
| 77 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 78 | + | pub agent: Option<String>, | |
| 74 | 79 | pub commits: u32, | |
| 75 | 80 | /// RFC 3339: their first and latest commit read. | |
| 76 | 81 | pub first_at: String, | |
| 1 | + | //! Coding agents and bots that commit under their own names (Claude Code, | |
| 2 | + | //! Codex, Copilot, Dependabot…), matched by the address on their commits. | |
| 3 | + | //! The same ids, addresses and logins as `packages/contracts/src/coding-agents.ts`, | |
| 4 | + | //! which also holds their names and marks; a test keeps the two equal. | |
| 5 | + | ||
| 6 | + | /// One agent: its id, the addresses it commits with, and the GitHub logins | |
| 7 | + | /// whose noreply addresses are its. All lowercase. | |
| 8 | + | pub struct CodingAgent { | |
| 9 | + | pub id: &'static str, | |
| 10 | + | pub emails: &'static [&'static str], | |
| 11 | + | pub logins: &'static [&'static str], | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | pub const CODING_AGENTS: &[CodingAgent] = &[ | |
| 15 | + | CodingAgent { id: "claude", emails: &["noreply@anthropic.com"], logins: &["claude", "claude[bot]"] }, | |
| 16 | + | CodingAgent { id: "codex", emails: &[], logins: &["chatgpt-codex-connector[bot]", "codex[bot]"] }, | |
| 17 | + | CodingAgent { id: "copilot", emails: &[], logins: &["copilot", "copilot-swe-agent[bot]", "copilot-pull-request-reviewer[bot]"] }, | |
| 18 | + | CodingAgent { id: "gemini", emails: &[], logins: &["gemini-code-assist[bot]", "gemini-cli[bot]"] }, | |
| 19 | + | CodingAgent { id: "jules", emails: &[], logins: &["google-labs-jules[bot]"] }, | |
| 20 | + | CodingAgent { id: "cursor", emails: &["cursoragent@cursor.com"], logins: &["cursor[bot]", "cursor-agent[bot]"] }, | |
| 21 | + | CodingAgent { id: "devin", emails: &[], logins: &["devin-ai-integration[bot]"] }, | |
| 22 | + | CodingAgent { id: "aider", emails: &["noreply@aider.chat", "aider@aider.chat"], logins: &[] }, | |
| 23 | + | CodingAgent { id: "dependabot", emails: &["support@dependabot.com"], logins: &["dependabot[bot]", "dependabot-preview[bot]"] }, | |
| 24 | + | CodingAgent { id: "renovate", emails: &["bot@renovateapp.com"], logins: &["renovate[bot]"] }, | |
| 25 | + | CodingAgent { id: "github-actions", emails: &["action@github.com"], logins: &["github-actions[bot]"] }, | |
| 26 | + | ]; | |
| 27 | + | ||
| 28 | + | /// The login a GitHub noreply address names (`123+login@users.noreply.github.com`). | |
| 29 | + | fn github_login(address: &str) -> Option<&str> { | |
| 30 | + | let local = address.strip_suffix("@users.noreply.github.com")?; | |
| 31 | + | Some(local.split_once('+').map_or(local, |(_, login)| login)) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | /// The id of the coding agent a commit's address belongs to. | |
| 35 | + | pub fn coding_agent_for(email: &str) -> Option<&'static str> { | |
| 36 | + | let address = email.trim().to_ascii_lowercase(); | |
| 37 | + | if address.is_empty() { | |
| 38 | + | return None; | |
| 39 | + | } | |
| 40 | + | let login = github_login(&address); | |
| 41 | + | CODING_AGENTS | |
| 42 | + | .iter() | |
| 43 | + | .find(|agent| agent.emails.contains(&address.as_str()) || login.is_some_and(|login| agent.logins.contains(&login))) | |
| 44 | + | .map(|agent| agent.id) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | #[cfg(test)] | |
| 48 | + | mod tests { | |
| 49 | + | use super::*; | |
| 50 | + | ||
| 51 | + | #[test] | |
| 52 | + | fn agents_are_found_by_their_commit_addresses() { | |
| 53 | + | assert_eq!(coding_agent_for("noreply@anthropic.com"), Some("claude")); | |
| 54 | + | assert_eq!(coding_agent_for(" NoReply@Anthropic.com "), Some("claude")); | |
| 55 | + | assert_eq!(coding_agent_for("49699333+dependabot[bot]@users.noreply.github.com"), Some("dependabot")); | |
| 56 | + | assert_eq!(coding_agent_for("198982749+Copilot@users.noreply.github.com"), Some("copilot")); | |
| 57 | + | assert_eq!(coding_agent_for("ada@example.com"), None); | |
| 58 | + | assert_eq!(coding_agent_for("1+ada@users.noreply.github.com"), None); | |
| 59 | + | assert_eq!(coding_agent_for(""), None); | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | /// The site's list (packages/contracts/src/coding-agents.ts) has every | |
| 63 | + | /// agent here, with the same addresses and logins, and no others. | |
| 64 | + | #[test] | |
| 65 | + | fn the_typescript_list_is_the_same() { | |
| 66 | + | let ts = include_str!("../../../packages/contracts/src/coding-agents.ts"); | |
| 67 | + | let ids: Vec<&str> = ts.match_indices(" id: \"").map(|(at, m)| &ts[at + m.len()..]).map(|rest| &rest[..rest.find('"').unwrap()]).collect(); | |
| 68 | + | assert_eq!(ids, CODING_AGENTS.iter().map(|a| a.id).collect::<Vec<_>>()); | |
| 69 | + | for agent in CODING_AGENTS { | |
| 70 | + | let start = ts.find(&format!(" id: \"{}\"", agent.id)).unwrap(); | |
| 71 | + | let block = &ts[start..ts[start..].find("\n },").map(|end| start + end).unwrap()]; | |
| 72 | + | let list = |key: &str| -> Vec<String> { | |
| 73 | + | let line = block.lines().find(|l| l.trim_start().starts_with(&format!("{key}:"))).unwrap(); | |
| 74 | + | line.split('"').skip(1).step_by(2).map(str::to_owned).collect() | |
| 75 | + | }; | |
| 76 | + | assert_eq!(list("emails"), agent.emails.iter().map(|s| s.to_string()).collect::<Vec<_>>(), "{} emails", agent.id); | |
| 77 | + | assert_eq!(list("logins"), agent.logins.iter().map(|s| s.to_string()).collect::<Vec<_>>(), "{} logins", agent.id); | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | } |
| 17 | 17 | pub mod capture; | |
| 18 | 18 | pub mod checks; | |
| 19 | 19 | pub mod codeowners; | |
| 20 | + | pub mod coding_agents; | |
| 20 | 21 | pub mod credentials; | |
| 21 | 22 | pub mod datasets; | |
| 22 | 23 | pub mod deploy_keys; |
| 4 | 4 | "private": true, | |
| 5 | 5 | "type": "module", | |
| 6 | 6 | "license": "MIT", | |
| 7 | − | "exports": { ".": "./src/index.ts", "./og": "./src/og.ts", "./status": "./src/status.ts", "./scopes": "./src/scopes.ts", "./rate-limits": "./src/rate-limits.ts", "./connectors": "./src/connectors.ts", "./abilities": "./src/abilities.ts", "./agent-look": "./src/agent-look.ts", "./marketplace": "./src/marketplace.ts", "./chat-markdown": "./src/chat-markdown.ts", "./sounds": "./src/sounds.ts", "./oauth": "./src/oauth.ts", "./customize": "./src/customize.ts", "./claude-plugin": "./src/claude-plugin.ts", "./skills": "./src/skills.ts", "./discovery": "./src/discovery.ts" }, | |
| 7 | + | "exports": { ".": "./src/index.ts", "./og": "./src/og.ts", "./status": "./src/status.ts", "./scopes": "./src/scopes.ts", "./rate-limits": "./src/rate-limits.ts", "./connectors": "./src/connectors.ts", "./abilities": "./src/abilities.ts", "./agent-look": "./src/agent-look.ts", "./marketplace": "./src/marketplace.ts", "./chat-markdown": "./src/chat-markdown.ts", "./sounds": "./src/sounds.ts", "./oauth": "./src/oauth.ts", "./customize": "./src/customize.ts", "./claude-plugin": "./src/claude-plugin.ts", "./coding-agents": "./src/coding-agents.ts", "./skills": "./src/skills.ts", "./discovery": "./src/discovery.ts" }, | |
| 8 | 8 | "scripts": { "test": "node --test src/*.test.ts", "typecheck": "tsc -p tsconfig.json" } | |
| 9 | 9 | } |
| 28 | 28 | path: string; | |
| 29 | 29 | }; | |
| 30 | 30 | ||
| 31 | − | /** `user`: matched to an account. `g1t`: g1t itself. `author`: the name on the commits. */ | |
| 32 | − | export type ContributorKind = "user" | "g1t" | "author"; | |
| 31 | + | /** `user`: matched to an account. `g1t`: g1t itself. `agent`: a known coding agent or bot (./coding-agents.ts). `author`: the name on the commits. */ | |
| 32 | + | export type ContributorKind = "user" | "g1t" | "agent" | "author"; | |
| 33 | 33 | ||
| 34 | 34 | /** Commits in the week starting on Monday `week` (`YYYY-MM-DD`, UTC). */ | |
| 35 | 35 | export type WeekCommits = { week: string; commits: number }; | |
| ⋯ | |||
| 39 | 39 | name: string; | |
| 40 | 40 | username?: string | null; | |
| 41 | 41 | avatar?: string | null; | |
| 42 | + | /** The coding agent's id, for an `agent`. */ | |
| 43 | + | agent?: string | null; | |
| 42 | 44 | commits: number; | |
| 43 | 45 | firstAt: string; | |
| 44 | 46 | lastAt: string; | |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { CODING_AGENTS, codingAgentById, codingAgentFor } from "./coding-agents.ts"; | |
| 5 | + | ||
| 6 | + | test("a coding agent is found by the address on its commits, not by its name", () => { | |
| 7 | + | assert.equal(codingAgentFor("noreply@anthropic.com")?.id, "claude"); | |
| 8 | + | assert.equal(codingAgentFor(" NoReply@Anthropic.com ")?.id, "claude"); | |
| 9 | + | assert.equal(codingAgentFor("209825114+claude[bot]@users.noreply.github.com")?.id, "claude"); | |
| 10 | + | assert.equal(codingAgentFor("198982749+Copilot@users.noreply.github.com")?.id, "copilot"); | |
| 11 | + | assert.equal(codingAgentFor("49699333+dependabot[bot]@users.noreply.github.com")?.id, "dependabot"); | |
| 12 | + | assert.equal(codingAgentFor("41898282+github-actions[bot]@users.noreply.github.com")?.id, "github-actions"); | |
| 13 | + | assert.equal(codingAgentFor("cursoragent@cursor.com")?.id, "cursor"); | |
| 14 | + | assert.equal(codingAgentFor("ada@example.com"), null); | |
| 15 | + | assert.equal(codingAgentFor("123+ada@users.noreply.github.com"), null); | |
| 16 | + | assert.equal(codingAgentFor(""), null); | |
| 17 | + | assert.equal(codingAgentFor(null), null); | |
| 18 | + | }); | |
| 19 | + | ||
| 20 | + | test("every agent has a unique id, lowercase addresses and logins, and a mark colour that is a hex or none", () => { | |
| 21 | + | const ids = new Set<string>(); | |
| 22 | + | const addresses = new Set<string>(); | |
| 23 | + | for (const agent of CODING_AGENTS) { | |
| 24 | + | assert.ok(!ids.has(agent.id), agent.id); | |
| 25 | + | ids.add(agent.id); | |
| 26 | + | assert.ok(agent.emails.length + agent.logins.length > 0, `${agent.id} matches nothing`); | |
| 27 | + | for (const value of [...agent.emails, ...agent.logins]) { | |
| 28 | + | assert.equal(value, value.toLowerCase(), value); | |
| 29 | + | assert.ok(!addresses.has(value), `${value} is listed twice`); | |
| 30 | + | addresses.add(value); | |
| 31 | + | } | |
| 32 | + | assert.ok(agent.color === null || /^[0-9A-F]{6}$/.test(agent.color), agent.id); | |
| 33 | + | assert.ok(agent.url.startsWith("https://"), agent.id); | |
| 34 | + | } | |
| 35 | + | assert.equal(codingAgentById("claude")?.name, "Claude"); | |
| 36 | + | assert.equal(codingAgentById("nobody"), null); | |
| 37 | + | }); |
| 1 | + | /** | |
| 2 | + | * Coding agents and bots that commit to repositories under their own | |
| 3 | + | * names: Claude Code, Codex, Copilot, Gemini, Cursor and the dependency | |
| 4 | + | * bots. None of them is an account on g1t, but each writes the same | |
| 5 | + | * address on every commit (or a GitHub bot's noreply address), so g1t | |
| 6 | + | * shows them as what they are, with their maker's mark and a card, | |
| 7 | + | * rather than as an unknown author. | |
| 8 | + | * | |
| 9 | + | * Matched by the commit's address only, never by the name: anyone can | |
| 10 | + | * write "Claude" as their name. An account that owns an address always | |
| 11 | + | * wins (lib/commit-people.ts in the site, services/repos contributors.rs); | |
| 12 | + | * these addresses can't be confirmed by anyone, so that never happens. | |
| 13 | + | * | |
| 14 | + | * The Rust side (crates/contracts/src/coding_agents.rs) keeps the same | |
| 15 | + | * ids, addresses and logins; a test there reads this file to keep them | |
| 16 | + | * equal. No imports, so it runs anywhere. | |
| 17 | + | */ | |
| 18 | + | ||
| 19 | + | export type CodingAgentId = "claude" | "codex" | "copilot" | "gemini" | "jules" | "cursor" | "devin" | "aider" | "dependabot" | "renovate" | "github-actions"; | |
| 20 | + | ||
| 21 | + | /** Whether it writes code for people (`agent`) or keeps dependencies and builds running (`bot`). */ | |
| 22 | + | export type CodingAgentKind = "agent" | "bot"; | |
| 23 | + | ||
| 24 | + | export type CodingAgent = { | |
| 25 | + | id: CodingAgentId; | |
| 26 | + | /** What it's called, as its card names it. */ | |
| 27 | + | name: string; | |
| 28 | + | /** Who makes it. */ | |
| 29 | + | maker: string; | |
| 30 | + | kind: CodingAgentKind; | |
| 31 | + | /** One line about it, for its card. */ | |
| 32 | + | about: string; | |
| 33 | + | /** Its own page. */ | |
| 34 | + | url: string; | |
| 35 | + | /** Addresses it writes on commits, lowercase. */ | |
| 36 | + | emails: readonly string[]; | |
| 37 | + | /** GitHub logins whose noreply addresses (`123+login@users.noreply.github.com`) are its, lowercase. */ | |
| 38 | + | logins: readonly string[]; | |
| 39 | + | /** Its mark's background, as a hex colour without `#`; null to use the page's own colours. */ | |
| 40 | + | color: string | null; | |
| 41 | + | }; | |
| 42 | + | ||
| 43 | + | export const CODING_AGENTS: readonly CodingAgent[] = [ | |
| 44 | + | { | |
| 45 | + | id: "claude", | |
| 46 | + | name: "Claude", | |
| 47 | + | maker: "Anthropic", | |
| 48 | + | kind: "agent", | |
| 49 | + | about: "Anthropic's coding agent. Commits made with Claude Code carry its address as author or co-author.", | |
| 50 | + | url: "https://claude.com/product/claude-code", | |
| 51 | + | emails: ["noreply@anthropic.com"], | |
| 52 | + | logins: ["claude", "claude[bot]"], | |
| 53 | + | color: "D97757", | |
| 54 | + | }, | |
| 55 | + | { | |
| 56 | + | id: "codex", | |
| 57 | + | name: "Codex", | |
| 58 | + | maker: "OpenAI", | |
| 59 | + | kind: "agent", | |
| 60 | + | about: "OpenAI's coding agent, committing through its GitHub app.", | |
| 61 | + | url: "https://openai.com/codex", | |
| 62 | + | emails: [], | |
| 63 | + | logins: ["chatgpt-codex-connector[bot]", "codex[bot]"], | |
| 64 | + | color: null, | |
| 65 | + | }, | |
| 66 | + | { | |
| 67 | + | id: "copilot", | |
| 68 | + | name: "Copilot", | |
| 69 | + | maker: "GitHub", | |
| 70 | + | kind: "agent", | |
| 71 | + | about: "GitHub's coding agent.", | |
| 72 | + | url: "https://github.com/features/copilot", | |
| 73 | + | emails: [], | |
| 74 | + | logins: ["copilot", "copilot-swe-agent[bot]", "copilot-pull-request-reviewer[bot]"], | |
| 75 | + | color: null, | |
| 76 | + | }, | |
| 77 | + | { | |
| 78 | + | id: "gemini", | |
| 79 | + | name: "Gemini", | |
| 80 | + | maker: "Google", | |
| 81 | + | kind: "agent", | |
| 82 | + | about: "Google's coding assistant, committing through its GitHub app.", | |
| 83 | + | url: "https://codeassist.google", | |
| 84 | + | emails: [], | |
| 85 | + | logins: ["gemini-code-assist[bot]", "gemini-cli[bot]"], | |
| 86 | + | color: "8E75B2", | |
| 87 | + | }, | |
| 88 | + | { | |
| 89 | + | id: "jules", | |
| 90 | + | name: "Jules", | |
| 91 | + | maker: "Google", | |
| 92 | + | kind: "agent", | |
| 93 | + | about: "Google's asynchronous coding agent.", | |
| 94 | + | url: "https://jules.google", | |
| 95 | + | emails: [], | |
| 96 | + | logins: ["google-labs-jules[bot]"], | |
| 97 | + | color: "8E75B2", | |
| 98 | + | }, | |
| 99 | + | { | |
| 100 | + | id: "cursor", | |
| 101 | + | name: "Cursor", | |
| 102 | + | maker: "Anysphere", | |
| 103 | + | kind: "agent", | |
| 104 | + | about: "Cursor's background agent.", | |
| 105 | + | url: "https://cursor.com", | |
| 106 | + | emails: ["cursoragent@cursor.com"], | |
| 107 | + | logins: ["cursor[bot]", "cursor-agent[bot]"], | |
| 108 | + | color: null, | |
| 109 | + | }, | |
| 110 | + | { | |
| 111 | + | id: "devin", | |
| 112 | + | name: "Devin", | |
| 113 | + | maker: "Cognition", | |
| 114 | + | kind: "agent", | |
| 115 | + | about: "Cognition's coding agent.", | |
| 116 | + | url: "https://devin.ai", | |
| 117 | + | emails: [], | |
| 118 | + | logins: ["devin-ai-integration[bot]"], | |
| 119 | + | color: null, | |
| 120 | + | }, | |
| 121 | + | { | |
| 122 | + | id: "aider", | |
| 123 | + | name: "Aider", | |
| 124 | + | maker: "Aider", | |
| 125 | + | kind: "agent", | |
| 126 | + | about: "An open-source coding agent for the terminal.", | |
| 127 | + | url: "https://aider.chat", | |
| 128 | + | emails: ["noreply@aider.chat", "aider@aider.chat"], | |
| 129 | + | logins: [], | |
| 130 | + | color: null, | |
| 131 | + | }, | |
| 132 | + | { | |
| 133 | + | id: "dependabot", | |
| 134 | + | name: "Dependabot", | |
| 135 | + | maker: "GitHub", | |
| 136 | + | kind: "bot", | |
| 137 | + | about: "Opens pull requests that update dependencies.", | |
| 138 | + | url: "https://github.com/dependabot", | |
| 139 | + | emails: ["support@dependabot.com"], | |
| 140 | + | logins: ["dependabot[bot]", "dependabot-preview[bot]"], | |
| 141 | + | color: "025E8C", | |
| 142 | + | }, | |
| 143 | + | { | |
| 144 | + | id: "renovate", | |
| 145 | + | name: "Renovate", | |
| 146 | + | maker: "Mend", | |
| 147 | + | kind: "bot", | |
| 148 | + | about: "Opens pull requests that update dependencies.", | |
| 149 | + | url: "https://docs.renovatebot.com", | |
| 150 | + | emails: ["bot@renovateapp.com"], | |
| 151 | + | logins: ["renovate[bot]"], | |
| 152 | + | color: "308BE3", | |
| 153 | + | }, | |
| 154 | + | { | |
| 155 | + | id: "github-actions", | |
| 156 | + | name: "GitHub Actions", | |
| 157 | + | maker: "GitHub", | |
| 158 | + | kind: "bot", | |
| 159 | + | about: "Commits made by a workflow on GitHub.", | |
| 160 | + | url: "https://github.com/features/actions", | |
| 161 | + | emails: ["action@github.com"], | |
| 162 | + | logins: ["github-actions[bot]"], | |
| 163 | + | color: "2088FF", | |
| 164 | + | }, | |
| 165 | + | ]; | |
| 166 | + | ||
| 167 | + | const GITHUB_NOREPLY = /^(?:\d+\+)?([^@]+)@users\.noreply\.github\.com$/; | |
| 168 | + | ||
| 169 | + | /** The coding agent a commit's address belongs to, or null. */ | |
| 170 | + | export function codingAgentFor(email: string | null | undefined): CodingAgent | null { | |
| 171 | + | const address = (email ?? "").trim().toLowerCase(); | |
| 172 | + | if (!address) return null; | |
| 173 | + | const login = GITHUB_NOREPLY.exec(address)?.[1] ?? null; | |
| 174 | + | return CODING_AGENTS.find((agent) => agent.emails.includes(address) || (login !== null && agent.logins.includes(login))) ?? null; | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | /** An agent by id, or null for one this g1t doesn't know (an older or newer list). */ | |
| 178 | + | export function codingAgentById(id: string | null | undefined): CodingAgent | null { | |
| 179 | + | return CODING_AGENTS.find((agent) => agent.id === id) ?? null; | |
| 180 | + | } |
| 16 | 16 | export * from "./connectors"; | |
| 17 | 17 | export * from "./context"; | |
| 18 | 18 | export * from "./claude-plugin"; | |
| 19 | + | export * from "./coding-agents"; | |
| 19 | 20 | export * from "./customize"; | |
| 20 | 21 | export * from "./d1"; | |
| 21 | 22 | export * from "./datasets"; |
| 7 | 7 | ||
| 8 | 8 | use g1t_contracts::about::{Contributor, ContributorKind, MAX_CONTRIBUTOR_WEEKS, MAX_CONTRIBUTORS, WeekCommits, next_week, week_of}; | |
| 9 | 9 | use g1t_contracts::accounts::EmailOwner; | |
| 10 | + | use g1t_contracts::coding_agents::coding_agent_for; | |
| 10 | 11 | ||
| 11 | 12 | /// The address g1t's own commits carry. | |
| 12 | 13 | pub const G1T_EMAIL: &str = "g1t@users.noreply.g1t.sh"; | |
| ⋯ | |||
| 37 | 38 | name: String, | |
| 38 | 39 | username: Option<String>, | |
| 39 | 40 | avatar: Option<String>, | |
| 41 | + | agent: Option<String>, | |
| 40 | 42 | commits: u32, | |
| 41 | 43 | first_at: String, | |
| 42 | 44 | last_at: String, | |
| ⋯ | |||
| 52 | 54 | let mut all_weeks: BTreeMap<String, u32> = BTreeMap::new(); | |
| 53 | 55 | for commit in commits { | |
| 54 | 56 | let email = commit.email.trim().to_ascii_lowercase(); | |
| 55 | − | let (key, kind, name, username, avatar) = if is_g1t(commit) { | |
| 56 | − | ("g1t".to_owned(), ContributorKind::G1t, "g1t".to_owned(), None, None) | |
| 57 | + | let (key, kind, name, username, avatar, agent) = if is_g1t(commit) { | |
| 58 | + | ("g1t".to_owned(), ContributorKind::G1t, "g1t".to_owned(), None, None, None) | |
| 57 | 59 | } else if let Some(owner) = owners.get(&email) { | |
| 58 | − | (format!("user:{}", owner.id), ContributorKind::User, owner.username.clone(), Some(owner.username.clone()), owner.avatar.clone()) | |
| 60 | + | (format!("user:{}", owner.id), ContributorKind::User, owner.username.clone(), Some(owner.username.clone()), owner.avatar.clone(), None) | |
| 61 | + | } else if let Some(agent) = coding_agent_for(&email) { | |
| 62 | + | // One contributor per agent, whichever of its addresses a commit carries. | |
| 63 | + | (format!("agent:{agent}"), ContributorKind::Agent, commit.name.trim().to_owned(), None, None, Some(agent.to_owned())) | |
| 59 | 64 | } else { | |
| 60 | 65 | let key = if email.is_empty() { format!("name:{}", commit.name.trim().to_lowercase()) } else { format!("email:{email}") }; | |
| 61 | − | (key, ContributorKind::Author, commit.name.trim().to_owned(), None, None) | |
| 66 | + | (key, ContributorKind::Author, commit.name.trim().to_owned(), None, None, None) | |
| 62 | 67 | }; | |
| 63 | 68 | let tally = by_key.entry(key).or_default(); | |
| 64 | 69 | if tally.kind.is_none() { | |
| 65 | − | *tally = Tally { kind: Some(kind), name, username, avatar, first_at: commit.at.clone(), last_at: commit.at.clone(), ..Tally::default() }; | |
| 70 | + | *tally = Tally { kind: Some(kind), name, username, avatar, agent, first_at: commit.at.clone(), last_at: commit.at.clone(), ..Tally::default() }; | |
| 66 | 71 | } | |
| 67 | 72 | tally.commits += 1; | |
| 68 | 73 | if commit.at < tally.first_at { | |
| ⋯ | |||
| 88 | 93 | name: tally.name, | |
| 89 | 94 | username: tally.username, | |
| 90 | 95 | avatar: tally.avatar, | |
| 96 | + | agent: tally.agent, | |
| 91 | 97 | commits: tally.commits, | |
| 92 | 98 | first_at: tally.first_at, | |
| 93 | 99 | last_at: tally.last_at, | |
| ⋯ | |||
| 157 | 163 | } | |
| 158 | 164 | ||
| 159 | 165 | #[test] | |
| 166 | + | fn coding_agents_are_agents_by_their_address_whatever_the_name() { | |
| 167 | + | let commits = vec![ | |
| 168 | + | authored("Claude", "noreply@anthropic.com", "2026-10-01T10:00:00Z"), | |
| 169 | + | authored("claude[bot]", "209825114+claude[bot]@users.noreply.github.com", "2026-10-02T10:00:00Z"), | |
| 170 | + | authored("Claude", "claude@example.com", "2026-10-03T10:00:00Z"), | |
| 171 | + | ]; | |
| 172 | + | let (contributors, total, _) = tally(&commits, &HashMap::new()); | |
| 173 | + | assert_eq!(total, 2); | |
| 174 | + | assert_eq!(contributors[0].kind, ContributorKind::Agent); | |
| 175 | + | assert_eq!(contributors[0].agent.as_deref(), Some("claude")); | |
| 176 | + | assert_eq!(contributors[0].commits, 2, "both of its addresses"); | |
| 177 | + | assert_eq!(contributors[1].kind, ContributorKind::Author, "a person who calls themselves Claude stays a person"); | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | #[test] | |
| 160 | 181 | fn g1t_older_addresses_are_g1t() { | |
| 161 | 182 | let commits = vec![ | |
| 162 | 183 | authored("g1t agent", "agent@g1t.sh", "2026-10-01T10:00:00Z"), | |
| 46 | 46 | /// Where a security policy may be. | |
| 47 | 47 | const POLICY_DIRS: [&str; 4] = ["", ".g1t/", ".github/", "docs/"]; | |
| 48 | 48 | ||
| 49 | − | /// How the About is worked out; a row kept by an older version is worked | |
| 49 | + | /// How the About is worked out (2: coding agents are contributors of their own); a row kept by an older version is worked | |
| 50 | 50 | /// out again. 1: g1t's older commit addresses count as g1t. | |
| 51 | − | pub const STATS_VERSION: f64 = 1.0; | |
| 51 | + | pub const STATS_VERSION: f64 = 2.0; | |
| 52 | 52 | ||
| 53 | 53 | /// What `repo_stats` keeps for the About, without the full contributors. | |
| 54 | 54 | #[derive(Debug, Default, Deserialize)] | |
| ⋯ | |||
| 423 | 423 | name: "ana".into(), | |
| 424 | 424 | username: Some("ana".into()), | |
| 425 | 425 | avatar: None, | |
| 426 | + | agent: None, | |
| 426 | 427 | commits: 3, | |
| 427 | 428 | first_at: "2026-10-01T00:00:00Z".into(), | |
| 428 | 429 | last_at: "2026-10-02T00:00:00Z".into(), | |