Skip to content
126 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge commit authors and hovercards: commits show their accounts, every name opens a card (repos 0016)1/**
2 * The card that opens over a person's name or avatar: who they are, as
3 * their public profile says, and how they relate to what the viewer is
4 * looking at. Only what the viewer may see: workspaces through
5 * `profile_workspaces` (those the viewer shares with them, and those they
6 * made a public project in), and commits to a repository only when the
7 * viewer may read it. Never an address.
8 *
9 * The body is snake_case, like every API body g1t answers with.
10 */
11import type { Contributors, Profile, ProfileWorkspace, RepoPath, Result, Viewer } from "@g1t/contracts";
12
13/** How recently someone committed to the repository the card opened in. */
14export type CommittedWithin = "day" | "week" | "month";
15
16export type UserCard = {
17 kind: "user";
18 username: string;
19 name: string | null;
20 pronouns: string | null;
21 bio: string | null;
22 location: string | null;
23 avatar: string | null;
24 /** Workspaces the viewer may know they belong to, at most `MAX_WORKSPACES`. */
25 workspaces: { slug: string; name: string; avatar: string | null }[];
26 /** Workspaces past those listed. */
27 more_workspaces: number;
28 /** Their latest commit to the repository, by how long ago; null when none or not asked. */
29 committed: CommittedWithin | null;
30};
31
32/** g1t itself: no profile, a card of its own. */
33export type G1tCard = { kind: "g1t"; username: "g1t" };
34
35export type Card = UserCard | G1tCard;
36
37export const MAX_WORKSPACES = 3;
38
39const DAY_MS = 86_400_000;
40
41/** `day`, `week` or `month` for a commit at `at` (RFC 3339), else null. */
42export function committedWithin(at: string | null | undefined, now: number): CommittedWithin | null {
43 if (!at) return null;
44 const ago = now - Date.parse(at);
45 if (!Number.isFinite(ago) || ago < -DAY_MS) return null;
46 if (ago <= DAY_MS) return "day";
47 if (ago <= 7 * DAY_MS) return "week";
48 if (ago <= 31 * DAY_MS) return "month";
49 return null;
50}
51
52/** The line the card shows for it. */
53export function committedLabel(within: CommittedWithin): string {
54 return `Committed to this repository in the past ${within}`;
55}
56
57/** `owner/name` from the card's `repo` parameter, or null. */
58export function parseRepo(value: string | null | undefined): RepoPath | null {
59 const match = /^([A-Za-z0-9][A-Za-z0-9._-]{0,99})\/([A-Za-z0-9._-]{1,100})$/.exec(value?.trim() ?? "");
60 return match ? { namespace: match[1]!, name: match[2]! } : null;
61}
62
63/** What building a card reads, so tests can stand in for the services. */
64export type CardSources = {
65 profile(username: string): Promise<Profile | null>;
66 /** The account's id, to find where it has public projects. */
67 accountId(username: string): Promise<string | null>;
68 publicNamespaces(userId: string): Promise<string[]>;
69 profileWorkspaces(username: string, viewer: Viewer, publicIn: string[]): Promise<ProfileWorkspace[]>;
70 contributors(path: RepoPath, viewer: Viewer): Promise<Result<Contributors>>;
71};
72
73/**
74 * The card for `username` as `viewer` sees it, opened in `repo` if any;
75 * null when there is nobody by that name (or for ghost, a deleted
76 * account, which has no card).
77 */
78export async function buildCard(
79 username: string,
80 viewer: Viewer,
81 repo: RepoPath | null,
82 sources: CardSources,
83 now: number,
84): Promise<Card | null> {
85 const name = username.trim().toLowerCase();
86 if (name === "g1t") return { kind: "g1t", username: "g1t" };
87 if (!/^[a-z0-9-]{1,39}$/.test(name) || name === "ghost") return null;
88 const profile = await sources.profile(name).catch(() => null);
89 if (!profile) return null;
90 const workspaces = sources
91 .accountId(profile.username)
92 .then((id) => (id ? sources.publicNamespaces(id) : []))
93 .catch(() => [] as string[])
94 .then((publicIn) => sources.profileWorkspaces(profile.username, viewer, publicIn))
95 .catch(() => [] as ProfileWorkspace[]);
96 // The repository answers for the viewer: one they cannot read says nothing.
97 const committed = repo
98 ? sources
99 .contributors(repo, viewer)
100 .then((found) => {
101 if (!found.ok) return null;
102 const them = found.value.contributors.find((one) => one.username?.toLowerCase() === profile.username.toLowerCase());
103 return committedWithin(them?.lastAt, now);
104 })
105 .catch(() => null)
106 : Promise.resolve(null);
107 const [shown, within] = await Promise.all([workspaces, committed]);
108 return {
109 kind: "user",
110 username: profile.username,
111 name: profile.name,
112 pronouns: profile.pronouns,
113 bio: profile.bio,
114 location: profile.location,
115 avatar: profile.avatar,
116 workspaces: shown.slice(0, MAX_WORKSPACES).map((one) => ({ slug: one.slug, name: one.name, avatar: one.avatar })),
117 more_workspaces: Math.max(0, shown.length - MAX_WORKSPACES),
118 committed: within,
119 };
120}
121
122/** Where the card for `username` is read from, opened in `repo` if any. */
123export function cardHref(username: string, repo: string | null): string {
124 const path = `/-/hovercard/user/${encodeURIComponent(username.toLowerCase())}`;
125 return repo ? `${path}?repo=${encodeURIComponent(repo)}` : path;
126}

This file's history is long; its oldest lines are credited to the oldest commit read.