Skip to content
147 linesCodeBlameRaw
1/**
2 * Who a commit is by, as g1t shows it: the account its author address
3 * belongs to, else the name written on the commit.
4 *
5 * Git records a name and an address; g1t matches the address to an
6 * account by a confirmed address or the account's noreply address
7 * (`email_owners` in services/identity), never an unconfirmed one. The
8 * address itself never reaches the page: a commit is shown with the
9 * person, not the address, so a private address stays private.
10 */
11import type { Blame, Commit, Contributor, EmailOwner } from "@g1t/contracts";
12import { codingAgentById, codingAgentFor } from "@g1t/contracts/coding-agents";
13
14/**
15 * One person on a commit. `user` is an account; `g1t` is g1t itself;
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.
20 */
21export type CommitPerson = {
22 kind: "user" | "g1t" | "ghost" | "agent" | "author";
23 /** The name written on the commit. Beside an account it is a hint. */
24 name: string;
25 /** The account's username; null for `author`. */
26 username: string | null;
27 /** The account's uploaded avatar, by hash. */
28 avatar: string | null;
29 /** The coding agent's id, for an `agent`. */
30 agent?: string | null;
31};
32
33/** A commit as the page gets it: its people, never their addresses. */
34export type ShownCommit = Omit<Commit, "author"> & {
35 author: CommitPerson;
36 /** From `Co-authored-by` trailers, in order. */
37 coAuthors: CommitPerson[];
38};
39
40/** Who last changed each line, with the commits' people. */
41export type ShownBlame = Omit<Blame, "commits"> & { commits: ShownCommit[] };
42
43/** A commit as a file list's row shows it: no people at all. */
44export type FileCommit = Pick<Commit, "hash" | "message" | "authoredAt">;
45
46/** Each entry's last commit; `complete` is false when some were not reached. */
47export type FileCommits = { entries: { name: string; commit: FileCommit }[]; complete: boolean };
48
49/**
50 * The addresses on g1t's own commits: its noreply address, and those its
51 * agents and merge queue used before (as "g1t agent"), which history
52 * keeps. Every one is shown as g1t.
53 */
54export const G1T_COMMIT_EMAILS: ReadonlySet<string> = new Set([
55 "g1t@users.noreply.g1t.sh",
56 "agent@g1t.sh",
57 "queue@g1t.sh",
58 "mergecheck@g1t.sh",
59]);
60
61const CO_AUTHOR = /^co-authored-by:\s*(.*?)\s*<([^>]*)>\s*$/i;
62
63/** The name and address in each `Co-authored-by` trailer of a message. */
64export function coAuthorsOf(message: string): { name: string; email: string }[] {
65 const lines = message.trim().split("\n");
66 const last = lines.slice(lines.lastIndexOf("") + 1);
67 const found: { name: string; email: string }[] = [];
68 for (const line of last) {
69 const match = CO_AUTHOR.exec(line.trim());
70 if (match) found.push({ name: match[1]!.trim(), email: match[2]!.trim() });
71 }
72 return found;
73}
74
75/** Lowercased and trimmed, as `email_owners` keys them. */
76export function normalizeEmail(email: string): string {
77 return email.trim().toLowerCase();
78}
79
80/**
81 * Every distinct address the commits name, authors and co-authors, that
82 * needs asking about: g1t's own are known already.
83 */
84export function addressesToMatch(commits: readonly Pick<Commit, "author" | "message">[]): string[] {
85 const seen = new Set<string>();
86 const add = (email: string) => {
87 const one = normalizeEmail(email);
88 if (one && one.includes("@") && !G1T_COMMIT_EMAILS.has(one)) seen.add(one);
89 };
90 for (const commit of commits) {
91 add(commit.author.email);
92 for (const co of coAuthorsOf(commit.message)) add(co.email);
93 }
94 return [...seen];
95}
96
97/** The person a name and address on a commit belong to. */
98export function personFor(name: string, email: string, owners: Readonly<Record<string, EmailOwner>>): CommitPerson {
99 const written = name.trim() || "Unknown";
100 const address = normalizeEmail(email);
101 if (G1T_COMMIT_EMAILS.has(address)) return { kind: "g1t", name: written, username: "g1t", avatar: null };
102 const owner = address ? owners[address] : undefined;
103 if (owner?.username === "ghost") return { kind: "ghost", name: written, username: "ghost", avatar: null };
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 };
107 return { kind: "author", name: written, username: null, avatar: null };
108}
109
110/** A commit with its people found and its author's address left out. */
111export function showCommit(commit: Commit, owners: Readonly<Record<string, EmailOwner>>): ShownCommit {
112 const { author, ...rest } = commit;
113 return {
114 ...rest,
115 author: personFor(author.name, author.email, owners),
116 coAuthors: coAuthorsOf(commit.message).map((co) => personFor(co.name, co.email, owners)),
117 };
118}
119
120/**
121 * A repository's contributor as a person on its commits. The repos
122 * service tallies them by the same rules (services/repos/src/contributors.rs:
123 * `email_owners`, then g1t's addresses, then the name), so the About, the
124 * Contributors page and every commit show one person the same way.
125 */
126export function contributorPerson(contributor: Pick<Contributor, "kind" | "name" | "username" | "avatar" | "agent">): CommitPerson {
127 if (contributor.kind === "g1t") return { kind: "g1t", name: contributor.name, username: "g1t", avatar: null };
128 if (contributor.kind === "user" && contributor.username === "ghost") return { kind: "ghost", name: contributor.name, username: "ghost", avatar: null };
129 if (contributor.kind === "user" && contributor.username) {
130 return { kind: "user", name: contributor.name, username: contributor.username, avatar: contributor.avatar ?? null };
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 }
135 return { kind: "author", name: contributor.name, username: null, avatar: null };
136}
137
138/** What a person on a commit is called on the page: an agent by its own name, whatever the commit wrote. */
139export function shownName(person: CommitPerson): string {
140 if (person.kind === "agent") return codingAgentById(person.agent)?.name ?? person.name;
141 return person.username ?? person.name;
142}
143
144/** Where a person on a commit links to, if anywhere. */
145export function profileHref(person: CommitPerson): string | null {
146 return person.kind === "user" && person.username ? `/u/${person.username}` : null;
147}