Skip to content

Commit

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.

syntaqxcommitted Parent62f5012Browse files
16 files+543−290/16 viewed
+2−1
6868 | --- | --- |
6969 | A person | By an address they confirmed on their account, or their noreply address. Several addresses of one account count as one. |
7070 | 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. |
7273
7374 The newest 3,000 commits are counted.
7475
+118−0
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&apos;t one any account has confirmed. If they&apos;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&apos;ll show as you.
113+ </p>
114+ </div>
115+ </HoverCardContent>
116+ </HoverCard>
117+ );
118+}
+45−4
11 import { User } from "lucide-react";
22 import { Link } from "react-router";
33
4+import { codingAgentById } from "@g1t/contracts/coding-agents";
5+
46 import { type CommitPerson, type ShownCommit, profileHref, shownName } from "../lib/commit-people";
57 import { cn } from "../lib/cn";
68
9+import { AgentBadge, AgentCard, AgentMark, AuthorCard } from "./coding-agent";
710 import { UserCard } from "./user-card";
811 import { Avatar } from "./ui/avatar";
912
1013 /**
1114 * 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.
1317 */
1418 export function CommitAvatar({ person, size = 20 }: { person: CommitPerson; size?: number }) {
1519 if (person.kind === "user" || person.kind === "g1t") {
2832 </UserCard>
2933 );
3034 }
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 = (
3346 <span
3447 aria-hidden="true"
3548 className="inline-flex shrink-0 items-center justify-center rounded-full bg-line text-faint"
3851 <User size={Math.round(size * 0.62)} strokeWidth={2.25} />
3952 </span>
4053 );
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+ );
4162 }
4263
4364 /**
7899 </UserCard>
79100 );
80101 }
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+ }
81113 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+ }
83124 return (
84125 <UserCard username={person.username}>
85126 <Link to={href} className={cn("relative z-10 hover:underline", className)}>
+4−7
3535 import { TimeAgo } from "./ui";
3636 import { Avatar } from "./ui/avatar";
3737 import { Badge } from "./ui/badge";
38−import { Hint } from "./ui/hint";
3938 import { SkeletonLine } from "./ui/skeleton";
4039
4140 /** How many contributors' avatars the About shows. */
246245 )}
247246 </UserCard>
248247 ) : (
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>
255252 )}
256253 </li>
257254 );
+18−0
108108 assert.equal(contributorPerson({ ...base, kind: "user", username: "ghost" }).kind, "ghost");
109109 assert.deepEqual(contributorPerson({ ...base, kind: "author", name: "Sam" }), { kind: "author", name: "Sam", username: null, avatar: null });
110110 });
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+});
+16−5
99 * person, not the address, so a private address stays private.
1010 */
1111 import type { Blame, Commit, Contributor, EmailOwner } from "@g1t/contracts";
12+import { codingAgentById, codingAgentFor } from "@g1t/contracts/coding-agents";
1213
1314 /**
1415 * 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.
1720 */
1821 export type CommitPerson = {
19− kind: "user" | "g1t" | "ghost" | "author";
22+ kind: "user" | "g1t" | "ghost" | "agent" | "author";
2023 /** The name written on the commit. Beside an account it is a hint. */
2124 name: string;
2225 /** The account's username; null for `author`. */
2326 username: string | null;
2427 /** The account's uploaded avatar, by hash. */
2528 avatar: string | null;
29+ /** The coding agent's id, for an `agent`. */
30+ agent?: string | null;
2631 };
2732
2833 /** A commit as the page gets it: its people, never their addresses. */
97102 const owner = address ? owners[address] : undefined;
98103 if (owner?.username === "ghost") return { kind: "ghost", name: written, username: "ghost", avatar: null };
99104 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 };
100107 return { kind: "author", name: written, username: null, avatar: null };
101108 }
102109
116123 * `email_owners`, then g1t's addresses, then the name), so the About, the
117124 * Contributors page and every commit show one person the same way.
118125 */
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 {
120127 if (contributor.kind === "g1t") return { kind: "g1t", name: contributor.name, username: "g1t", avatar: null };
121128 if (contributor.kind === "user" && contributor.username === "ghost") return { kind: "ghost", name: contributor.name, username: "ghost", avatar: null };
122129 if (contributor.kind === "user" && contributor.username) {
123130 return { kind: "user", name: contributor.name, username: contributor.username, avatar: contributor.avatar ?? null };
124131 }
132+ if (contributor.kind === "agent" && codingAgentById(contributor.agent)) {
133+ return { kind: "agent", name: contributor.name, username: null, avatar: null, agent: contributor.agent ?? null };
134+ }
125135 return { kind: "author", name: contributor.name, username: null, avatar: null };
126136 }
127137
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. */
129139 export function shownName(person: CommitPerson): string {
140+ if (person.kind === "agent") return codingAgentById(person.agent)?.name ?? person.name;
130141 return person.username ?? person.name;
131142 }
132143
+7−2
4040 }
4141
4242 /// 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.
4546 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
4647 #[serde(rename_all = "snake_case")]
4748 pub enum ContributorKind {
4849 User,
4950 G1t,
51+ Agent,
5052 Author,
5153 }
5254
7173 /// The uploaded avatar's hash, for a `user` who has one.
7274 #[serde(default)]
7375 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>,
7479 pub commits: u32,
7580 /// RFC 3339: their first and latest commit read.
7681 pub first_at: String,
+80−0
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+}
+1−0
1717 pub mod capture;
1818 pub mod checks;
1919 pub mod codeowners;
20+pub mod coding_agents;
2021 pub mod credentials;
2122 pub mod datasets;
2223 pub mod deploy_keys;
+1−1
44 "private": true,
55 "type": "module",
66 "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" },
88 "scripts": { "test": "node --test src/*.test.ts", "typecheck": "tsc -p tsconfig.json" }
99 }
+4−2
2828 path: string;
2929 };
3030
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";
3333
3434 /** Commits in the week starting on Monday `week` (`YYYY-MM-DD`, UTC). */
3535 export type WeekCommits = { week: string; commits: number };
3939 name: string;
4040 username?: string | null;
4141 avatar?: string | null;
42+ /** The coding agent's id, for an `agent`. */
43+ agent?: string | null;
4244 commits: number;
4345 firstAt: string;
4446 lastAt: string;
+37−0
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+});
+180−0
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+}
+1−0
1616 export * from "./connectors";
1717 export * from "./context";
1818 export * from "./claude-plugin";
19+export * from "./coding-agents";
1920 export * from "./customize";
2021 export * from "./d1";
2122 export * from "./datasets";
+26−5
77
88 use g1t_contracts::about::{Contributor, ContributorKind, MAX_CONTRIBUTOR_WEEKS, MAX_CONTRIBUTORS, WeekCommits, next_week, week_of};
99 use g1t_contracts::accounts::EmailOwner;
10+use g1t_contracts::coding_agents::coding_agent_for;
1011
1112 /// The address g1t's own commits carry.
1213 pub const G1T_EMAIL: &str = "g1t@users.noreply.g1t.sh";
3738 name: String,
3839 username: Option<String>,
3940 avatar: Option<String>,
41+ agent: Option<String>,
4042 commits: u32,
4143 first_at: String,
4244 last_at: String,
5254 let mut all_weeks: BTreeMap<String, u32> = BTreeMap::new();
5355 for commit in commits {
5456 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)
5759 } 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()))
5964 } else {
6065 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)
6267 };
6368 let tally = by_key.entry(key).or_default();
6469 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() };
6671 }
6772 tally.commits += 1;
6873 if commit.at < tally.first_at {
8893 name: tally.name,
8994 username: tally.username,
9095 avatar: tally.avatar,
96+ agent: tally.agent,
9197 commits: tally.commits,
9298 first_at: tally.first_at,
9399 last_at: tally.last_at,
157163 }
158164
159165 #[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]
160181 fn g1t_older_addresses_are_g1t() {
161182 let commits = vec![
162183 authored("g1t agent", "agent@g1t.sh", "2026-10-01T10:00:00Z"),
+3−2
4646 /// Where a security policy may be.
4747 const POLICY_DIRS: [&str; 4] = ["", ".g1t/", ".github/", "docs/"];
4848
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
5050 /// 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;
5252
5353 /// What `repo_stats` keeps for the About, without the full contributors.
5454 #[derive(Debug, Default, Deserialize)]
423423 name: "ana".into(),
424424 username: Some("ana".into()),
425425 avatar: None,
426+ agent: None,
426427 commits: 3,
427428 first_at: "2026-10-01T00:00:00Z".into(),
428429 last_at: "2026-10-02T00:00:00Z".into(),