Merge commit authors and hovercards: commits show their accounts, every name opens a card (repos 0016)
# Conflicts: # apps/web/app/components/ui/index.tsx
50 files+1381−1910/50 viewed
| 412 | 412 | (`<username>@users.g1t.sh`) count as yours too. An unconfirmed address | |
| 413 | 413 | never attributes a commit, so nobody can claim your commits by adding your | |
| 414 | 414 | address. Commits whose address matches no account show the name in the | |
| 415 | − | commit. | |
| 415 | + | commit. See [Commits and your account](/guides/workspaces/#commits-and-your-account) | |
| 416 | + | for setting your noreply address in git. | |
| 416 | 417 | ||
| 417 | 418 | ### Email addresses through the API | |
| 418 | 419 |
| 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, Fragment, 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 && ( |
| 102 | 102 | import { cloneUrl, useAddresses } from "../../lib/addresses"; | |
| 103 | 103 | import { readBranches } from "../../lib/branches.server"; | |
| 104 | 104 | import { commitChecksFor } from "../../lib/commit-checks.server"; | |
| 105 | + | import { showCommits } from "../../lib/commit-people.server"; | |
| 106 | + | import { CommitAvatars, CommitNames } from "../../components/commit-person"; | |
| 105 | 107 | import { CommitChecksBadge } from "../../components/commit-checks"; | |
| 106 | 108 | ||
| 107 | 109 | const MAX_LANDED = 6; | |
| ⋯ | |||
| 214 | 216 | listP, | |
| 215 | 217 | openP, | |
| 216 | 218 | soft(work.listPulls(path, viewer, "closed")), | |
| 217 | − | soft(repos.log(path, viewer, null, COMMITS_SHOWN)), | |
| 219 | + | // With their people, in one identity call. | |
| 220 | + | soft(repos.log(path, viewer, null, COMMITS_SHOWN).then(async (found) => (found.ok ? { ok: true as const, value: await showCommits(found.value) } : found))), | |
| 218 | 221 | soft(countsFor(context, params)), | |
| 219 | 222 | soft(projects.dependencies(params.owner, params.repo, viewer)), | |
| 220 | 223 | soft(agents.listRuns(viewer, { repo: path, limit: 60 })), | |
| ⋯ | |||
| 438 | 441 | builds: builds.slice(0, 30), | |
| 439 | 442 | live: ok(list)?.live ?? [], | |
| 440 | 443 | commit: ok(log)?.[0] ?? null, | |
| 441 | − | commits: (ok(log) ?? []).map((one) => ({ hash: one.hash, message: one.message.split("\n")[0] ?? "", author: one.author.name, at: one.authoredAt })), | |
| 444 | + | commits: (ok(log) ?? []).map((one) => ({ hash: one.hash, message: one.message.split("\n")[0] ?? "", author: one.author, coAuthors: one.coAuthors, at: one.authoredAt })), | |
| 442 | 445 | release: latestTag(ok(tagList) ?? []), | |
| 443 | 446 | checklistTitle: plan.title, | |
| 444 | 447 | // Production as reported from outside g1t, for an app deployed elsewhere. | |
| ⋯ | |||
| 1010 | 1013 | {commit && ( | |
| 1011 | 1014 | <p className="truncate border-t border-line px-5 py-2.5 text-xs text-muted sm:px-6"> | |
| 1012 | 1015 | <GitCommitHorizontal size={12} className="mr-1.5 inline text-faint" /> | |
| 1013 | − | {commit.message.split("\n")[0]} <span className="text-faint">· {commit.author.name}</span> | |
| 1016 | + | {commit.message.split("\n")[0]}{" "} | |
| 1017 | + | <span className="text-faint"> | |
| 1018 | + | · <CommitNames commit={commit} className="hover:text-fg" /> | |
| 1019 | + | </span> | |
| 1014 | 1020 | </p> | |
| 1015 | 1021 | )} | |
| 1016 | 1022 | </section> | |
| ⋯ | |||
| 1209 | 1215 | <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface"> | |
| 1210 | 1216 | {loaderData.commits.map((one) => ( | |
| 1211 | 1217 | <li key={one.hash} className="flex items-center gap-3 px-4 py-2.5 text-sm"> | |
| 1212 | − | <Avatar name={one.author} size={18} /> | |
| 1218 | + | <CommitAvatars commit={one} size={18} max={2} /> | |
| 1213 | 1219 | <span className="min-w-0 grow"> | |
| 1214 | 1220 | <Link to={`${base}/commit/${one.hash}`} className="block truncate hover:text-accent"> | |
| 1215 | 1221 | {one.message} | |
| 1216 | 1222 | </Link> | |
| 1217 | − | <span className="text-xs text-muted">{one.author}</span> | |
| 1223 | + | <span className="text-xs text-muted"> | |
| 1224 | + | <CommitNames commit={one} className="hover:text-fg" /> | |
| 1225 | + | </span> | |
| 1218 | 1226 | </span> | |
| 1219 | 1227 | <Link to={`${base}/commit/${one.hash}`} className="hidden shrink-0 font-mono text-xs text-faint hover:text-fg sm:block"> | |
| 1220 | 1228 | {one.hash.slice(0, 7)} | |
| 1512 | 1512 | ) : ( | |
| 1513 | 1513 | <Avatar name={name} size={20} /> | |
| 1514 | 1514 | )} | |
| 1515 | − | <span className="grow truncate font-mono text-xs">{name}</span> | |
| 1515 | + | <span className="grow truncate font-mono text-xs"> | |
| 1516 | + | <PersonLink name={name} className="hover:underline" /> | |
| 1517 | + | </span> | |
| 1516 | 1518 | {pending ? ( | |
| 1517 | 1519 | <span className="flex items-center gap-1.5 text-xs text-muted"> | |
| 1518 | 1520 | <span className="size-1.5 animate-pulse rounded-full bg-accent" /> | |
| ⋯ | |||
| 1610 | 1612 | {pull.assignees.map((name) => ( | |
| 1611 | 1613 | <li key={name} className="flex items-center gap-2 px-1"> | |
| 1612 | 1614 | <Avatar name={name} size={20} /> | |
| 1613 | − | <span className="grow truncate font-mono text-xs">{name}</span> | |
| 1615 | + | <span className="grow truncate font-mono text-xs"> | |
| 1616 | + | <PersonLink name={name} className="hover:underline" /> | |
| 1617 | + | </span> | |
| 1614 | 1618 | </li> | |
| 1615 | 1619 | ))} | |
| 1616 | 1620 | {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() }; | |