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