Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039)
25 files+577−990/25 viewed
| 26 | 26 | ||
| 27 | 27 | | Page | Address | What is on it | | |
| 28 | 28 | | --- | --- | --- | | |
| 29 | − | | Profile | [`/settings/profile`](https://g1t.sh/settings/profile) | Your picture, and your [public profile](/guides/workspaces/#profiles): name, pronouns, bio, location and website. | | |
| 29 | + | | Profile | [`/settings/profile`](https://g1t.sh/settings/profile) | Your picture, and your [public profile](/guides/workspaces/#profiles): name, pronouns, bio, location, website and time zone. | | |
| 30 | 30 | | Emails | [`/settings/emails`](https://g1t.sh/settings/emails) | Your [email addresses](#email-addresses), the backup address, and [keeping your address private](#keeping-your-address-private). | | |
| 31 | 31 | | Invites | [`/settings/invites`](https://g1t.sh/settings/invites) | [Making, copying and revoking invites](#invites). | | |
| 32 | 32 | | SSH keys | [`/settings/keys`](https://g1t.sh/settings/keys) | Public keys for [git over SSH](/guides/git/#ssh), each with when it was added and last used. | | |
| ⋯ | |||
| 1042 | 1042 | | Access tokens, SSH keys and applications | Your personal access tokens (classic and fine-grained), SSH keys, connected applications and sign-ins from a tool stop working and are removed, and so do the deploy keys you added to repositories. A workspace's own tokens are not affected, even ones you made. | | |
| 1043 | 1043 | | Workspaces, teams and repositories | You leave every workspace and team, and lose the roles you were given on single repositories. Repository invitations waiting for you are withdrawn, and invites you made that nobody used are revoked. | | |
| 1044 | 1044 | | Your profile | `g1t.sh/<username>` answers 404, and you drop out of search. Nobody can add you to a workspace, team or repository, and nothing more is emailed to you. | | |
| 1045 | − | | What you wrote | Stays where it is, under your username for now. | | |
| 1045 | + | | What you wrote | Stays where it is, under your username for now. Commits made with your confirmed or noreply addresses show as `ghost`, and as yours again if your account is restored. | | |
| 1046 | 1046 | | Your username | Held for your account. Nobody else can take it. | | |
| 1047 | 1047 | ||
| 1048 | 1048 | Within 30 days, support can restore it: write to support@g1t.sh from one | |
| 566 | 566 | their own*. An agent's change landed without you when g1t merged it, by | |
| 567 | 567 | auto-merge or from the [merge queue](/guides/merge-queue/), with no person | |
| 568 | 568 | pressing merge. People's changes are their merged pull requests and the | |
| 569 | − | commits they pushed straight to the default branch. **Review N that need you** jumps to the | |
| 569 | + | commits they pushed straight to the default branch. A push is a person's | |
| 570 | + | by the account that signed in to make it, not by the name on its commits: | |
| 571 | + | pushes by g1t or a workflow job's token, and commits g1t wrote, are not | |
| 572 | + | counted as people's. **Review N that need you** jumps to the | |
| 570 | 573 | list, and **New issue** opens a new issue in the project you pick. | |
| 571 | 574 | ||
| 572 | 575 | | Across the top | What it counts | | |
| ⋯ | |||
| 669 | 672 | `example.com` is saved as `https://example.com`. Your email address is | |
| 670 | 673 | never shown. | |
| 671 | 674 | ||
| 675 | + | **Time zone.** Pick the time zone you are in, by city or region (such as | |
| 676 | + | `America/Denver`), and the [card over your name](#the-card-over-a-name) | |
| 677 | + | shows your local time, so people can tell whether it is a good moment to | |
| 678 | + | ask you something. If your browser's time zone differs from the one | |
| 679 | + | saved, the field offers **Use my browser's time zone**. Choose **Not | |
| 680 | + | shown** to clear it. | |
| 681 | + | ||
| 672 | 682 | **Who sees what.** A profile is public, but the work and workspaces on it | |
| 673 | 683 | are filtered for whoever is looking: | |
| 674 | 684 | ||
| ⋯ | |||
| 692 | 702 | | --- | --- | | |
| 693 | 703 | | Picture, name, username and pronouns | Always | | |
| 694 | 704 | | Bio and location | They filled them in | | |
| 705 | + | | Their local time, such as **3:42 PM local time** | They set a [time zone](#profiles) | | |
| 695 | 706 | | **Member of** | The same workspaces their profile shows you, at most three named | | |
| 696 | 707 | | **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 | 708 | ||
| ⋯ | |||
| 729 | 740 | | One of your confirmed addresses, or your noreply address | You | | |
| 730 | 741 | | An address added to an account but not confirmed | The name in the commit | | |
| 731 | 742 | | 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` | | |
| 743 | + | | A deleted account's noreply address, or any of its confirmed addresses during the 30 days it can be restored | `ghost` | | |
| 733 | 744 | | g1t's own (`g1t@users.noreply.g1t.sh`) | `g1t` | | |
| 734 | 745 | ||
| 735 | 746 | `Co-authored-by` trailers are matched the same way, and their pictures sit | |
| 1 | 1 | import { Check } from "lucide-react"; | |
| 2 | − | import { useState } from "react"; | |
| 2 | + | import { useEffect, useMemo, useState } from "react"; | |
| 3 | 3 | import { Form, Link } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import { PROFILE_LIMITS, type Profile } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | + | import { browserTimeZone, timeZoneLabel, timeZoneNames, utcOffset } from "../lib/time-zone"; | |
| 7 | 8 | import { SubmitButton, usePending } from "./ui"; | |
| 9 | + | import { Combobox } from "./ui/combobox"; | |
| 8 | 10 | import { Field, FieldDescription, FieldError, FieldLabel } from "./ui/field"; | |
| 9 | 11 | import { Input } from "./ui/input"; | |
| 10 | 12 | import { Textarea } from "./ui/textarea"; | |
| ⋯ | |||
| 26 | 28 | }) { | |
| 27 | 29 | const busy = usePending({ intent: "profile" }); | |
| 28 | 30 | const [bio, setBio] = useState(profile?.bio ?? ""); | |
| 31 | + | const [timezone, setTimezone] = useState(profile?.timezone ?? ""); | |
| 32 | + | // The browser's zone is only known once the page runs in it. | |
| 33 | + | const [browserZone, setBrowserZone] = useState<string | null>(null); | |
| 34 | + | useEffect(() => setBrowserZone(browserTimeZone()), []); | |
| 35 | + | const zones = useMemo(() => { | |
| 36 | + | const now = Date.now(); | |
| 37 | + | return [ | |
| 38 | + | { value: "", label: "Not shown" }, | |
| 39 | + | ...timeZoneNames(profile?.timezone).map((zone) => ({ | |
| 40 | + | value: zone, | |
| 41 | + | label: timeZoneLabel(zone), | |
| 42 | + | description: utcOffset(zone, now) ?? undefined, | |
| 43 | + | keywords: [zone], | |
| 44 | + | })), | |
| 45 | + | ]; | |
| 46 | + | }, [profile?.timezone]); | |
| 29 | 47 | return ( | |
| 30 | 48 | <section id="profile" className="scroll-mt-20"> | |
| 31 | 49 | <div className="flex flex-wrap items-baseline justify-between gap-2"> | |
| ⋯ | |||
| 97 | 115 | /> | |
| 98 | 116 | <FieldDescription>An https:// address.</FieldDescription> | |
| 99 | 117 | </Field> | |
| 118 | + | <Field> | |
| 119 | + | <FieldLabel htmlFor="profile-timezone">Time zone</FieldLabel> | |
| 120 | + | <Combobox | |
| 121 | + | id="profile-timezone" | |
| 122 | + | name="timezone" | |
| 123 | + | value={timezone} | |
| 124 | + | onValueChange={setTimezone} | |
| 125 | + | options={zones} | |
| 126 | + | placeholder="Not shown" | |
| 127 | + | searchPlaceholder="Find a city or region" | |
| 128 | + | emptyText="No time zone by that name." | |
| 129 | + | /> | |
| 130 | + | <FieldDescription> | |
| 131 | + | The card over your name shows your local time. | |
| 132 | + | {browserZone && browserZone !== timezone && ( | |
| 133 | + | <> | |
| 134 | + | {" "} | |
| 135 | + | <button | |
| 136 | + | type="button" | |
| 137 | + | onClick={() => setTimezone(browserZone)} | |
| 138 | + | className="text-accent underline-offset-4 hover:underline" | |
| 139 | + | > | |
| 140 | + | Use my browser's time zone ({timeZoneLabel(browserZone)}) | |
| 141 | + | </button> | |
| 142 | + | </> | |
| 143 | + | )} | |
| 144 | + | </FieldDescription> | |
| 145 | + | </Field> | |
| 100 | 146 | <div className="flex flex-wrap items-center gap-3 sm:col-span-2"> | |
| 101 | 147 | <SubmitButton pending="Saving…" match={{ intent: "profile" }}> | |
| 102 | 148 | Save profile | |
| 1 | − | import { Building2, GitCommitHorizontal, MapPin } from "lucide-react"; | |
| 1 | + | import { Building2, Clock, GitCommitHorizontal, MapPin } from "lucide-react"; | |
| 2 | 2 | import { type ReactElement, type ReactNode, useEffect, useState } from "react"; | |
| 3 | 3 | import { Link, useParams } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import { type Card, type UserCard as UserCardData, cardHref, committedLabel } from "../lib/hovercard"; | |
| 6 | 6 | import { G1T_MENTION_HREF } from "../lib/markdown-plugins"; | |
| 7 | + | import { localTime } from "../lib/time-zone"; | |
| 7 | 8 | import { Avatar } from "./ui"; | |
| 8 | 9 | import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card"; | |
| 9 | 10 | import { Skeleton } from "./ui/skeleton"; | |
| ⋯ | |||
| 86 | 87 | ||
| 87 | 88 | function PersonCard({ card }: { card: UserCardData }) { | |
| 88 | 89 | const profile = `/u/${card.username}`; | |
| 90 | + | // Cards are only drawn in the browser, so this is the viewer's clock. | |
| 91 | + | const time = localTime(card.timezone, Date.now()); | |
| 89 | 92 | return ( | |
| 90 | 93 | <div className="space-y-3"> | |
| 91 | 94 | <div className="flex items-start gap-3"> | |
| ⋯ | |||
| 107 | 110 | </div> | |
| 108 | 111 | </div> | |
| 109 | 112 | {card.bio && <p className="leading-relaxed text-fg/90 wrap-anywhere">{card.bio}</p>} | |
| 110 | − | {(card.location || card.workspaces.length > 0 || card.committed) && ( | |
| 113 | + | {(card.location || time || card.workspaces.length > 0 || card.committed) && ( | |
| 111 | 114 | <ul className="space-y-1.5 text-[0.8125rem] text-muted"> | |
| 112 | 115 | {card.location && ( | |
| 113 | 116 | <Line icon={<MapPin size={14} />}> | |
| 114 | 117 | <span className="wrap-anywhere">{card.location}</span> | |
| 115 | 118 | </Line> | |
| 116 | 119 | )} | |
| 120 | + | {time && <Line icon={<Clock size={14} />}>{time} local time</Line>} | |
| 117 | 121 | {card.workspaces.length > 0 && ( | |
| 118 | 122 | <Line icon={<Building2 size={14} />}> | |
| 119 | 123 | Member of{" "} | |
| 10 | 10 | | --- | --- | | |
| 11 | 11 | | Username and email address | To identify you, sign you in, and reach you about your account. Your username is public; your email address is not. | | |
| 12 | 12 | | Password | To sign you in. We store only a salted hash of it (PBKDF2-SHA256), never the password itself. | | |
| 13 | − | | Profile: name, bio, location, website and pronouns, if you add them | Shown on your public profile. All optional. | | |
| 13 | + | | Profile: name, bio, location, website, pronouns and time zone, if you add them | Shown on your public profile, and your local time on the card over your name. All optional. | | |
| 14 | 14 | | Avatar, if you upload one | Shown next to your name. Stored by its content's hash and served publicly at `g1t.sh/avatars/…`. | | |
| 15 | 15 | | Sessions, access tokens, SSH keys, and apps you've approved through sign-in with g1t | To keep you signed in and let your tools act for you. Session and token secrets are stored only as hashes. | | |
| 16 | 16 | | Whether your email address is confirmed | Unconfirmed accounts can't create repositories or push. | |
| 15 | 15 | location: "London", | |
| 16 | 16 | website: "https://ada.example", | |
| 17 | 17 | pronouns: "she/her", | |
| 18 | + | timezone: "Europe/London", | |
| 18 | 19 | avatar: "cafe", | |
| 19 | 20 | createdAt: "2026-01-01T00:00:00Z", | |
| 20 | 21 | }; | |
| ⋯ | |||
| 97 | 98 | assert.equal(hidden?.kind === "user" && hidden.committed, null); | |
| 98 | 99 | }); | |
| 99 | 100 | ||
| 101 | + | test("the card carries the time zone a profile gives, and none when it gives none", async () => { | |
| 102 | + | const card = await buildCard("ada", me, null, sources(), NOW); | |
| 103 | + | assert.equal(card?.kind === "user" && card.timezone, "Europe/London"); | |
| 104 | + | const without = await buildCard("ada", me, null, sources({ profile: async () => ({ ...ada, timezone: null }) }), NOW); | |
| 105 | + | assert.equal(without?.kind === "user" && without.timezone, null); | |
| 106 | + | }); | |
| 107 | + | ||
| 100 | 108 | test("a failing service leaves its part out, not the card", async () => { | |
| 101 | 109 | const card = await buildCard( | |
| 102 | 110 | "ada", | |
| 20 | 20 | pronouns: string | null; | |
| 21 | 21 | bio: string | null; | |
| 22 | 22 | location: string | null; | |
| 23 | + | /** The IANA time zone they gave, such as `America/Denver`; the card shows their local time from it. */ | |
| 24 | + | timezone: string | null; | |
| 23 | 25 | avatar: string | null; | |
| 24 | 26 | /** Workspaces the viewer may know they belong to, at most `MAX_WORKSPACES`. */ | |
| 25 | 27 | workspaces: { slug: string; name: string; avatar: string | null }[]; | |
| ⋯ | |||
| 112 | 114 | pronouns: profile.pronouns, | |
| 113 | 115 | bio: profile.bio, | |
| 114 | 116 | location: profile.location, | |
| 117 | + | timezone: profile.timezone ?? null, | |
| 115 | 118 | avatar: profile.avatar, | |
| 116 | 119 | workspaces: shown.slice(0, MAX_WORKSPACES).map((one) => ({ slug: one.slug, name: one.name, avatar: one.avatar })), | |
| 117 | 120 | more_workspaces: Math.max(0, shown.length - MAX_WORKSPACES), | |
| 21 | 21 | sortRows, | |
| 22 | 22 | stallReason, | |
| 23 | 23 | summaryLine, | |
| 24 | + | pushedByPerson, | |
| 24 | 25 | pushedCommits, | |
| 25 | 26 | waitingRows, | |
| 26 | 27 | weekOf, | |
| ⋯ | |||
| 175 | 176 | files: [], | |
| 176 | 177 | }); | |
| 177 | 178 | ||
| 178 | − | test("a push to the default branch counts a person's own commits, not merges or agents'", () => { | |
| 179 | − | const c = (hash: string, author: string, parents = 1) => ({ hash, author: { name: author }, parents: Array(parents).fill("p") }); | |
| 180 | − | const history = [c("e", "Chase"), c("d", "g1t"), c("m", "g1t", 2), c("b", "Chase"), c("a", "Chase")]; | |
| 181 | − | assert.deepEqual(pushedCommits(history, "a").map((x) => x.hash), ["e", "b"]); | |
| 179 | + | test("a push to the default branch counts a person's own commits, not merges or g1t's", () => { | |
| 180 | + | const c = (hash: string, email: string, parents = 1) => ({ hash, author: { name: "g1t", email }, parents: Array(parents).fill("p") }); | |
| 181 | + | // Every commit is named "g1t": the name decides nothing, the address does. | |
| 182 | + | const history = [c("e", "chase@example.com"), c("d", "agent@g1t.sh"), c("m", "chase@example.com", 2), c("b", "chase@example.com"), c("a", "chase@example.com")]; | |
| 183 | + | const byG1t = (commit: { author: { email: string } }) => commit.author.email === "agent@g1t.sh"; | |
| 184 | + | assert.deepEqual(pushedCommits(history, "a", byG1t).map((x) => x.hash), ["e", "b"]); | |
| 182 | 185 | // Where it pointed before was not read: everything read counts. | |
| 183 | − | assert.deepEqual(pushedCommits(history, "zz").map((x) => x.hash), ["e", "b", "a"]); | |
| 186 | + | assert.deepEqual(pushedCommits(history, "zz", byG1t).map((x) => x.hash), ["e", "b", "a"]); | |
| 187 | + | }); | |
| 188 | + | ||
| 189 | + | test("who pushed is the account that signed in, not the name on the commits", () => { | |
| 190 | + | assert.ok(pushedByPerson({ actor: "usr_chase", data: {} })); | |
| 191 | + | assert.ok(pushedByPerson({ actor: null, data: {} })); | |
| 192 | + | assert.ok(!pushedByPerson({ actor: "usr_g1t_agent", data: {} })); | |
| 193 | + | assert.ok(!pushedByPerson({ actor: "g1t_policy", data: {} })); | |
| 194 | + | // A workflow job's own token is not a person either. | |
| 195 | + | assert.ok(!pushedByPerson({ actor: "usr_chase", data: { causedByJob: "run_1" } })); | |
| 196 | + | ||
| 197 | + | const c = (hash: string, name: string) => ({ hash, parents: ["p"], author: { name, email: `${hash}@example.com` } }); | |
| 198 | + | // A person whose git name is "g1t" pushed m1; g1t pushed w1 under a person's name. | |
| 199 | + | const history = [c("w1", "Chase Pierce"), c("m1", "g1t"), c("old", "Chase Pierce")]; | |
| 200 | + | const pushes = [ | |
| 201 | + | { time: "2026-10-07T12:00:00Z", actor: "usr_g1t_agent", data: { after: "w1", before: "m1" } }, | |
| 202 | + | { time: "2026-10-05T12:00:00Z", actor: "usr_chase", data: { after: "m1", before: "old" } }, | |
| 203 | + | ]; | |
| 204 | + | assert.deepEqual(placePushes(history, pushes), [{ hash: "m1", at: "2026-10-05T12:00:00Z" }]); | |
| 184 | 205 | }); | |
| 185 | 206 | ||
| 186 | 207 | test("a change landed without a person when g1t merged it", () => { | |
| ⋯ | |||
| 373 | 394 | }); | |
| 374 | 395 | ||
| 375 | 396 | test("each push to the default branch places the commits it brought, at its time, from one read of the history", () => { | |
| 376 | − | const c = (hash: string, parents = ["p"], author = "Chase Pierce") => ({ hash, parents, author: { name: author } }); | |
| 397 | + | const c = (hash: string, parents = ["p"], email = "chase@example.com") => ({ hash, parents, author: { name: "Chase Pierce", email } }); | |
| 377 | 398 | // Newest first: a Wednesday push of two, a Monday push of one, a merge, and g1t's own commit. | |
| 378 | − | const history = [c("w2"), c("w1"), c("m1"), c("merge", ["a", "b"]), c("bot", ["p"], "g1t"), c("old")]; | |
| 399 | + | const history = [c("w2"), c("w1"), c("m1"), c("merge", ["a", "b"]), c("bot", ["p"], "g1t@users.noreply.g1t.sh"), c("old")]; | |
| 379 | 400 | const pushes = [ | |
| 380 | − | { time: "2026-10-07T12:00:00Z", data: { after: "w2", before: "m1" } }, | |
| 381 | − | { time: "2026-10-05T12:00:00Z", data: { after: "m1", before: "old" } }, | |
| 382 | − | { time: "2026-10-01T12:00:00Z", data: { after: "gone", before: "older" } }, | |
| 401 | + | { time: "2026-10-07T12:00:00Z", actor: "usr_chase", data: { after: "w2", before: "m1" } }, | |
| 402 | + | { time: "2026-10-05T12:00:00Z", actor: "usr_chase", data: { after: "m1", before: "old" } }, | |
| 403 | + | { time: "2026-10-01T12:00:00Z", actor: "usr_chase", data: { after: "gone", before: "older" } }, | |
| 383 | 404 | ]; | |
| 384 | − | assert.deepEqual(placePushes(history, pushes), [ | |
| 405 | + | const byG1t = (commit: { author: { email: string } }) => commit.author.email.endsWith("@users.noreply.g1t.sh"); | |
| 406 | + | assert.deepEqual(placePushes(history, pushes, byG1t), [ | |
| 385 | 407 | { hash: "m1", at: "2026-10-05T12:00:00Z" }, | |
| 386 | 408 | { hash: "w2", at: "2026-10-07T12:00:00Z" }, | |
| 387 | 409 | { hash: "w1", at: "2026-10-07T12:00:00Z" }, | |
| 432 | 432 | ||
| 433 | 433 | // --- Landed ----------------------------------------------------------------- | |
| 434 | 434 | ||
| 435 | − | /** A merged pull request, as the week counts it. */ | |
| 436 | 435 | /** | |
| 437 | − | * The commits a push to the default branch brought, from the history at its | |
| 438 | − | * `after` (newest first) back to its `before`: people's own, not merges (a | |
| 439 | − | * pull request landing) and not agents'. A push whose `before` is not in | |
| 440 | − | * what was read gives what was read. | |
| 436 | + | * The accounts g1t itself acts as on the event log: its agent, and the | |
| 437 | + | * policy that merges and pushes for it. An event's `actor` is an account | |
| 438 | + | * id, so this is what tells g1t apart, whatever name a commit carries. | |
| 439 | + | */ | |
| 440 | + | export const G1T_ACCOUNT_IDS: ReadonlySet<string> = new Set(["usr_g1t_agent", "g1t_policy"]); | |
| 441 | + | ||
| 442 | + | /** A `git.push` from the log, as far as placing its commits needs it. */ | |
| 443 | + | export type PushRecord = { | |
| 444 | + | time: string; | |
| 445 | + | /** The account that pushed; null when the log does not say. */ | |
| 446 | + | actor?: string | null; | |
| 447 | + | data: { after: string; before?: string; causedByJob?: string }; | |
| 448 | + | }; | |
| 449 | + | ||
| 450 | + | /** | |
| 451 | + | * Whether a person pushed: not g1t or one of its agents (by the account | |
| 452 | + | * that signed in to push), and not a workflow job's own token. | |
| 441 | 453 | */ | |
| 442 | − | export function pushedCommits<C extends { hash: string; parents: string[]; author: { name: string } }>( | |
| 454 | + | export function pushedByPerson(push: Pick<PushRecord, "actor" | "data">): boolean { | |
| 455 | + | if (push.data.causedByJob) return false; | |
| 456 | + | return !(push.actor && G1T_ACCOUNT_IDS.has(push.actor)); | |
| 457 | + | } | |
| 458 | + | ||
| 459 | + | /** | |
| 460 | + | * The commits a person's push to the default branch brought, from the | |
| 461 | + | * history at its `after` (newest first) back to its `before`: their own, | |
| 462 | + | * not merges (a pull request landing) and not g1t's (`byG1t`: a commit | |
| 463 | + | * g1t wrote, told by its author address, which a person may fast-forward | |
| 464 | + | * onto the branch). A push whose `before` is not in what was read gives | |
| 465 | + | * what was read. Who pushed is the caller's to decide (`pushedByPerson`); | |
| 466 | + | * a commit's author name says nothing about it. | |
| 467 | + | */ | |
| 468 | + | export function pushedCommits<C extends { hash: string; parents: string[] }>( | |
| 443 | 469 | history: C[], | |
| 444 | 470 | before: string | undefined, | |
| 471 | + | byG1t: (commit: C) => boolean = () => false, | |
| 445 | 472 | ): C[] { | |
| 446 | 473 | const end = before ? history.findIndex((commit) => commit.hash === before) : -1; | |
| 447 | 474 | const brought = end === -1 ? history : history.slice(0, end); | |
| 448 | − | return brought.filter((commit) => commit.parents.length <= 1 && !isAgent(commit.author.name)); | |
| 475 | + | return brought.filter((commit) => commit.parents.length <= 1 && !byG1t(commit)); | |
| 449 | 476 | } | |
| 450 | 477 | ||
| 451 | 478 | /** | |
| 452 | − | * Each commit of `history` (newest first) a push in `pushes` (newest | |
| 453 | − | * first) brought, at that push's time. A commit pushed twice (after a | |
| 454 | − | * force push, say) counts once, at its first landing; a push whose `after` | |
| 455 | − | * is no longer in the history (rewritten) brings nothing. | |
| 479 | + | * Each commit of `history` (newest first) a person's push in `pushes` | |
| 480 | + | * (newest first) brought, at that push's time. A commit pushed twice | |
| 481 | + | * (after a force push, say) counts once, at its first landing; a push | |
| 482 | + | * whose `after` is no longer in the history (rewritten) brings nothing, | |
| 483 | + | * and g1t's own pushes bring nothing here: pull requests count those. | |
| 456 | 484 | */ | |
| 457 | − | export function placePushes<C extends { hash: string; parents: string[]; author: { name: string } }>( | |
| 485 | + | export function placePushes<C extends { hash: string; parents: string[] }>( | |
| 458 | 486 | history: C[], | |
| 459 | − | pushes: { time: string; data: { after: string; before?: string } }[], | |
| 487 | + | pushes: PushRecord[], | |
| 488 | + | byG1t: (commit: C) => boolean = () => false, | |
| 460 | 489 | ): { hash: string; at: string }[] { | |
| 461 | 490 | const index = new Map(history.map((commit, i) => [commit.hash, i])); | |
| 462 | − | const seen = new Map<string, string>(); | |
| 491 | + | // Each commit's first landing: the time, or null when g1t landed it. | |
| 492 | + | const seen = new Map<string, string | null>(); | |
| 463 | 493 | for (const push of [...pushes].reverse()) { | |
| 464 | 494 | const start = index.get(push.data.after); | |
| 465 | 495 | if (start === undefined) continue; | |
| 466 | 496 | const end = push.data.before ? index.get(push.data.before) : undefined; | |
| 467 | − | for (const commit of pushedCommits(history.slice(start, end ?? history.length), undefined)) { | |
| 468 | − | if (!seen.has(commit.hash)) seen.set(commit.hash, push.time); | |
| 497 | + | const range = history.slice(start, end ?? history.length); | |
| 498 | + | const at = pushedByPerson(push) ? push.time : null; | |
| 499 | + | for (const commit of pushedCommits(range, undefined, byG1t)) { | |
| 500 | + | if (!seen.has(commit.hash)) seen.set(commit.hash, at); | |
| 469 | 501 | } | |
| 470 | 502 | } | |
| 471 | − | return [...seen].map(([hash, at]) => ({ hash, at })); | |
| 503 | + | return [...seen].flatMap(([hash, at]) => (at ? [{ hash, at }] : [])); | |
| 472 | 504 | } | |
| 473 | 505 | ||
| 506 | + | /** A merged pull request, as the week counts it. */ | |
| 474 | 507 | export type Merged = { | |
| 475 | 508 | repo: RepoPath; | |
| 476 | 509 | number: number; |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { knownTimeZone, localTime, timeZoneLabel, timeZoneNames, utcOffset } from "./time-zone.ts"; | |
| 5 | + | ||
| 6 | + | // 2026-10-08 21:42 UTC: 3:42 PM in Denver (MDT), the next morning in Tokyo. | |
| 7 | + | const NOW = Date.UTC(2026, 9, 8, 21, 42); | |
| 8 | + | ||
| 9 | + | test("a profile's local time is the time of day in its zone", () => { | |
| 10 | + | // ICU puts a narrow no-break space before AM/PM in some versions. | |
| 11 | + | const at = (zone: string) => localTime(zone, NOW, "en-US")?.replace(/\s/gu, " "); | |
| 12 | + | assert.equal(at("America/Denver"), "3:42 PM"); | |
| 13 | + | assert.equal(at("Asia/Tokyo"), "6:42 AM"); | |
| 14 | + | assert.equal(at("UTC"), "9:42 PM"); | |
| 15 | + | }); | |
| 16 | + | ||
| 17 | + | test("no zone, or one the runtime does not know, shows no time", () => { | |
| 18 | + | assert.equal(localTime(null, NOW, "en-US"), null); | |
| 19 | + | assert.equal(localTime("", NOW, "en-US"), null); | |
| 20 | + | assert.equal(localTime("Mars/Olympus_Mons", NOW, "en-US"), null); | |
| 21 | + | assert.ok(!knownTimeZone("Mars/Olympus_Mons")); | |
| 22 | + | assert.ok(knownTimeZone("Europe/Berlin")); | |
| 23 | + | }); | |
| 24 | + | ||
| 25 | + | test("the zones to pick from are sorted, with UTC and a saved one always there", () => { | |
| 26 | + | const names = timeZoneNames("Mars/Olympus_Mons"); | |
| 27 | + | assert.ok(names.includes("UTC")); | |
| 28 | + | assert.ok(names.includes("America/Denver")); | |
| 29 | + | assert.ok(names.includes("Mars/Olympus_Mons")); | |
| 30 | + | assert.deepEqual(names, [...names].sort((a, b) => a.localeCompare(b))); | |
| 31 | + | }); | |
| 32 | + | ||
| 33 | + | test("zones read as places, with their offset", () => { | |
| 34 | + | assert.equal(timeZoneLabel("America/Port_of_Spain"), "America/Port of Spain"); | |
| 35 | + | assert.equal(utcOffset("America/Denver", NOW), "UTC−06:00"); | |
| 36 | + | assert.equal(utcOffset("Asia/Kolkata", NOW), "UTC+05:30"); | |
| 37 | + | assert.equal(utcOffset("UTC", NOW), "UTC+00:00"); | |
| 38 | + | }); |
| 1 | + | /** | |
| 2 | + | * Time zones on a profile: the IANA names a person picks from in their | |
| 3 | + | * settings, and the local time the card over their name shows. Pure, so it | |
| 4 | + | * is tested on its own. | |
| 5 | + | */ | |
| 6 | + | ||
| 7 | + | /** Whether the runtime knows `zone` as a time zone. */ | |
| 8 | + | export function knownTimeZone(zone: string | null | undefined): zone is string { | |
| 9 | + | if (!zone) return false; | |
| 10 | + | try { | |
| 11 | + | new Intl.DateTimeFormat("en-US", { timeZone: zone }); | |
| 12 | + | return true; | |
| 13 | + | } catch { | |
| 14 | + | return false; | |
| 15 | + | } | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | /** | |
| 19 | + | * Every IANA zone the runtime knows, sorted, with `current` kept in the | |
| 20 | + | * list even when the runtime does not know it, so a saved choice is never | |
| 21 | + | * dropped. UTC is always there. | |
| 22 | + | */ | |
| 23 | + | export function timeZoneNames(current?: string | null): string[] { | |
| 24 | + | let names: string[] = []; | |
| 25 | + | try { | |
| 26 | + | names = Intl.supportedValuesOf("timeZone"); | |
| 27 | + | } catch { | |
| 28 | + | names = []; | |
| 29 | + | } | |
| 30 | + | const all = new Set(names); | |
| 31 | + | all.add("UTC"); | |
| 32 | + | if (current) all.add(current); | |
| 33 | + | return [...all].sort((a, b) => a.localeCompare(b)); | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | /** A zone's name as a person reads it: `America/Port_of_Spain` as `America/Port of Spain`. */ | |
| 37 | + | export function timeZoneLabel(zone: string): string { | |
| 38 | + | return zone.replaceAll("_", " "); | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /** The zone's offset from UTC at `now`, such as `UTC−06:00`; null when unknown. */ | |
| 42 | + | export function utcOffset(zone: string, now: number): string | null { | |
| 43 | + | try { | |
| 44 | + | const part = new Intl.DateTimeFormat("en-US", { timeZone: zone, timeZoneName: "longOffset" }) | |
| 45 | + | .formatToParts(now) | |
| 46 | + | .find((one) => one.type === "timeZoneName")?.value; | |
| 47 | + | if (!part) return null; | |
| 48 | + | // "GMT-06:00", or plain "GMT" at UTC itself. | |
| 49 | + | const offset = part.replace(/^GMT/, "") || "+00:00"; | |
| 50 | + | return `UTC${offset.replace("-", "−")}`; | |
| 51 | + | } catch { | |
| 52 | + | return null; | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | /** | |
| 57 | + | * The time of day at `now` in `zone`, such as `3:42 PM`, in `locale` (the | |
| 58 | + | * viewer's own when left out); null when there is no zone or the runtime | |
| 59 | + | * does not know it. | |
| 60 | + | */ | |
| 61 | + | export function localTime(zone: string | null | undefined, now: number, locale?: string): string | null { | |
| 62 | + | if (!knownTimeZone(zone)) return null; | |
| 63 | + | return new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "2-digit", timeZone: zone }).format(now); | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | /** The browser's own zone, or null where it cannot say (on the server, say). */ | |
| 67 | + | export function browserTimeZone(): string | null { | |
| 68 | + | try { | |
| 69 | + | const zone = Intl.DateTimeFormat().resolvedOptions().timeZone; | |
| 70 | + | return knownTimeZone(zone) ? zone : null; | |
| 71 | + | } catch { | |
| 72 | + | return null; | |
| 73 | + | } | |
| 74 | + | } |
| 37 | 37 | stuckMinutes, | |
| 38 | 38 | waitedFor, | |
| 39 | 39 | } from "../lib/mission"; | |
| 40 | + | import { G1T_COMMIT_EMAILS, normalizeEmail } from "../lib/commit-people"; | |
| 40 | 41 | import { | |
| 41 | 42 | type Fact, | |
| 42 | 43 | type Merged, | |
| ⋯ | |||
| 187 | 188 | const path = { namespace: repo.namespace, name: repo.name }; | |
| 188 | 189 | const history = await reposApi.log(path, viewer, pushes[0].data.after, HISTORY_READ).catch(() => null); | |
| 189 | 190 | if (!history?.ok) return []; | |
| 190 | − | return placePushes(history.value, pushes); | |
| 191 | + | // g1t's own commits are told by their author address, never by name. | |
| 192 | + | return placePushes(history.value, pushes, (commit) => G1T_COMMIT_EMAILS.has(normalizeEmail(commit.author.email))); | |
| 191 | 193 | } | |
| 192 | 194 | ||
| 193 | 195 | ||
| 23 | 23 | } from "../../lib/session.server"; | |
| 24 | 24 | import { useRefreshWhile } from "../../lib/refresh"; | |
| 25 | 25 | ||
| 26 | + | /** The most of an outcome's activity the page shows. */ | |
| 27 | + | const ACTIVITY_LIMIT = 40; | |
| 26 | 28 | ||
| 27 | 29 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 28 | 30 | return page(args, { title: `Plan · ${params.owner}/${params.repo} · g1t` }); | |
| ⋯ | |||
| 49 | 51 | // What happened across the outcome's issues and pull requests. | |
| 50 | 52 | let activity: G1tEvent[] = []; | |
| 51 | 53 | if (found.status === "applied" && found.progress.length > 0) { | |
| 52 | − | const numbers = new Set([ | |
| 53 | − | ...found.progress.map((item) => item.number), | |
| 54 | − | ...found.progress.flatMap((item) => (item.pull != null ? [item.pull] : [])), | |
| 54 | + | const numbers = [ | |
| 55 | + | ...new Set([ | |
| 56 | + | ...found.progress.map((item) => item.number), | |
| 57 | + | ...found.progress.flatMap((item) => (item.pull != null ? [item.pull] : [])), | |
| 58 | + | ]), | |
| 59 | + | ]; | |
| 60 | + | const since = found.finishedAt ?? found.createdAt; | |
| 61 | + | // The log is read for this outcome alone, so a busy repository's other | |
| 62 | + | // work never crowds it out: events on its issues and pull requests, and | |
| 63 | + | // issues an agent filed while working on it, which belong to it too. | |
| 64 | + | const [own, filed] = await Promise.all([ | |
| 65 | + | events.list({ repoId: found.repoId, numbers, since, limit: ACTIVITY_LIMIT }).catch(() => []), | |
| 66 | + | events | |
| 67 | + | .list({ repoId: found.repoId, types: ["issue.opened"], actor: "usr_g1t_agent", since, limit: ACTIVITY_LIMIT }) | |
| 68 | + | .catch(() => []), | |
| 55 | 69 | ]); | |
| 56 | − | const since = found.finishedAt ?? found.createdAt; | |
| 57 | − | const recent = await events.list({ repoId: found.repoId, limit: 200 }).catch(() => []); | |
| 58 | − | activity = recent | |
| 59 | − | .filter((event) => event.time >= since) | |
| 60 | − | .filter((event) => { | |
| 61 | − | const data = event.data as { number?: number; issue?: number }; | |
| 62 | − | // Issues an agent filed while working on this outcome belong to it too. | |
| 63 | − | if (event.type === "issue.opened" && event.actor === "usr_g1t_agent") return true; | |
| 64 | − | return (data.number != null && numbers.has(data.number)) || (data.issue != null && numbers.has(data.issue)); | |
| 65 | − | }) | |
| 66 | − | .slice(0, 40); | |
| 70 | + | const seen = new Set<string>(); | |
| 71 | + | activity = [...own, ...filed] | |
| 72 | + | .filter((event) => (seen.has(event.id) ? false : (seen.add(event.id), true))) | |
| 73 | + | .sort((a, b) => (a.id < b.id ? 1 : a.id > b.id ? -1 : 0)) | |
| 74 | + | .slice(0, ACTIVITY_LIMIT); | |
| 67 | 75 | // Events name accounts by id; show names. | |
| 68 | 76 | const named = await identity | |
| 69 | 77 | .usernames([...new Set(activity.flatMap((event) => (event.actor ? [event.actor] : [])))]) | |
| 31 | 31 | location: text("location"), | |
| 32 | 32 | website: text("website"), | |
| 33 | 33 | pronouns: text("pronouns"), | |
| 34 | + | timezone: text("timezone"), | |
| 34 | 35 | }); | |
| 35 | 36 | return result.ok ? { profileSaved: true } : { profileError: result.error.message }; | |
| 36 | 37 | } |
| 559 | 559 | /// Only events older than this event id. | |
| 560 | 560 | #[serde(default)] | |
| 561 | 561 | pub before: Option<String>, | |
| 562 | + | /// Only events by this account id. | |
| 563 | + | #[serde(default)] | |
| 564 | + | pub actor: Option<String>, | |
| 565 | + | /// Only events about these issues or pull requests: their `number`, or | |
| 566 | + | /// the `issue` a comment, review or link is on. All when empty. | |
| 567 | + | #[serde(default)] | |
| 568 | + | pub numbers: Vec<u32>, | |
| 569 | + | /// Only events at or after this RFC 3339 time. | |
| 570 | + | #[serde(default)] | |
| 571 | + | pub since: Option<String>, | |
| 562 | 572 | #[serde(default)] | |
| 563 | 573 | pub limit: Option<u32>, | |
| 564 | 574 | } |
| 975 | 975 | pub const MAX_PROFILE_LOCATION: usize = 80; | |
| 976 | 976 | pub const MAX_PROFILE_WEBSITE: usize = 200; | |
| 977 | 977 | pub const MAX_PROFILE_PRONOUNS: usize = 40; | |
| 978 | + | pub const MAX_PROFILE_TIMEZONE: usize = 64; | |
| 978 | 979 | ||
| 979 | 980 | /// What anyone may see about a person. | |
| 980 | 981 | #[derive(Clone, Debug, Default, Serialize, Deserialize)] | |
| ⋯ | |||
| 989 | 990 | /// An `https://` address. | |
| 990 | 991 | pub website: Option<String>, | |
| 991 | 992 | pub pronouns: Option<String>, | |
| 993 | + | /// The time zone they are in, an IANA name such as `America/Denver`. | |
| 994 | + | #[serde(default)] | |
| 995 | + | pub timezone: Option<String>, | |
| 992 | 996 | /// The uploaded avatar's hash, served at `/avatars/<avatar>`. | |
| 993 | 997 | pub avatar: Option<String>, | |
| 994 | 998 | /// When the account was made. RFC 3339. | |
| ⋯ | |||
| 1014 | 1018 | pub website: String, | |
| 1015 | 1019 | #[serde(default)] | |
| 1016 | 1020 | pub pronouns: String, | |
| 1021 | + | /// An IANA time zone name, such as `America/Denver`. | |
| 1022 | + | #[serde(default)] | |
| 1023 | + | pub timezone: String, | |
| 1017 | 1024 | } | |
| 1018 | 1025 | ||
| 1019 | 1026 | /// `profile_workspaces`: the workspaces shown on a person's profile, as | |
| 177 | 177 | * One branch moved by a push. `ref` is the full ref, `after` the commit it | |
| 178 | 178 | * points to now, and `defaultBranch` whether it is the default branch. | |
| 179 | 179 | */ | |
| 180 | − | /** `before` is where the ref pointed before; absent for a new branch or tag. */ | |
| 181 | − | "git.push": { repoId: string; ref: string; before?: string; after: string; defaultBranch: boolean }; | |
| 182 | 180 | /** | |
| 181 | + | * `before` is where the ref pointed before; absent for a new branch or | |
| 182 | + | * tag. `causedByJob` is set when a workflow job's token pushed: the run's id. | |
| 183 | + | */ | |
| 184 | + | "git.push": { repoId: string; ref: string; before?: string; after: string; defaultBranch: boolean; causedByJob?: string }; | |
| 185 | + | /** | |
| 183 | 186 | * `author` is who opened it: g1t, for one its agent filed while at work, | |
| 184 | 187 | * with `requestedBy` the person it was working for. Every issue and pull | |
| 185 | 188 | * request event carries both. | |
| ⋯ | |||
| 483 | 486 | types?: EventType[]; | |
| 484 | 487 | /** Return events older than this event id. */ | |
| 485 | 488 | before?: string; | |
| 489 | + | /** Only events by this account id. */ | |
| 490 | + | actor?: string; | |
| 491 | + | /** Only events about these issues or pull requests (`number`, or the `issue` a comment or review is on). */ | |
| 492 | + | numbers?: number[]; | |
| 493 | + | /** Only events at or after this RFC 3339 time. */ | |
| 494 | + | since?: string; | |
| 486 | 495 | limit?: number; | |
| 487 | 496 | }; | |
| 488 | 497 | ||
| 966 | 966 | export type AgentScope = { repo: RepoPath; operations: string[]; run?: RunBinding }; | |
| 967 | 967 | ||
| 968 | 968 | /** The most characters each profile field takes. Mirrors `crates/contracts/src/identity.rs`. */ | |
| 969 | − | export const PROFILE_LIMITS = { name: 80, bio: 160, location: 80, website: 200, pronouns: 40 } as const; | |
| 969 | + | export const PROFILE_LIMITS = { name: 80, bio: 160, location: 80, website: 200, pronouns: 40, timezone: 64 } as const; | |
| 970 | 970 | ||
| 971 | 971 | /** What anyone may see about a person, at `g1t.sh/u/<username>`. */ | |
| 972 | 972 | export type Profile = { | |
| ⋯ | |||
| 978 | 978 | /** Always an `https://` address. */ | |
| 979 | 979 | website: string | null; | |
| 980 | 980 | pronouns: string | null; | |
| 981 | + | /** The time zone they are in, an IANA name such as `America/Denver`. */ | |
| 982 | + | timezone: string | null; | |
| 981 | 983 | /** The uploaded avatar's hash, served at `/avatars/<avatar>`. */ | |
| 982 | 984 | avatar: string | null; | |
| 983 | 985 | /** When the account was made. RFC 3339. */ | |
| ⋯ | |||
| 992 | 994 | /** `https://…`; a bare `example.com` is taken as `https://example.com`. */ | |
| 993 | 995 | website: string; | |
| 994 | 996 | pronouns: string; | |
| 997 | + | /** An IANA time zone name, such as `America/Denver`; empty clears it. */ | |
| 998 | + | timezone: string; | |
| 995 | 999 | }; | |
| 996 | 1000 | ||
| 997 | 1001 | /** A workspace on a person's profile. */ | |
| 117 | 117 | /// Newest first. Callers must have checked that the viewer may see the | |
| 118 | 118 | /// repository asked about. | |
| 119 | 119 | async fn list(&self, a: ListArgs) -> Result<Vec<Event>> { | |
| 120 | − | let mut conditions = Vec::new(); | |
| 121 | − | let mut values: Vec<JsValue> = Vec::new(); | |
| 122 | − | if let Some(repo_id) = &a.repo_id { | |
| 123 | − | conditions.push("repo_id = ?".to_owned()); | |
| 124 | − | values.push(repo_id.as_str().into()); | |
| 125 | − | } | |
| 126 | − | if !a.types.is_empty() { | |
| 127 | − | let marks = vec!["?"; a.types.len()].join(", "); | |
| 128 | − | conditions.push(format!("type IN ({marks})")); | |
| 129 | − | values.extend(a.types.iter().map(|kind| JsValue::from(kind.as_str()))); | |
| 130 | − | } else { | |
| 131 | − | // What CI and integrations report on commits goes to webhooks, | |
| 132 | − | // and is read from each commit's checks; a timeline asked for | |
| 133 | − | // everything would be little else on a busy repository. | |
| 134 | − | let marks = vec!["?"; REPORTING.len()].join(", "); | |
| 135 | − | conditions.push(format!("type NOT IN ({marks})")); | |
| 136 | − | values.extend(REPORTING.iter().map(|kind| JsValue::from(*kind))); | |
| 137 | − | } | |
| 138 | − | if let Some(before) = &a.before { | |
| 139 | − | conditions.push("id < ?".to_owned()); | |
| 140 | − | values.push(before.as_str().into()); | |
| 141 | − | } | |
| 142 | − | let filter = if conditions.is_empty() { | |
| 143 | − | String::new() | |
| 144 | − | } else { | |
| 145 | − | format!("WHERE {}", conditions.join(" AND ")) | |
| 146 | − | }; | |
| 120 | + | let (filter, binds) = list_filter(&a); | |
| 121 | + | let mut values: Vec<JsValue> = binds | |
| 122 | + | .into_iter() | |
| 123 | + | .map(|bind| match bind { | |
| 124 | + | Bind::Text(text) => JsValue::from(text), | |
| 125 | + | Bind::Number(number) => JsValue::from(number), | |
| 126 | + | }) | |
| 127 | + | .collect(); | |
| 147 | 128 | values.push(a.limit.unwrap_or(DEFAULT_PAGE).min(MAX_PAGE).into()); | |
| 148 | 129 | let rows = self | |
| 149 | 130 | .db | |
| ⋯ | |||
| 319 | 300 | Err(error) => worker::console_error!("audit entries kept past their plan's days: {error}"), | |
| 320 | 301 | } | |
| 321 | 302 | } | |
| 303 | + | ||
| 304 | + | /// A value bound to a `?` in [`list_filter`]'s clause. | |
| 305 | + | #[derive(Debug, PartialEq)] | |
| 306 | + | enum Bind { | |
| 307 | + | Text(String), | |
| 308 | + | Number(f64), | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | /// The `WHERE` clause `list` reads with, and what it binds, in order. | |
| 312 | + | fn list_filter(a: &ListArgs) -> (String, Vec<Bind>) { | |
| 313 | + | let mut conditions = Vec::new(); | |
| 314 | + | let mut values = Vec::new(); | |
| 315 | + | if let Some(repo_id) = &a.repo_id { | |
| 316 | + | conditions.push("repo_id = ?".to_owned()); | |
| 317 | + | values.push(Bind::Text(repo_id.clone())); | |
| 318 | + | } | |
| 319 | + | if !a.types.is_empty() { | |
| 320 | + | let marks = vec!["?"; a.types.len()].join(", "); | |
| 321 | + | conditions.push(format!("type IN ({marks})")); | |
| 322 | + | values.extend(a.types.iter().map(|kind| Bind::Text(kind.clone()))); | |
| 323 | + | } else { | |
| 324 | + | // What CI and integrations report on commits goes to webhooks, | |
| 325 | + | // and is read from each commit's checks; a timeline asked for | |
| 326 | + | // everything would be little else on a busy repository. | |
| 327 | + | let marks = vec!["?"; REPORTING.len()].join(", "); | |
| 328 | + | conditions.push(format!("type NOT IN ({marks})")); | |
| 329 | + | values.extend(REPORTING.iter().map(|kind| Bind::Text((*kind).to_owned()))); | |
| 330 | + | } | |
| 331 | + | if let Some(actor) = &a.actor { | |
| 332 | + | conditions.push("actor = ?".to_owned()); | |
| 333 | + | values.push(Bind::Text(actor.clone())); | |
| 334 | + | } | |
| 335 | + | if !a.numbers.is_empty() { | |
| 336 | + | // An issue or pull request's own events name it as `number`; a | |
| 337 | + | // comment, review or link on it names it as `issue`. | |
| 338 | + | let marks = vec!["?"; a.numbers.len()].join(", "); | |
| 339 | + | conditions.push(format!( | |
| 340 | + | "(json_extract(data, '$.number') IN ({marks}) OR json_extract(data, '$.issue') IN ({marks}))" | |
| 341 | + | )); | |
| 342 | + | for _ in 0..2 { | |
| 343 | + | values.extend(a.numbers.iter().map(|number| Bind::Number(f64::from(*number)))); | |
| 344 | + | } | |
| 345 | + | } | |
| 346 | + | if let Some(since) = &a.since { | |
| 347 | + | conditions.push("time >= ?".to_owned()); | |
| 348 | + | values.push(Bind::Text(since.clone())); | |
| 349 | + | } | |
| 350 | + | if let Some(before) = &a.before { | |
| 351 | + | conditions.push("id < ?".to_owned()); | |
| 352 | + | values.push(Bind::Text(before.clone())); | |
| 353 | + | } | |
| 354 | + | let filter = if conditions.is_empty() { | |
| 355 | + | String::new() | |
| 356 | + | } else { | |
| 357 | + | format!("WHERE {}", conditions.join(" AND ")) | |
| 358 | + | }; | |
| 359 | + | (filter, values) | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | #[cfg(test)] | |
| 363 | + | mod tests { | |
| 364 | + | use super::*; | |
| 365 | + | ||
| 366 | + | #[test] | |
| 367 | + | fn a_plain_list_leaves_out_what_is_reported_on_commits() { | |
| 368 | + | let (filter, binds) = list_filter(&ListArgs { repo_id: Some("rep_1".into()), ..ListArgs::default() }); | |
| 369 | + | assert!(filter.starts_with("WHERE repo_id = ? AND type NOT IN (")); | |
| 370 | + | assert_eq!(binds[0], Bind::Text("rep_1".into())); | |
| 371 | + | assert_eq!(binds.len(), 1 + REPORTING.len()); | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | #[test] | |
| 375 | + | fn numbers_match_an_item_or_what_is_said_on_it_since_a_time() { | |
| 376 | + | let (filter, binds) = list_filter(&ListArgs { | |
| 377 | + | repo_id: Some("rep_1".into()), | |
| 378 | + | types: vec!["issue.opened".into()], | |
| 379 | + | numbers: vec![4, 9], | |
| 380 | + | since: Some("2026-10-01T00:00:00Z".into()), | |
| 381 | + | actor: Some("usr_g1t_agent".into()), | |
| 382 | + | ..ListArgs::default() | |
| 383 | + | }); | |
| 384 | + | assert_eq!( | |
| 385 | + | filter, | |
| 386 | + | "WHERE repo_id = ? AND type IN (?) AND actor = ? AND \ | |
| 387 | + | (json_extract(data, '$.number') IN (?, ?) OR json_extract(data, '$.issue') IN (?, ?)) AND time >= ?" | |
| 388 | + | ); | |
| 389 | + | assert_eq!( | |
| 390 | + | binds, | |
| 391 | + | vec![ | |
| 392 | + | Bind::Text("rep_1".into()), | |
| 393 | + | Bind::Text("issue.opened".into()), | |
| 394 | + | Bind::Text("usr_g1t_agent".into()), | |
| 395 | + | Bind::Number(4.0), | |
| 396 | + | Bind::Number(9.0), | |
| 397 | + | Bind::Number(4.0), | |
| 398 | + | Bind::Number(9.0), | |
| 399 | + | Bind::Text("2026-10-01T00:00:00Z".into()), | |
| 400 | + | ] | |
| 401 | + | ); | |
| 402 | + | } | |
| 403 | + | } | |
| 1 | + | -- The time zone a person is in, as an IANA name such as America/Denver, | |
| 2 | + | -- so the card over their name can show their local time. Optional and | |
| 3 | + | -- public like the rest of a profile; null means not given. | |
| 4 | + | ALTER TABLE users ADD COLUMN timezone TEXT; |
| 982 | 982 | id: String, | |
| 983 | 983 | username: String, | |
| 984 | 984 | avatar: Option<String>, | |
| 985 | − | /// 1 for a purged account's username (`deleted_users`). | |
| 985 | + | /// 1 for an account that is deleted: purged (its username is | |
| 986 | + | /// in `deleted_users`) or in its window to be restored. | |
| 986 | 987 | #[serde(default)] | |
| 987 | − | purged: u8, | |
| 988 | + | gone: u8, | |
| 988 | 989 | } | |
| 989 | 990 | let mut owners = HashMap::new(); | |
| 990 | 991 | let mut plain: Vec<String> = Vec::new(); | |
| ⋯ | |||
| 1007 | 1008 | let rows = self | |
| 1008 | 1009 | .db | |
| 1009 | 1010 | .prepare(format!( | |
| 1010 | − | "SELECT e.email, u.id, u.username, u.avatar FROM user_emails e JOIN users u ON u.id = e.user_id | |
| 1011 | − | WHERE e.verified_at IS NOT NULL AND u.deleted_at IS NULL AND e.email IN ({marks})" | |
| 1011 | + | "SELECT e.email, u.id, u.username, u.avatar, u.deleted_at IS NOT NULL AS gone | |
| 1012 | + | FROM user_emails e JOIN users u ON u.id = e.user_id | |
| 1013 | + | WHERE e.verified_at IS NOT NULL AND e.email IN ({marks})" | |
| 1012 | 1014 | )) | |
| 1013 | 1015 | .bind(&bind)? | |
| 1014 | 1016 | .all() | |
| 1015 | 1017 | .await? | |
| 1016 | 1018 | .results::<Row>()?; | |
| 1017 | 1019 | for row in rows { | |
| 1018 | − | owners.insert(row.email, EmailOwner { id: row.id, username: row.username, avatar: row.avatar }); | |
| 1020 | + | owners.insert(row.email, shown_owner(row.id, row.username, row.avatar, row.gone != 0)); | |
| 1019 | 1021 | } | |
| 1020 | 1022 | } | |
| 1021 | 1023 | if !by_name.is_empty() { | |
| ⋯ | |||
| 1025 | 1027 | let rows = self | |
| 1026 | 1028 | .db | |
| 1027 | 1029 | .prepare(format!( | |
| 1028 | − | "SELECT username AS email, id, username, avatar, 0 AS purged FROM users | |
| 1029 | − | WHERE username IN ({marks}) AND deleted_at IS NULL | |
| 1030 | + | "SELECT username AS email, id, username, avatar, deleted_at IS NOT NULL AS gone FROM users | |
| 1031 | + | WHERE username IN ({marks}) | |
| 1030 | 1032 | UNION ALL | |
| 1031 | − | SELECT username AS email, user_id AS id, username, NULL AS avatar, 1 AS purged FROM deleted_users | |
| 1033 | + | SELECT username AS email, user_id AS id, username, NULL AS avatar, 1 AS gone FROM deleted_users | |
| 1032 | 1034 | WHERE username IN ({marks})" | |
| 1033 | 1035 | )) | |
| 1034 | 1036 | .bind(&[bind.clone(), bind].concat())? | |
| ⋯ | |||
| 1039 | 1041 | if let Some(row) = rows.iter().find(|row| row.username == username) | |
| 1040 | 1042 | && suffix.as_deref().is_none_or(|suffix| id_suffix(&row.id) == suffix) | |
| 1041 | 1043 | { | |
| 1042 | − | // A purged account's commits are ghost's (account_deletion.rs). | |
| 1043 | − | let owner = if row.purged != 0 { | |
| 1044 | − | EmailOwner { id: GHOST_ID.to_owned(), username: GHOST_USERNAME.to_owned(), avatar: None } | |
| 1045 | − | } else { | |
| 1046 | − | EmailOwner { id: row.id.clone(), username: row.username.clone(), avatar: row.avatar.clone() } | |
| 1047 | − | }; | |
| 1048 | − | owners.insert(email, owner); | |
| 1044 | + | owners.insert(email, shown_owner(row.id.clone(), row.username.clone(), row.avatar.clone(), row.gone != 0)); | |
| 1049 | 1045 | } | |
| 1050 | 1046 | } | |
| 1051 | 1047 | } | |
| ⋯ | |||
| 1237 | 1233 | pub display: String, | |
| 1238 | 1234 | } | |
| 1239 | 1235 | ||
| 1236 | + | /// Who an address's commits are shown as: the account, or ghost for a | |
| 1237 | + | /// deleted one. An account in its window to be restored is ghost already, | |
| 1238 | + | /// as it will be once purged (account_deletion.rs), and is itself again if | |
| 1239 | + | /// staff restore it, since nothing about it is changed here. | |
| 1240 | + | fn shown_owner(id: String, username: String, avatar: Option<String>, gone: bool) -> EmailOwner { | |
| 1241 | + | if gone { | |
| 1242 | + | EmailOwner { id: GHOST_ID.to_owned(), username: GHOST_USERNAME.to_owned(), avatar: None } | |
| 1243 | + | } else { | |
| 1244 | + | EmailOwner { id, username, avatar } | |
| 1245 | + | } | |
| 1246 | + | } | |
| 1247 | + | ||
| 1240 | 1248 | #[cfg(test)] | |
| 1241 | 1249 | mod tests { | |
| 1242 | 1250 | use super::*; | |
| 1243 | 1251 | ||
| 1252 | + | #[test] | |
| 1253 | + | fn a_deleted_account_is_ghost_on_its_commits_until_restored() { | |
| 1254 | + | let live = shown_owner("usr_ana".into(), "ana".into(), Some("abc".into()), false); | |
| 1255 | + | assert_eq!((live.id.as_str(), live.username.as_str(), live.avatar.as_deref()), ("usr_ana", "ana", Some("abc"))); | |
| 1256 | + | // Deleted and restorable, or purged: ghost, with nothing of the account. | |
| 1257 | + | let gone = shown_owner("usr_ana".into(), "ana".into(), Some("abc".into()), true); | |
| 1258 | + | assert_eq!((gone.id.as_str(), gone.username.as_str(), gone.avatar), (GHOST_ID, GHOST_USERNAME, None)); | |
| 1259 | + | } | |
| 1260 | + | ||
| 1244 | 1261 | fn row(id: &str, email: &str, verified: bool, created: &str) -> EmailRow { | |
| 1245 | 1262 | EmailRow { | |
| 1246 | 1263 | id: id.into(), | |
| 21 | 21 | location: Option<String>, | |
| 22 | 22 | website: Option<String>, | |
| 23 | 23 | pronouns: Option<String>, | |
| 24 | + | timezone: Option<String>, | |
| 24 | 25 | avatar: Option<String>, | |
| 25 | 26 | created_at: String, | |
| 26 | 27 | } | |
| ⋯ | |||
| 34 | 35 | location: row.location, | |
| 35 | 36 | website: row.website, | |
| 36 | 37 | pronouns: row.pronouns, | |
| 38 | + | timezone: row.timezone, | |
| 37 | 39 | avatar: row.avatar, | |
| 38 | 40 | created_at: row.created_at, | |
| 39 | 41 | } | |
| ⋯ | |||
| 41 | 43 | } | |
| 42 | 44 | ||
| 43 | 45 | const PROFILE_COLUMNS: &str = | |
| 44 | − | "username, display_name, bio, location, website, pronouns, avatar, created_at"; | |
| 46 | + | "username, display_name, bio, location, website, pronouns, timezone, avatar, created_at"; | |
| 45 | 47 | ||
| 46 | 48 | /// A field as it is kept: whitespace runs made single spaces, control | |
| 47 | 49 | /// characters dropped, trimmed. Empty is none. Too long is refused. | |
| ⋯ | |||
| 113 | 115 | Ok(Some(address)) | |
| 114 | 116 | } | |
| 115 | 117 | ||
| 118 | + | /// An IANA time zone name as it is kept, such as `America/Denver` or | |
| 119 | + | /// `UTC`: empty is none. Only the name's shape is checked here; the web | |
| 120 | + | /// app offers the zones its runtime knows, and one it does not know is | |
| 121 | + | /// shown without a local time. | |
| 122 | + | fn timezone(value: &str) -> std::result::Result<Option<String>, &'static str> { | |
| 123 | + | const REFUSED: &str = "That is not a time zone. Pick one from the list, such as America/Denver."; | |
| 124 | + | let name = value.trim(); | |
| 125 | + | if name.is_empty() { | |
| 126 | + | return Ok(None); | |
| 127 | + | } | |
| 128 | + | let well_formed = name.len() <= MAX_PROFILE_TIMEZONE | |
| 129 | + | && name.split('/').all(|part| { | |
| 130 | + | part.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) | |
| 131 | + | && part.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '+')) | |
| 132 | + | }); | |
| 133 | + | if well_formed { Ok(Some(name.to_owned())) } else { Err(REFUSED) } | |
| 134 | + | } | |
| 135 | + | ||
| 116 | 136 | /// The fields of an update, checked, or the first thing wrong. | |
| 117 | 137 | pub struct Checked { | |
| 118 | 138 | pub name: Option<String>, | |
| ⋯ | |||
| 120 | 140 | pub location: Option<String>, | |
| 121 | 141 | pub website: Option<String>, | |
| 122 | 142 | pub pronouns: Option<String>, | |
| 143 | + | pub timezone: Option<String>, | |
| 123 | 144 | } | |
| 124 | 145 | ||
| 125 | 146 | pub fn check(a: &UpdateProfileArgs) -> std::result::Result<Checked, String> { | |
| ⋯ | |||
| 129 | 150 | location: tidy(&a.location, MAX_PROFILE_LOCATION, "location")?, | |
| 130 | 151 | website: website(&a.website).map_err(str::to_owned)?, | |
| 131 | 152 | pronouns: tidy(&a.pronouns, MAX_PROFILE_PRONOUNS, "pronouns")?, | |
| 153 | + | timezone: timezone(&a.timezone).map_err(str::to_owned)?, | |
| 132 | 154 | }) | |
| 133 | 155 | } | |
| 134 | 156 | ||
| ⋯ | |||
| 162 | 184 | let row = self | |
| 163 | 185 | .db | |
| 164 | 186 | .prepare(format!( | |
| 165 | − | "UPDATE users SET display_name = ?, bio = ?, location = ?, website = ?, pronouns = ? | |
| 187 | + | "UPDATE users SET display_name = ?, bio = ?, location = ?, website = ?, pronouns = ?, timezone = ? | |
| 166 | 188 | WHERE id = ? RETURNING {PROFILE_COLUMNS}" | |
| 167 | 189 | )) | |
| 168 | 190 | .bind(&[ | |
| ⋯ | |||
| 171 | 193 | optional(&fields.location), | |
| 172 | 194 | optional(&fields.website), | |
| 173 | 195 | optional(&fields.pronouns), | |
| 196 | + | optional(&fields.timezone), | |
| 174 | 197 | a.actor.id.as_str().into(), | |
| 175 | 198 | ])? | |
| 176 | 199 | .first::<ProfileRow>(None) | |
| ⋯ | |||
| 286 | 309 | assert_eq!(fields.website.as_deref(), Some("https://syntaqx.com")); | |
| 287 | 310 | assert_eq!(fields.pronouns.as_deref(), Some("he/him")); | |
| 288 | 311 | assert_eq!(fields.location, None); | |
| 312 | + | assert_eq!(fields.timezone, None); | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | #[test] | |
| 316 | + | fn a_time_zone_is_an_iana_name_or_nothing() { | |
| 317 | + | for name in ["America/Denver", "UTC", "America/Argentina/Buenos_Aires", "Etc/GMT+7", "America/Port-au-Prince"] { | |
| 318 | + | assert_eq!(timezone(name).unwrap().as_deref(), Some(name)); | |
| 319 | + | } | |
| 320 | + | assert_eq!(timezone(" Europe/Berlin ").unwrap().as_deref(), Some("Europe/Berlin")); | |
| 321 | + | assert_eq!(timezone("").unwrap(), None); | |
| 322 | + | for bad in ["America/", "/UTC", "Europe/Ber lin", "<script>", "../etc", &"A".repeat(65)] { | |
| 323 | + | assert!(timezone(bad).is_err(), "{bad}"); | |
| 324 | + | } | |
| 289 | 325 | } | |
| 290 | 326 | } | |
| 52 | 52 | slug === "acme" ? ({ slug: "acme", name: "Acme", description: "Rockets", id: "w", createdAt: "", memberCount: 3, avatar: null } as Workspace) : null, | |
| 53 | 53 | profile: async (username) => | |
| 54 | 54 | username === "ada" | |
| 55 | − | ? { username: "ada", name: "Ada Lovelace", bio: "Engines.", location: null, website: null, pronouns: null, avatar: null, createdAt: "" } | |
| 55 | + | ? { username: "ada", name: "Ada Lovelace", bio: "Engines.", location: null, website: null, pronouns: null, timezone: null, avatar: null, createdAt: "" } | |
| 56 | 56 | : null, | |
| 57 | 57 | }, | |
| 58 | 58 | repos: { |
| 2686 | 2686 | } | |
| 2687 | 2687 | continue; | |
| 2688 | 2688 | } | |
| 2689 | + | // An account deleted or restored: kept contributors that name it | |
| 2690 | + | // (or ghost, for a restore) are worked out again, so it shows as | |
| 2691 | + | // ghost for its 30 days, and as itself again if restored. | |
| 2692 | + | if let Some(needle) = stats::shown_differently(&event.kind, &event.data) { | |
| 2693 | + | let db = env.d1("DB")?; | |
| 2694 | + | if let Err(error) = stats::rework_naming(&db, &needle).await { | |
| 2695 | + | worker::console_error!("{} {}: contributors not marked to be counted again: {error}", event.kind, event.id); | |
| 2696 | + | } | |
| 2697 | + | continue; | |
| 2698 | + | } | |
| 2689 | 2699 | if event.kind != "workspace.renamed" { | |
| 2690 | 2700 | continue; | |
| 2691 | 2701 | } |
| 13 | 13 | use std::collections::{HashMap, HashSet}; | |
| 14 | 14 | ||
| 15 | 15 | use g1t_contracts::about::{ABOUT_CONTRIBUTORS, Contributor, Contributors, Freshness, LanguageShare, Languages, License, WeekCommits}; | |
| 16 | + | use g1t_contracts::account_deletion::GHOST_USERNAME; | |
| 16 | 17 | use g1t_contracts::accounts::{EmailOwner, EmailOwnersArgs}; | |
| 17 | 18 | use g1t_contracts::repos::{FileEntry, MAX_LISTED_FILES, Repo}; | |
| 18 | 19 | use g1t_contracts::time::rfc3339; | |
| ⋯ | |||
| 377 | 378 | owners | |
| 378 | 379 | } | |
| 379 | 380 | ||
| 381 | + | /// What a kept contributors answer says when it names an account that | |
| 382 | + | /// `kind` (a `user.deleting` or `user.restored` event with `data`) changes: | |
| 383 | + | /// a deleted account's username, which is ghost from now on, or ghost's, | |
| 384 | + | /// which a restored account may be among. `None` for any other event. | |
| 385 | + | pub fn shown_differently(kind: &str, data: &serde_json::Value) -> Option<String> { | |
| 386 | + | let username = match kind { | |
| 387 | + | "user.deleting" => data["username"].as_str().filter(|name| !name.is_empty())?.to_owned(), | |
| 388 | + | "user.restored" => GHOST_USERNAME.to_owned(), | |
| 389 | + | _ => return None, | |
| 390 | + | }; | |
| 391 | + | Some(format!("\"username\":{}", serde_json::Value::String(username))) | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | /// Marks every repository whose kept contributors contain `needle` (see | |
| 395 | + | /// [`shown_differently`]) to be worked out again on its next view, as an | |
| 396 | + | /// older version's would be. | |
| 397 | + | pub async fn rework_naming(db: &D1Database, needle: &str) -> Result<()> { | |
| 398 | + | db.prepare("UPDATE repo_stats SET version = 0 WHERE instr(contributors, ?1) > 0") | |
| 399 | + | .bind(&[needle.into()])? | |
| 400 | + | .run() | |
| 401 | + | .await?; | |
| 402 | + | Ok(()) | |
| 403 | + | } | |
| 404 | + | ||
| 380 | 405 | /// Whether the repository is one whose About is worked out: not a pull | |
| 381 | 406 | /// request's working copy. | |
| 382 | 407 | pub fn has_about(repo: &Repo) -> bool { | |
| ⋯ | |||
| 392 | 417 | } | |
| 393 | 418 | ||
| 394 | 419 | #[test] | |
| 420 | + | fn deleting_or_restoring_an_account_reworks_the_answers_that_name_it() { | |
| 421 | + | let kept = serde_json::to_string(&Contributor { | |
| 422 | + | kind: g1t_contracts::about::ContributorKind::User, | |
| 423 | + | name: "ana".into(), | |
| 424 | + | username: Some("ana".into()), | |
| 425 | + | avatar: None, | |
| 426 | + | commits: 3, | |
| 427 | + | first_at: "2026-10-01T00:00:00Z".into(), | |
| 428 | + | last_at: "2026-10-02T00:00:00Z".into(), | |
| 429 | + | weeks: Vec::new(), | |
| 430 | + | }) | |
| 431 | + | .unwrap(); | |
| 432 | + | let deleting = shown_differently("user.deleting", &serde_json::json!({ "userId": "usr_ana", "username": "ana" })).unwrap(); | |
| 433 | + | assert!(kept.contains(&deleting)); | |
| 434 | + | // Not a longer name that starts the same. | |
| 435 | + | assert!(!kept.replace("\"ana\"", "\"anabel\"").contains(&deleting)); | |
| 436 | + | // A restored account may be any ghost: those answers are worked out again. | |
| 437 | + | let restored = shown_differently("user.restored", &serde_json::json!({ "userId": "usr_ana", "username": "ana" })).unwrap(); | |
| 438 | + | assert!(kept.replace("\"ana\"", "\"ghost\"").contains(&restored)); | |
| 439 | + | assert_eq!(shown_differently("user.updated", &serde_json::json!({ "username": "ana" })), None); | |
| 440 | + | assert_eq!(shown_differently("user.deleting", &serde_json::json!({})), None); | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | #[test] | |
| 395 | 444 | fn a_security_policy_is_found_where_it_may_be() { | |
| 396 | 445 | assert_eq!(security_policy(&[file("src/SECURITY.md"), file(".github/SECURITY.md")]).as_deref(), Some(".github/SECURITY.md")); | |
| 397 | 446 | assert_eq!(security_policy(&[file("docs/security.md"), file("SECURITY.md")]).as_deref(), Some("SECURITY.md")); | |