Skip to content

Commit

Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039)

syntaqxcommitted Parentse4a6e83afb1a16Browse files
25 files+577−990/25 viewed
+2−2
2626
2727 | Page | Address | What is on it |
2828 | --- | --- | --- |
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. |
3030 | 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). |
3131 | Invites | [`/settings/invites`](https://g1t.sh/settings/invites) | [Making, copying and revoking invites](#invites). |
3232 | 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. |
10421042 | 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. |
10431043 | 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. |
10441044 | 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. |
10461046 | Your username | Held for your account. Nobody else can take it. |
10471047
10481048 Within 30 days, support can restore it: write to support@g1t.sh from one
+13−2
566566 their own*. An agent's change landed without you when g1t merged it, by
567567 auto-merge or from the [merge queue](/guides/merge-queue/), with no person
568568 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
570573 list, and **New issue** opens a new issue in the project you pick.
571574
572575 | Across the top | What it counts |
669672 `example.com` is saved as `https://example.com`. Your email address is
670673 never shown.
671674
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+
672682 **Who sees what.** A profile is public, but the work and workspaces on it
673683 are filtered for whoever is looking:
674684
692702 | --- | --- |
693703 | Picture, name, username and pronouns | Always |
694704 | Bio and location | They filled them in |
705+| Their local time, such as **3:42 PM local time** | They set a [time zone](#profiles) |
695706 | **Member of** | The same workspaces their profile shows you, at most three named |
696707 | **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 |
697708
729740 | One of your confirmed addresses, or your noreply address | You |
730741 | An address added to an account but not confirmed | The name in the commit |
731742 | 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` |
733744 | g1t's own (`g1t@users.noreply.g1t.sh`) | `g1t` |
734745
735746 `Co-authored-by` trailers are matched the same way, and their pictures sit
+47−1
11 import { Check } from "lucide-react";
2−import { useState } from "react";
2+import { useEffect, useMemo, useState } from "react";
33 import { Form, Link } from "react-router";
44
55 import { PROFILE_LIMITS, type Profile } from "@g1t/contracts";
66
7+import { browserTimeZone, timeZoneLabel, timeZoneNames, utcOffset } from "../lib/time-zone";
78 import { SubmitButton, usePending } from "./ui";
9+import { Combobox } from "./ui/combobox";
810 import { Field, FieldDescription, FieldError, FieldLabel } from "./ui/field";
911 import { Input } from "./ui/input";
1012 import { Textarea } from "./ui/textarea";
2628 }) {
2729 const busy = usePending({ intent: "profile" });
2830 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]);
2947 return (
3048 <section id="profile" className="scroll-mt-20">
3149 <div className="flex flex-wrap items-baseline justify-between gap-2">
97115 />
98116 <FieldDescription>An https:// address.</FieldDescription>
99117 </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>
100146 <div className="flex flex-wrap items-center gap-3 sm:col-span-2">
101147 <SubmitButton pending="Saving…" match={{ intent: "profile" }}>
102148 Save profile
+6−2
1−import { Building2, GitCommitHorizontal, MapPin } from "lucide-react";
1+import { Building2, Clock, GitCommitHorizontal, MapPin } from "lucide-react";
22 import { type ReactElement, type ReactNode, useEffect, useState } from "react";
33 import { Link, useParams } from "react-router";
44
55 import { type Card, type UserCard as UserCardData, cardHref, committedLabel } from "../lib/hovercard";
66 import { G1T_MENTION_HREF } from "../lib/markdown-plugins";
7+import { localTime } from "../lib/time-zone";
78 import { Avatar } from "./ui";
89 import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card";
910 import { Skeleton } from "./ui/skeleton";
8687
8788 function PersonCard({ card }: { card: UserCardData }) {
8889 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());
8992 return (
9093 <div className="space-y-3">
9194 <div className="flex items-start gap-3">
107110 </div>
108111 </div>
109112 {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) && (
111114 <ul className="space-y-1.5 text-[0.8125rem] text-muted">
112115 {card.location && (
113116 <Line icon={<MapPin size={14} />}>
114117 <span className="wrap-anywhere">{card.location}</span>
115118 </Line>
116119 )}
120+ {time && <Line icon={<Clock size={14} />}>{time} local time</Line>}
117121 {card.workspaces.length > 0 && (
118122 <Line icon={<Building2 size={14} />}>
119123 Member of{" "}
+1−1
1010 | --- | --- |
1111 | 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. |
1212 | 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. |
1414 | Avatar, if you upload one | Shown next to your name. Stored by its content's hash and served publicly at `g1t.sh/avatars/…`. |
1515 | 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. |
1616 | Whether your email address is confirmed | Unconfirmed accounts can't create repositories or push. |
+8−0
1515 location: "London",
1616 website: "https://ada.example",
1717 pronouns: "she/her",
18+ timezone: "Europe/London",
1819 avatar: "cafe",
1920 createdAt: "2026-01-01T00:00:00Z",
2021 };
9798 assert.equal(hidden?.kind === "user" && hidden.committed, null);
9899 });
99100
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+
100108 test("a failing service leaves its part out, not the card", async () => {
101109 const card = await buildCard(
102110 "ada",
+3−0
2020 pronouns: string | null;
2121 bio: string | null;
2222 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;
2325 avatar: string | null;
2426 /** Workspaces the viewer may know they belong to, at most `MAX_WORKSPACES`. */
2527 workspaces: { slug: string; name: string; avatar: string | null }[];
112114 pronouns: profile.pronouns,
113115 bio: profile.bio,
114116 location: profile.location,
117+ timezone: profile.timezone ?? null,
115118 avatar: profile.avatar,
116119 workspaces: shown.slice(0, MAX_WORKSPACES).map((one) => ({ slug: one.slug, name: one.name, avatar: one.avatar })),
117120 more_workspaces: Math.max(0, shown.length - MAX_WORKSPACES),
+33−11
2121 sortRows,
2222 stallReason,
2323 summaryLine,
24+ pushedByPerson,
2425 pushedCommits,
2526 waitingRows,
2627 weekOf,
175176 files: [],
176177 });
177178
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"]);
182185 // 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" }]);
184205 });
185206
186207 test("a change landed without a person when g1t merged it", () => {
373394 });
374395
375396 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 } });
377398 // 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")];
379400 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" } },
383404 ];
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), [
385407 { hash: "m1", at: "2026-10-05T12:00:00Z" },
386408 { hash: "w2", at: "2026-10-07T12:00:00Z" },
387409 { hash: "w1", at: "2026-10-07T12:00:00Z" },
+50−17
432432
433433 // --- Landed -----------------------------------------------------------------
434434
435−/** A merged pull request, as the week counts it. */
436435 /**
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.
441453 */
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[] }>(
443469 history: C[],
444470 before: string | undefined,
471+ byG1t: (commit: C) => boolean = () => false,
445472 ): C[] {
446473 const end = before ? history.findIndex((commit) => commit.hash === before) : -1;
447474 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));
449476 }
450477
451478 /**
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.
456484 */
457−export function placePushes<C extends { hash: string; parents: string[]; author: { name: string } }>(
485+export function placePushes<C extends { hash: string; parents: string[] }>(
458486 history: C[],
459− pushes: { time: string; data: { after: string; before?: string } }[],
487+ pushes: PushRecord[],
488+ byG1t: (commit: C) => boolean = () => false,
460489 ): { hash: string; at: string }[] {
461490 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>();
463493 for (const push of [...pushes].reverse()) {
464494 const start = index.get(push.data.after);
465495 if (start === undefined) continue;
466496 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);
469501 }
470502 }
471− return [...seen].map(([hash, at]) => ({ hash, at }));
503+ return [...seen].flatMap(([hash, at]) => (at ? [{ hash, at }] : []));
472504 }
473505
506+/** A merged pull request, as the week counts it. */
474507 export type Merged = {
475508 repo: RepoPath;
476509 number: number;
+38−0
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+});
+74−0
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+}
+3−1
3737 stuckMinutes,
3838 waitedFor,
3939 } from "../lib/mission";
40+import { G1T_COMMIT_EMAILS, normalizeEmail } from "../lib/commit-people";
4041 import {
4142 type Fact,
4243 type Merged,
187188 const path = { namespace: repo.namespace, name: repo.name };
188189 const history = await reposApi.log(path, viewer, pushes[0].data.after, HISTORY_READ).catch(() => null);
189190 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)));
191193 }
192194
193195
+22−14
2323 } from "../../lib/session.server";
2424 import { useRefreshWhile } from "../../lib/refresh";
2525
26+/** The most of an outcome's activity the page shows. */
27+const ACTIVITY_LIMIT = 40;
2628
2729 export function meta({ params, ...args }: Route.MetaArgs) {
2830 return page(args, { title: `Plan · ${params.owner}/${params.repo} · g1t` });
4951 // What happened across the outcome's issues and pull requests.
5052 let activity: G1tEvent[] = [];
5153 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(() => []),
5569 ]);
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);
6775 // Events name accounts by id; show names.
6876 const named = await identity
6977 .usernames([...new Set(activity.flatMap((event) => (event.actor ? [event.actor] : [])))])
+1−0
3131 location: text("location"),
3232 website: text("website"),
3333 pronouns: text("pronouns"),
34+ timezone: text("timezone"),
3435 });
3536 return result.ok ? { profileSaved: true } : { profileError: result.error.message };
3637 }
+10−0
559559 /// Only events older than this event id.
560560 #[serde(default)]
561561 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>,
562572 #[serde(default)]
563573 pub limit: Option<u32>,
564574 }
+7−0
975975 pub const MAX_PROFILE_LOCATION: usize = 80;
976976 pub const MAX_PROFILE_WEBSITE: usize = 200;
977977 pub const MAX_PROFILE_PRONOUNS: usize = 40;
978+pub const MAX_PROFILE_TIMEZONE: usize = 64;
978979
979980 /// What anyone may see about a person.
980981 #[derive(Clone, Debug, Default, Serialize, Deserialize)]
989990 /// An `https://` address.
990991 pub website: Option<String>,
991992 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>,
992996 /// The uploaded avatar's hash, served at `/avatars/<avatar>`.
993997 pub avatar: Option<String>,
994998 /// When the account was made. RFC 3339.
10141018 pub website: String,
10151019 #[serde(default)]
10161020 pub pronouns: String,
1021+ /// An IANA time zone name, such as `America/Denver`.
1022+ #[serde(default)]
1023+ pub timezone: String,
10171024 }
10181025
10191026 /// `profile_workspaces`: the workspaces shown on a person's profile, as
+11−2
177177 * One branch moved by a push. `ref` is the full ref, `after` the commit it
178178 * points to now, and `defaultBranch` whether it is the default branch.
179179 */
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 };
182180 /**
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+ /**
183186 * `author` is who opened it: g1t, for one its agent filed while at work,
184187 * with `requestedBy` the person it was working for. Every issue and pull
185188 * request event carries both.
483486 types?: EventType[];
484487 /** Return events older than this event id. */
485488 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;
486495 limit?: number;
487496 };
488497
+5−1
966966 export type AgentScope = { repo: RepoPath; operations: string[]; run?: RunBinding };
967967
968968 /** 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;
970970
971971 /** What anyone may see about a person, at `g1t.sh/u/<username>`. */
972972 export type Profile = {
978978 /** Always an `https://` address. */
979979 website: string | null;
980980 pronouns: string | null;
981+ /** The time zone they are in, an IANA name such as `America/Denver`. */
982+ timezone: string | null;
981983 /** The uploaded avatar's hash, served at `/avatars/<avatar>`. */
982984 avatar: string | null;
983985 /** When the account was made. RFC 3339. */
992994 /** `https://…`; a bare `example.com` is taken as `https://example.com`. */
993995 website: string;
994996 pronouns: string;
997+ /** An IANA time zone name, such as `America/Denver`; empty clears it. */
998+ timezone: string;
995999 };
9961000
9971001 /** A workspace on a person's profile. */
+109−27
117117 /// Newest first. Callers must have checked that the viewer may see the
118118 /// repository asked about.
119119 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();
147128 values.push(a.limit.unwrap_or(DEFAULT_PAGE).min(MAX_PAGE).into());
148129 let rows = self
149130 .db
319300 Err(error) => worker::console_error!("audit entries kept past their plan's days: {error}"),
320301 }
321302 }
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+}
+4−0
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;
+32−15
982982 id: String,
983983 username: String,
984984 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.
986987 #[serde(default)]
987− purged: u8,
988+ gone: u8,
988989 }
989990 let mut owners = HashMap::new();
990991 let mut plain: Vec<String> = Vec::new();
10071008 let rows = self
10081009 .db
10091010 .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})"
10121014 ))
10131015 .bind(&bind)?
10141016 .all()
10151017 .await?
10161018 .results::<Row>()?;
10171019 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));
10191021 }
10201022 }
10211023 if !by_name.is_empty() {
10251027 let rows = self
10261028 .db
10271029 .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})
10301032 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
10321034 WHERE username IN ({marks})"
10331035 ))
10341036 .bind(&[bind.clone(), bind].concat())?
10391041 if let Some(row) = rows.iter().find(|row| row.username == username)
10401042 && suffix.as_deref().is_none_or(|suffix| id_suffix(&row.id) == suffix)
10411043 {
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));
10491045 }
10501046 }
10511047 }
12371233 pub display: String,
12381234 }
12391235
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+
12401248 #[cfg(test)]
12411249 mod tests {
12421250 use super::*;
12431251
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+
12441261 fn row(id: &str, email: &str, verified: bool, created: &str) -> EmailRow {
12451262 EmailRow {
12461263 id: id.into(),
+38−2
2121 location: Option<String>,
2222 website: Option<String>,
2323 pronouns: Option<String>,
24+ timezone: Option<String>,
2425 avatar: Option<String>,
2526 created_at: String,
2627 }
3435 location: row.location,
3536 website: row.website,
3637 pronouns: row.pronouns,
38+ timezone: row.timezone,
3739 avatar: row.avatar,
3840 created_at: row.created_at,
3941 }
4143 }
4244
4345 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";
4547
4648 /// A field as it is kept: whitespace runs made single spaces, control
4749 /// characters dropped, trimmed. Empty is none. Too long is refused.
113115 Ok(Some(address))
114116 }
115117
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+
116136 /// The fields of an update, checked, or the first thing wrong.
117137 pub struct Checked {
118138 pub name: Option<String>,
120140 pub location: Option<String>,
121141 pub website: Option<String>,
122142 pub pronouns: Option<String>,
143+ pub timezone: Option<String>,
123144 }
124145
125146 pub fn check(a: &UpdateProfileArgs) -> std::result::Result<Checked, String> {
129150 location: tidy(&a.location, MAX_PROFILE_LOCATION, "location")?,
130151 website: website(&a.website).map_err(str::to_owned)?,
131152 pronouns: tidy(&a.pronouns, MAX_PROFILE_PRONOUNS, "pronouns")?,
153+ timezone: timezone(&a.timezone).map_err(str::to_owned)?,
132154 })
133155 }
134156
162184 let row = self
163185 .db
164186 .prepare(format!(
165− "UPDATE users SET display_name = ?, bio = ?, location = ?, website = ?, pronouns = ?
187+ "UPDATE users SET display_name = ?, bio = ?, location = ?, website = ?, pronouns = ?, timezone = ?
166188 WHERE id = ? RETURNING {PROFILE_COLUMNS}"
167189 ))
168190 .bind(&[
171193 optional(&fields.location),
172194 optional(&fields.website),
173195 optional(&fields.pronouns),
196+ optional(&fields.timezone),
174197 a.actor.id.as_str().into(),
175198 ])?
176199 .first::<ProfileRow>(None)
286309 assert_eq!(fields.website.as_deref(), Some("https://syntaqx.com"));
287310 assert_eq!(fields.pronouns.as_deref(), Some("he/him"));
288311 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+ }
289325 }
290326 }
+1−1
5252 slug === "acme" ? ({ slug: "acme", name: "Acme", description: "Rockets", id: "w", createdAt: "", memberCount: 3, avatar: null } as Workspace) : null,
5353 profile: async (username) =>
5454 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: "" }
5656 : null,
5757 },
5858 repos: {
+10−0
26862686 }
26872687 continue;
26882688 }
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+ }
26892699 if event.kind != "workspace.renamed" {
26902700 continue;
26912701 }
+49−0
1313 use std::collections::{HashMap, HashSet};
1414
1515 use g1t_contracts::about::{ABOUT_CONTRIBUTORS, Contributor, Contributors, Freshness, LanguageShare, Languages, License, WeekCommits};
16+use g1t_contracts::account_deletion::GHOST_USERNAME;
1617 use g1t_contracts::accounts::{EmailOwner, EmailOwnersArgs};
1718 use g1t_contracts::repos::{FileEntry, MAX_LISTED_FILES, Repo};
1819 use g1t_contracts::time::rfc3339;
377378 owners
378379 }
379380
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+
380405 /// Whether the repository is one whose About is worked out: not a pull
381406 /// request's working copy.
382407 pub fn has_about(repo: &Repo) -> bool {
392417 }
393418
394419 #[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]
395444 fn a_security_policy_is_found_where_it_may_be() {
396445 assert_eq!(security_policy(&[file("src/SECURITY.md"), file(".github/SECURITY.md")]).as_deref(), Some(".github/SECURITY.md"));
397446 assert_eq!(security_policy(&[file("docs/security.md"), file("SECURITY.md")]).as_deref(), Some("SECURITY.md"));