Commits show their accounts, and every name opens a card (repos 0016)
One shared helper (apps/web/app/lib/commit-people.ts, .server.ts) turns a commit's author and Co-authored-by trailers into people: the account an address belongs to by email_owners (confirmed or noreply addresses only), g1t for its own address and the ones its agents and merge queue used before (agent@, queue@, mergecheck@g1t.sh), ghost for a deleted account, else the name on the commit with a plain avatar and no link. One identity call per page, answers kept a minute in the isolate. Addresses never reach the page: commits are sent with people, file rows without authors, and trailers on the commit page drop their addresses. Used by the Files commit bar, history, the commit page, blame and its why panel, branches, tags, compare and the overview. Contributors (About and the Contributors page) render through the same person mapping; the repos tally now counts g1t's older addresses as g1t, and a stats version (migration 0016) makes kept answers be worked out again. A shadcn HoverCard (components/ui/hover-card.tsx) opens a person's card on hover or focus, never on touch: profile, workspaces the viewer may see, and recent commits to the repository the viewer can read, from /-/hovercard/user/:username (snake_case, private max-age 60). @g1t has a card of its own; ghost has none. PersonLink, mentions, commit names and avatars, contributors, reviewers, assignees and member lists use it. Docs: profiles guide gains the card and "Commits and your account" with noreply setup.
| 386 | 386 | (`<username>@users.g1t.sh`) count as yours too. An unconfirmed address | |
| 387 | 387 | never attributes a commit, so nobody can claim your commits by adding your | |
| 388 | 388 | address. Commits whose address matches no account show the name in the | |
| 389 | − | commit. | |
| 389 | + | commit. See [Commits and your account](/guides/workspaces/#commits-and-your-account) | |
| 390 | + | for setting your noreply address in git. | |
| 390 | 391 | ||
| 391 | 392 | ### Email addresses through the API | |
| 392 | 393 |
| 681 | 681 | Someone signed out sees your public work and the workspaces where you made | |
| 682 | 682 | a public project; nothing else. The link preview for a profile uses only | |
| 683 | 683 | public work. | |
| 684 | + | ||
| 685 | + | ### The card over a name | |
| 686 | + | ||
| 687 | + | Hold the pointer over a person's name or picture anywhere on g1t, or move | |
| 688 | + | the keyboard focus to their name, and a card opens with their profile at a | |
| 689 | + | glance: | |
| 690 | + | ||
| 691 | + | | On the card | Shown when | | |
| 692 | + | | --- | --- | | |
| 693 | + | | Picture, name, username and pronouns | Always | | |
| 694 | + | | Bio and location | They filled them in | | |
| 695 | + | | **Member of** | The same workspaces their profile shows you, at most three named | | |
| 696 | + | | **Committed to this repository in the past day**, **week** or **month** | You opened it inside a repository you can read, and their latest commit on its default branch is that recent | | |
| 697 | + | ||
| 698 | + | On a touch screen no card opens: a tap goes to the profile. `@g1t` has a | |
| 699 | + | card of its own, about putting g1t to work. `ghost`, which stands in for | |
| 700 | + | deleted accounts, has no card. | |
| 701 | + | ||
| 702 | + | ### Commits and your account | |
| 703 | + | ||
| 704 | + | A commit shows as yours, with your username, picture, profile link and | |
| 705 | + | card, when its author address is one of your | |
| 706 | + | [confirmed addresses](/guides/authentication/#email-addresses) or your | |
| 707 | + | noreply address. This holds everywhere a commit appears: the Files page, | |
| 708 | + | history, a commit, blame, branches, tags, comparisons and the | |
| 709 | + | Contributors list, which counts every address of yours as one person. | |
| 710 | + | ||
| 711 | + | To have commits made on your own machine show as yours without publishing | |
| 712 | + | your address, commit with your noreply address: | |
| 713 | + | ||
| 714 | + | 1. Open [Settings → Emails](https://g1t.sh/settings/emails) and copy your | |
| 715 | + | noreply address. It looks like | |
| 716 | + | `<8 characters of your account id>+<username>@users.noreply.g1t.sh`. | |
| 717 | + | 2. Set it for every repository, or leave out `--global` for one: | |
| 718 | + | ||
| 719 | + | ```sh | |
| 720 | + | git config --global user.email "6c1d0efg+sam@users.noreply.g1t.sh" | |
| 721 | + | ``` | |
| 722 | + | ||
| 723 | + | 3. Commit and push as usual. Commits you made before keep the address they | |
| 724 | + | were made with; add that address to your account and confirm it to have | |
| 725 | + | them show as yours. | |
| 726 | + | ||
| 727 | + | | A commit's address | Shown as | | |
| 728 | + | | --- | --- | | |
| 729 | + | | One of your confirmed addresses, or your noreply address | You | | |
| 730 | + | | An address added to an account but not confirmed | The name in the commit | | |
| 731 | + | | An address no account has | The name in the commit, with a plain picture, no link and no card | | |
| 732 | + | | A deleted account's noreply address | `ghost` | | |
| 733 | + | | g1t's own (`g1t@users.noreply.g1t.sh`) | `g1t` | | |
| 734 | + | ||
| 735 | + | `Co-authored-by` trailers are matched the same way, and their pictures sit | |
| 736 | + | beside the author's. An address itself is never shown on g1t. |
| 11 | 11 | import { Fragment, useEffect, useMemo, useState } from "react"; | |
| 12 | 12 | import { Link, useFetcher } from "react-router"; | |
| 13 | 13 | ||
| 14 | − | import type { Blame, Commit } from "@g1t/contracts"; | |
| 14 | + | import type { ShownBlame, ShownCommit } from "../lib/commit-people"; | |
| 15 | 15 | ||
| 16 | 16 | import type { loader as whyLoader } from "../routes/repo/why"; | |
| 17 | 17 | import { repoAt } from "../lib/markdown-plugins"; | |
| 18 | + | import { CommitAvatars, CommitNames } from "./commit-person"; | |
| 18 | 19 | import { Markdown } from "./markdown"; | |
| 19 | − | import { Avatar, TimeAgo } from "./ui"; | |
| 20 | + | import { TimeAgo } from "./ui"; | |
| 20 | 21 | import { Skeleton } from "./ui/skeleton"; | |
| 21 | 22 | ||
| 22 | 23 | /** | |
| ⋯ | |||
| 36 | 37 | lines: string[]; | |
| 37 | 38 | /** Highlighted HTML per line, when the language is known. */ | |
| 38 | 39 | html: string[] | null; | |
| 39 | − | blame: Blame; | |
| 40 | + | blame: ShownBlame; | |
| 40 | 41 | }) { | |
| 41 | 42 | const commits = useMemo( | |
| 42 | − | () => new Map<string, Commit>(blame.commits.map((commit) => [commit.hash, commit])), | |
| 43 | + | () => new Map<string, ShownCommit>(blame.commits.map((commit) => [commit.hash, commit])), | |
| 43 | 44 | [blame.commits], | |
| 44 | 45 | ); | |
| 45 | 46 | // Newer commits are brighter: rank them by when they were written. | |
| ⋯ | |||
| 121 | 122 | <td className="w-72 max-w-72 border-r border-line px-3 align-top font-sans"> | |
| 122 | 123 | {first && commit && ( | |
| 123 | 124 | <span className="flex items-center gap-2 py-0.5 text-xs"> | |
| 124 | − | <Avatar name={commit.author.name} size={16} /> | |
| 125 | + | <CommitAvatars commit={commit} size={16} max={2} /> | |
| 125 | 126 | <span className={`min-w-0 grow truncate ${selected ? "text-fg" : "text-muted"}`}> | |
| 126 | 127 | {commit.message.split("\n")[0]} | |
| 127 | 128 | </span> | |
| ⋯ | |||
| 232 | 233 | {current.commit.message.split("\n")[0]} | |
| 233 | 234 | </Link> | |
| 234 | 235 | <p className="mt-1.5 flex items-center gap-2 text-xs text-muted"> | |
| 235 | − | <Avatar name={current.commit.author.name} size={14} /> | |
| 236 | − | {current.commit.author.name} · <TimeAgo at={current.commit.authoredAt} /> ·{" "} | |
| 236 | + | <CommitAvatars commit={current.commit} size={14} /> | |
| 237 | + | <CommitNames commit={current.commit} className="text-fg" /> · <TimeAgo at={current.commit.authoredAt} /> ·{" "} | |
| 237 | 238 | <span className="font-mono">{current.commit.hash.slice(0, 7)}</span> | |
| 238 | 239 | </p> | |
| 239 | 240 | </section> | |
| 4 | 4 | import type { Pull } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import type { Drift } from "../lib/branches"; | |
| 7 | + | import type { CommitPerson } from "../lib/commit-people"; | |
| 8 | + | import { CommitAvatars, CommitNames } from "./commit-person"; | |
| 7 | 9 | import { CheckBadge } from "./checks"; | |
| 8 | 10 | import { type ChecksSource, CommitChecksBadge } from "./commit-checks"; | |
| 9 | 11 | import { host } from "./deploy"; | |
| 10 | − | import { Avatar, TimeAgo } from "./ui"; | |
| 12 | + | import { TimeAgo } from "./ui"; | |
| 11 | 13 | import { Hint } from "./ui/hint"; | |
| 12 | 14 | ||
| 13 | 15 | function drift({ ahead, behind }: Drift, main: string): string { | |
| ⋯ | |||
| 16 | 18 | ||
| 17 | 19 | export type ActiveBranch = { | |
| 18 | 20 | name: string; | |
| 19 | − | commit: { hash: string; message: string; author: string; at: string } | null; | |
| 21 | + | commit: { hash: string; message: string; author: CommitPerson; coAuthors: CommitPerson[]; at: string } | null; | |
| 20 | 22 | /** Null when the two histories were not read far enough to meet. */ | |
| 21 | 23 | drift: Drift | null; | |
| 22 | 24 | pull: { number: number; title: string; checkStatus: Pull["checkStatus"]; draft: boolean } | null; | |
| ⋯ | |||
| 50 | 52 | </Link> | |
| 51 | 53 | {branch.commit && ( | |
| 52 | 54 | <span className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted"> | |
| 53 | − | <Avatar name={branch.commit.author} size={13} /> | |
| 54 | − | <span className="shrink-0">{branch.commit.author}</span> | |
| 55 | + | <CommitAvatars commit={branch.commit} size={14} max={2} /> | |
| 56 | + | <span className="shrink-0"> | |
| 57 | + | <CommitNames commit={branch.commit} className="hover:text-fg" /> | |
| 58 | + | </span> | |
| 55 | 59 | <span className="text-faint">·</span> | |
| 56 | 60 | <Hint label={branch.commit.message}> | |
| 57 | 61 | <Link to={`${base}/commit/${branch.commit.hash}`} className="min-w-0 truncate hover:text-fg"> | |
| 17 | 17 | ||
| 18 | 18 | import { Badge } from "./ui/badge"; | |
| 19 | 19 | import { Skeleton } from "./ui/skeleton"; | |
| 20 | + | import { UserCard } from "./user-card"; | |
| 20 | 21 | ||
| 21 | 22 | /** The guide to the file. */ | |
| 22 | 23 | export const CODEOWNERS_DOCS = "https://docs.g1t.sh/guides/codeowners/"; | |
| ⋯ | |||
| 45 | 46 | } | |
| 46 | 47 | if (owner.startsWith("@") && /^[a-z0-9-]{1,39}$/i.test(name) && name !== "g1t") { | |
| 47 | 48 | return ( | |
| 48 | − | <Link to={`/u/${name.toLowerCase()}`} className={className}> | |
| 49 | − | {owner} | |
| 50 | − | </Link> | |
| 49 | + | <UserCard username={name.toLowerCase()}> | |
| 50 | + | <Link to={`/u/${name.toLowerCase()}`} className={className}> | |
| 51 | + | {owner} | |
| 52 | + | </Link> | |
| 53 | + | </UserCard> | |
| 51 | 54 | ); | |
| 52 | 55 | } | |
| 53 | 56 | return <span className="font-mono text-xs text-fg-soft">{owner}</span>; | |
| 1 | + | import { User } from "lucide-react"; | |
| 2 | + | import { Link } from "react-router"; | |
| 3 | + | ||
| 4 | + | import { type CommitPerson, type ShownCommit, profileHref, shownName } from "../lib/commit-people"; | |
| 5 | + | import { cn } from "../lib/cn"; | |
| 6 | + | import { Avatar } from "./ui"; | |
| 7 | + | import { UserCard } from "./user-card"; | |
| 8 | + | ||
| 9 | + | /** | |
| 10 | + | * A person on a commit, as an avatar: theirs and their card when the | |
| 11 | + | * commit is matched to an account (or is g1t's), else a plain one. | |
| 12 | + | */ | |
| 13 | + | export function CommitAvatar({ person, size = 20 }: { person: CommitPerson; size?: number }) { | |
| 14 | + | if (person.kind === "user" || person.kind === "g1t") { | |
| 15 | + | const href = profileHref(person); | |
| 16 | + | const avatar = <Avatar name={shownName(person)} image={person.avatar} size={size} />; | |
| 17 | + | return ( | |
| 18 | + | <UserCard username={person.username}> | |
| 19 | + | {href ? ( | |
| 20 | + | // Its name beside it is the link a keyboard reaches; this is for the pointer. | |
| 21 | + | <Link to={href} tabIndex={-1} aria-hidden="true" className="relative z-10 inline-flex shrink-0 rounded-full"> | |
| 22 | + | {avatar} | |
| 23 | + | </Link> | |
| 24 | + | ) : ( | |
| 25 | + | <span className="relative z-10 inline-flex shrink-0">{avatar}</span> | |
| 26 | + | )} | |
| 27 | + | </UserCard> | |
| 28 | + | ); | |
| 29 | + | } | |
| 30 | + | // Nobody on g1t, or a deleted account: a plain silhouette, no card. | |
| 31 | + | return ( | |
| 32 | + | <span | |
| 33 | + | aria-hidden="true" | |
| 34 | + | className="inline-flex shrink-0 items-center justify-center rounded-full bg-line text-faint" | |
| 35 | + | style={{ width: size, height: size }} | |
| 36 | + | > | |
| 37 | + | <User size={Math.round(size * 0.62)} strokeWidth={2.25} /> | |
| 38 | + | </span> | |
| 39 | + | ); | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | /** | |
| 43 | + | * A commit's author and co-authors, as overlapping avatars: the author's | |
| 44 | + | * on top, at most `max`. | |
| 45 | + | */ | |
| 46 | + | export function CommitAvatars({ commit, size = 20, max = 3 }: { commit: Pick<ShownCommit, "author" | "coAuthors">; size?: number; max?: number }) { | |
| 47 | + | const people = [commit.author, ...commit.coAuthors].slice(0, max); | |
| 48 | + | if (people.length === 1) return <CommitAvatar person={commit.author} size={size} />; | |
| 49 | + | return ( | |
| 50 | + | <span className="inline-flex shrink-0 items-center"> | |
| 51 | + | {people.map((person, index) => ( | |
| 52 | + | <span | |
| 53 | + | key={`${index}:${person.username ?? person.name}`} | |
| 54 | + | className="inline-flex rounded-full ring-2 ring-bg" | |
| 55 | + | style={{ marginLeft: index === 0 ? 0 : -Math.round(size * 0.35), zIndex: people.length - index }} | |
| 56 | + | > | |
| 57 | + | <CommitAvatar person={person} size={size} /> | |
| 58 | + | </span> | |
| 59 | + | ))} | |
| 60 | + | </span> | |
| 61 | + | ); | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | /** | |
| 65 | + | * A person on a commit, by name: their username, linked to their profile | |
| 66 | + | * with their card, for an account; g1t with its label and card; ghost for | |
| 67 | + | * a deleted account; else the name written on the commit, as text. | |
| 68 | + | */ | |
| 69 | + | export function CommitName({ person, className }: { person: CommitPerson; className?: string }) { | |
| 70 | + | if (person.kind === "g1t") { | |
| 71 | + | return ( | |
| 72 | + | <UserCard username="g1t"> | |
| 73 | + | <span tabIndex={0} className="relative z-10 inline-flex items-baseline gap-1 rounded outline-none focus-visible:ring-2 focus-visible:ring-accent"> | |
| 74 | + | <span className={className}>g1t</span> | |
| 75 | + | <span className="rounded border border-line px-1 text-[0.625rem] leading-[1.35] font-medium text-muted">bot</span> | |
| 76 | + | </span> | |
| 77 | + | </UserCard> | |
| 78 | + | ); | |
| 79 | + | } | |
| 80 | + | const href = profileHref(person); | |
| 81 | + | if (!href) return <span className={className}>{shownName(person)}</span>; | |
| 82 | + | return ( | |
| 83 | + | <UserCard username={person.username}> | |
| 84 | + | <Link to={href} className={cn("relative z-10 hover:underline", className)}> | |
| 85 | + | {shownName(person)} | |
| 86 | + | </Link> | |
| 87 | + | </UserCard> | |
| 88 | + | ); | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | /** | |
| 92 | + | * Who made a commit, in words: the author, and its co-authors after them | |
| 93 | + | * ("ada and grace", "ada, grace and 2 others"). | |
| 94 | + | */ | |
| 95 | + | export function CommitNames({ | |
| 96 | + | commit, | |
| 97 | + | className, | |
| 98 | + | all, | |
| 99 | + | }: { | |
| 100 | + | commit: Pick<ShownCommit, "author" | "coAuthors">; | |
| 101 | + | className?: string; | |
| 102 | + | /** Name every co-author, rather than counting past the first. */ | |
| 103 | + | all?: boolean; | |
| 104 | + | }) { | |
| 105 | + | const others = commit.coAuthors; | |
| 106 | + | if (others.length === 0) return <CommitName person={commit.author} className={className} />; | |
| 107 | + | if (all && others.length > 1) { | |
| 108 | + | const people = [commit.author, ...others]; | |
| 109 | + | return ( | |
| 110 | + | <> | |
| 111 | + | {people.map((person, index) => ( | |
| 112 | + | <span key={index}> | |
| 113 | + | {index > 0 && (index === people.length - 1 ? " and " : ", ")} | |
| 114 | + | <CommitName person={person} className={className} /> | |
| 115 | + | </span> | |
| 116 | + | ))} | |
| 117 | + | </> | |
| 118 | + | ); | |
| 119 | + | } | |
| 120 | + | if (others.length === 1) { | |
| 121 | + | return ( | |
| 122 | + | <> | |
| 123 | + | <CommitName person={commit.author} className={className} /> and <CommitName person={others[0]!} className={className} /> | |
| 124 | + | </> | |
| 125 | + | ); | |
| 126 | + | } | |
| 127 | + | return ( | |
| 128 | + | <> | |
| 129 | + | <CommitName person={commit.author} className={className} /> and {others.length} others | |
| 130 | + | </> | |
| 131 | + | ); | |
| 132 | + | } |
| 16 | 16 | import remarkGfm from "remark-gfm"; | |
| 17 | 17 | ||
| 18 | 18 | import { Checkbox } from "./ui/checkbox"; | |
| 19 | − | import { type AlertKind, type MarkdownRepo, rehypeAlerts, rehypeReferences } from "../lib/markdown-plugins"; | |
| 19 | + | import { type AlertKind, G1T_MENTION_HREF, type MarkdownRepo, rehypeAlerts, rehypeReferences } from "../lib/markdown-plugins"; | |
| 20 | + | import { UserCard } from "./user-card"; | |
| 20 | 21 | ||
| 21 | 22 | /** The text inside a React tree, for anchors and copying. */ | |
| 22 | 23 | function textOf(node: ReactNode): string { | |
| ⋯ | |||
| 178 | 179 | h4: ({ children }) => <Heading level={4}>{children}</Heading>, | |
| 179 | 180 | a({ href = "", children, node }) { | |
| 180 | 181 | const ref = (node?.properties as { dataRef?: string } | undefined)?.dataRef; | |
| 182 | + | if (ref === "mention") { | |
| 183 | + | // `@name` may be a person (with a card) or a workspace (none). | |
| 184 | + | const name = href === G1T_MENTION_HREF ? "g1t" : href.replace(/^\//, ""); | |
| 185 | + | return ( | |
| 186 | + | <UserCard username={name}> | |
| 187 | + | <Link to={href} prefetch="intent" className="font-medium"> | |
| 188 | + | {children} | |
| 189 | + | </Link> | |
| 190 | + | </UserCard> | |
| 191 | + | ); | |
| 192 | + | } | |
| 181 | 193 | if (ref) { | |
| 182 | 194 | return ( | |
| 183 | 195 | <Link | |
| 10 | 10 | ||
| 11 | 11 | import { Markdown } from "./markdown"; | |
| 12 | 12 | import { Pill, TimeAgo } from "./ui"; | |
| 13 | + | import { UserCard } from "./user-card"; | |
| 13 | 14 | ||
| 14 | 15 | export function encodeTag(tag: string): string { | |
| 15 | 16 | return tag.split("/").map(encodeURIComponent).join("/"); | |
| ⋯ | |||
| 52 | 53 | </div> | |
| 53 | 54 | {release.author && ( | |
| 54 | 55 | <p className="mt-1 text-xs text-faint"> | |
| 55 | − | <Link to={`/u/${release.author}`} className="font-medium text-muted hover:text-accent"> | |
| 56 | − | {release.author} | |
| 57 | − | </Link>{" "} | |
| 56 | + | <UserCard username={release.author}> | |
| 57 | + | <Link to={`/u/${release.author}`} className="font-medium text-muted hover:text-accent"> | |
| 58 | + | {release.author} | |
| 59 | + | </Link> | |
| 60 | + | </UserCard>{" "} | |
| 58 | 61 | {release.draft ? "drafted this" : "released this"} <TimeAgo at={when} /> | |
| 59 | 62 | </p> | |
| 60 | 63 | )} | |
| 26 | 26 | ||
| 27 | 27 | import type { PackageSummary, Repo, RepoAbout } from "@g1t/contracts"; | |
| 28 | 28 | ||
| 29 | − | import { compact, contributorHref, count, languageBar, licenseLabel } from "../lib/about"; | |
| 29 | + | import { compact, count, languageBar, licenseLabel } from "../lib/about"; | |
| 30 | + | import { contributorPerson, profileHref, shownName } from "../lib/commit-people"; | |
| 31 | + | import { CommitAvatar } from "./commit-person"; | |
| 32 | + | import { UserCard } from "./user-card"; | |
| 30 | 33 | import { PackageIcon as EcosystemIcon } from "./package-icon"; | |
| 31 | 34 | import { Topics } from "./topics"; | |
| 32 | 35 | import { Avatar, Pill, TimeAgo } from "./ui"; | |
| ⋯ | |||
| 221 | 224 | <> | |
| 222 | 225 | <ul className="flex flex-wrap gap-1.5"> | |
| 223 | 226 | {shown.map((contributor) => { | |
| 224 | − | const href = contributorHref(contributor); | |
| 225 | − | const label = `${contributor.name} · ${count(contributor.commits, "commit")}`; | |
| 226 | − | const face = <Avatar name={contributor.name} image={contributor.avatar} size={30} system={contributor.kind === "g1t"} />; | |
| 227 | + | // The same person, avatar, link and card as on their commits. | |
| 228 | + | const person = contributorPerson(contributor); | |
| 229 | + | const href = profileHref(person); | |
| 230 | + | const label = `${shownName(person)} · ${count(contributor.commits, "commit")}`; | |
| 231 | + | const face = <Avatar name={shownName(person)} image={person.avatar} size={30} />; | |
| 227 | 232 | return ( | |
| 228 | − | <li key={`${contributor.kind}:${contributor.name}`}> | |
| 229 | − | <Hint label={label}> | |
| 230 | − | {href ? ( | |
| 231 | − | <Link to={href} aria-label={label} className="block rounded-full"> | |
| 232 | − | {face} | |
| 233 | − | </Link> | |
| 234 | − | ) : ( | |
| 235 | − | <span aria-label={label} className="block rounded-full"> | |
| 236 | − | {face} | |
| 233 | + | <li key={`${contributor.kind}:${contributor.username ?? contributor.name}`}> | |
| 234 | + | {person.kind === "user" || person.kind === "g1t" ? ( | |
| 235 | + | <UserCard username={person.username}> | |
| 236 | + | {href ? ( | |
| 237 | + | <Link to={href} aria-label={label} className="block rounded-full"> | |
| 238 | + | {face} | |
| 239 | + | </Link> | |
| 240 | + | ) : ( | |
| 241 | + | <span tabIndex={0} aria-label={label} className="block rounded-full outline-none focus-visible:ring-2 focus-visible:ring-accent"> | |
| 242 | + | {face} | |
| 243 | + | </span> | |
| 244 | + | )} | |
| 245 | + | </UserCard> | |
| 246 | + | ) : ( | |
| 247 | + | // Nobody on g1t: no card, only the name on their commits. | |
| 248 | + | <Hint label={label}> | |
| 249 | + | <span tabIndex={0} aria-label={label} className="block rounded-full"> | |
| 250 | + | <CommitAvatar person={person} size={30} /> | |
| 237 | 251 | </span> | |
| 238 | − | )} | |
| 239 | − | </Hint> | |
| 252 | + | </Hint> | |
| 253 | + | )} | |
| 240 | 254 | </li> | |
| 241 | 255 | ); | |
| 242 | 256 | })} | |
| 2 | 2 | import { type ReactNode, Suspense } from "react"; | |
| 3 | 3 | import { Await, Form, Link } from "react-router"; | |
| 4 | 4 | ||
| 5 | − | import type { Blame, BlobView as Blob, Branch, Commit, LastCommits, TreeView as Tree } from "@g1t/contracts"; | |
| 5 | + | import type { BlobView as Blob, Branch, TreeView as RawTree } from "@g1t/contracts"; | |
| 6 | + | ||
| 7 | + | import type { FileCommits, ShownBlame, ShownCommit } from "../lib/commit-people"; | |
| 8 | + | import { CommitAvatars, CommitNames } from "./commit-person"; | |
| 6 | 9 | ||
| 7 | 10 | import type { DeploymentEnvironments } from "@g1t/contracts"; | |
| 8 | 11 | ||
| ⋯ | |||
| 16 | 19 | import { type ChecksSource, CommitChecksBadge } from "./commit-checks"; | |
| 17 | 20 | import { type AboutData, RepoAboutPanel } from "./repo-about"; | |
| 18 | 21 | import { Markdown } from "./markdown"; | |
| 19 | − | import { Avatar, CopyLine, TimeAgo, notACredential } from "./ui"; | |
| 22 | + | import { CopyLine, TimeAgo, notACredential } from "./ui"; | |
| 20 | 23 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from "./ui/dropdown-menu"; | |
| 21 | 24 | import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; | |
| 22 | 25 | import { Hint } from "./ui/hint"; | |
| ⋯ | |||
| 68 | 71 | } | |
| 69 | 72 | ||
| 70 | 73 | /** The bar above a file listing: the latest commit, its checks, and the way to the history. */ | |
| 71 | − | function CommitBar({ commit, base, checks }: { commit: Commit; base: string; checks?: ChecksSource }) { | |
| 74 | + | /** A tree as the page gets it: its latest commit with its people. */ | |
| 75 | + | export type Tree = Omit<RawTree, "head"> & { head: ShownCommit | null }; | |
| 76 | + | ||
| 77 | + | function CommitBar({ commit, base, checks }: { commit: ShownCommit; base: string; checks?: ChecksSource }) { | |
| 72 | 78 | return ( | |
| 73 | 79 | <div className="flex items-center gap-3 border-b border-line bg-surface px-4 py-2.5 text-sm"> | |
| 74 | − | <Avatar name={commit.author.name} /> | |
| 75 | − | <span className="shrink-0 font-medium">{commit.author.name}</span> | |
| 80 | + | <CommitAvatars commit={commit} /> | |
| 81 | + | <span className="shrink-0 font-medium"> | |
| 82 | + | <CommitNames commit={commit} /> | |
| 83 | + | </span> | |
| 76 | 84 | <Link to={`${base}/commit/${commit.hash}`} className="truncate text-muted hover:text-fg hover:underline"> | |
| 77 | 85 | {commit.message.split("\n")[0]} | |
| 78 | 86 | </Link> | |
| ⋯ | |||
| 241 | 249 | gitRef: string; | |
| 242 | 250 | prefix: string; | |
| 243 | 251 | entries: Tree["entries"]; | |
| 244 | − | last: LastCommits | null | undefined; | |
| 252 | + | last: FileCommits | null | undefined; | |
| 245 | 253 | }) { | |
| 246 | 254 | const byName = new Map((last?.entries ?? []).map((entry) => [entry.name, entry.commit])); | |
| 247 | 255 | return ( | |
| ⋯ | |||
| 294 | 302 | tree: Tree; | |
| 295 | 303 | branches?: Branch[] | null; | |
| 296 | 304 | /** Each entry's last commit, streamed in after the list. */ | |
| 297 | − | lastCommits?: Promise<LastCommits | null> | null; | |
| 305 | + | lastCommits?: Promise<FileCommits | null> | null; | |
| 298 | 306 | /** The latest commit's checks, streamed in. */ | |
| 299 | 307 | checks?: ChecksSource; | |
| 300 | 308 | /** The About beside the files, at the root: streamed in after them. */ | |
| ⋯ | |||
| 406 | 414 | /** Syntax-highlighted HTML per line, when the language is known. */ | |
| 407 | 415 | html: string[] | null; | |
| 408 | 416 | /** Shown instead of the plain file when asked for, with HTML per line. */ | |
| 409 | − | blame?: { blame: Blame; lines: string[] | null } | null; | |
| 417 | + | blame?: { blame: ShownBlame; lines: string[] | null } | null; | |
| 410 | 418 | }) { | |
| 411 | 419 | const { repo, ref, path, size, text } = blob; | |
| 412 | 420 | const lines = text?.replace(/\n$/, "").split("\n"); | |
| 1 | + | import { HoverCard as Primitive } from "radix-ui"; | |
| 2 | + | import type { ComponentProps } from "react"; | |
| 3 | + | ||
| 4 | + | import { cn } from "../../lib/cn"; | |
| 5 | + | ||
| 6 | + | // shadcn/ui's hover card, styled with g1t's tokens. It opens on hover and | |
| 7 | + | // keyboard focus, never on touch: a tap on its trigger does what the | |
| 8 | + | // trigger does. Hover hints (a label, nothing more) use the Tooltip. | |
| 9 | + | ||
| 10 | + | export function HoverCard({ openDelay = 450, closeDelay = 150, ...props }: ComponentProps<typeof Primitive.Root>) { | |
| 11 | + | return <Primitive.Root openDelay={openDelay} closeDelay={closeDelay} {...props} />; | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | export const HoverCardTrigger = Primitive.Trigger; | |
| 15 | + | ||
| 16 | + | export function HoverCardContent({ | |
| 17 | + | className, | |
| 18 | + | align = "start", | |
| 19 | + | sideOffset = 6, | |
| 20 | + | ...props | |
| 21 | + | }: ComponentProps<typeof Primitive.Content>) { | |
| 22 | + | return ( | |
| 23 | + | <Primitive.Portal> | |
| 24 | + | <Primitive.Content | |
| 25 | + | align={align} | |
| 26 | + | sideOffset={sideOffset} | |
| 27 | + | collisionPadding={12} | |
| 28 | + | className={cn( | |
| 29 | + | "z-50 w-80 max-w-[calc(100vw-2rem)] rounded-xl border border-line-strong bg-raised p-4 text-sm text-fg shadow-xl shadow-black/40 outline-none", | |
| 30 | + | "origin-(--radix-hover-card-content-transform-origin) data-[state=open]:animate-pop-in data-[state=closed]:animate-pop-out motion-reduce:animate-none", | |
| 31 | + | className, | |
| 32 | + | )} | |
| 33 | + | {...props} | |
| 34 | + | /> | |
| 35 | + | </Primitive.Portal> | |
| 36 | + | ); | |
| 37 | + | } |
| 1 | − | import { Check, Copy, LoaderCircle } from "lucide-react"; | |
| 1 | + | import { Check, Copy, LoaderCircle, User } from "lucide-react"; | |
| 2 | 2 | import { type ComponentProps, type ReactNode, useState } from "react"; | |
| 3 | 3 | import { Link, type LinkProps, NavLink, useLocation, useNavigation } from "react-router"; | |
| 4 | 4 | ||
| ⋯ | |||
| 316 | 316 | </span> | |
| 317 | 317 | ); | |
| 318 | 318 | } | |
| 319 | + | // ghost stands in for deleted accounts: a plain silhouette, as for | |
| 320 | + | // anyone on a commit who has no account. | |
| 321 | + | if (name === "ghost" && !image) { | |
| 322 | + | return ( | |
| 323 | + | <span | |
| 324 | + | aria-hidden="true" | |
| 325 | + | className="inline-flex shrink-0 items-center justify-center rounded-full bg-line text-faint" | |
| 326 | + | style={{ width: size, height: size }} | |
| 327 | + | > | |
| 328 | + | <User size={Math.round(size * 0.62)} strokeWidth={2.25} /> | |
| 329 | + | </span> | |
| 330 | + | ); | |
| 331 | + | } | |
| 319 | 332 | if (image && failed !== image) { | |
| 320 | 333 | return ( | |
| 321 | 334 | <img | |
| 1 | + | import { Building2, GitCommitHorizontal, MapPin } from "lucide-react"; | |
| 2 | + | import { type ReactElement, type ReactNode, useEffect, useState } from "react"; | |
| 3 | + | import { Link, useParams } from "react-router"; | |
| 4 | + | ||
| 5 | + | import { type Card, type UserCard as UserCardData, cardHref, committedLabel } from "../lib/hovercard"; | |
| 6 | + | import { G1T_MENTION_HREF } from "../lib/markdown-plugins"; | |
| 7 | + | import { Avatar } from "./ui"; | |
| 8 | + | import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card"; | |
| 9 | + | import { Skeleton } from "./ui/skeleton"; | |
| 10 | + | ||
| 11 | + | /** | |
| 12 | + | * Cards read this visit, by address: each is asked for once, whichever | |
| 13 | + | * name or avatar opened it. A failure is forgotten, so the next hover asks | |
| 14 | + | * again. | |
| 15 | + | */ | |
| 16 | + | const cards = new Map<string, Promise<Card | null>>(); | |
| 17 | + | ||
| 18 | + | function readCard(href: string): Promise<Card | null> { | |
| 19 | + | let found = cards.get(href); | |
| 20 | + | if (!found) { | |
| 21 | + | found = fetch(href, { headers: { accept: "application/json" } }) | |
| 22 | + | .then((response) => (response.ok ? (response.json() as Promise<Card>) : response.status === 404 ? null : Promise.reject(new Error(String(response.status))))) | |
| 23 | + | .catch(() => { | |
| 24 | + | cards.delete(href); | |
| 25 | + | return null; | |
| 26 | + | }); | |
| 27 | + | cards.set(href, found); | |
| 28 | + | } | |
| 29 | + | return found; | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | /** Whether a name can have a card: a username, and not a deleted account's. */ | |
| 33 | + | export function hasCard(username: string | null | undefined): username is string { | |
| 34 | + | return Boolean(username) && username !== "ghost" && /^[a-z0-9-]{1,39}$/i.test(username!); | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | /** | |
| 38 | + | * A person's card, over their name or avatar (`children`, one element that | |
| 39 | + | * takes a ref). It opens on hover and on keyboard focus after a moment, | |
| 40 | + | * and is read only then. On a touch screen it never opens: a tap does what | |
| 41 | + | * the name does. Inside a repository it says whether they committed there | |
| 42 | + | * lately. Ghost, a deleted account, has none. | |
| 43 | + | */ | |
| 44 | + | export function UserCard({ username, children }: { username: string | null | undefined; children: ReactElement }) { | |
| 45 | + | const params = useParams(); | |
| 46 | + | const [open, setOpen] = useState(false); | |
| 47 | + | const [card, setCard] = useState<Card | null | undefined>(undefined); | |
| 48 | + | const name = username?.toLowerCase() ?? ""; | |
| 49 | + | const repo = params.owner && params.repo ? `${params.owner}/${params.repo}` : null; | |
| 50 | + | const g1t = name === "g1t"; | |
| 51 | + | const href = cardHref(name, repo); | |
| 52 | + | ||
| 53 | + | useEffect(() => { | |
| 54 | + | if (!open || g1t || !hasCard(name)) return; | |
| 55 | + | let current = true; | |
| 56 | + | void readCard(href).then((found) => { | |
| 57 | + | if (current) setCard(found); | |
| 58 | + | }); | |
| 59 | + | return () => { | |
| 60 | + | current = false; | |
| 61 | + | }; | |
| 62 | + | }, [open, g1t, name, href]); | |
| 63 | + | ||
| 64 | + | if (!hasCard(name)) return children; | |
| 65 | + | // Nobody by that name (a workspace mentioned, say): no card at all. | |
| 66 | + | const body = g1t || card?.kind === "g1t" ? <G1tCard /> : card === undefined ? <Loading /> : card ? <PersonCard card={card} /> : null; | |
| 67 | + | return ( | |
| 68 | + | <HoverCard open={open} onOpenChange={setOpen}> | |
| 69 | + | <HoverCardTrigger asChild>{children}</HoverCardTrigger> | |
| 70 | + | {body && <HoverCardContent aria-label={g1t ? "About g1t" : `About ${name}`}>{body}</HoverCardContent>} | |
| 71 | + | </HoverCard> | |
| 72 | + | ); | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | function Loading() { | |
| 76 | + | return ( | |
| 77 | + | <div aria-busy="true" className="flex items-center gap-3"> | |
| 78 | + | <Skeleton className="size-12 rounded-full" /> | |
| 79 | + | <div className="grow space-y-2"> | |
| 80 | + | <Skeleton className="block h-3.5 w-28" /> | |
| 81 | + | <Skeleton className="block h-3 w-20" /> | |
| 82 | + | </div> | |
| 83 | + | </div> | |
| 84 | + | ); | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | function PersonCard({ card }: { card: UserCardData }) { | |
| 88 | + | const profile = `/u/${card.username}`; | |
| 89 | + | return ( | |
| 90 | + | <div className="space-y-3"> | |
| 91 | + | <div className="flex items-start gap-3"> | |
| 92 | + | <Link to={profile} tabIndex={-1} className="shrink-0 rounded-full"> | |
| 93 | + | <Avatar name={card.username} image={card.avatar} size={48} /> | |
| 94 | + | </Link> | |
| 95 | + | <div className="min-w-0 pt-0.5"> | |
| 96 | + | <Link to={profile} className="block leading-tight hover:underline"> | |
| 97 | + | {card.name ? ( | |
| 98 | + | <> | |
| 99 | + | <span className="font-semibold wrap-anywhere">{card.name}</span>{" "} | |
| 100 | + | <span className="font-mono text-[0.8125rem] text-muted">{card.username}</span> | |
| 101 | + | </> | |
| 102 | + | ) : ( | |
| 103 | + | <span className="font-mono font-semibold">{card.username}</span> | |
| 104 | + | )} | |
| 105 | + | </Link> | |
| 106 | + | {card.pronouns && <p className="mt-0.5 text-xs text-faint">{card.pronouns}</p>} | |
| 107 | + | </div> | |
| 108 | + | </div> | |
| 109 | + | {card.bio && <p className="leading-relaxed text-fg/90 wrap-anywhere">{card.bio}</p>} | |
| 110 | + | {(card.location || card.workspaces.length > 0 || card.committed) && ( | |
| 111 | + | <ul className="space-y-1.5 text-[0.8125rem] text-muted"> | |
| 112 | + | {card.location && ( | |
| 113 | + | <Line icon={<MapPin size={14} />}> | |
| 114 | + | <span className="wrap-anywhere">{card.location}</span> | |
| 115 | + | </Line> | |
| 116 | + | )} | |
| 117 | + | {card.workspaces.length > 0 && ( | |
| 118 | + | <Line icon={<Building2 size={14} />}> | |
| 119 | + | Member of{" "} | |
| 120 | + | {card.workspaces.map((workspace, index) => ( | |
| 121 | + | <span key={workspace.slug}> | |
| 122 | + | {index > 0 && ", "} | |
| 123 | + | <Link to={`/${workspace.slug}`} className="font-medium text-fg hover:underline"> | |
| 124 | + | {workspace.name} | |
| 125 | + | </Link> | |
| 126 | + | </span> | |
| 127 | + | ))} | |
| 128 | + | {card.more_workspaces > 0 && ` and ${card.more_workspaces} more`} | |
| 129 | + | </Line> | |
| 130 | + | )} | |
| 131 | + | {card.committed && <Line icon={<GitCommitHorizontal size={14} />}>{committedLabel(card.committed)}</Line>} | |
| 132 | + | </ul> | |
| 133 | + | )} | |
| 134 | + | </div> | |
| 135 | + | ); | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | function G1tCard() { | |
| 139 | + | return ( | |
| 140 | + | <div className="space-y-3"> | |
| 141 | + | <div className="flex items-center gap-3"> | |
| 142 | + | <Avatar name="g1t" size={48} /> | |
| 143 | + | <div className="min-w-0"> | |
| 144 | + | <p className="flex items-baseline gap-1.5 leading-tight"> | |
| 145 | + | <span className="font-mono font-semibold">g1t</span> | |
| 146 | + | <span className="rounded border border-line px-1 text-[0.625rem] leading-[1.35] font-medium text-muted">bot</span> | |
| 147 | + | </p> | |
| 148 | + | <p className="mt-0.5 text-xs text-muted">g1t's agent</p> | |
| 149 | + | </div> | |
| 150 | + | </div> | |
| 151 | + | <p className="leading-relaxed text-fg/90"> | |
| 152 | + | Mention <span className="font-medium text-accent">@g1t</span> in a comment or assign it an issue, and it opens a | |
| 153 | + | pull request and sees it through. | |
| 154 | + | </p> | |
| 155 | + | <a href={G1T_MENTION_HREF} className="inline-block text-[0.8125rem] text-accent hover:underline"> | |
| 156 | + | Working with g1t | |
| 157 | + | </a> | |
| 158 | + | </div> | |
| 159 | + | ); | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | function Line({ icon, children }: { icon: ReactElement; children: ReactNode }) { | |
| 163 | + | return ( | |
| 164 | + | <li className="flex items-start gap-2"> | |
| 165 | + | <span className="mt-0.5 shrink-0 text-faint">{icon}</span> | |
| 166 | + | <span className="min-w-0">{children}</span> | |
| 167 | + | </li> | |
| 168 | + | ); | |
| 169 | + | } |
| 24 | 24 | } from "./ui/alert-dialog"; | |
| 25 | 25 | import { CheckboxOption } from "./ui/checkbox"; | |
| 26 | 26 | import { Hint } from "./ui/hint"; | |
| 27 | + | import { UserCard } from "./user-card"; | |
| 27 | 28 | ||
| 28 | 29 | export { IssueIcon, PullIcon }; | |
| 29 | 30 | ||
| ⋯ | |||
| 203 | 204 | label?: string; | |
| 204 | 205 | children?: ReactNode; | |
| 205 | 206 | }) { | |
| 206 | − | // g1t itself has no profile; its name carries a small label instead. | |
| 207 | + | // g1t itself has no profile; its name carries a small label instead, and | |
| 208 | + | // its card says what it is. | |
| 207 | 209 | if (name === "g1t" && !children) { | |
| 208 | 210 | return ( | |
| 209 | − | <span className="inline-flex items-baseline gap-1"> | |
| 210 | − | <span className={className}>g1t</span> | |
| 211 | − | <span className="rounded border border-line px-1 text-[0.625rem] leading-[1.35] font-medium text-muted">bot</span> | |
| 212 | − | </span> | |
| 211 | + | <UserCard username="g1t"> | |
| 212 | + | <span tabIndex={0} className="inline-flex items-baseline gap-1 rounded outline-none focus-visible:ring-2 focus-visible:ring-accent"> | |
| 213 | + | <span className={className}>g1t</span> | |
| 214 | + | <span className="rounded border border-line px-1 text-[0.625rem] leading-[1.35] font-medium text-muted">bot</span> | |
| 215 | + | </span> | |
| 216 | + | </UserCard> | |
| 217 | + | ); | |
| 218 | + | } | |
| 219 | + | if (name === "g1t") { | |
| 220 | + | return ( | |
| 221 | + | <UserCard username="g1t"> | |
| 222 | + | <span className={className}>{children}</span> | |
| 223 | + | </UserCard> | |
| 213 | 224 | ); | |
| 214 | 225 | } | |
| 215 | − | if (name === "g1t" || !/^[a-z0-9-]{1,39}$/i.test(name)) { | |
| 226 | + | // Ghost, a deleted account, has no profile and no card. | |
| 227 | + | if (name === "ghost" || !/^[a-z0-9-]{1,39}$/i.test(name)) { | |
| 216 | 228 | return <span className={className}>{children ?? name}</span>; | |
| 217 | 229 | } | |
| 218 | 230 | return ( | |
| 219 | − | <Link to={`/u/${name.toLowerCase()}`} className={className} aria-label={label}> | |
| 220 | − | {children ?? name} | |
| 221 | − | </Link> | |
| 231 | + | <UserCard username={name}> | |
| 232 | + | <Link to={`/u/${name.toLowerCase()}`} className={className} aria-label={label}> | |
| 233 | + | {children ?? name} | |
| 234 | + | </Link> | |
| 235 | + | </UserCard> | |
| 222 | 236 | ); | |
| 223 | 237 | } | |
| 224 | 238 | ||
| 3 | 3 | ||
| 4 | 4 | import type { G1tEvent } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | − | import { activityLine, alignWeeks, contributorHref, count, languageBar, licenseLabel, peak } from "./about.ts"; | |
| 6 | + | import { activityLine, alignWeeks, count, languageBar, licenseLabel, peak } from "./about.ts"; | |
| 7 | 7 | ||
| 8 | 8 | test("a license is named by its SPDX id, or not at all", () => { | |
| 9 | 9 | assert.equal(licenseLabel({ spdxId: "MIT", name: "MIT License", path: "LICENSE" }), "MIT license"); | |
| ⋯ | |||
| 30 | 30 | assert.equal(bar[2].percent, 0.5); | |
| 31 | 31 | assert.deepEqual(languageBar([rust, { ...nix, percent: 0 }]), [rust], "nothing to show for nothing"); | |
| 32 | 32 | assert.deepEqual(languageBar([]), []); | |
| 33 | − | }); | |
| 34 | − | ||
| 35 | − | test("only accounts lead to a profile", () => { | |
| 36 | − | const base = { name: "ada", commits: 1, firstAt: "", lastAt: "", weeks: [] }; | |
| 37 | − | assert.equal(contributorHref({ ...base, kind: "user", username: "ada" }), "/u/ada"); | |
| 38 | − | assert.equal(contributorHref({ ...base, kind: "author" }), null); | |
| 39 | − | assert.equal(contributorHref({ ...base, kind: "g1t" }), null); | |
| 40 | 33 | }); | |
| 41 | 34 | ||
| 42 | 35 | test("weeks line up with the repository's", () => { | |
| 3 | 3 | * pages say, worked out without the server: labels, the language bar, | |
| 4 | 4 | * and an activity line for each event worth one. | |
| 5 | 5 | */ | |
| 6 | − | import type { Contributor, G1tEvent, LanguageShare, License, Project, WeekCommits } from "@g1t/contracts"; | |
| 6 | + | import type { G1tEvent, LanguageShare, License, Project, WeekCommits } from "@g1t/contracts"; | |
| 7 | 7 | ||
| 8 | 8 | /** | |
| 9 | 9 | * The address the About links to: the project's homepage (its own, or its | |
| ⋯ | |||
| 41 | 41 | return [...shown, { name: "Other", color: null, bytes, percent }]; | |
| 42 | 42 | } | |
| 43 | 43 | ||
| 44 | − | /** Where a contributor's name leads: their profile, for an account. */ | |
| 45 | − | export function contributorHref(contributor: Contributor): string | null { | |
| 46 | − | return contributor.kind === "user" && contributor.username ? `/u/${contributor.username}` : null; | |
| 47 | − | } | |
| 48 | 44 | ||
| 49 | 45 | /** The tallest week, for a chart's scale: never zero. */ | |
| 50 | 46 | export function peak(weeks: WeekCommits[]): number { | |
| 4 | 4 | * it with its checks, and its preview. The overview shows the newest few; | |
| 5 | 5 | * the Branches page shows them all. | |
| 6 | 6 | */ | |
| 7 | − | import type { Branch, BranchDrifts, Pull, RepoPath, Viewer } from "@g1t/contracts"; | |
| 7 | + | import type { Branch, BranchDrifts, Commit, Pull, RepoPath, Viewer } from "@g1t/contracts"; | |
| 8 | 8 | ||
| 9 | 9 | import type { ActiveBranch } from "../components/branches"; | |
| 10 | 10 | import { activeBranches, branchesToRead, summary } from "./branches"; | |
| 11 | + | import { addressesToMatch, showCommit } from "./commit-people"; | |
| 12 | + | import { emailOwners } from "./commit-people.server"; | |
| 11 | 13 | import { repos } from "./services.server"; | |
| 12 | 14 | ||
| 13 | 15 | type Preview = { branch?: string | null; number?: number | null; url: string }; | |
| ⋯ | |||
| 37 | 39 | .then((found) => (found.ok ? found.value : null)) | |
| 38 | 40 | .catch(() => null) | |
| 39 | 41 | : null; | |
| 40 | − | return { main: input.defaultBranch, total, shown: activeBranches(reading, measured, input), head: summary(measured?.base) }; | |
| 42 | + | // Every head commit's people, in one identity call. | |
| 43 | + | const heads = [measured?.base, ...(measured?.branches ?? []).map((one) => one.commit)].filter((commit): commit is Commit => commit != null); | |
| 44 | + | const owners = await emailOwners(addressesToMatch(heads)); | |
| 45 | + | const people = (commit: Commit) => showCommit(commit, owners); | |
| 46 | + | return { main: input.defaultBranch, total, shown: activeBranches(reading, measured, input, people), head: summary(measured?.base, people) }; | |
| 41 | 47 | } | |
| 47 | 47 | assert.deepEqual(shown.map((b) => b.name), ["idea", "fix", "old"]); | |
| 48 | 48 | assert.deepEqual(shown[1], { | |
| 49 | 49 | name: "fix", | |
| 50 | − | commit: { hash: "f2", message: "f2", author: "Ada", at: "2026-10-07T00:00:00Z" }, | |
| 50 | + | // No accounts given: the name on the commit, and never its address. | |
| 51 | + | commit: { hash: "f2", message: "f2", author: { kind: "author", name: "Ada", username: null, avatar: null }, coAuthors: [], at: "2026-10-07T00:00:00Z" }, | |
| 51 | 52 | drift: { ahead: 2, behind: 1 }, | |
| 52 | 53 | pull: { number: 7, title: "Fix it", checkStatus: "passed", draft: false }, | |
| 53 | 54 | preview: "https://fix.g1t.page", |
| 6 | 6 | import type { Branch, BranchDrifts, Commit, Pull } from "@g1t/contracts"; | |
| 7 | 7 | ||
| 8 | 8 | import type { ActiveBranch } from "../components/branches"; | |
| 9 | + | import type { ShownCommit } from "./commit-people"; | |
| 9 | 10 | ||
| 10 | 11 | /** | |
| 11 | 12 | * How far a branch has moved from the default branch: commits it has that | |
| ⋯ | |||
| 17 | 18 | ||
| 18 | 19 | type Preview = { branch?: string | null; number?: number | null; url: string }; | |
| 19 | 20 | ||
| 20 | − | /** A commit as a branch row shows it: its first line. */ | |
| 21 | − | export const summary = (commit: Commit | null | undefined): ActiveBranch["commit"] => | |
| 22 | − | commit ? { hash: commit.hash, message: commit.message.split("\n")[0] ?? "", author: commit.author.name, at: commit.authoredAt } : null; | |
| 21 | + | /** A commit's people, as lib/commit-people.ts `showCommit` finds them. */ | |
| 22 | + | export type People = (commit: Commit) => Pick<ShownCommit, "author" | "coAuthors">; | |
| 23 | 23 | ||
| 24 | + | /** Nobody matched to an account: the name on the commit. */ | |
| 25 | + | const byName: People = (commit) => ({ | |
| 26 | + | author: { kind: "author", name: commit.author.name, username: null, avatar: null }, | |
| 27 | + | coAuthors: [], | |
| 28 | + | }); | |
| 29 | + | ||
| 24 | 30 | /** | |
| 31 | + | * A commit as a branch row shows it: its first line, and its people as | |
| 32 | + | * `people` finds them (lib/branches.server.ts passes the accounts). | |
| 33 | + | */ | |
| 34 | + | export function summary(commit: Commit | null | undefined, people: People = byName): ActiveBranch["commit"] { | |
| 35 | + | if (!commit) return null; | |
| 36 | + | const shown = people(commit); | |
| 37 | + | return { hash: commit.hash, message: commit.message.split("\n")[0] ?? "", author: shown.author, coAuthors: shown.coAuthors, at: commit.authoredAt }; | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | /** | |
| 25 | 41 | * The branches other than the default that are read (at most `read`, those | |
| 26 | 42 | * with an open pull request first), how many there are, and the default | |
| 27 | 43 | * branch's head commit. | |
| ⋯ | |||
| 46 | 62 | reading: Branch[], | |
| 47 | 63 | measured: BranchDrifts | null, | |
| 48 | 64 | input: { pulls: Pick<Pull, "branch" | "number" | "title" | "checkStatus" | "status">[]; previews: Preview[] }, | |
| 65 | + | people: People = byName, | |
| 49 | 66 | ): ActiveBranch[] { | |
| 50 | 67 | const pullOn = new Map(input.pulls.flatMap((pull) => (pull.branch ? [[pull.branch, pull] as const] : []))); | |
| 51 | 68 | const byHead = new Map((measured?.branches ?? []).map((found) => [found.head, found])); | |
| ⋯ | |||
| 55 | 72 | const found = byHead.get(branch.hash); | |
| 56 | 73 | return { | |
| 57 | 74 | name: branch.name, | |
| 58 | − | commit: summary(found?.commit), | |
| 75 | + | commit: summary(found?.commit, people), | |
| 59 | 76 | drift: found?.drift ?? null, | |
| 60 | 77 | pull: pull ? { number: pull.number, title: pull.title, checkStatus: pull.checkStatus, draft: pull.status === "draft" } : null, | |
| 61 | 78 | preview: input.previews.find((app) => app.branch === branch.name || (pull != null && app.number === pull.number))?.url ?? null, | |
| 20 | 20 | assert.deepEqual(parsed.trailers, [{ key: "G1t-Pull", value: "syntaqx/hello#72" }]); | |
| 21 | 21 | }); | |
| 22 | 22 | ||
| 23 | + | test("a trailer shows its name, never an address", () => { | |
| 24 | + | const parsed = parseCommitMessage("Fix it\n\nSigned-off-by: Ada <ada@private.example>\nReviewed-by: <sam@x.io>"); | |
| 25 | + | assert.deepEqual(parsed.trailers, [{ key: "Signed-off-by", value: "Ada" }]); | |
| 26 | + | }); | |
| 27 | + | ||
| 23 | 28 | test("prose that happens to contain a colon stays prose", () => { | |
| 24 | 29 | const parsed = parseCommitMessage("Fix it\n\nNote: this also renames a function.\nAnd tidies the tests."); | |
| 25 | 30 | assert.equal(parsed.body, "Note: this also renames a function.\nAnd tidies the tests."); |
| 8 | 8 | body: string; | |
| 9 | 9 | /** The names in `Co-Authored-By` trailers, without their addresses. */ | |
| 10 | 10 | coAuthors: string[]; | |
| 11 | − | /** Every other trailer, as written. */ | |
| 11 | + | /** Every other trailer, as written but for any email address in it. */ | |
| 12 | 12 | trailers: { key: string; value: string }[]; | |
| 13 | 13 | }; | |
| 14 | 14 | ||
| ⋯ | |||
| 28 | 28 | for (const line of last) { | |
| 29 | 29 | const [, key, value] = TRAILER.exec(line.trim())!; | |
| 30 | 30 | if (key.toLowerCase() === "co-authored-by") coAuthors.push(value.replace(/\s*<[^>]*>\s*$/, "").trim()); | |
| 31 | − | else trailers.push({ key, value }); | |
| 31 | + | else { | |
| 32 | + | // `Signed-off-by: Ada <ada@example.com>` shows Ada: g1t never shows an address. | |
| 33 | + | const shown = value.replace(/\s*<[^<>]*@[^<>]*>/g, "").trim(); | |
| 34 | + | if (shown) trailers.push({ key, value: shown }); | |
| 35 | + | } | |
| 32 | 36 | } | |
| 33 | 37 | } | |
| 34 | 38 | return { subject, body: prose.join("\n").trim(), coAuthors, trailers }; | |
| 1 | + | /** | |
| 2 | + | * The one place commits get their people (lib/commit-people.ts): every | |
| 3 | + | * page that shows a commit's author asks here, once for all its commits. | |
| 4 | + | * One identity call per page for the addresses not already known, and | |
| 5 | + | * each answer kept a minute in the isolate, so a page of fifty commits by | |
| 6 | + | * three people asks about three addresses, and the next page asks about | |
| 7 | + | * none. A failure matches nobody: the names on the commits are shown. | |
| 8 | + | */ | |
| 9 | + | import type { Commit, EmailOwner } from "@g1t/contracts"; | |
| 10 | + | ||
| 11 | + | import { type ShownCommit, addressesToMatch, showCommit } from "./commit-people"; | |
| 12 | + | import { accounts } from "./services.server"; | |
| 13 | + | ||
| 14 | + | /** How long an answer is kept: an address can be confirmed or removed. */ | |
| 15 | + | const KEEP_MS = 60_000; | |
| 16 | + | /** Addresses kept at most; the oldest go first. */ | |
| 17 | + | const MAX_KEPT = 5_000; | |
| 18 | + | /** `email_owners` takes at most this many at once. */ | |
| 19 | + | const BATCH = 200; | |
| 20 | + | ||
| 21 | + | /** Each address's owner, or null for nobody, until `until`. */ | |
| 22 | + | const kept = new Map<string, { until: number; owner: EmailOwner | null }>(); | |
| 23 | + | ||
| 24 | + | function keep(email: string, owner: EmailOwner | null) { | |
| 25 | + | kept.delete(email); | |
| 26 | + | kept.set(email, { until: Date.now() + KEEP_MS, owner }); | |
| 27 | + | if (kept.size > MAX_KEPT) kept.delete(kept.keys().next().value as string); | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | /** The accounts behind these (lowercased) addresses; unknown ones are left out. */ | |
| 31 | + | export async function emailOwners(emails: string[]): Promise<Record<string, EmailOwner>> { | |
| 32 | + | const owners: Record<string, EmailOwner> = {}; | |
| 33 | + | const ask: string[] = []; | |
| 34 | + | const now = Date.now(); | |
| 35 | + | for (const email of new Set(emails)) { | |
| 36 | + | const hit = kept.get(email); | |
| 37 | + | if (hit && hit.until > now) { | |
| 38 | + | if (hit.owner) owners[email] = hit.owner; | |
| 39 | + | } else { | |
| 40 | + | ask.push(email); | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | for (let start = 0; start < ask.length; start += BATCH) { | |
| 44 | + | const chunk = ask.slice(start, start + BATCH); | |
| 45 | + | const found = await accounts.emailOwners(chunk).catch(() => null); | |
| 46 | + | if (!found) continue; | |
| 47 | + | for (const email of chunk) { | |
| 48 | + | const owner = found[email] ?? null; | |
| 49 | + | keep(email, owner); | |
| 50 | + | if (owner) owners[email] = owner; | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | return owners; | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | /** Commits with their people, in one identity call at most. */ | |
| 57 | + | export async function showCommits(commits: Commit[]): Promise<ShownCommit[]> { | |
| 58 | + | if (commits.length === 0) return []; | |
| 59 | + | const owners = await emailOwners(addressesToMatch(commits)); | |
| 60 | + | return commits.map((commit) => showCommit(commit, owners)); | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | /** One commit, or null, with its people. */ | |
| 64 | + | export async function showOneCommit(commit: Commit | null | undefined): Promise<ShownCommit | null> { | |
| 65 | + | return commit ? (await showCommits([commit]))[0]! : null; | |
| 66 | + | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import type { Commit, EmailOwner } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import { addressesToMatch, coAuthorsOf, contributorPerson, personFor, profileHref, showCommit, shownName } from "./commit-people.ts"; | |
| 7 | + | ||
| 8 | + | const syntaqx: EmailOwner = { id: "usr_01hx", username: "syntaqx", avatar: "f00d" }; | |
| 9 | + | ||
| 10 | + | /** | |
| 11 | + | * What `email_owners` answers (services/identity/src/emails.rs): confirmed | |
| 12 | + | * addresses and noreply addresses only, so an unconfirmed address, or a | |
| 13 | + | * noreply address whose id does not match, is simply missing. | |
| 14 | + | */ | |
| 15 | + | const owners: Record<string, EmailOwner> = { | |
| 16 | + | "syntaqx@gmail.com": syntaqx, | |
| 17 | + | "01hx0000+syntaqx@users.noreply.g1t.sh": syntaqx, | |
| 18 | + | "1234abcd+gone@users.noreply.g1t.sh": { id: "usr_ghost", username: "ghost", avatar: null }, | |
| 19 | + | }; | |
| 20 | + | ||
| 21 | + | function commit(name: string, email: string, message = "Change something"): Commit { | |
| 22 | + | return { hash: "a".repeat(40), treeHash: "b".repeat(40), message, author: { name, email }, parents: [], authoredAt: "2026-10-08T12:00:00Z" }; | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | test("a confirmed address is the account, whatever name the commit carries", () => { | |
| 26 | + | const person = personFor("Chase Pierce", "Syntaqx@Gmail.com ", owners); | |
| 27 | + | assert.deepEqual(person, { kind: "user", name: "Chase Pierce", username: "syntaqx", avatar: "f00d" }); | |
| 28 | + | assert.equal(shownName(person), "syntaqx"); | |
| 29 | + | assert.equal(profileHref(person), "/u/syntaqx"); | |
| 30 | + | }); | |
| 31 | + | ||
| 32 | + | test("an unconfirmed address matches nobody: the name on the commit, no link", () => { | |
| 33 | + | // email_owners leaves unconfirmed addresses out of its answer. | |
| 34 | + | const person = personFor("Chase Pierce", "unconfirmed@example.com", owners); | |
| 35 | + | assert.deepEqual(person, { kind: "author", name: "Chase Pierce", username: null, avatar: null }); | |
| 36 | + | assert.equal(shownName(person), "Chase Pierce"); | |
| 37 | + | assert.equal(profileHref(person), null); | |
| 38 | + | }); | |
| 39 | + | ||
| 40 | + | test("the noreply address is the account", () => { | |
| 41 | + | assert.equal(personFor("syntaqx", "01hx0000+syntaqx@users.noreply.g1t.sh", owners).username, "syntaqx"); | |
| 42 | + | }); | |
| 43 | + | ||
| 44 | + | test("a deleted account's commits are ghost's, with no profile", () => { | |
| 45 | + | const person = personFor("Gone", "1234abcd+gone@users.noreply.g1t.sh", owners); | |
| 46 | + | assert.equal(person.kind, "ghost"); | |
| 47 | + | assert.equal(shownName(person), "ghost"); | |
| 48 | + | assert.equal(profileHref(person), null); | |
| 49 | + | }); | |
| 50 | + | ||
| 51 | + | test("g1t's commits, now and as its agents and queue made them before, are g1t", () => { | |
| 52 | + | for (const [name, email] of [ | |
| 53 | + | ["g1t", "g1t@users.noreply.g1t.sh"], | |
| 54 | + | ["g1t agent", "agent@g1t.sh"], | |
| 55 | + | ["g1t merge queue", "queue@g1t.sh"], | |
| 56 | + | ["g1t", "mergecheck@g1t.sh"], | |
| 57 | + | ]) { | |
| 58 | + | const person = personFor(name, email, {}); | |
| 59 | + | assert.equal(person.kind, "g1t", email); | |
| 60 | + | assert.equal(shownName(person), "g1t"); | |
| 61 | + | assert.equal(profileHref(person), null); | |
| 62 | + | } | |
| 63 | + | // Only g1t's own addresses: a name alone is not enough. | |
| 64 | + | assert.equal(personFor("g1t agent", "someone@example.com", {}).kind, "author"); | |
| 65 | + | }); | |
| 66 | + | ||
| 67 | + | test("a commit sent to the page carries no address at all", () => { | |
| 68 | + | const shown = showCommit( | |
| 69 | + | commit("Chase Pierce", "syntaqx@gmail.com", "Fix it\n\nCo-authored-by: Ada <ada@private.example>\nCo-authored-by: Sam <syntaqx@gmail.com>"), | |
| 70 | + | owners, | |
| 71 | + | ); | |
| 72 | + | const sent = JSON.stringify(shown.author) + JSON.stringify(shown.coAuthors); | |
| 73 | + | assert.ok(!sent.includes("@"), sent); | |
| 74 | + | assert.equal("email" in shown.author, false); | |
| 75 | + | assert.deepEqual( | |
| 76 | + | shown.coAuthors.map((person) => [person.kind, shownName(person)]), | |
| 77 | + | [ | |
| 78 | + | ["author", "Ada"], | |
| 79 | + | ["user", "syntaqx"], | |
| 80 | + | ], | |
| 81 | + | ); | |
| 82 | + | }); | |
| 83 | + | ||
| 84 | + | test("one question per page: every distinct address, g1t's left out", () => { | |
| 85 | + | const asked = addressesToMatch([ | |
| 86 | + | commit("A", "Ada@Example.com"), | |
| 87 | + | commit("A", "ada@example.com", "x\n\nCo-Authored-By: Grace <grace@example.com>"), | |
| 88 | + | commit("g1t", "g1t@users.noreply.g1t.sh"), | |
| 89 | + | commit("g1t agent", "agent@g1t.sh"), | |
| 90 | + | commit("Nobody", ""), | |
| 91 | + | ]); | |
| 92 | + | assert.deepEqual(asked.sort(), ["ada@example.com", "grace@example.com"]); | |
| 93 | + | }); | |
| 94 | + | ||
| 95 | + | test("co-authors are read from the trailers only", () => { | |
| 96 | + | assert.deepEqual(coAuthorsOf("Subject\n\nCo-authored-by: Ada <ada@x.io>\n"), [{ name: "Ada", email: "ada@x.io" }]); | |
| 97 | + | assert.deepEqual(coAuthorsOf("Subject\n\nCo-authored-by: Ada <ada@x.io> said hi\n\nThe end."), []); | |
| 98 | + | }); | |
| 99 | + | ||
| 100 | + | test("contributors and commits show one person the same way", () => { | |
| 101 | + | const base = { name: "syntaqx", commits: 361, firstAt: "", lastAt: "", weeks: [] }; | |
| 102 | + | const fromTally = contributorPerson({ ...base, kind: "user", username: "syntaqx", avatar: "f00d" }); | |
| 103 | + | const fromCommit = personFor("Chase Pierce", "syntaqx@gmail.com", owners); | |
| 104 | + | assert.equal(shownName(fromTally), shownName(fromCommit)); | |
| 105 | + | assert.equal(fromTally.avatar, fromCommit.avatar); | |
| 106 | + | assert.equal(profileHref(fromTally), profileHref(fromCommit)); | |
| 107 | + | assert.equal(contributorPerson({ ...base, kind: "g1t", name: "g1t" }).kind, "g1t"); | |
| 108 | + | assert.equal(contributorPerson({ ...base, kind: "user", username: "ghost" }).kind, "ghost"); | |
| 109 | + | assert.deepEqual(contributorPerson({ ...base, kind: "author", name: "Sam" }), { kind: "author", name: "Sam", username: null, avatar: null }); | |
| 110 | + | }); |
| 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 | + | */ | |
| 11 | + | import 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 | + | */ | |
| 18 | + | export 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. */ | |
| 29 | + | export 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. */ | |
| 36 | + | export type ShownBlame = Omit<Blame, "commits"> & { commits: ShownCommit[] }; | |
| 37 | + | ||
| 38 | + | /** A commit as a file list's row shows it: no people at all. */ | |
| 39 | + | export type FileCommit = Pick<Commit, "hash" | "message" | "authoredAt">; | |
| 40 | + | ||
| 41 | + | /** Each entry's last commit; `complete` is false when some were not reached. */ | |
| 42 | + | export 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 | + | */ | |
| 49 | + | export 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 | + | ||
| 56 | + | const CO_AUTHOR = /^co-authored-by:\s*(.*?)\s*<([^>]*)>\s*$/i; | |
| 57 | + | ||
| 58 | + | /** The name and address in each `Co-authored-by` trailer of a message. */ | |
| 59 | + | export 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. */ | |
| 71 | + | export 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 | + | */ | |
| 79 | + | export 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. */ | |
| 93 | + | export 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. */ | |
| 104 | + | export 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 | + | */ | |
| 119 | + | export 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. */ | |
| 129 | + | export function shownName(person: CommitPerson): string { | |
| 130 | + | return person.username ?? person.name; | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | /** Where a person on a commit links to, if anywhere. */ | |
| 134 | + | export function profileHref(person: CommitPerson): string | null { | |
| 135 | + | return person.kind === "user" && person.username ? `/u/${person.username}` : null; | |
| 136 | + | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import type { Contributors, Profile, ProfileWorkspace, RepoPath, Result, Viewer } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import { type CardSources, buildCard, cardHref, committedLabel, committedWithin, parseRepo } from "./hovercard.ts"; | |
| 7 | + | ||
| 8 | + | const NOW = Date.parse("2026-10-08T12:00:00Z"); | |
| 9 | + | const DAY = 86_400_000; | |
| 10 | + | ||
| 11 | + | const ada: Profile = { | |
| 12 | + | username: "ada", | |
| 13 | + | name: "Ada Lovelace", | |
| 14 | + | bio: "Engines.", | |
| 15 | + | location: "London", | |
| 16 | + | website: "https://ada.example", | |
| 17 | + | pronouns: "she/her", | |
| 18 | + | avatar: "cafe", | |
| 19 | + | createdAt: "2026-01-01T00:00:00Z", | |
| 20 | + | }; | |
| 21 | + | ||
| 22 | + | const me: Viewer = { id: "usr_me", username: "me", kind: "user", verified: true } as Viewer; | |
| 23 | + | ||
| 24 | + | /** | |
| 25 | + | * Stand-ins for identity and repos that behave as they do: workspaces are | |
| 26 | + | * shown only when the viewer shares them, or when ada has a public project | |
| 27 | + | * there; the repository answers only viewers who may read it. | |
| 28 | + | */ | |
| 29 | + | function sources(over: Partial<CardSources> = {}): CardSources & { asked: { viewer: Viewer; publicIn: string[] }[] } { | |
| 30 | + | const asked: { viewer: Viewer; publicIn: string[] }[] = []; | |
| 31 | + | const memberships: ProfileWorkspace[] = [ | |
| 32 | + | { slug: "acme", name: "Acme", avatar: null }, | |
| 33 | + | { slug: "secret-lab", name: "Secret Lab", avatar: null }, | |
| 34 | + | ]; | |
| 35 | + | return { | |
| 36 | + | asked, | |
| 37 | + | profile: async (username) => (username === "ada" ? ada : null), | |
| 38 | + | accountId: async (username) => (username === "ada" ? "usr_ada" : null), | |
| 39 | + | publicNamespaces: async () => ["acme"], | |
| 40 | + | profileWorkspaces: async (_username, viewer, publicIn) => { | |
| 41 | + | asked.push({ viewer, publicIn }); | |
| 42 | + | // secret-lab is private: only someone who belongs to it sees it. | |
| 43 | + | return memberships.filter((one) => publicIn.includes(one.slug) || (viewer?.username === "insider" && one.slug === "secret-lab")); | |
| 44 | + | }, | |
| 45 | + | contributors: async (path: RepoPath, viewer: Viewer): Promise<Result<Contributors>> => { | |
| 46 | + | if (path.name === "private" && viewer?.username !== "insider") { | |
| 47 | + | return { ok: false, error: { code: "not_found", message: "Repository not found." } } as Result<Contributors>; | |
| 48 | + | } | |
| 49 | + | return { | |
| 50 | + | ok: true, | |
| 51 | + | value: { | |
| 52 | + | head: "h", | |
| 53 | + | commit: "h", | |
| 54 | + | computedAt: null, | |
| 55 | + | pending: false, | |
| 56 | + | partial: false, | |
| 57 | + | total: 1, | |
| 58 | + | commits: 3, | |
| 59 | + | weeks: [], | |
| 60 | + | contributors: [{ kind: "user", name: "ada", username: "ada", avatar: "cafe", commits: 3, firstAt: "2026-09-01T00:00:00Z", lastAt: new Date(NOW - 3 * DAY).toISOString(), weeks: [] }], | |
| 61 | + | }, | |
| 62 | + | }; | |
| 63 | + | }, | |
| 64 | + | ...over, | |
| 65 | + | }; | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | test("the card is the public profile, signed out too", async () => { | |
| 69 | + | const card = await buildCard("Ada", null, null, sources(), NOW); | |
| 70 | + | assert.ok(card && card.kind === "user"); | |
| 71 | + | assert.equal(card.username, "ada"); | |
| 72 | + | assert.equal(card.name, "Ada Lovelace"); | |
| 73 | + | assert.equal(card.pronouns, "she/her"); | |
| 74 | + | assert.equal(card.bio, "Engines."); | |
| 75 | + | assert.equal(card.location, "London"); | |
| 76 | + | assert.equal(card.avatar, "cafe"); | |
| 77 | + | assert.equal(card.committed, null, "no repository, nothing about commits"); | |
| 78 | + | // Never an address. | |
| 79 | + | assert.ok(!JSON.stringify(card).includes("@")); | |
| 80 | + | }); | |
| 81 | + | ||
| 82 | + | test("a private workspace is hidden from someone outside it", async () => { | |
| 83 | + | const outside = sources(); | |
| 84 | + | const outsider = await buildCard("ada", me, null, outside, NOW); | |
| 85 | + | assert.deepEqual(outsider?.kind === "user" && outsider.workspaces.map((one) => one.slug), ["acme"]); | |
| 86 | + | // The viewer is passed through, for identity to decide. | |
| 87 | + | assert.equal(outside.asked[0]!.viewer, me); | |
| 88 | + | assert.deepEqual(outside.asked[0]!.publicIn, ["acme"]); | |
| 89 | + | const insider = await buildCard("ada", { ...me, username: "insider" } as Viewer, null, sources(), NOW); | |
| 90 | + | assert.deepEqual(insider?.kind === "user" && insider.workspaces.map((one) => one.slug), ["acme", "secret-lab"]); | |
| 91 | + | }); | |
| 92 | + | ||
| 93 | + | test("commits to a repository show only to those who may read it", async () => { | |
| 94 | + | const open = await buildCard("ada", null, { namespace: "acme", name: "web" }, sources(), NOW); | |
| 95 | + | assert.equal(open?.kind === "user" && open.committed, "week"); | |
| 96 | + | const hidden = await buildCard("ada", me, { namespace: "acme", name: "private" }, sources(), NOW); | |
| 97 | + | assert.equal(hidden?.kind === "user" && hidden.committed, null); | |
| 98 | + | }); | |
| 99 | + | ||
| 100 | + | test("a failing service leaves its part out, not the card", async () => { | |
| 101 | + | const card = await buildCard( | |
| 102 | + | "ada", | |
| 103 | + | me, | |
| 104 | + | { namespace: "acme", name: "web" }, | |
| 105 | + | sources({ profileWorkspaces: async () => Promise.reject(new Error("down")), contributors: async () => Promise.reject(new Error("down")) }), | |
| 106 | + | NOW, | |
| 107 | + | ); | |
| 108 | + | assert.ok(card && card.kind === "user"); | |
| 109 | + | assert.deepEqual(card.workspaces, []); | |
| 110 | + | assert.equal(card.committed, null); | |
| 111 | + | }); | |
| 112 | + | ||
| 113 | + | test("g1t has its own card; ghost and strangers have none", async () => { | |
| 114 | + | assert.deepEqual(await buildCard("g1t", null, null, sources(), NOW), { kind: "g1t", username: "g1t" }); | |
| 115 | + | assert.equal(await buildCard("ghost", me, null, sources(), NOW), null); | |
| 116 | + | assert.equal(await buildCard("nobody", me, null, sources(), NOW), null); | |
| 117 | + | assert.equal(await buildCard("../etc", me, null, sources(), NOW), null); | |
| 118 | + | }); | |
| 119 | + | ||
| 120 | + | test("workspaces past three are counted", async () => { | |
| 121 | + | const many = Array.from({ length: 5 }, (_, index) => ({ slug: `w${index}`, name: `W${index}`, avatar: null })); | |
| 122 | + | const card = await buildCard("ada", me, null, sources({ profileWorkspaces: async () => many }), NOW); | |
| 123 | + | assert.ok(card && card.kind === "user"); | |
| 124 | + | assert.equal(card.workspaces.length, 3); | |
| 125 | + | assert.equal(card.more_workspaces, 2); | |
| 126 | + | }); | |
| 127 | + | ||
| 128 | + | test("how recently, in the words the card uses", () => { | |
| 129 | + | assert.equal(committedWithin(new Date(NOW - 2 * 3_600_000).toISOString(), NOW), "day"); | |
| 130 | + | assert.equal(committedWithin(new Date(NOW - 6 * DAY).toISOString(), NOW), "week"); | |
| 131 | + | assert.equal(committedWithin(new Date(NOW - 20 * DAY).toISOString(), NOW), "month"); | |
| 132 | + | assert.equal(committedWithin(new Date(NOW - 60 * DAY).toISOString(), NOW), null); | |
| 133 | + | assert.equal(committedWithin(null, NOW), null); | |
| 134 | + | assert.equal(committedLabel("week"), "Committed to this repository in the past week"); | |
| 135 | + | }); | |
| 136 | + | ||
| 137 | + | test("the card's address and its repository", () => { | |
| 138 | + | assert.equal(cardHref("Ada", "acme/web"), "/-/hovercard/user/ada?repo=acme%2Fweb"); | |
| 139 | + | assert.equal(cardHref("ada", null), "/-/hovercard/user/ada"); | |
| 140 | + | assert.deepEqual(parseRepo("acme/web"), { namespace: "acme", name: "web" }); | |
| 141 | + | assert.equal(parseRepo("acme"), null); | |
| 142 | + | assert.equal(parseRepo("../x/y"), null); | |
| 143 | + | }); |
| 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 | + | */ | |
| 11 | + | import type { Contributors, Profile, ProfileWorkspace, RepoPath, Result, Viewer } from "@g1t/contracts"; | |
| 12 | + | ||
| 13 | + | /** How recently someone committed to the repository the card opened in. */ | |
| 14 | + | export type CommittedWithin = "day" | "week" | "month"; | |
| 15 | + | ||
| 16 | + | export 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. */ | |
| 33 | + | export type G1tCard = { kind: "g1t"; username: "g1t" }; | |
| 34 | + | ||
| 35 | + | export type Card = UserCard | G1tCard; | |
| 36 | + | ||
| 37 | + | export const MAX_WORKSPACES = 3; | |
| 38 | + | ||
| 39 | + | const DAY_MS = 86_400_000; | |
| 40 | + | ||
| 41 | + | /** `day`, `week` or `month` for a commit at `at` (RFC 3339), else null. */ | |
| 42 | + | export 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. */ | |
| 53 | + | export 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. */ | |
| 58 | + | export 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. */ | |
| 64 | + | export 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 | + | */ | |
| 78 | + | export 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. */ | |
| 123 | + | export 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 | + | } |
| 8 | 8 | */ | |
| 9 | 9 | import { waitUntil } from "cloudflare:workers"; | |
| 10 | 10 | ||
| 11 | − | import type { LastCommits, RepoPath, Viewer } from "@g1t/contracts"; | |
| 11 | + | import type { RepoPath, Viewer } from "@g1t/contracts"; | |
| 12 | 12 | ||
| 13 | + | import type { FileCommits } from "./commit-people"; | |
| 13 | 14 | import { repos } from "./services.server"; | |
| 14 | 15 | ||
| 15 | 16 | /** How long the page waits for the column. */ | |
| 16 | 17 | const WAIT_MS = 3_000; | |
| 17 | 18 | ||
| 18 | − | export function lastCommitsFor(path: RepoPath, viewer: Viewer, ref: string | null, treePath: string): Promise<LastCommits | null> { | |
| 19 | + | export function lastCommitsFor(path: RepoPath, viewer: Viewer, ref: string | null, treePath: string): Promise<FileCommits | null> { | |
| 19 | 20 | const walk = repos | |
| 20 | 21 | .lastCommits(path, viewer, ref, treePath) | |
| 21 | − | .then((found) => (found.ok ? found.value : null)) | |
| 22 | + | // The rows show a commit's subject and age, never who made it: their | |
| 23 | + | // addresses stay on the server. | |
| 24 | + | .then((found): FileCommits | null => | |
| 25 | + | found.ok | |
| 26 | + | ? { | |
| 27 | + | complete: found.value.complete, | |
| 28 | + | entries: found.value.entries.map(({ name, commit }) => ({ | |
| 29 | + | name, | |
| 30 | + | commit: { hash: commit.hash, message: commit.message, authoredAt: commit.authoredAt }, | |
| 31 | + | })), | |
| 32 | + | } | |
| 33 | + | : null, | |
| 34 | + | ) | |
| 22 | 35 | .catch(() => null); | |
| 23 | 36 | waitUntil(walk); | |
| 24 | 37 | return Promise.race([walk, new Promise<null>((resolve) => setTimeout(() => resolve(null), WAIT_MS))]); |
| 46 | 46 | route("settings/menu.json", "routes/settings-menu-json.ts"), | |
| 47 | 47 | // People, apart from workspaces: `u` is a reserved name. | |
| 48 | 48 | route("u/:username", "routes/user.tsx"), | |
| 49 | + | // The card that opens over a person's name or avatar (lib/hovercard.ts). | |
| 50 | + | // `-` is no workspace's name, so nothing else is ever found here. | |
| 51 | + | route("-/hovercard/user/:username", "routes/hovercard-user.ts"), | |
| 49 | 52 | route("explore", "routes/explore.tsx", { id: "explore" }), | |
| 50 | 53 | route("pricing", "routes/pricing.tsx"), | |
| 51 | 54 | route("search", "routes/search.tsx"), |
| 1 | + | /** | |
| 2 | + | * The card over a person's name or avatar, fetched when it first opens | |
| 3 | + | * (components/user-card.tsx): their public profile, the workspaces the | |
| 4 | + | * viewer may know they belong to, and, given `?repo=owner/name` the viewer | |
| 5 | + | * can read, how recently they committed there. Works signed out, with | |
| 6 | + | * public answers only. Kept a minute by the browser, for this viewer only. | |
| 7 | + | */ | |
| 8 | + | import type { Route } from "./+types/hovercard-user"; | |
| 9 | + | import { buildCard, parseRepo } from "../lib/hovercard"; | |
| 10 | + | import { identity, repos } from "../lib/services.server"; | |
| 11 | + | import { getViewer } from "../lib/session.server"; | |
| 12 | + | ||
| 13 | + | export async function loader({ params, request, context }: Route.LoaderArgs) { | |
| 14 | + | const viewer = getViewer(context); | |
| 15 | + | const repo = parseRepo(new URL(request.url).searchParams.get("repo")); | |
| 16 | + | const card = await buildCard( | |
| 17 | + | params.username, | |
| 18 | + | viewer, | |
| 19 | + | repo, | |
| 20 | + | { | |
| 21 | + | profile: (username) => identity.profile(username), | |
| 22 | + | accountId: async (username) => (await identity.userByUsername(username))?.id ?? null, | |
| 23 | + | publicNamespaces: (id) => repos.publicNamespaces(id), | |
| 24 | + | profileWorkspaces: (username, who, publicIn) => identity.profileWorkspaces(username, who, publicIn), | |
| 25 | + | contributors: (path, who) => repos.contributors(path, who), | |
| 26 | + | }, | |
| 27 | + | Date.now(), | |
| 28 | + | ); | |
| 29 | + | const headers = { "cache-control": "private, max-age=60", vary: "Cookie" }; | |
| 30 | + | if (!card) return Response.json({ error: "not_found" }, { status: 404, headers }); | |
| 31 | + | return Response.json(card, { headers }); | |
| 32 | + | } |
| 11 | 11 | import { ACTIVITY_TYPES, type ActivityLine, activityLine } from "../../lib/about"; | |
| 12 | 12 | import { page } from "../../lib/meta"; | |
| 13 | 13 | import { events, identity } from "../../lib/services.server"; | |
| 14 | + | import { UserCard } from "../../components/user-card"; | |
| 14 | 15 | ||
| 15 | 16 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 16 | 17 | return page(args, { title: `Activity · ${params.owner}/${params.repo} · g1t` }); | |
| ⋯ | |||
| 112 | 113 | <span className="inline-flex items-center gap-1.5 align-middle"> | |
| 113 | 114 | <Avatar name={who ?? "someone"} size={18} system={who === "g1t"} /> | |
| 114 | 115 | {who && who !== "g1t" ? ( | |
| 115 | − | <Link to={`/u/${who}`} className="font-medium hover:text-accent"> | |
| 116 | − | {who} | |
| 117 | − | </Link> | |
| 116 | + | <UserCard username={who}> | |
| 117 | + | <Link to={`/u/${who}`} className="font-medium hover:text-accent"> | |
| 118 | + | {who} | |
| 119 | + | </Link> | |
| 120 | + | </UserCard> | |
| 118 | 121 | ) : ( | |
| 119 | 122 | <span className="font-medium">{who ?? "Someone"}</span> | |
| 120 | 123 | )} | |
| 6 | 6 | import { BlobView } from "../../components/repo-view"; | |
| 7 | 7 | import { highlightLines } from "../../lib/highlight.server"; | |
| 8 | 8 | import { repos, work } from "../../lib/services.server"; | |
| 9 | + | import { showCommits } from "../../lib/commit-people.server"; | |
| 9 | 10 | import { redirectIfBranchRenamed } from "../../lib/branch-redirect.server"; | |
| 10 | 11 | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 11 | 12 | ||
| ⋯ | |||
| 35 | 36 | return { | |
| 36 | 37 | blob, | |
| 37 | 38 | html: null, | |
| 38 | − | blame: { blame: blame.value, lines: await highlightLines(blob.path, blob.text) }, | |
| 39 | + | blame: { | |
| 40 | + | // Every commit's people at once. | |
| 41 | + | blame: { ...blame.value, commits: await showCommits(blame.value.commits) }, | |
| 42 | + | lines: await highlightLines(blob.path, blob.text), | |
| 43 | + | }, | |
| 39 | 44 | branches, | |
| 40 | 45 | codeowners, | |
| 41 | 46 | }; | |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/branches"; | |
| 5 | 5 | import { ActiveBranches } from "../../components/branches"; | |
| 6 | − | import { Avatar, EmptyState, TimeAgo, notACredential } from "../../components/ui"; | |
| 6 | + | import { CommitAvatars, CommitNames } from "../../components/commit-person"; | |
| 7 | + | import { EmptyState, TimeAgo, notACredential } from "../../components/ui"; | |
| 7 | 8 | import { Hint } from "../../components/ui/hint"; | |
| 8 | 9 | import { readBranches } from "../../lib/branches.server"; | |
| 9 | 10 | import { commitChecksFor } from "../../lib/commit-checks.server"; | |
| ⋯ | |||
| 99 | 100 | <span className="rounded-full border border-line px-2 py-px text-xs text-muted">default</span> | |
| 100 | 101 | {head && ( | |
| 101 | 102 | <span className="flex min-w-0 grow basis-64 items-center gap-1.5 text-xs text-muted"> | |
| 102 | − | <Avatar name={head.author} size={13} /> | |
| 103 | − | <span className="shrink-0">{head.author}</span> | |
| 103 | + | <CommitAvatars commit={head} size={14} max={2} /> | |
| 104 | + | <span className="shrink-0"> | |
| 105 | + | <CommitNames commit={head} className="hover:text-fg" /> | |
| 106 | + | </span> | |
| 104 | 107 | <span className="text-faint">·</span> | |
| 105 | 108 | <Hint label={head.message}> | |
| 106 | 109 | <Link to={`${base}/commit/${head.hash}`} className="min-w-0 truncate hover:text-fg"> | |
| 5 | 5 | import { aboutFor } from "../../lib/about.server"; | |
| 6 | 6 | import { environmentsFor } from "../../lib/deployments.server"; | |
| 7 | 7 | import { lastCommitsFor } from "../../lib/last-commits.server"; | |
| 8 | + | import { showOneCommit } from "../../lib/commit-people.server"; | |
| 8 | 9 | import { repos } from "../../lib/services.server"; | |
| 9 | 10 | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 10 | 11 | import { projectHomepage } from "../../lib/about"; | |
| ⋯ | |||
| 26 | 27 | ]); | |
| 27 | 28 | // Each entry's last commit walks history: streamed in after the list. | |
| 28 | 29 | const lastCommits = lastCommitsFor(path, viewer, null, ""); | |
| 29 | − | const value = unwrap(tree); | |
| 30 | + | const found = unwrap(tree); | |
| 31 | + | // The latest commit's author, as their account where there is one. | |
| 32 | + | const value = { ...found, head: await showOneCommit(found.head) }; | |
| 30 | 33 | // The About beside the files, streamed in after them. | |
| 31 | 34 | const about = value.head ? aboutFor(path, viewer, value.repo.id) : null; | |
| 32 | 35 | // The latest commit's checks: streamed in beside it. | |
| 9 | 9 | import { page } from "../../lib/meta"; | |
| 10 | 10 | import { CommitChecksBadge } from "../../components/commit-checks"; | |
| 11 | 11 | import { DiffView } from "../../components/diff-view"; | |
| 12 | − | import { Avatar, TimeAgo } from "../../components/ui"; | |
| 13 | − | import { Hint } from "../../components/ui/hint"; | |
| 12 | + | import { CommitAvatars, CommitNames } from "../../components/commit-person"; | |
| 13 | + | import { TimeAgo } from "../../components/ui"; | |
| 14 | 14 | import { Skeleton } from "../../components/ui/skeleton"; | |
| 15 | 15 | import { commitChecksFor } from "../../lib/commit-checks.server"; | |
| 16 | 16 | import { immutable } from "../../lib/immutable.server"; | |
| 17 | 17 | import { pullForCommit } from "../../lib/provenance.server"; | |
| 18 | − | import { accounts, repos } from "../../lib/services.server"; | |
| 18 | + | import { showOneCommit } from "../../lib/commit-people.server"; | |
| 19 | + | import { repos } from "../../lib/services.server"; | |
| 19 | 20 | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 20 | 21 | ||
| 21 | 22 | export function headers({ loaderHeaders }: Route.HeadersArgs) { | |
| ⋯ | |||
| 65 | 66 | return { commit, comparison }; | |
| 66 | 67 | }); | |
| 67 | 68 | if (!loaded) throw new Response("Commit not found.", { status: 404 }); | |
| 68 | − | // The account the author address belongs to, if any; not cached, since | |
| 69 | − | // an address can be confirmed or removed later. | |
| 70 | − | const email = loaded.commit.author.email.toLowerCase(); | |
| 71 | − | const owners = await accounts.emailOwners([email]).catch(() => ({}) as Record<string, never>); | |
| 72 | − | const owner: { username: string; avatar: string | null } | null = owners[email] ?? null; | |
| 69 | + | // Its people, as their accounts where there are any; not kept with the | |
| 70 | + | // commit, since an address can be confirmed or removed later. | |
| 71 | + | const commit = (await showOneCommit(loaded.commit))!; | |
| 73 | 72 | return data( | |
| 74 | 73 | { | |
| 75 | 74 | ...loaded, | |
| 76 | − | owner, | |
| 75 | + | commit, | |
| 77 | 76 | // Streamed: the page shows the commit while this is worked out. | |
| 78 | 77 | pull: pullForCommit(path, viewer, loaded.commit.hash), | |
| 79 | 78 | // Never cached: checks report on a commit long after it was made. | |
| ⋯ | |||
| 133 | 132 | } | |
| 134 | 133 | ||
| 135 | 134 | export default function CommitPage({ loaderData, params }: Route.ComponentProps) { | |
| 136 | − | const { commit, pull, comparison, owner, checks } = loaderData; | |
| 135 | + | const { commit, pull, comparison, checks } = loaderData; | |
| 137 | 136 | const base = `/${params.owner}/${params.repo}`; | |
| 138 | − | const { subject, body, coAuthors, trailers } = parseCommitMessage(commit.message); | |
| 137 | + | const { subject, body, trailers } = parseCommitMessage(commit.message); | |
| 139 | 138 | return ( | |
| 140 | 139 | <div> | |
| 141 | 140 | <section className="overflow-hidden rounded-xl border border-line bg-surface"> | |
| ⋯ | |||
| 160 | 159 | </div> | |
| 161 | 160 | <div className="flex flex-wrap items-center gap-x-5 gap-y-3 border-t border-line bg-bg/40 px-5 py-3 text-sm"> | |
| 162 | 161 | <span className="flex items-center gap-2"> | |
| 163 | − | <Avatar name={owner?.username ?? commit.author.name} image={owner?.avatar} size={20} /> | |
| 164 | − | {owner ? ( | |
| 165 | − | <Hint label={commit.author.name}> | |
| 166 | − | <Link to={`/u/${owner.username}`} className="font-medium hover:underline"> | |
| 167 | − | {owner.username} | |
| 168 | − | </Link> | |
| 169 | − | </Hint> | |
| 170 | − | ) : ( | |
| 171 | − | <span className="font-medium">{commit.author.name}</span> | |
| 172 | − | )} | |
| 173 | − | {coAuthors.length > 0 && ( | |
| 174 | − | <span className="text-muted"> | |
| 175 | − | and <span className="font-medium text-fg">{coAuthors.join(", ")}</span> | |
| 176 | − | </span> | |
| 177 | − | )} | |
| 162 | + | <CommitAvatars commit={commit} size={20} /> | |
| 163 | + | <span className="font-medium"> | |
| 164 | + | <CommitNames commit={commit} all /> | |
| 165 | + | </span> | |
| 178 | 166 | <span className="text-muted"> | |
| 179 | 167 | committed <TimeAgo at={commit.authoredAt} /> | |
| 180 | 168 | </span> | |
| 1 | 1 | import { GitCommitHorizontal } from "lucide-react"; | |
| 2 | 2 | import { Link } from "react-router"; | |
| 3 | 3 | ||
| 4 | − | import type { Commit } from "@g1t/contracts"; | |
| 4 | + | import type { ShownCommit } from "../../lib/commit-people"; | |
| 5 | + | import { parseCommitMessage } from "../../lib/commit-message"; | |
| 5 | 6 | ||
| 6 | 7 | import type { Route } from "./+types/commits"; | |
| 7 | 8 | import { page } from "../../lib/meta"; | |
| 8 | 9 | import { CommitChecksBadge } from "../../components/commit-checks"; | |
| 9 | − | import { Avatar, EmptyState, TimeAgo } from "../../components/ui"; | |
| 10 | − | import { Hint } from "../../components/ui/hint"; | |
| 10 | + | import { CommitAvatars, CommitNames } from "../../components/commit-person"; | |
| 11 | + | import { EmptyState, TimeAgo } from "../../components/ui"; | |
| 11 | 12 | import { commitChecksFor } from "../../lib/commit-checks.server"; | |
| 12 | − | import { accounts, repos } from "../../lib/services.server"; | |
| 13 | + | import { showCommits } from "../../lib/commit-people.server"; | |
| 14 | + | import { repos } from "../../lib/services.server"; | |
| 13 | 15 | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 14 | 16 | ||
| 15 | 17 | const PAGE_SIZE = 50; | |
| ⋯ | |||
| 21 | 23 | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 22 | 24 | const path = { namespace: params.owner, name: params.repo }; | |
| 23 | 25 | const viewer = getViewer(context); | |
| 24 | − | const commits = unwrap(await repos.log(path, viewer, null, PAGE_SIZE)); | |
| 26 | + | const log = unwrap(await repos.log(path, viewer, null, PAGE_SIZE)); | |
| 25 | 27 | // Every commit's checks in one call, streamed in beside each. | |
| 26 | − | const checks = commitChecksFor(path, viewer, commits.map((commit) => commit.hash)); | |
| 27 | − | // Who wrote each commit, by its author address: confirmed and noreply | |
| 28 | − | // addresses only. Without an answer, the name in the commit is shown. | |
| 29 | − | const owners = await accounts.emailOwners([...new Set(commits.map((commit) => commit.author.email))]).catch(() => ({})); | |
| 30 | − | return { commits, owners: owners as Record<string, { username: string; avatar: string | null }>, checks }; | |
| 28 | + | const checks = commitChecksFor(path, viewer, log.map((commit) => commit.hash)); | |
| 29 | + | // Who wrote each commit, as their account where there is one. | |
| 30 | + | const commits = await showCommits(log); | |
| 31 | + | return { commits, checks }; | |
| 31 | 32 | } | |
| 32 | 33 | ||
| 33 | 34 | /** Commits by the day they were made, newest first. */ | |
| 34 | − | function byDay(commits: Commit[]): [string, Commit[]][] { | |
| 35 | − | const days = new Map<string, Commit[]>(); | |
| 35 | + | function byDay(commits: ShownCommit[]): [string, ShownCommit[]][] { | |
| 36 | + | const days = new Map<string, ShownCommit[]>(); | |
| 36 | 37 | for (const commit of commits) { | |
| 37 | 38 | const day = commit.authoredAt.slice(0, 10); | |
| 38 | 39 | days.set(day, [...(days.get(day) ?? []), commit]); | |
| ⋯ | |||
| 52 | 53 | } | |
| 53 | 54 | ||
| 54 | 55 | export default function Commits({ loaderData, params }: Route.ComponentProps) { | |
| 55 | − | const { commits, owners, checks } = loaderData; | |
| 56 | + | const { commits, checks } = loaderData; | |
| 56 | 57 | const base = `/${params.owner}/${params.repo}`; | |
| 57 | 58 | if (commits.length === 0) { | |
| 58 | 59 | return <EmptyState title="No commits yet" />; | |
| ⋯ | |||
| 67 | 68 | </h2> | |
| 68 | 69 | <ol className="mt-3 divide-y divide-line overflow-hidden rounded-xl border border-line"> | |
| 69 | 70 | {list.map((commit) => { | |
| 70 | − | const [subject, ...body] = commit.message.split("\n"); | |
| 71 | − | const rest = body.join("\n").trim(); | |
| 71 | + | // The body's prose: trailers (co-authors and their addresses) are not shown. | |
| 72 | + | const { subject, body: rest } = parseCommitMessage(commit.message); | |
| 72 | 73 | const to = `${base}/commit/${commit.hash}`; | |
| 73 | − | const owner = owners[commit.author.email.toLowerCase()]; | |
| 74 | 74 | return ( | |
| 75 | 75 | <li key={commit.hash} className="group relative flex items-start gap-3 px-4 py-3 transition-colors hover:bg-surface"> | |
| 76 | − | <Avatar name={owner?.username ?? commit.author.name} image={owner?.avatar} size={24} /> | |
| 76 | + | <span className="mt-0.5 flex"> | |
| 77 | + | <CommitAvatars commit={commit} size={24} /> | |
| 78 | + | </span> | |
| 77 | 79 | <div className="min-w-0 grow"> | |
| 78 | 80 | <div className="flex min-w-0 items-center gap-1.5"> | |
| 79 | 81 | <Link to={to} prefetch="intent" className="truncate font-medium after:absolute after:inset-0 group-hover:text-accent"> | |
| ⋯ | |||
| 83 | 85 | </div> | |
| 84 | 86 | {rest && <p className="mt-1 line-clamp-1 text-sm text-muted">{rest}</p>} | |
| 85 | 87 | <p className="mt-1 text-xs text-faint"> | |
| 86 | − | {owner ? ( | |
| 87 | − | <Hint label={commit.author.name}> | |
| 88 | − | <Link to={`/u/${owner.username}`} className="relative z-10 text-muted hover:text-fg"> | |
| 89 | − | {owner.username} | |
| 90 | − | </Link> | |
| 91 | − | </Hint> | |
| 92 | − | ) : ( | |
| 93 | − | commit.author.name | |
| 94 | − | )}{" "} | |
| 88 | + | <CommitNames commit={commit} className="text-muted hover:text-fg" />{" "} | |
| 95 | 89 | committed <TimeAgo at={commit.authoredAt} /> | |
| 96 | 90 | {commit.parents.length > 1 && " · merge"} | |
| 97 | 91 | </p> | |
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/compare"; | |
| 7 | 7 | import { DiffView } from "../../components/diff-view"; | |
| 8 | − | import { Avatar, ButtonLink, EmptyState, TimeAgo } from "../../components/ui"; | |
| 8 | + | import { CommitAvatars, CommitNames } from "../../components/commit-person"; | |
| 9 | + | import { ButtonLink, EmptyState, TimeAgo } from "../../components/ui"; | |
| 9 | 10 | import { Hint } from "../../components/ui/hint"; | |
| 10 | 11 | import { page } from "../../lib/meta"; | |
| 12 | + | import { showCommits } from "../../lib/commit-people.server"; | |
| 11 | 13 | import { repos } from "../../lib/services.server"; | |
| 12 | 14 | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 13 | 15 | ||
| ⋯ | |||
| 51 | 53 | // What head has that base does not: its history down to the first commit base also has. | |
| 52 | 54 | const onBase = new Set(baseLog.ok ? baseLog.value.map((commit) => commit.hash) : []); | |
| 53 | 55 | const shared = headLog.value.findIndex((commit) => onBase.has(commit.hash)); | |
| 54 | − | const commits = (shared === -1 ? headLog.value : headLog.value.slice(0, shared)).map((commit) => ({ | |
| 56 | + | // With their people, in one identity call. | |
| 57 | + | const commits = (await showCommits(shared === -1 ? headLog.value : headLog.value.slice(0, shared))).map((commit) => ({ | |
| 55 | 58 | hash: commit.hash, | |
| 56 | 59 | message: commit.message.split("\n")[0] ?? "", | |
| 57 | − | author: commit.author.name, | |
| 60 | + | author: commit.author, | |
| 61 | + | coAuthors: commit.coAuthors, | |
| 58 | 62 | at: commit.authoredAt, | |
| 59 | 63 | })); | |
| 60 | 64 | // The changes from where the two last agreed, as a pull request would | |
| ⋯ | |||
| 133 | 137 | {commits.map((commit) => ( | |
| 134 | 138 | <li key={commit.hash} className="flex items-center gap-3 px-4 py-2.5 text-sm"> | |
| 135 | 139 | <GitCommitHorizontal size={15} className="shrink-0 text-faint" /> | |
| 136 | − | <Avatar name={commit.author} size={16} /> | |
| 140 | + | <CommitAvatars commit={commit} size={16} max={2} /> | |
| 137 | 141 | <Hint label={commit.message}> | |
| 138 | 142 | <Link to={`${repoBase}/commit/${commit.hash}`} className="min-w-0 grow truncate hover:text-accent"> | |
| 139 | 143 | {commit.message} | |
| 140 | 144 | </Link> | |
| 141 | 145 | </Hint> | |
| 142 | − | <span className="hidden shrink-0 text-xs text-muted sm:inline">{commit.author}</span> | |
| 146 | + | <span className="hidden shrink-0 text-xs text-muted sm:inline"> | |
| 147 | + | <CommitNames commit={commit} className="hover:text-fg" /> | |
| 148 | + | </span> | |
| 143 | 149 | <Link to={`${repoBase}/commit/${commit.hash}`} className="shrink-0 font-mono text-xs text-faint hover:text-fg"> | |
| 144 | 150 | {commit.hash.slice(0, 7)} | |
| 145 | 151 | </Link> | |
| 1 | − | import { Link } from "react-router"; | |
| 2 | 1 | ||
| 3 | 2 | import type { Contributor, WeekCommits } from "@g1t/contracts"; | |
| 4 | 3 | ||
| 5 | 4 | import type { Route } from "./+types/contributors"; | |
| 6 | − | import { Avatar, EmptyState, TimeAgo } from "../../components/ui"; | |
| 5 | + | import { CommitAvatar, CommitName } from "../../components/commit-person"; | |
| 6 | + | import { EmptyState, TimeAgo } from "../../components/ui"; | |
| 7 | 7 | import { Hint } from "../../components/ui/hint"; | |
| 8 | 8 | import { requireRepo } from "../../lib/access.server"; | |
| 9 | − | import { alignWeeks, contributorHref, count, peak } from "../../lib/about"; | |
| 9 | + | import { alignWeeks, count, peak } from "../../lib/about"; | |
| 10 | + | import { contributorPerson } from "../../lib/commit-people"; | |
| 10 | 11 | import { page } from "../../lib/meta"; | |
| 11 | 12 | import { repos } from "../../lib/services.server"; | |
| 12 | 13 | import { unwrap } from "../../lib/session.server"; | |
| ⋯ | |||
| 48 | 49 | } | |
| 49 | 50 | ||
| 50 | 51 | function Person({ contributor, rank, weeks, scale }: { contributor: Contributor; rank: number; weeks: WeekCommits[]; scale: number }) { | |
| 51 | − | const href = contributorHref(contributor); | |
| 52 | − | const name = href ? ( | |
| 53 | − | <Link to={href} className="font-medium hover:text-accent"> | |
| 54 | − | {contributor.name} | |
| 55 | − | </Link> | |
| 56 | − | ) : ( | |
| 57 | − | <span className="font-medium">{contributor.name}</span> | |
| 58 | − | ); | |
| 52 | + | // The same person, avatar, link and card as on their commits. | |
| 53 | + | const person = contributorPerson(contributor); | |
| 59 | 54 | return ( | |
| 60 | 55 | <li className="rounded-xl border border-line bg-surface p-4"> | |
| 61 | 56 | <div className="flex items-center gap-3"> | |
| 62 | − | <Avatar name={contributor.name} image={contributor.avatar} size={36} system={contributor.kind === "g1t"} /> | |
| 57 | + | <CommitAvatar person={person} size={36} /> | |
| 63 | 58 | <div className="min-w-0 grow"> | |
| 64 | 59 | <p className="flex items-center gap-2 truncate text-sm"> | |
| 65 | − | {name} | |
| 66 | − | {contributor.kind === "g1t" && <span className="text-xs text-faint">agent</span>} | |
| 60 | + | <CommitName person={person} className="font-medium" /> | |
| 67 | 61 | </p> | |
| 68 | 62 | <p className="text-xs text-muted"> | |
| 69 | 63 | {count(contributor.commits, "commit")} · last <TimeAgo at={contributor.lastAt} /> | |
| 474 | 474 | {issue.assignees.map((name) => ( | |
| 475 | 475 | <li key={name} className="flex items-center gap-2 px-1"> | |
| 476 | 476 | <Avatar name={name} size={20} /> | |
| 477 | − | <span className="grow truncate font-mono text-xs">{name}</span> | |
| 477 | + | <span className="grow truncate font-mono text-xs"> | |
| 478 | + | <PersonLink name={name} className="hover:underline" /> | |
| 479 | + | </span> | |
| 478 | 480 | </li> | |
| 479 | 481 | ))} | |
| 480 | 482 | {open && issue.queued && !assigned && ( |
| 101 | 101 | import { cloneUrl, useAddresses } from "../../lib/addresses"; | |
| 102 | 102 | import { readBranches } from "../../lib/branches.server"; | |
| 103 | 103 | import { commitChecksFor } from "../../lib/commit-checks.server"; | |
| 104 | + | import { showCommits } from "../../lib/commit-people.server"; | |
| 105 | + | import { CommitAvatars, CommitNames } from "../../components/commit-person"; | |
| 104 | 106 | import { CommitChecksBadge } from "../../components/commit-checks"; | |
| 105 | 107 | ||
| 106 | 108 | const MAX_LANDED = 6; | |
| ⋯ | |||
| 209 | 211 | listP, | |
| 210 | 212 | openP, | |
| 211 | 213 | soft(work.listPulls(path, viewer, "closed")), | |
| 212 | − | soft(repos.log(path, viewer, null, COMMITS_SHOWN)), | |
| 214 | + | // With their people, in one identity call. | |
| 215 | + | soft(repos.log(path, viewer, null, COMMITS_SHOWN).then(async (found) => (found.ok ? { ok: true as const, value: await showCommits(found.value) } : found))), | |
| 213 | 216 | soft(countsFor(context, params)), | |
| 214 | 217 | soft(projects.dependencies(params.owner, params.repo, viewer)), | |
| 215 | 218 | soft(agents.listRuns(viewer, { repo: path, limit: 60 })), | |
| ⋯ | |||
| 435 | 438 | builds: builds.slice(0, 30), | |
| 436 | 439 | live: ok(list)?.live ?? [], | |
| 437 | 440 | commit: ok(log)?.[0] ?? null, | |
| 438 | − | commits: (ok(log) ?? []).map((one) => ({ hash: one.hash, message: one.message.split("\n")[0] ?? "", author: one.author.name, at: one.authoredAt })), | |
| 441 | + | commits: (ok(log) ?? []).map((one) => ({ hash: one.hash, message: one.message.split("\n")[0] ?? "", author: one.author, coAuthors: one.coAuthors, at: one.authoredAt })), | |
| 439 | 442 | release: latestTag(ok(tagList) ?? []), | |
| 440 | 443 | checklistTitle: plan.title, | |
| 441 | 444 | // Production as reported from outside g1t, for an app deployed elsewhere. | |
| ⋯ | |||
| 1007 | 1010 | {commit && ( | |
| 1008 | 1011 | <p className="truncate border-t border-line px-5 py-2.5 text-xs text-muted sm:px-6"> | |
| 1009 | 1012 | <GitCommitHorizontal size={12} className="mr-1.5 inline text-faint" /> | |
| 1010 | − | {commit.message.split("\n")[0]} <span className="text-faint">· {commit.author.name}</span> | |
| 1013 | + | {commit.message.split("\n")[0]}{" "} | |
| 1014 | + | <span className="text-faint"> | |
| 1015 | + | · <CommitNames commit={commit} className="hover:text-fg" /> | |
| 1016 | + | </span> | |
| 1011 | 1017 | </p> | |
| 1012 | 1018 | )} | |
| 1013 | 1019 | </section> | |
| ⋯ | |||
| 1206 | 1212 | <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface"> | |
| 1207 | 1213 | {loaderData.commits.map((one) => ( | |
| 1208 | 1214 | <li key={one.hash} className="flex items-center gap-3 px-4 py-2.5 text-sm"> | |
| 1209 | − | <Avatar name={one.author} size={18} /> | |
| 1215 | + | <CommitAvatars commit={one} size={18} max={2} /> | |
| 1210 | 1216 | <span className="min-w-0 grow"> | |
| 1211 | 1217 | <Link to={`${base}/commit/${one.hash}`} className="block truncate hover:text-accent"> | |
| 1212 | 1218 | {one.message} | |
| 1213 | 1219 | </Link> | |
| 1214 | − | <span className="text-xs text-muted">{one.author}</span> | |
| 1220 | + | <span className="text-xs text-muted"> | |
| 1221 | + | <CommitNames commit={one} className="hover:text-fg" /> | |
| 1222 | + | </span> | |
| 1215 | 1223 | </span> | |
| 1216 | 1224 | <Link to={`${base}/commit/${one.hash}`} className="hidden shrink-0 font-mono text-xs text-faint hover:text-fg sm:block"> | |
| 1217 | 1225 | {one.hash.slice(0, 7)} | |
| 1435 | 1435 | ) : ( | |
| 1436 | 1436 | <Avatar name={name} size={20} /> | |
| 1437 | 1437 | )} | |
| 1438 | − | <span className="grow truncate font-mono text-xs">{name}</span> | |
| 1438 | + | <span className="grow truncate font-mono text-xs"> | |
| 1439 | + | <PersonLink name={name} className="hover:underline" /> | |
| 1440 | + | </span> | |
| 1439 | 1441 | {pending ? ( | |
| 1440 | 1442 | <span className="flex items-center gap-1.5 text-xs text-muted"> | |
| 1441 | 1443 | <span className="size-1.5 animate-pulse rounded-full bg-accent" /> | |
| ⋯ | |||
| 1533 | 1535 | {pull.assignees.map((name) => ( | |
| 1534 | 1536 | <li key={name} className="flex items-center gap-2 px-1"> | |
| 1535 | 1537 | <Avatar name={name} size={20} /> | |
| 1536 | − | <span className="grow truncate font-mono text-xs">{name}</span> | |
| 1538 | + | <span className="grow truncate font-mono text-xs"> | |
| 1539 | + | <PersonLink name={name} className="hover:underline" /> | |
| 1540 | + | </span> | |
| 1537 | 1541 | </li> | |
| 1538 | 1542 | ))} | |
| 1539 | 1543 | {pull.assignees.length === 0 && ( | |
| 35 | 35 | import { refusal, requireInsider } from "../../lib/access.server"; | |
| 36 | 36 | import { billing, identity } from "../../lib/services.server"; | |
| 37 | 37 | import { assertSameOrigin, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 38 | + | import { UserCard } from "../../components/user-card"; | |
| 38 | 39 | ||
| 39 | 40 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 40 | 41 | return page(args, { title: `Access · ${params.owner}/${params.repo} · g1t` }); | |
| ⋯ | |||
| 362 | 363 | <Avatar name={person.username} image={person.avatar} size={32} /> | |
| 363 | 364 | <div className="min-w-0 grow basis-40"> | |
| 364 | 365 | <p className="flex flex-wrap items-center gap-x-2 gap-y-1"> | |
| 365 | − | <Link to={`/u/${person.username}`} className="font-mono text-sm hover:text-accent"> | |
| 366 | − | {person.username} | |
| 367 | − | </Link> | |
| 366 | + | <UserCard username={person.username}> | |
| 367 | + | <Link to={`/u/${person.username}`} className="font-mono text-sm hover:text-accent"> | |
| 368 | + | {person.username} | |
| 369 | + | </Link> | |
| 370 | + | </UserCard> | |
| 368 | 371 | {person.name && <span className="truncate text-sm text-muted">{person.name}</span>} | |
| 369 | 372 | {person.source === "owner" && <Badge tone="accent">Owner</Badge>} | |
| 370 | 373 | {outside && <Badge tone="info">Outside collaborator</Badge>} | |
| 6 | 6 | import { page } from "../../lib/meta"; | |
| 7 | 7 | import { repos } from "../../lib/services.server"; | |
| 8 | 8 | import { unwrap } from "../../lib/session.server"; | |
| 9 | + | import { UserCard } from "../../components/user-card"; | |
| 9 | 10 | ||
| 10 | 11 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 11 | 12 | return page(args, { title: `Stargazers · ${params.owner}/${params.repo} · g1t` }); | |
| ⋯ | |||
| 36 | 37 | <li key={person.username} className="flex items-center gap-3 rounded-xl border border-line bg-surface p-3"> | |
| 37 | 38 | <Avatar name={person.username} image={person.avatar} size={36} /> | |
| 38 | 39 | <div className="min-w-0"> | |
| 39 | − | <Link to={`/u/${person.username}`} className="block truncate text-sm font-medium hover:text-accent"> | |
| 40 | − | {person.username} | |
| 41 | − | </Link> | |
| 40 | + | <UserCard username={person.username}> | |
| 41 | + | <Link to={`/u/${person.username}`} className="block truncate text-sm font-medium hover:text-accent"> | |
| 42 | + | {person.username} | |
| 43 | + | </Link> | |
| 44 | + | </UserCard> | |
| 42 | 45 | <p className="text-xs text-faint"> | |
| 43 | 46 | Starred <TimeAgo at={person.starredAt} /> | |
| 44 | 47 | </p> | |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/tags"; | |
| 5 | 5 | import { CommitChecksBadge } from "../../components/commit-checks"; | |
| 6 | − | import { Avatar, EmptyState, TimeAgo } from "../../components/ui"; | |
| 6 | + | import { CommitAvatars, CommitNames } from "../../components/commit-person"; | |
| 7 | + | import { EmptyState, TimeAgo } from "../../components/ui"; | |
| 7 | 8 | import { Hint } from "../../components/ui/hint"; | |
| 8 | 9 | import { page } from "../../lib/meta"; | |
| 9 | 10 | import { commitChecksFor } from "../../lib/commit-checks.server"; | |
| 11 | + | import { showCommits } from "../../lib/commit-people.server"; | |
| 10 | 12 | import { repos } from "../../lib/services.server"; | |
| 11 | 13 | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 12 | 14 | ||
| ⋯ | |||
| 17 | 19 | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 18 | 20 | const path = { namespace: params.owner, name: params.repo }; | |
| 19 | 21 | const viewer = getViewer(context); | |
| 20 | − | const tags = unwrap(await repos.tags(path, viewer)); | |
| 22 | + | const found = unwrap(await repos.tags(path, viewer)); | |
| 21 | 23 | // Each tagged commit's checks, in one call, streamed in beside it. | |
| 22 | − | return { tags, checks: commitChecksFor(path, viewer, tags.map((tag) => tag.commit?.hash)) }; | |
| 24 | + | const checks = commitChecksFor(path, viewer, found.map((tag) => tag.commit?.hash)); | |
| 25 | + | // Every tagged commit's people, in one identity call. | |
| 26 | + | const shown = await showCommits(found.flatMap((tag) => (tag.commit ? [tag.commit] : []))); | |
| 27 | + | const byHash = new Map(shown.map((commit) => [commit.hash, commit])); | |
| 28 | + | const tags = found.map((tag) => ({ name: tag.name, commit: tag.commit ? byHash.get(tag.commit.hash)! : null })); | |
| 29 | + | return { tags, checks }; | |
| 23 | 30 | } | |
| 24 | 31 | ||
| 25 | 32 | export default function Tags({ loaderData, params }: Route.ComponentProps) { | |
| ⋯ | |||
| 47 | 54 | </Link> | |
| 48 | 55 | {tag.commit && ( | |
| 49 | 56 | <span className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted"> | |
| 50 | − | <Avatar name={tag.commit.author.name} size={13} /> | |
| 51 | − | <span className="shrink-0">{tag.commit.author.name}</span> | |
| 57 | + | <CommitAvatars commit={tag.commit} size={14} max={2} /> | |
| 58 | + | <span className="shrink-0"> | |
| 59 | + | <CommitNames commit={tag.commit} className="hover:text-fg" /> | |
| 60 | + | </span> | |
| 52 | 61 | <span className="text-faint">·</span> | |
| 53 | 62 | <Hint label={tag.commit.message}> | |
| 54 | 63 | <Link to={`${base}/commit/${tag.commit.hash}`} className="min-w-0 truncate hover:text-fg"> | |
| 6 | 6 | import { aboutFor } from "../../lib/about.server"; | |
| 7 | 7 | import { environmentsFor } from "../../lib/deployments.server"; | |
| 8 | 8 | import { lastCommitsFor } from "../../lib/last-commits.server"; | |
| 9 | + | import { showOneCommit } from "../../lib/commit-people.server"; | |
| 9 | 10 | import { repos } from "../../lib/services.server"; | |
| 10 | 11 | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 11 | 12 | import { projectHomepage } from "../../lib/about"; | |
| ⋯ | |||
| 30 | 31 | if (!tree.ok && tree.error.code === "not_found") await redirectIfBranchRenamed(request, path, viewer, params.ref); | |
| 31 | 32 | // Each entry's last commit walks history: streamed in after the list. | |
| 32 | 33 | const lastCommits = lastCommitsFor(path, viewer, params.ref, params["*"] ?? ""); | |
| 33 | − | const value = unwrap(tree); | |
| 34 | + | const found = unwrap(tree); | |
| 35 | + | // The latest commit's author, as their account where there is one. | |
| 36 | + | const value = { ...found, head: await showOneCommit(found.head) }; | |
| 34 | 37 | // The About beside the files, streamed in after them. | |
| 35 | 38 | const about = value.head && !value.path ? aboutFor(path, viewer, value.repo.id) : null; | |
| 36 | 39 | // The latest commit's checks: streamed in beside it. | |
| 3 | 3 | import type { SessionEntry } from "@g1t/contracts"; | |
| 4 | 4 | ||
| 5 | 5 | import type { Route } from "./+types/why"; | |
| 6 | + | import { showOneCommit } from "../../lib/commit-people.server"; | |
| 6 | 7 | import { pullForCommit } from "../../lib/provenance.server"; | |
| 7 | 8 | import { repos, work } from "../../lib/services.server"; | |
| 8 | 9 | import { getViewer } from "../../lib/session.server"; | |
| ⋯ | |||
| 65 | 66 | repos.log(path, viewer, params.hash, 1), | |
| 66 | 67 | pullForCommit(path, viewer, params.hash), | |
| 67 | 68 | ]); | |
| 68 | − | const commit = log.ok ? log.value[0] : undefined; | |
| 69 | + | const commit = await showOneCommit(log.ok ? log.value[0] : undefined); | |
| 69 | 70 | if (!commit) throw data(null, { status: 404 }); | |
| 70 | 71 | if (!pull) return { commit, pull: null, issue: null, steps: [] as SessionEntry[] }; | |
| 71 | 72 | const [detail, session] = await Promise.all([ | |
| 50 | 50 | roleIn, | |
| 51 | 51 | unwrap, | |
| 52 | 52 | } from "../../lib/session.server"; | |
| 53 | + | import { UserCard } from "../../components/user-card"; | |
| 53 | 54 | ||
| 54 | 55 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 55 | 56 | return page(args, { title: `People · ${params.owner} · g1t` }); | |
| ⋯ | |||
| 318 | 319 | <li key={member.username} className="flex flex-wrap items-center gap-3 px-4 py-3"> | |
| 319 | 320 | <Avatar name={member.username} image={member.avatar} size={28} /> | |
| 320 | 321 | <div className="min-w-0 grow truncate"> | |
| 321 | − | <Link to={`/u/${member.username}`} className="font-mono text-sm hover:text-accent"> | |
| 322 | − | {member.username} | |
| 323 | − | </Link> | |
| 322 | + | <UserCard username={member.username}> | |
| 323 | + | <Link to={`/u/${member.username}`} className="font-mono text-sm hover:text-accent"> | |
| 324 | + | {member.username} | |
| 325 | + | </Link> | |
| 326 | + | </UserCard> | |
| 324 | 327 | {member.name && <span className="ml-2 hidden text-sm text-muted sm:inline">{member.name}</span>} | |
| 325 | 328 | {(teams[member.username] ?? []).length > 0 && ( | |
| 326 | 329 | <div className="mt-1 flex flex-wrap gap-1"> | |
| ⋯ | |||
| 584 | 587 | <div className="flex flex-wrap items-center gap-x-3 gap-y-2"> | |
| 585 | 588 | <Avatar name={person.username} image={person.avatar} size={28} /> | |
| 586 | 589 | <div className="min-w-0 grow basis-32"> | |
| 587 | − | <Link to={`/u/${person.username}`} className="font-mono text-sm hover:text-accent"> | |
| 588 | − | {person.username} | |
| 589 | − | </Link> | |
| 590 | + | <UserCard username={person.username}> | |
| 591 | + | <Link to={`/u/${person.username}`} className="font-mono text-sm hover:text-accent"> | |
| 592 | + | {person.username} | |
| 593 | + | </Link> | |
| 594 | + | </UserCard> | |
| 590 | 595 | {person.name && <span className="ml-2 text-sm text-muted">{person.name}</span>} | |
| 591 | 596 | </div> | |
| 592 | 597 | {!free && ( | |
| 12 | 12 | import { teamPath } from "../../../lib/teams"; | |
| 13 | 13 | import { identity } from "../../../lib/services.server"; | |
| 14 | 14 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../../lib/session.server"; | |
| 15 | + | import { UserCard } from "../../../components/user-card"; | |
| 15 | 16 | ||
| 16 | 17 | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 17 | 18 | const viewer = getViewer(context); | |
| ⋯ | |||
| 75 | 76 | <div className="flex flex-wrap items-center gap-x-3 gap-y-2"> | |
| 76 | 77 | <Avatar name={member.username} image={member.avatar} size={28} /> | |
| 77 | 78 | <div className="min-w-0 grow basis-32 truncate"> | |
| 78 | − | <Link to={`/u/${member.username}`} className="font-mono text-sm hover:text-accent"> | |
| 79 | − | {member.username} | |
| 80 | − | </Link> | |
| 79 | + | <UserCard username={member.username}> | |
| 80 | + | <Link to={`/u/${member.username}`} className="font-mono text-sm hover:text-accent"> | |
| 81 | + | {member.username} | |
| 82 | + | </Link> | |
| 83 | + | </UserCard> | |
| 81 | 84 | {member.name && <span className="ml-2 hidden text-sm text-muted sm:inline">{member.name}</span>} | |
| 82 | 85 | </div> | |
| 83 | 86 | {member.via ? ( | |
| 1 | + | -- How a repository's About was worked out (src/stats.rs, STATS_VERSION). | |
| 2 | + | -- A row worked out by an older version is worked out again on the next | |
| 3 | + | -- view, as if its head had moved: version 1 counts the addresses g1t's | |
| 4 | + | -- agents and merge queue committed as before 2026-10-06 as g1t. | |
| 5 | + | -- Additive; existing rows are version 0. | |
| 6 | + | ALTER TABLE repo_stats ADD COLUMN version INTEGER NOT NULL DEFAULT 0; |
| 11 | 11 | /// The address g1t's own commits carry. | |
| 12 | 12 | pub const G1T_EMAIL: &str = "g1t@users.noreply.g1t.sh"; | |
| 13 | 13 | ||
| 14 | + | /// The addresses g1t's agents and merge queue committed as before they | |
| 15 | + | /// committed as g1t (2026-10-06): history keeps them, and they are g1t's. | |
| 16 | + | /// The site matches the same set (apps/web/app/lib/commit-people.ts). | |
| 17 | + | pub const LEGACY_G1T_EMAILS: [&str; 3] = ["agent@g1t.sh", "queue@g1t.sh", "mergecheck@g1t.sh"]; | |
| 18 | + | ||
| 14 | 19 | /// One commit's author, as history gives it. | |
| 15 | 20 | #[derive(Clone, Debug)] | |
| 16 | 21 | pub struct Authored { | |
| ⋯ | |||
| 22 | 27 | ||
| 23 | 28 | /// Whether a commit's author is g1t itself. | |
| 24 | 29 | pub fn is_g1t(author: &Authored) -> bool { | |
| 25 | − | author.email.eq_ignore_ascii_case(G1T_EMAIL) | |
| 30 | + | let email = author.email.trim(); | |
| 31 | + | email.eq_ignore_ascii_case(G1T_EMAIL) || LEGACY_G1T_EMAILS.iter().any(|legacy| email.eq_ignore_ascii_case(legacy)) | |
| 26 | 32 | } | |
| 27 | 33 | ||
| 28 | 34 | #[derive(Default)] | |
| ⋯ | |||
| 151 | 157 | } | |
| 152 | 158 | ||
| 153 | 159 | #[test] | |
| 160 | + | fn g1t_older_addresses_are_g1t() { | |
| 161 | + | let commits = vec![ | |
| 162 | + | authored("g1t agent", "agent@g1t.sh", "2026-10-01T10:00:00Z"), | |
| 163 | + | authored("g1t merge queue", "Queue@g1t.sh", "2026-10-02T10:00:00Z"), | |
| 164 | + | authored("g1t", G1T_EMAIL, "2026-10-07T10:00:00Z"), | |
| 165 | + | authored("Not g1t", "someone@g1t.sh", "2026-10-07T10:00:00Z"), | |
| 166 | + | ]; | |
| 167 | + | let (contributors, total, _) = tally(&commits, &HashMap::new()); | |
| 168 | + | assert_eq!(total, 2); | |
| 169 | + | assert_eq!(contributors[0].kind, ContributorKind::G1t); | |
| 170 | + | assert_eq!(contributors[0].name, "g1t"); | |
| 171 | + | assert_eq!(contributors[0].commits, 3); | |
| 172 | + | assert_eq!(contributors[1].kind, ContributorKind::Author, "only g1t's own addresses"); | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | #[test] | |
| 154 | 176 | fn no_history_is_no_one() { | |
| 155 | 177 | let (contributors, total, weeks) = tally(&[], &HashMap::new()); | |
| 156 | 178 | assert!(contributors.is_empty() && weeks.is_empty()); | |
| 45 | 45 | /// Where a security policy may be. | |
| 46 | 46 | const POLICY_DIRS: [&str; 4] = ["", ".g1t/", ".github/", "docs/"]; | |
| 47 | 47 | ||
| 48 | + | /// How the About is worked out; a row kept by an older version is worked | |
| 49 | + | /// out again. 1: g1t's older commit addresses count as g1t. | |
| 50 | + | pub const STATS_VERSION: f64 = 1.0; | |
| 51 | + | ||
| 48 | 52 | /// What `repo_stats` keeps for the About, without the full contributors. | |
| 49 | 53 | #[derive(Debug, Default, Deserialize)] | |
| 50 | 54 | pub struct Kept { | |
| ⋯ | |||
| 57 | 61 | pub languages: Option<String>, | |
| 58 | 62 | pub contributors_total: f64, | |
| 59 | 63 | pub contributors_top: Option<String>, | |
| 64 | + | /// [`STATS_VERSION`] when it was worked out. | |
| 65 | + | #[serde(default)] | |
| 66 | + | pub version: f64, | |
| 60 | 67 | } | |
| 61 | 68 | ||
| 62 | 69 | impl Kept { | |
| ⋯ | |||
| 69 | 76 | /// or what is kept is partial, and none is under way. | |
| 70 | 77 | pub fn wants_run(&self, head: Option<&str>, now: u64) -> bool { | |
| 71 | 78 | let Some(head) = head else { return false }; | |
| 72 | − | let behind = self.commit_hash.as_deref() != Some(head) || self.partial > 0.0; | |
| 79 | + | let behind = self.commit_hash.as_deref() != Some(head) || self.partial > 0.0 || self.version < STATS_VERSION; | |
| 73 | 80 | // A partial answer is tried again at most every lease. | |
| 74 | 81 | let rested = self.partial == 0.0 || self.started_ms.is_none_or(|started| now.saturating_sub(started as u64) >= LEASE_MS); | |
| 75 | 82 | behind && rested && !self.running(now) | |
| ⋯ | |||
| 102 | 109 | pub async fn kept(db: &D1Database, repo_id: &str) -> Result<Kept> { | |
| 103 | 110 | Ok(db | |
| 104 | 111 | .prepare( | |
| 105 | − | "SELECT commit_hash, computed_at, started_ms, partial, license, security_policy, languages, contributors_total, contributors_top | |
| 112 | + | "SELECT commit_hash, computed_at, started_ms, partial, license, security_policy, languages, contributors_total, contributors_top, version | |
| 106 | 113 | FROM repo_stats WHERE repo_id = ?", | |
| 107 | 114 | ) | |
| 108 | 115 | .bind(&[repo_id.into()])? | |
| ⋯ | |||
| 190 | 197 | db.prepare( | |
| 191 | 198 | "UPDATE repo_stats SET commit_hash = ?2, computed_at = ?3, started_ms = CASE WHEN ?4 = 1 THEN started_ms ELSE NULL END, | |
| 192 | 199 | partial = ?4, license = ?5, security_policy = ?6, languages = ?7, contributors_total = ?8, contributors_top = ?9, | |
| 193 | − | contributors = ?10 | |
| 200 | + | contributors = ?10, version = ?11 | |
| 194 | 201 | WHERE repo_id = ?1", | |
| 195 | 202 | ) | |
| 196 | 203 | .bind(&[ | |
| ⋯ | |||
| 210 | 217 | JsValue::from_f64(f64::from(worked.contributors_total)), | |
| 211 | 218 | json(&top).into(), | |
| 212 | 219 | json(&full).into(), | |
| 220 | + | JsValue::from_f64(STATS_VERSION), | |
| 213 | 221 | ])? | |
| 214 | 222 | .run() | |
| 215 | 223 | .await?; | |
| ⋯ | |||
| 398 | 406 | let none = Kept::default(); | |
| 399 | 407 | assert!(none.wants_run(Some("c2"), now)); | |
| 400 | 408 | assert!(!none.wants_run(None, now), "an empty repository has nothing to work out"); | |
| 401 | − | let current = Kept { commit_hash: Some("c2".into()), ..Kept::default() }; | |
| 409 | + | let current = Kept { commit_hash: Some("c2".into()), version: STATS_VERSION, ..Kept::default() }; | |
| 402 | 410 | assert!(!current.wants_run(Some("c2"), now)); | |
| 403 | 411 | assert!(current.wants_run(Some("c3"), now)); | |
| 412 | + | let older = Kept { commit_hash: Some("c2".into()), version: STATS_VERSION - 1.0, ..Kept::default() }; | |
| 413 | + | assert!(older.wants_run(Some("c2"), now), "an answer worked out the old way is worked out again"); | |
| 404 | 414 | let running = Kept { commit_hash: Some("c2".into()), started_ms: Some((now - 1_000) as f64), ..Kept::default() }; | |
| 405 | 415 | assert!(!running.wants_run(Some("c3"), now)); | |
| 406 | 416 | let died = Kept { commit_hash: Some("c2".into()), started_ms: Some((now - LEASE_MS - 1) as f64), ..Kept::default() }; | |