Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

Commit

Merge branch 'worktree-agent-a972386958e10ee2b' into ship-blame

syntaqxcommitted Parentsb3f9aed4a36b16Browse files
39 files+1747−1557 files not counted0/39 viewed
+1−1
33753375 "view": {
33763376 "type": "string",
33773377 "enum": ["inbox", "saved", "done"],
3378− "description": "inbox (the default): not done and not snoozed. saved: what you saved. done: what you marked done.",
3378+ "description": "inbox (the default): not done and not snoozed. saved: what you saved. done: what is done, marked by you or settled by itself; an item that settled itself carries `settled_reason` (such as `CI passed in run 330` or `Merged`) and `settled_by` (a username, or g1t, when someone did).",
33793379 },
33803380 "reason": {
33813381 "type": "string",
+1−1
7777 | `g1t-events-webhooks` | `webhooks` | Everything |
7878 | `g1t-events-security` | `security` | `git.push`, pull request changes, `comment.created`, `repo.created`, `repo.visibility_changed` |
7979 | `g1t-events-search` | `search` | `git.push`, `repo.*`, `issue.*`, `pull.*`, `user.*`, `workspace.*` |
80−| `g1t-events-deployments` | `deployments` | Pushes, the pull request lifecycle, workspace deletion and restore |
80+| `g1t-events-deployments` | `deployments` | Pushes, the pull request lifecycle, workspace deletion and restore, and deployment statuses (what each production deployment shipped) |
8181 | `g1t-events-projects` | `projects` | Repositories, packages and deployments |
8282 | `g1t-events-repos` | `repos` | `pull.merged`, `pull.closed`, `pull.reopened`, deletions and restores |
8383 | `g1t-events-packages` | `packages` | `git.push`, `repo.visibility_changed`, workspace and team changes |
+60−6
88
99 1. **Where you're needed**: everything waiting on you, however long ago
1010 it started, with the action beside each item.
11−2. **What happened while you were away**: what people did; what landed;
12− deploys, decisions and spend, since you were last here.
11+2. **What happened while you were away**: what settled by itself and
12+ what went live; what people did; what landed; deploys and spend,
13+ since you were last here.
1314 3. **What is running now**: workflow runs and builds still going.
1415
1516 **Start here** picks the one item to do first.
103104 When Code and Chat both failed to answer, the sentence ends with "what
104105 people did couldn't be read" rather than reading as quiet.
105106
107+### While you were away
108+
109+The first card says what finished by itself in the span, and what is
110+live. (Over **Last 24 hours** and **Last 7 days** it is called **Settled
111+for you**.) Its first line counts:
112+
113+| Count | What it is |
114+| --- | --- |
115+| *4 notifications settled* | Your notifications in this workspace that [settled themselves](/guides/notifications/#threads-that-settle-themselves) in the span: a later run passed, the pull request merged, the deployment went out. One you marked done yourself is not counted |
116+| *3 changes live*, *1 deploying*, *1 waiting to deploy*, *1 deploy failed* | The merges and pushes under [Landed](#landed), by whether each is [live](#live-after-merge) |
117+| *2 things still need you* | The rows of [Where you're needed](#where-youre-needed); it takes you there |
118+
119+Under it, each notification that settled, newest first, with why it
120+settled and who did it, such as **CI passed in run 330** or **Merged by
121+@ana**. **Done** opens the notifications that are done.
122+
106123 ### People
107124
108−The first card, **People**, lists what people did in the span, one line
125+The second card, **People**, lists what people did in the span, one line
109126 per kind of thing with its count, and each line opens the place that lists
110127 them: a project's Activity tab for pushes, its pull requests or issues,
111128 Artifacts, Chat. Beside the heading are the faces of the five most active,
135152 | Row | What it is |
136153 | --- | --- |
137154 | A pull request merged | With who wrote it, and who merged it when that was someone else |
138−| Commits pushed to a default branch | One row per project with any: how many commits landed on its default branch without a pull request, in how many pushes, and by whom |
155+| A push to a default branch | One row per push a person made straight to a default branch: its commit's first line when it brought one, else how many commits, and who pushed. For the ten projects pushed to most in the span; past those, one row per project with how many commits and pushes |
139156 | A production build that went live | A project's production build that built and was served (previews stay under Deploys) |
140157 | A release published | Its tag and name, and who released it |
141158 | A package version published | Its name and version, and who published it |
142159
143160 The card's line counts them by kind. The first
144−five show; **Show more** opens the rest. When one of its sources (pull
161+five show; **Show more** opens the rest. Over **Last 7 days** the rows are
162+grouped by day (**Today**, **Yesterday**, **Sun, Oct 11**), five of each
163+day showing.
164+
165+#### Live after merge
166+
167+Each merge and push says whether it is live yet, for a project that
168+deploys to production. Landing on `main` is not shipping: a change is
169+shipped once a production deployment that contains it succeeds.
170+
171+| Label | When |
172+| --- | --- |
173+| **Live 9 min after merge** | A production deployment that succeeded shipped it, this long after it landed. The label opens that deployment, or the workflow run that deployed it |
174+| **Deploying** | A production deployment after it is under way |
175+| **Deploy failed** | The production deployment after it failed, and none has shipped it since |
176+| **Waiting to deploy** | Nothing has deployed it yet |
177+
178+How g1t knows: when a production deployment succeeds, a
179+[g1t.page build](/guides/deployments/) or one a workflow or the API
180+reported, g1t reads its commit's history back and writes down each
181+commit no production deployment shipped before as shipped by this one.
182+So a commit's first production deployment is the one that took it live,
183+and a rollback to an older commit ships nothing new. Home reads only what
184+was written down, so this costs nothing when the page loads. A change
185+that landed before g1t began writing this down for its project goes live
186+with the first production deployment that succeeded after it.
187+
188+#### Made with an agent
189+
190+A merge or push made in a harness session, such as Claude Code working
191+for you, carries the agent marker beside its title; it opens the
192+session. A pull request is marked when a session is tied to it (it
193+pushed the branch, named the pull request, or a commit on it carries a
194+`G1t-Session:` trailer); a push when one of its commits carries the
195+trailer. The change is still yours, and stays in the same list. When one of its sources (pull
145196 requests, pushes and releases, deployments) didn't answer, the card says
146197 so under the list.
147198
200251 - People counts what could be read, and names the part that is missing
201252 (Code or Chat). If nothing could be read, the card says so, and the
202253 sentence under the heading says what people did couldn't be read.
203−- Landed, Deploys and Spent each say their part didn't answer.
254+- Landed, Deploys and Spent each say their part didn't answer. When
255+ deployments or sessions can't be read, Landed shows its rows without
256+ their live labels or agent markers.
257+- While you were away says when your notifications can't be read.
204258 - If your last visit can't be read, the span is the last 24 hours and the
205259 page says why.
206260
+10−0
117117 | A repository's security alerts | A scan finds none of that kind open |
118118 | g1t stopping on a change it made for you | It picks back up, its head moves, a merge is asked for, or it is merged or closed |
119119
120+A thread that settled by itself says why under its title in **Done**, in
121+a few words, and who did it when someone did: **CI passed in run 330**,
122+**Merged by @ana**, **Production of site deployed at 8e30fb4**,
123+**Review no longer asked of you**. A thread you marked done yourself says
124+nothing there. [Home](/guides/home/#while-you-were-away) lists what
125+settled for you since your last visit.
126+
127+A notification that settled is no longer shown again: tabs you open later
128+do not replay it, and tabs already open drop it from their recent list.
129+
120130 What g1t does itself and leaves nothing for you to do is never a
121131 notification: what landed and what deployed is on [Home](/guides/home/).
122132
+0−0

Binary or large file; its contents are not shown.

+185−21
99 ArrowRight,
1010 AtSign,
1111 Bell,
12+ CheckCheck,
1213 GitCommitHorizontal,
1314 GitMerge,
1415 GitPullRequest,
2930 type Attention,
3031 type AttentionKind,
3132 type AttentionRow,
33+ type Away,
3234 type DeployRow,
3335 type Digest,
3436 type DigestActor,
3537 type DigestColumn,
3638 type LandedChange,
3739 type RunningRow,
40+ type ShipInfo,
3841 type Span,
3942 type Spend,
4043 type Stake,
4144 WINDOWS,
45+ agentLabel,
46+ landedByDay,
4247 listOf,
48+ shippedLabel,
49+ timeOfDay,
4350 waited,
4451 whenShort,
4552 } from "../lib/home";
4653 import { cn } from "../lib/cn";
4754 import { money } from "../lib/money";
4855 import { ButtonLink } from "./ui";
49−import { Avatar } from "./ui/avatar";
50−import { Badge } from "./ui/badge";
56+import { AgentMarker, Avatar } from "./ui/avatar";
57+import { Badge, type BadgeTone } from "./ui/badge";
5158 import { Skeleton } from "./ui/skeleton";
5259 import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
5360
433440 /** Rows each list shows before the rest fold away. */
434441 const LIST_ROWS = 5;
435442
436−function When({ at, now, tz }: { at: number; now: number; tz: string | null }) {
443+function When({ at, now, tz, timeOnly }: { at: number; now: number; tz: string | null; timeOnly?: boolean }) {
437444 return (
438445 <span className="shrink-0 text-xs text-faint tabular-nums" suppressHydrationWarning>
439− {whenShort(at, now, tz)}
446+ {timeOnly ? timeOfDay(at, tz) : whenShort(at, now, tz)}
440447 </span>
441448 );
442449 }
483490 return parts.join(" · ");
484491 }
485492
493+const SHIP_TONE: Record<ShipInfo["state"], BadgeTone> = {
494+ live: "success",
495+ deploying: "info",
496+ waiting: "neutral",
497+ failed: "danger",
498+};
499+
500+/** Whether a merge or a push is live yet, linked to the deployment that shipped it (or the one under way, or the one that failed). */
501+function Shipped({ change }: { change: LandedChange }) {
502+ const ship = change.shipped;
503+ if (!ship) return null;
504+ const label = shippedLabel(ship, change);
505+ const badge = (
506+ <Badge tone={SHIP_TONE[ship.state]} className="relative z-10">
507+ {ship.state === "live" && <Rocket size={10} aria-hidden="true" />}
508+ {ship.state === "deploying" && <Loader size={10} aria-hidden="true" className="animate-spin motion-reduce:animate-none" />}
509+ {label}
510+ </Badge>
511+ );
512+ return ship.to ? (
513+ <Link to={ship.to} className="relative z-10 rounded-full focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none">
514+ {badge}
515+ </Link>
516+ ) : (
517+ badge
518+ );
519+}
520+
521+/** The agent marker on a change made in a harness session, linked to the session. */
522+function Agent({ change }: { change: LandedChange }) {
523+ const mark = change.agent;
524+ if (!mark) return null;
525+ const label = agentLabel(mark);
526+ const marker = <AgentMarker size={16} inline />;
527+ return (
528+ <Tooltip>
529+ <TooltipTrigger asChild>
530+ {mark.to ? (
531+ <Link to={mark.to} aria-label={label} className="relative z-10 inline-flex shrink-0 rounded-full focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none">
532+ {marker}
533+ </Link>
534+ ) : (
535+ <span role="img" aria-label={label} className="inline-flex shrink-0">
536+ {marker}
537+ </span>
538+ )}
539+ </TooltipTrigger>
540+ <TooltipContent>{label}</TooltipContent>
541+ </Tooltip>
542+ );
543+}
544+
545+function LandedItem({ change, now, tz, timeOnly }: { change: LandedChange; now: number; tz: string | null; timeOnly?: boolean }) {
546+ return (
547+ <li className={cn(rowClass, "items-start")}>
548+ <span className="mt-0.5 shrink-0">{LANDED_ICON[change.kind]}</span>
549+ <div className="min-w-0 grow">
550+ <div className="flex min-w-0 items-center gap-1.5">
551+ <Link to={change.to} className="line-clamp-2 text-sm text-fg hover:underline sm:truncate">
552+ {change.title}
553+ </Link>
554+ <Agent change={change} />
555+ </div>
556+ <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
557+ <p className="min-w-0 truncate text-xs text-muted">{change.detail}</p>
558+ <Shipped change={change} />
559+ </div>
560+ </div>
561+ <When at={change.at} now={now} tz={tz} timeOnly={timeOnly} />
562+ </li>
563+ );
564+}
565+
486566 /**
487567 * Everything that landed in the span, newest first: pull requests merged,
488568 * commits pushed straight to a default branch, production builds that went
489− * live, releases and package versions published.
569+ * live, releases and package versions published. Each merge and push says
570+ * whether it is live yet, and one made in a harness session carries the
571+ * agent marker. Over the last 7 days, day by day.
490572 */
491−export function LandedCard({ landed, missing, code, now, tz }: { landed: LandedChange[] | null; missing: string[]; code: boolean; now: number; tz: string | null }) {
573+export function LandedCard({
574+ landed,
575+ missing,
576+ code,
577+ now,
578+ tz,
579+ byDay = false,
580+}: {
581+ landed: LandedChange[] | null;
582+ missing: string[];
583+ code: boolean;
584+ now: number;
585+ tz: string | null;
586+ byDay?: boolean;
587+}) {
492588 if (!code) return null;
589+ const render = (change: LandedChange) => <LandedItem key={change.key} change={change} now={now} tz={tz} timeOnly={byDay} />;
493590 return (
494591 <Card title="Landed" hint={landed && landed.length > 0 ? landedHint(landed) : undefined}>
495592 {!landed ? (
496593 <Quiet>Code didn&apos;t answer, so what landed can&apos;t be listed right now.</Quiet>
497594 ) : landed.length === 0 ? (
498595 <Quiet>{missing.length > 0 ? "Nothing landed in what could be read." : "Nothing merged, pushed to a default branch, deployed to production or published in this span."}</Quiet>
596+ ) : byDay ? (
597+ landedByDay(landed, now, tz).map((day) => (
598+ <section key={day.key} aria-label={day.label} className="border-t border-line first-of-type:border-t-0">
599+ <h3 className="flex items-baseline gap-2 bg-raised/40 px-5 py-1.5 text-xs font-medium text-fg-soft">
600+ {day.label}
601+ <span className="font-normal text-faint tabular-nums">{day.rows.length.toLocaleString("en-US")}</span>
602+ </h3>
603+ <Folded items={day.rows} render={render} />
604+ </section>
605+ ))
499606 ) : (
500− <Folded
501− items={landed}
502− render={(change) => (
503− <li key={change.key} className={rowClass}>
504− {LANDED_ICON[change.kind]}
505− <div className="min-w-0 grow">
506− <Link to={change.to} className="block truncate text-sm text-fg hover:underline">
507− {change.title}
508− </Link>
509− <p className="truncate text-xs text-muted">{change.detail}</p>
510− </div>
511− <When at={change.at} now={now} tz={tz} />
512− </li>
513− )}
514− />
607+ <Folded items={landed} render={render} />
515608 )}
516609 {landed && <Missing names={missing} what="everything that landed" />}
517610 </Card>
518611 );
519612 }
520613
614+// --- While you were away -----------------------------------------------------------
615+
616+/**
617+ * What g1t settled for you since you were last here (a later run passed,
618+ * it merged, it deployed), what shipped, and what still needs you: the
619+ * counts on one line, then each thing that settled and why.
620+ */
621+export function AwayCard({ away, span, now }: { away: Away | null; span: Pick<Span, "key" | "note" | "words">; now: number }) {
622+ const title = span.key === "last" && span.note == null ? "While you were away" : "Settled for you";
623+ if (!away) {
624+ return (
625+ <Card title={title}>
626+ <Quiet>Your notifications couldn&apos;t be read, so what settled can&apos;t be listed right now.</Quiet>
627+ </Card>
628+ );
629+ }
630+ const facts: { key: string; text: string; tone: BadgeTone; to: string | null }[] = [];
631+ const plural = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString("en-US")} ${n === 1 ? one : many}`;
632+ facts.push({ key: "settled", text: `${plural(away.settled.length, "notification")} settled`, tone: away.settled.length > 0 ? "success" : "neutral", to: "/notifications?view=done" });
633+ if (away.shipped > 0) facts.push({ key: "shipped", text: `${plural(away.shipped, "change")} live`, tone: "success", to: null });
634+ if (away.deploying > 0) facts.push({ key: "deploying", text: `${away.deploying.toLocaleString("en-US")} deploying`, tone: "info", to: null });
635+ if (away.waiting > 0) facts.push({ key: "waiting", text: `${away.waiting.toLocaleString("en-US")} waiting to deploy`, tone: "neutral", to: null });
636+ if (away.failed > 0) facts.push({ key: "failed", text: `${away.failed.toLocaleString("en-US")} deploy failed`, tone: "danger", to: null });
637+ facts.push({ key: "needs", text: away.needs > 0 ? `${plural(away.needs, "thing")} still ${away.needs === 1 ? "needs" : "need"} you` : "Nothing needs you", tone: away.needs > 0 ? "warn" : "neutral", to: "#needed" });
638+ return (
639+ <Card title={title} hint="What finished by itself, and what is live" link={{ label: "Done", to: "/notifications?view=done" }}>
640+ <ul className="flex flex-wrap gap-1.5 px-5 pb-3" aria-label="In short">
641+ {facts.map((fact) => (
642+ <li key={fact.key}>
643+ {fact.to ? (
644+ <Link to={fact.to} className="rounded-full focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none">
645+ <Badge tone={fact.tone} size="md">
646+ {fact.text}
647+ </Badge>
648+ </Link>
649+ ) : (
650+ <Badge tone={fact.tone} size="md">
651+ {fact.text}
652+ </Badge>
653+ )}
654+ </li>
655+ ))}
656+ </ul>
657+ {away.settled.length > 0 && (
658+ <div className="border-t border-line">
659+ <Folded
660+ items={away.settled}
661+ render={(row) => (
662+ <li key={row.key} className={rowClass}>
663+ <CheckCheck size={15} className="shrink-0 text-success" aria-hidden="true" />
664+ <div className="min-w-0 grow">
665+ <Link to={row.to} className="block truncate text-sm text-fg hover:underline">
666+ {row.title}
667+ </Link>
668+ <p className="truncate text-xs text-muted">
669+ <span className="text-fg-soft">{row.why}</span>
670+ {row.where && ` · ${row.where}`}
671+ </p>
672+ </div>
673+ <span className="shrink-0 text-xs text-faint tabular-nums" suppressHydrationWarning>
674+ {waited(row.at, now)} ago
675+ </span>
676+ </li>
677+ )}
678+ />
679+ </div>
680+ )}
681+ </Card>
682+ );
683+}
684+
521685 export function DeploysCard({ deploys, complete, code, now, tz }: { deploys: DeployRow[] | null; complete: boolean; code: boolean; now: number; tz: string | null }) {
522686 // A workspace that doesn't deploy has nothing to say here.
523687 if (!code || (deploys && deploys.length === 0)) return null;
+8−1
1616 import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
1717 import { cn } from "../lib/cn";
1818 import { isPending } from "../lib/pending";
19−import { INBOX_TABS, type InboxTab, REASON_LABEL, SEVERITY_LABEL, SNOOZES, bellCount, emptyFor, isUnread, tabCount, updatesLabel, whenShort } from "../lib/inbox";
19+import { INBOX_TABS, type InboxTab, REASON_LABEL, SEVERITY_LABEL, SNOOZES, bellCount, emptyFor, isUnread, settledLine, tabCount, updatesLabel, whenShort } from "../lib/inbox";
2020 import { useOwnPresence } from "../lib/notify-client";
2121 import { dndOn } from "../lib/presence";
2222 import type { InboxPanelData } from "../routes/inbox-json";
101101 {item.title}
102102 </Link>
103103 {item.body && <p className="mt-0.5 truncate text-xs text-muted">{item.body}</p>}
104+ {/* Done by itself: what ended it, and who. */}
105+ {settledLine(item) && (
106+ <p className="mt-1.5 flex min-w-0 items-center gap-1.5 text-xs text-fg-soft">
107+ <CheckCheck size={12} aria-hidden="true" className="shrink-0 text-success" />
108+ <span className="truncate">{settledLine(item)}</span>
109+ </p>
110+ )}
104111 <div className="mt-2 flex min-w-0 items-center gap-2 text-xs text-faint [@media(hover:none)]:pr-24">
105112 <time dateTime={item.updatedAt} suppressHydrationWarning className="shrink-0 whitespace-nowrap">
106113 {whenShort(item.updatedAt, now)}
+9−1
210210 * that the squircle tile alone says it). The same mark everywhere, so
211211 * nobody has to read a name to know they are looking at an agent.
212212 */
213−export function AgentMarker({ size, ring = "var(--color-bg)", className }: { size: number; ring?: string; className?: string }) {
213+export function AgentMarker({ size, ring = "var(--color-bg)", className, inline }: { size: number; ring?: string; className?: string; inline?: boolean }) {
214214 const disc = Math.max(9, Math.min(22, Math.round(size * 0.4)));
215215 const icon = Math.max(6, Math.round(disc * 0.66));
216216 const inset = Math.round(disc * 0.28);
217+ // `inline`: the marker on its own in a line of text, beside what an agent made, rather than on an avatar's corner.
218+ if (inline) {
219+ return (
220+ <span aria-hidden="true" className={`inline-flex shrink-0 items-center justify-center rounded-full bg-accent text-bg ${className ?? ""}`} style={{ width: size, height: size }}>
221+ <Bot size={Math.max(8, Math.round(size * 0.66))} strokeWidth={2.6} absoluteStrokeWidth={false} />
222+ </span>
223+ );
224+ }
217225 return (
218226 <span
219227 aria-hidden="true"
+123−2
88 import {
99 type ActivityDigest,
1010 type ChatActivity,
11+ type Commit,
1112 type Deployment,
13+ type G1tEvent,
14+ type InboxItem,
1215 MAX_DIGEST_REPOS,
1316 type ProjectDeploys,
17+ type PullSessions,
1418 type Repo,
1519 type RepoPath,
20+ type ShippedRepo,
1621 type User,
1722 type WorkflowRun,
1823 notifyClient,
1924 } from "@g1t/contracts";
2025
21−import { type CodePull, type CodeWork, G1T_ACTOR, type RepoRef, type Span, type Spend, deployActorKey, spanMonths, spendIn } from "./home";
26+import {
27+ type CodePull,
28+ type CodeWork,
29+ type DirectPush,
30+ G1T_ACTOR,
31+ type LandedChange,
32+ type RepoRef,
33+ type Span,
34+ type Spend,
35+ deployActorKey,
36+ shippedAsks,
37+ spanMonths,
38+ spendIn,
39+} from "./home";
2240 import { monthSpan } from "./usage";
23−import { actions, billing, chat, deployments, events, identity, repos, work } from "./services.server";
41+import { actions, billing, chat, deployments, events, identity, inbox, repos, work } from "./services.server";
2442
2543 /** Pull requests read per project and list: `LIST_PAGE` in services/work. */
2644 const PULL_PAGE = 100;
97115 for (const pull of [...entry.open, ...entry.closed]) {
98116 pulls.push({
99117 repo: path,
118+ repoId: repo.id,
119+ mergeCommit: pull.mergeCommit ?? null,
100120 number: pull.number,
101121 title: pull.title,
102122 status: pull.status,
263283 );
264284 }
265285
286+/** The most projects whose pushes to the default branch are listed one by one, pushes read per project, and commits read back. */
287+const MAX_PUSH_PROJECTS = 10;
288+const PUSHES_READ = 100;
289+const PUSH_HISTORY = 200;
290+
291+/**
292+ * Each person's push to a default branch in the span, for the projects
293+ * the digest says were pushed to (those pushed to most, at most
294+ * MAX_PUSH_PROJECTS), with each project's default branch read back from
295+ * its newest push to place each push's commits. `by` is the pusher's
296+ * member key, named by the caller. Null when events did not answer.
297+ */
298+export async function loadDirectPushes(
299+ viewer: User,
300+ activity: ActivityDigest | null,
301+ owned: Repo[] | null,
302+ span: Pick<Span, "from" | "now">,
303+): Promise<{ pushes: (Omit<DirectPush, "by"> & { actorKey: string })[]; history: Record<string, Commit[]>; complete: boolean } | null> {
304+ if (!activity || !owned) return null;
305+ const byId = new Map(owned.map((repo) => [repo.id, repo]));
306+ const pushedTo = activity.repos
307+ .filter((digest) => digest.pushes.default_branch.count > 0 && byId.has(digest.repo_id))
308+ .sort((a, b) => b.pushes.default_branch.count - a.pushes.default_branch.count || a.repo_id.localeCompare(b.repo_id));
309+ const read = pushedTo.slice(0, MAX_PUSH_PROJECTS);
310+ const lists = await Promise.all(
311+ read.map(async (digest) => {
312+ const repo = byId.get(digest.repo_id)!;
313+ const log = await events.list({ repoId: repo.id, types: ["git.push"], limit: PUSHES_READ }).catch(warn(`pushes of ${repo.name}`));
314+ if (!log) return null;
315+ const pushes = log.filter(
316+ (event): event is G1tEvent<"git.push"> =>
317+ event.type === "git.push" && (event as G1tEvent<"git.push">).data.defaultBranch && Date.parse(event.time) >= span.from && Date.parse(event.time) <= span.now,
318+ );
319+ if (pushes.length === 0) return { repo, pushes, history: [] as Commit[] };
320+ const path = { namespace: repo.namespace, name: repo.name };
321+ const history = await repos.log(path, viewer, pushes[0]!.data.after, PUSH_HISTORY).catch(warn(`history of ${repo.name}`));
322+ return { repo, pushes, history: history?.ok ? history.value : [] };
323+ }),
324+ );
325+ const pushes: (Omit<DirectPush, "by"> & { actorKey: string })[] = [];
326+ const history: Record<string, Commit[]> = {};
327+ for (const entry of lists) {
328+ if (!entry) continue;
329+ history[entry.repo.id] = entry.history;
330+ const ref: RepoRef = { namespace: entry.repo.namespace, name: entry.repo.name, defaultBranch: entry.repo.defaultBranch };
331+ for (const event of entry.pushes) {
332+ pushes.push({
333+ repoId: entry.repo.id,
334+ repo: ref,
335+ push: { id: event.id, time: event.time, actor: event.actor ?? null, data: event.data },
336+ actorKey: event.actor ? deployActorKey(event.actor) ?? "" : "",
337+ });
338+ }
339+ }
340+ return { pushes, history, complete: lists.every((entry) => entry != null) && pushedTo.length <= MAX_PUSH_PROJECTS };
341+}
342+
343+/**
344+ * Whether each merge and push that landed went live, from deployments'
345+ * record of what each production deployment shipped. Null when
346+ * deployments did not answer.
347+ */
348+export async function loadShipped(viewer: User, slug: string, rows: LandedChange[]): Promise<ShippedRepo[] | null> {
349+ const asks = shippedAsks(rows).slice(0, 20);
350+ if (asks.length === 0) return [];
351+ const answer = await deployments.shipped(slug, viewer, asks).catch(warn("shipped"));
352+ return answer?.ok ? answer.value : null;
353+}
354+
355+/** The harness sessions tied to each merged pull request, per project; null when work did not answer for any. */
356+export async function loadPullSessions(viewer: User, pulls: CodePull[]): Promise<{ repo: RepoPath; pulls: PullSessions[] }[] | null> {
357+ const byRepo = new Map<string, { repo: RepoPath; numbers: number[] }>();
358+ for (const pull of pulls) {
359+ if (pull.status !== "merged") continue;
360+ const key = `${pull.repo.namespace}/${pull.repo.name}`.toLowerCase();
361+ const entry = byRepo.get(key) ?? { repo: pull.repo, numbers: [] };
362+ entry.numbers.push(pull.number);
363+ byRepo.set(key, entry);
364+ }
365+ if (byRepo.size === 0) return [];
366+ const lists = await Promise.all(
367+ [...byRepo.values()].slice(0, MAX_PUSH_PROJECTS).map(({ repo, numbers }) =>
368+ work
369+ .pullSessions(repo, viewer, numbers.slice(0, 100))
370+ .then((result) => (result.ok ? { repo, pulls: result.value } : null))
371+ .catch(warn(`sessions of ${repo.name}`)),
372+ ),
373+ );
374+ const found = lists.filter((entry) => entry != null);
375+ return found.length === 0 && lists.length > 0 ? null : found;
376+}
377+
378+/** Notifications read for what settled by itself in the span. */
379+const SETTLED_READ = 100;
380+
381+/** The viewer's notifications done since the span began, newest activity first; null when the inbox did not answer. */
382+export async function loadSettled(viewer: User, span: Pick<Span, "from">): Promise<InboxItem[] | null> {
383+ const page = await inbox.list(viewer, { view: "done", limit: SETTLED_READ }).catch(warn("settled notifications"));
384+ return page ? page.items.filter((item) => item.doneAt != null && Date.parse(item.doneAt) >= span.from) : null;
385+}
386+
266387 /** How far back Code is read: 7 days before the span, for the comparison. */
267388 export function reachFor(span: Pick<Span, "from">): number {
268389 return span.from - 7 * DAY;
+134−0
77 type AttentionRow,
88 type CodeNeed,
99 type CodePull,
10+ type LandedChange,
1011 actorKind,
12+ agentLabel,
1113 attention,
14+ awayOf,
15+ landedByDay,
16+ pushRows,
17+ sessionTrailers,
18+ settledIn,
19+ shippedAsks,
20+ shippedLabel,
21+ withSessions,
22+ withShipped,
1223 chatRow,
1324 codeRow,
1425 dayIn,
646657 assert.equal(spend.monthMicros, 13_609_000);
647658 assert.equal(spendIn([september, october], { from: Date.parse("2026-09-29T15:00:00Z"), now: NOW }, null).monthMicros, null);
648659 });
660+
661+
662+// --- What landed: shipped, made in a session, day by day -----------------------
663+
664+const merge = (over: Partial<LandedChange> = {}): LandedChange => ({
665+ key: "landed:acme/web#12",
666+ kind: "merge",
667+ title: "Add retries",
668+ detail: "web#12 · by ana",
669+ to: "/acme/web/pull/12",
670+ at: NOW - 2 * HOUR,
671+ sha: "m12",
672+ repoId: "rep_web",
673+ ...over,
674+});
675+
676+test("each merge and push says whether it is live, from what deployments recorded", () => {
677+ const push = merge({ key: "push:rep_web:evt_1", kind: "push", sha: "p1", at: NOW - HOUR });
678+ const release = merge({ key: "release:rep_web:v1", kind: "release", sha: null });
679+ assert.deepEqual(shippedAsks([merge(), push, release]), [
680+ { repo_id: "rep_web", commits: [{ sha: "m12", landed_at: iso(NOW - 2 * HOUR) }, { sha: "p1", landed_at: iso(NOW - HOUR) }] },
681+ ]);
682+ const answers = [
683+ {
684+ repo_id: "rep_web",
685+ deploys: true,
686+ commits: [
687+ { sha: "m12", state: "live" as const, deployed_at: iso(NOW - 2 * HOUR + 9 * 60_000), url: "/acme/web/actions/runs/run_1", environment: "production" },
688+ { sha: "p1", state: "waiting" as const, deployed_at: null, url: null, environment: null },
689+ ],
690+ },
691+ ];
692+ const [shippedMerge, shippedPush, untouched] = withShipped([merge(), push, release], answers);
693+ assert.deepEqual(shippedMerge!.shipped, { state: "live", at: NOW - 2 * HOUR + 9 * 60_000, to: "/acme/web/actions/runs/run_1" });
694+ assert.equal(shippedLabel(shippedMerge!.shipped!, shippedMerge!), "Live 9 min after merge");
695+ assert.equal(shippedLabel(shippedPush!.shipped!, shippedPush!), "Waiting to deploy");
696+ assert.equal(shippedLabel({ state: "failed", at: null, to: "/x" }, push), "Deploy failed");
697+ assert.equal(shippedLabel({ state: "deploying", at: null, to: null }, push), "Deploying");
698+ assert.equal(shippedLabel({ state: "live", at: push.at + 3 * HOUR, to: null }, push), "Live 3 h after push");
699+ assert.equal(untouched!.shipped, undefined);
700+ // A project that does not deploy says nothing; deployments not answering leaves rows as they were.
701+ assert.equal(withShipped([merge()], [{ repo_id: "rep_web", deploys: false, commits: [] }])[0]!.shipped, undefined);
702+ assert.deepEqual(withShipped([merge()], null), [merge()]);
703+});
704+
705+test("a change made in a harness session carries the agent mark, linked to the session", () => {
706+ const [marked, plain] = withSessions([merge(), merge({ key: "landed:acme/web#13", sha: "m13" })], [
707+ { repo: { namespace: "acme", name: "web" }, pulls: [{ number: 12, sessions: [{ id: "ses_1", harness: "claude_code" }, { id: "ses_2", harness: "codex" }] }] },
708+ ]);
709+ assert.deepEqual(marked!.agent, { harness: "claude_code", to: "/acme/web/work/sessions/ses_1" });
710+ assert.equal(agentLabel(marked!.agent!), "Made in a Claude Code session");
711+ assert.equal(plain!.agent, undefined);
712+ assert.equal(agentLabel({ harness: null, to: null }), "Made in a session");
713+ assert.deepEqual(sessionTrailers("Fix the bell\n\nG1t-Session: ses_01ABC\ng1t-session: ses_01abc\nG1t-Session: ses_02x"), ["ses_01abc", "ses_02x"]);
714+ assert.deepEqual(sessionTrailers("Mentions G1t-Session: ses_1 in prose"), []);
715+});
716+
717+test("each person's push to a default branch is a row: its commit, who pushed, and its session", () => {
718+ const repo = { namespace: "acme", name: "web", defaultBranch: "main" };
719+ const history = {
720+ rep_web: [
721+ { hash: "c3", parents: ["c2"], message: "Fix the bell's count\n\nG1t-Session: ses_9" },
722+ { hash: "c2", parents: ["c1"], message: "Tidy" },
723+ { hash: "c1", parents: ["c0"], message: "Start" },
724+ { hash: "c0", parents: [], message: "Root" },
725+ ],
726+ };
727+ const push = (id: string, after: string, before: string, at: number, actor = "usr_ana") => ({
728+ repoId: "rep_web",
729+ repo,
730+ push: { id, time: iso(at), actor, data: { after, before } },
731+ by: "ana",
732+ });
733+ const span = { from: NOW - DAY, now: NOW };
734+ const rows = pushRows([push("evt_2", "c3", "c2", NOW - HOUR), push("evt_1", "c2", "c0", NOW - 2 * HOUR), push("evt_0", "c1", "c0", NOW - 2 * DAY)], history, span);
735+ assert.deepEqual(
736+ rows.map((row) => [row.title, row.detail, row.sha, row.to, row.agent?.to ?? null]),
737+ [
738+ ["Fix the bell's count", "web · pushed to main · by ana", "c3", "/acme/web/commit/c3", "/acme/web/work/sessions/ses_9"],
739+ ["2 commits pushed to main", "web · Tidy · by ana", "c2", "/acme/web/activity", null],
740+ ],
741+ );
742+ // g1t's own pushes (a pull request landing) are listed as the merge instead.
743+ assert.deepEqual(pushRows([push("evt_3", "c3", "c2", NOW - HOUR, "usr_g1t_agent")], history, span), []);
744+});
745+
746+test("what landed over the week reads day by day, newest first", () => {
747+ const tz = "America/Los_Angeles";
748+ const days = landedByDay([merge({ at: NOW - DAY }), merge({ key: "b", at: NOW - HOUR }), merge({ key: "c", at: NOW - 3 * DAY })], NOW, tz);
749+ assert.deepEqual(days.map((day) => [day.label, day.rows.length]), [["Today", 1], ["Yesterday", 1], ["Tue, Oct 6", 1]]);
750+});
751+
752+test("while you were away: what settled by itself, what shipped, and what still needs you", () => {
753+ const span = { from: NOW - DAY, now: NOW };
754+ const settled = settledIn(
755+ [
756+ item({ id: "n1", doneAt: iso(NOW - HOUR), settledReason: "CI passed in run 330" }),
757+ item({ id: "n2", doneAt: iso(NOW - 2 * HOUR), settledReason: "Merged", settledBy: "ana" }),
758+ // Marked done by hand: yours, not g1t's.
759+ item({ id: "n3", doneAt: iso(NOW - HOUR) }),
760+ // Before the span, or another workspace's.
761+ item({ id: "n4", doneAt: iso(NOW - 2 * DAY), settledReason: "Merged" }),
762+ item({ id: "n5", doneAt: iso(NOW - HOUR), settledReason: "Merged", workspace: "other", repo: "other/x" }),
763+ ],
764+ "acme",
765+ true,
766+ span,
767+ );
768+ assert.deepEqual(
769+ settled.map((row) => [row.key, row.why, row.where]),
770+ [
771+ ["settled:n1", "CI passed in run 330", "acme/web#12"],
772+ ["settled:n2", "Merged by @ana", "acme/web#12"],
773+ ],
774+ );
775+ assert.equal(settledIn([item({ doneAt: iso(NOW - HOUR), settledReason: "Merged" })], "acme", false, span).length, 0, "nothing about repositories without Code");
776+ const away = awayOf({
777+ settled,
778+ landed: [merge({ shipped: { state: "live", at: NOW, to: null } }), merge({ key: "x", shipped: { state: "waiting", at: null, to: null } }), merge({ key: "y" })],
779+ needs: 3,
780+ });
781+ assert.deepEqual({ ...away, settled: away.settled.length }, { settled: 2, shipped: 1, waiting: 1, deploying: 0, failed: 0, needs: 3 });
782+});
+243−0
1111 ActivityDigest,
1212 ActorCount,
1313 ChatActivity,
14+ Commit,
1415 Deployment,
16+ Harness,
1517 InboxItem,
1618 ProjectDeploys,
1719 Pull,
20+ PullSessions,
1821 RepoDigest,
1922 RepoPath,
23+ ShipState,
24+ ShippedRepo,
2025 Statement,
2126 WorkflowRun,
2227 } from "@g1t/contracts";
2328
29+import { HARNESS_NAMES } from "./coordination.ts";
30+import { type PushRecord, pushedByPerson, pushedCommits } from "./mission-control.ts";
31+import { settledLine } from "./inbox.ts";
32+
2433 const MINUTE = 60_000;
2534 const HOUR = 60 * MINUTE;
2635 const DAY = 24 * HOUR;
164173 /** A pull request with where it is: what Home reads from Code. */
165174 export type CodePull = Pick<Pull, "number" | "title" | "status" | "mergedAt" | "createdAt" | "updatedAt"> & {
166175 repo: RepoPath;
176+ /** The repository's id, and the commit the merge put on the base branch: what deployments ship. */
177+ repoId?: string;
178+ mergeCommit?: string | null;
167179 author: { username: string; kind?: string };
168180 /** Who merged it, when it merged. */
169181 mergedBy?: string | null;
504516 detail: string;
505517 to: string;
506518 at: number;
519+ /** A merge's or a push's commit on the default branch, and its repository's id: what a deployment ships. */
520+ sha?: string | null;
521+ repoId?: string | null;
522+ /** Whether it is live yet (`withShipped`); null when its project does not deploy, or it could not be read. */
523+ shipped?: ShipInfo | null;
524+ /** Made in a harness session (`withSessions`, or a `G1t-Session:` trailer): the agent marker, and the session. */
525+ agent?: AgentMark | null;
507526 };
508527
528+/** Whether a change is live: shipped by a production deployment, on its way, waiting, or the deployment after it failed. */
529+export type ShipInfo = {
530+ state: ShipState;
531+ /** Epoch ms: when the deployment that shipped it went live. */
532+ at: number | null;
533+ /** The deployment (or the run that deploys it). */
534+ to: string | null;
535+};
536+
537+/** A change made in a harness session: which harness when known, and the session's page. */
538+export type AgentMark = { harness: Harness | null; to: string | null };
539+
509540 /** Every pull request that merged in the span, newest first. */
510541 export function landedIn(pulls: CodePull[], span: Pick<Span, "from" | "now">): LandedChange[] {
511542 return pulls
520551 detail: `${pull.repo.name}#${pull.number} · by ${agent ? `@${pull.author.username}` : pull.author.username}${mergedBy}`,
521552 to: `/${pull.repo.namespace}/${pull.repo.name}/pull/${pull.number}`,
522553 at: Date.parse(pull.mergedAt!),
554+ sha: pull.mergeCommit ?? null,
555+ repoId: pull.repoId ?? null,
523556 };
524557 })
525558 .sort((a, b) => b.at - a.at || a.key.localeCompare(b.key));
601634 return parts.flat().sort((a, b) => b.at - a.at || a.key.localeCompare(b.key));
602635 }
603636
637+// --- Pushes straight to a default branch, each ---------------------------------
638+
639+/** A push to a default branch, with the history it brought read back. */
640+export type DirectPush = {
641+ repoId: string;
642+ repo: RepoRef;
643+ push: PushRecord & { id: string };
644+ /** Who pushed, by name; `someone` when it could not be read. */
645+ by: string;
646+};
647+
648+/** The sessions a commit message names in `G1t-Session:` trailers. */
649+export function sessionTrailers(message: string): string[] {
650+ const ids = [...message.matchAll(/^G1t-Session:\s*(ses_[0-9a-z]+)\s*$/gim)].map((match) => match[1]!.toLowerCase());
651+ return [...new Set(ids)];
652+}
653+
654+/** A commit message's first line. */
655+const subject = (message: string) => message.split("\n", 1)[0]!.trim();
656+
657+/**
658+ * Each person's push to a default branch in the span, newest first, as a
659+ * landed change: its commit when it brought one ("Fix the bell's count"),
660+ * else how many; the session a commit names in a `G1t-Session:` trailer
661+ * as its agent mark. `history` is each repository's default branch read
662+ * back from its newest push, newest first; a push whose commits are not
663+ * in it (rewritten since) is left out, and so is g1t's own (a pull
664+ * request landing: Landed lists the merge).
665+ */
666+export function pushRows(
667+ pushes: DirectPush[],
668+ history: Record<string, Pick<Commit, "hash" | "parents" | "message">[]>,
669+ span: Pick<Span, "from" | "now">,
670+ byG1t: (commit: Pick<Commit, "hash" | "parents" | "message">) => boolean = () => false,
671+): LandedChange[] {
672+ const rows: LandedChange[] = [];
673+ for (const { repoId, repo, push, by } of pushes) {
674+ if (!pushedByPerson(push) || !within(push.time, span)) continue;
675+ const commits = history[repoId] ?? [];
676+ const start = commits.findIndex((commit) => commit.hash === push.data.after);
677+ if (start === -1) continue;
678+ const brought = pushedCommits(commits.slice(start), push.data.before, byG1t);
679+ if (brought.length === 0) continue;
680+ const newest = brought[0]!;
681+ const session = brought.flatMap((commit) => sessionTrailers(commit.message))[0] ?? null;
682+ const branch = repo.defaultBranch;
683+ rows.push({
684+ key: `push:${repoId}:${push.id}`,
685+ kind: "push",
686+ title: brought.length === 1 ? subject(newest.message) || `1 commit pushed to ${branch}` : `${plural(brought.length, "commit")} pushed to ${branch}`,
687+ detail: `${repo.name} · ${brought.length === 1 ? `pushed to ${branch}` : subject(newest.message)} · by ${by}`,
688+ to: brought.length === 1 ? `/${repo.namespace}/${repo.name}/commit/${newest.hash}` : `/${repo.namespace}/${repo.name}/activity`,
689+ at: Date.parse(push.time),
690+ sha: push.data.after,
691+ repoId,
692+ agent: session ? { harness: null, to: `/${repo.namespace}/${repo.name}/work/sessions/${session}` } : null,
693+ });
694+ }
695+ return rows;
696+}
697+
698+// --- Shipped: merge to deploy --------------------------------------------------
699+
700+/**
701+ * Each merge and push with whether it is live yet, from what deployments
702+ * recorded as production deployed (`shipped`). A project that does not
703+ * deploy says nothing; a change deployments did not answer for keeps none.
704+ */
705+export function withShipped(rows: LandedChange[], answers: ShippedRepo[] | null): LandedChange[] {
706+ if (!answers) return rows;
707+ const byRepo = new Map(answers.map((answer) => [answer.repo_id, answer]));
708+ return rows.map((row) => {
709+ if (!row.sha || !row.repoId || (row.kind !== "merge" && row.kind !== "push")) return row;
710+ const answer = byRepo.get(row.repoId);
711+ const commit = answer?.deploys ? answer.commits.find((c) => c.sha === row.sha) : null;
712+ if (!commit) return row;
713+ return { ...row, shipped: { state: commit.state, at: commit.deployed_at ? Date.parse(commit.deployed_at) : null, to: commit.url } };
714+ });
715+}
716+
717+/** What to ask deployments: each repository's merges and pushes with their commit and when they landed. */
718+export function shippedAsks(rows: LandedChange[]): { repo_id: string; commits: { sha: string; landed_at: string }[] }[] {
719+ const byRepo = new Map<string, { sha: string; landed_at: string }[]>();
720+ for (const row of rows) {
721+ if (!row.sha || !row.repoId || (row.kind !== "merge" && row.kind !== "push")) continue;
722+ const list = byRepo.get(row.repoId) ?? [];
723+ list.push({ sha: row.sha, landed_at: new Date(row.at).toISOString() });
724+ byRepo.set(row.repoId, list);
725+ }
726+ return [...byRepo].map(([repo_id, commits]) => ({ repo_id, commits }));
727+}
728+
729+/** How long something took, short: "1 min", "45 min", "3 h", "2 d". */
730+function took(ms: number): string {
731+ const minutes = Math.max(1, Math.round(ms / MINUTE));
732+ if (minutes < 60) return `${minutes} min`;
733+ const hours = Math.round(minutes / 60);
734+ if (hours < 48) return `${hours} h`;
735+ return `${Math.round(hours / 24)} d`;
736+}
737+
738+/** "Live 9 min after merge", "Waiting to deploy", "Deploying", "Deploy failed". */
739+export function shippedLabel(ship: ShipInfo, row: Pick<LandedChange, "kind" | "at">): string {
740+ switch (ship.state) {
741+ case "live":
742+ return ship.at != null ? `Live ${took(ship.at - row.at)} after ${row.kind === "merge" ? "merge" : "push"}` : "Live";
743+ case "deploying":
744+ return "Deploying";
745+ case "failed":
746+ return "Deploy failed";
747+ default:
748+ return "Waiting to deploy";
749+ }
750+}
751+
752+// --- Done with an agent --------------------------------------------------------
753+
754+/**
755+ * Each merged pull request with the harness session that made it, when
756+ * work tied one to it (`pullSessions`): the first, by when it started.
757+ */
758+export function withSessions(rows: LandedChange[], sessions: { repo: RepoPath; pulls: PullSessions[] }[] | null): LandedChange[] {
759+ if (!sessions) return rows;
760+ const found = new Map<string, AgentMark>();
761+ for (const { repo, pulls } of sessions) {
762+ for (const pull of pulls) {
763+ const first = pull.sessions[0];
764+ if (first) found.set(`landed:${pullKey(repo, pull.number)}`, { harness: first.harness, to: `/${repo.namespace}/${repo.name}/work/sessions/${first.id}` });
765+ }
766+ }
767+ return rows.map((row) => (row.agent || !found.has(row.key) ? row : { ...row, agent: found.get(row.key)! }));
768+}
769+
770+/** The agent mark's words: "Made in a Claude Code session", or "Made in a session". */
771+export function agentLabel(mark: AgentMark): string {
772+ return mark.harness ? `Made in a ${HARNESS_NAMES[mark.harness] ?? "harness"} session` : "Made in a session";
773+}
774+
775+// --- Day by day ----------------------------------------------------------------
776+
777+/** What landed on one day, newest first: "Today", "Yesterday", "Mon, Oct 12". */
778+export type LandedDay = { key: string; label: string; rows: LandedChange[] };
779+
780+/** What landed, by the day it landed in the reader's time zone, newest day first. */
781+export function landedByDay(rows: LandedChange[], now: number, timeZone: string | null): LandedDay[] {
782+ const days: LandedDay[] = [];
783+ const today = dayIn(now, timeZone);
784+ const yesterday = dayIn(now - DAY, timeZone);
785+ for (const row of [...rows].sort((a, b) => b.at - a.at || a.key.localeCompare(b.key))) {
786+ const key = dayIn(row.at, timeZone);
787+ let day = days.find((d) => d.key === key);
788+ if (!day) {
789+ const label = key === today ? "Today" : key === yesterday ? "Yesterday" : formatIn(row.at, timeZone, { weekday: "short", month: "short", day: "numeric" });
790+ day = { key, label, rows: [] };
791+ days.push(day);
792+ }
793+ day.rows.push(row);
794+ }
795+ return days;
796+}
797+
798+// --- While you were away -------------------------------------------------------
799+
800+/** Something that settled by itself in the span: what it was, why it settled, where. */
801+export type SettledRow = { key: string; title: string; why: string; where: string | null; to: string; at: number };
802+
803+/** What g1t settled for you, what shipped, and what still needs you, in the span. */
804+export type Away = {
805+ settled: SettledRow[];
806+ /** Merges and pushes in the span that went live, are waiting, deploying, or whose deployment failed. */
807+ shipped: number;
808+ waiting: number;
809+ deploying: number;
810+ failed: number;
811+ /** Rows in Where you're needed. */
812+ needs: number;
813+};
814+
815+/**
816+ * Your notifications that settled by themselves in the span (a later run
817+ * passed, it merged, it deployed), newest first; one marked done by hand
818+ * is yours, not g1t's. Only this workspace's, and none about a repository
819+ * for a member without Code access.
820+ */
821+export function settledIn(items: InboxItem[] | null, slug: string, code: boolean, span: Pick<Span, "from" | "now">): SettledRow[] {
822+ return (items ?? [])
823+ .filter((item) => item.doneAt != null && settledLine(item) && within(item.doneAt, span))
824+ .filter((item) => (item.workspace ?? item.repo?.split("/")[0] ?? "").toLowerCase() === slug && (code || !item.repo))
825+ .map((item) => ({
826+ key: `settled:${item.id}`,
827+ // An issue's or pull request's own title; else the item's ("CI failed on main in acme/web").
828+ title: item.number != null ? item.body || item.title : item.title,
829+ why: settledLine(item),
830+ where: item.repo ? `${item.repo}${item.number != null ? `#${item.number}` : ""}` : null,
831+ to: item.url,
832+ at: Date.parse(item.doneAt!),
833+ }))
834+ .sort((a, b) => b.at - a.at || a.key.localeCompare(b.key));
835+}
836+
837+export function awayOf(input: { settled: SettledRow[]; landed: LandedChange[] | null; needs: number }): Away {
838+ const count = (state: ShipState) => (input.landed ?? []).filter((row) => row.shipped?.state === state).length;
839+ return { settled: input.settled, shipped: count("live"), waiting: count("waiting"), deploying: count("deploying"), failed: count("failed"), needs: input.needs };
840+}
841+
604842 /** A deployment's maker as a member key: an id becomes `user:<id>`, a key stays as it is. */
605843 export function deployActorKey(createdBy: string | null | undefined): string | null {
606844 const by = (createdBy ?? "").trim();
11011339 return `${Math.floor(minutes / (24 * 60))} d`;
11021340 }
11031341
1342+/** "3:40 PM": the time of day alone, for a row under its day's heading. */
1343+export function timeOfDay(at: number, timeZone: string | null): string {
1344+ return formatIn(at, timeZone, { hour: "numeric", minute: "2-digit" });
1345+}
1346+
11041347 /** "3:40 PM" when it is the same day as `now`, else "Oct 7". */
11051348 export function whenShort(at: number, now: number, timeZone: string | null): string {
11061349 if (dayIn(at, timeZone) === dayIn(now, timeZone)) return formatIn(at, timeZone, { hour: "numeric", minute: "2-digit" });
+10−0
2222 subscriptionFromForm,
2323 subscriptionLine,
2424 tabCount,
25+ settledLine,
2526 updatesLabel,
2627 watchFromForm,
2728 watchLabel,
157158 assert.equal(updatesLabel(4), "4 updates");
158159 });
159160
161+test("a done item that settled by itself says why, and who did it", () => {
162+ const done = "2026-10-14T12:00:00.000Z";
163+ assert.equal(settledLine({ doneAt: done, settledReason: "CI passed in run 330", settledBy: null }), "CI passed in run 330");
164+ assert.equal(settledLine({ doneAt: done, settledReason: "Merged", settledBy: "@syntaqx" }), "Merged by @syntaqx");
165+ // Marked done by hand, or not done: nothing.
166+ assert.equal(settledLine({ doneAt: done, settledReason: null, settledBy: null }), "");
167+ assert.equal(settledLine({ doneAt: null, settledReason: "Merged", settledBy: "ana" }), "");
168+});
169+
160170 test("watching is read from the menu, and a custom watch of nothing is the default", () => {
161171 const form = (fields: [string, string][]) => {
162172 const data = new FormData();
+12−0
209209 return count && count > 1 ? `${count} updates` : "";
210210 }
211211
212+/**
213+ * Why a done item settled by itself, as its card says it: the reason, and
214+ * who did it when someone did ("Merged by @ana", "CI passed in run 330").
215+ * Empty for an item not done, or marked done by hand.
216+ */
217+export function settledLine(item: Pick<InboxItem, "doneAt" | "settledReason" | "settledBy">): string {
218+ const reason = item.settledReason?.trim();
219+ if (!item.doneAt || !reason) return "";
220+ const by = item.settledBy?.trim().replace(/^@/, "");
221+ return by ? `${reason} by @${by}` : reason;
222+}
223+
212224 /** The ways to watch a repository, in the order the menu shows them. */
213225 export const WATCH_CHOICES: { level: WatchLevel; label: string; detail: string }[] = [
214226 { level: "participating", label: "Participating and @mentions", detail: "Only what you take part in, or are mentioned in." },
+5−2
495495 case "preferences":
496496 set({ preferences: event.preferences });
497497 break;
498− case "inbox":
499− set({ inbox: event.unread });
498+ case "inbox": {
499+ // Items that settled by themselves: their notifications go from every tab's recent list.
500+ const settled = new Set(Array.isArray(event.settled) ? event.settled : []);
501+ set(settled.size > 0 ? { inbox: event.unread, recent: state.recent.filter((n) => !n.inbox_thread || !settled.has(n.inbox_thread)) } : { inbox: event.unread });
500502 break;
503+ }
501504 case "presence": {
502505 const people = mergePeople(state.people, event.people, event.full);
503506 if (people !== state.people) set({ people });
+56−9
55
66 import type { Route } from "./+types/home";
77 import {
8+ AwayCard,
89 Card,
910 DeploysCard,
1011 LandedCard,
2223 import { sidebarOrNull } from "../../lib/chat.server";
2324 import {
2425 type Attention,
26+ type Away,
2527 type Digest,
2628 attention,
29+ awayOf,
2730 deploysIn,
2831 digestOf,
2932 landed,
3134 landedFromActivity,
3235 landedIn,
3336 parseWindow,
37+ pushRows,
38+ settledIn,
39+ withSessions,
40+ withShipped,
3441 running,
3542 runningDeploys,
3643 runningWorkflows,
3744 spanFor,
45+ within,
3846 spanSentence,
3947 waitingSentence,
4048 } from "../../lib/home";
4452 loadChatActivity,
4553 loadCodeWork,
4654 loadDeploys,
55+ loadDirectPushes,
4756 loadLastVisit,
4857 loadNames,
58+ loadPullSessions,
4959 loadRepos,
60+ loadSettled,
61+ loadShipped,
5062 loadSpend,
5163 loadWorkflowRuns,
5264 reachFor,
117129 const deploysP = code ? loadDeploys(viewer, slug, span.from).catch(() => null) : Promise.resolve(null);
118130 const activityP = reposP.then((owned) => loadActivity(slug, span, owned)).catch(() => null);
119131 const chatActivityP = loadChatActivity(viewer, slug, span).catch(() => null);
120− const namesP = Promise.all([activityP, chatActivityP, deploysP])
121− .then(([activity, chatActivity, deploys]) => loadNames(actorKeys(activity, chatActivity, deploys)))
132+ // Each push to a default branch, with the commits it brought: the projects the digest says were pushed to.
133+ const pushesP = code ? Promise.all([activityP, reposP]).then(([activity, owned]) => loadDirectPushes(viewer, activity, owned, span)).catch(() => null) : Promise.resolve(null);
134+ const namesP = Promise.all([activityP, chatActivityP, deploysP, pushesP])
135+ .then(([activity, chatActivity, deploys, pushes]) => loadNames([...actorKeys(activity, chatActivity, deploys), ...(pushes?.pushes ?? []).map((push) => push.actorKey)]))
122136 .catch(() => ({}) as Record<string, string>);
123137
124138 // What people did, from events and chat.
125139 const digest: Promise<Digest | null> = Promise.all([activityP, chatActivityP, namesP, reposP])
126140 .then(([activity, chatActivity, names, owned]) => digestOf({ slug, repos: repoRefs(owned), activity, chat: chatActivity, names, code }))
127141 .catch(() => null);
128− const happened = Promise.all([codeWorkP, deploysP, activityP, namesP, reposP])
129− .then(([codeWork, deploys, activity, names, owned]) => {
142+ const happened = Promise.all([codeWorkP, deploysP, activityP, namesP, reposP, pushesP])
143+ .then(async ([codeWork, deploys, activity, names, owned, pushes]) => {
130144 const deployRows = deploys ? deploysIn(deploys.projects, slug, span, names) : null;
131145 const merges = codeWork && codeWork !== "no_access" ? landedIn(codeWork.pulls, span) : null;
146+ // Each push on its own where its project's pushes were read; the digest's one row a project elsewhere.
147+ const named = (key: string) => (key ? (names[key] ?? "someone") : "an import");
148+ const each = pushes ? pushRows(pushes.pushes.map((push) => ({ ...push, by: named(push.actorKey) })), pushes.history, span) : [];
149+ const fromActivity = landedFromActivity(activity, repoRefs(owned), names, slug).filter((row) => !(row.kind === "push" && pushes?.history[row.key.slice("push:".length)]));
150+ const all = merges == null && activity == null ? null : landed([merges ?? [], each, fromActivity, landedDeploys(deployRows ?? [])]);
151+ // Whether each merge and push is live, and which pull requests a harness session made: read together.
152+ const [shipped, sessions] = all
153+ ? await Promise.all([
154+ loadShipped(viewer, slug, all).catch(() => null),
155+ codeWork && codeWork !== "no_access" ? loadPullSessions(viewer, codeWork.pulls.filter((pull) => pull.status === "merged" && within(pull.mergedAt, span))).catch(() => null) : null,
156+ ])
157+ : [null, null];
132158 return {
133159 // What landed: merges, direct pushes, releases and packages, production builds; null when nothing of Code could be read.
134− landed: merges == null && activity == null ? null : landed([merges ?? [], landedFromActivity(activity, repoRefs(owned), names, slug), landedDeploys(deployRows ?? [])]),
135− landedMissing: [...(code && merges == null ? ["Pull requests"] : []), ...(code && activity == null ? ["Pushes and releases"] : []), ...(code && deployRows == null ? ["Deployments"] : [])],
160+ landed: all ? withSessions(withShipped(all, shipped), sessions) : null,
161+ landedMissing: [
162+ ...(code && merges == null ? ["Pull requests"] : []),
163+ ...(code && activity == null ? ["Pushes and releases"] : []),
164+ ...(code && deployRows == null ? ["Deployments"] : []),
165+ ],
136166 deploys: deployRows,
137167 deploysComplete: deploys?.complete ?? true,
138168 };
139169 })
140170 .catch(() => null);
171+ // What g1t settled for you in the span, what shipped, and what still needs you.
172+ const settledP = loadSettled(viewer, span).catch(() => null);
173+ const away: Promise<Away | null> = Promise.all([settledP, happened, waiting])
174+ .then(([settled, value, att]) => (settled == null ? null : awayOf({ settled: settledIn(settled, slug, code, span), landed: value?.landed ?? null, needs: att.rows.length })))
175+ .catch(() => null);
141176 const workflowsP = code ? Promise.all([activityP, reposP]).then(([activity, owned]) => loadWorkflowRuns(viewer, activity, owned)).catch(() => null) : Promise.resolve(null);
142177 const runningNow = Promise.all([deploysP, workflowsP])
143178 .then(([deploys, workflows]) => ({
156191 digest,
157192 happened,
158193 attention: waiting,
194+ away,
159195 running: runningNow,
160196 /** The sentence under the heading needs the digest, what landed and what waits. */
161197 head: Promise.all([digest, happened, waiting]),
201237 }
202238
203239 export default function Home({ loaderData }: Route.ComponentProps) {
204− const { slug, now, tz, span, digest, happened, attention: waiting, running: runningNow, head, spend } = loaderData;
240+ const { slug, now, tz, span, digest, happened, attention: waiting, away, running: runningNow, head, spend } = loaderData;
205241 useMarkSeen(slug, now);
206242 return (
207243 <div className="mx-auto w-full max-w-260 space-y-5 px-4 py-5 md:px-10 md:py-8">
226262
227263 {/* 1. Where you're needed now: no window, first and biggest. Start here beside it on wide screens, under it on phones. */}
228264 <div className="grid items-start gap-5 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
229− <div className="lg:col-start-1 lg:row-start-1">
265+ <div id="needed" className="scroll-mt-4 lg:col-start-1 lg:row-start-1">
230266 <Card
231267 title="Where you're needed"
232268 hint="Everything waiting on you, however long ago it started"
260296 {span.key === "last" && span.note == null ? `${span.words} · ${span.length}` : spanNote(span.note)}
261297 </p>
262298 </div>
299+ <Suspense
300+ fallback={
301+ <Card title={span.key === "last" && span.note == null ? "While you were away" : "Settled for you"}>
302+ <RowsSkeleton rows={2} />
303+ </Card>
304+ }
305+ >
306+ <Await resolve={away}>{(value) => <AwayCard away={value} span={span} now={now} />}</Await>
307+ </Suspense>
263308 <Suspense fallback={<SpanSkeleton />}>
264309 <Await resolve={digest}>{(value) => <PeopleCard digest={value} span={span} code={loaderData.code} />}</Await>
265310 </Suspense>
272317 </Card>
273318 }
274319 >
275− <Await resolve={happened}>{(value) => <LandedCard landed={value?.landed ?? null} missing={value?.landedMissing ?? []} code={loaderData.code} now={now} tz={tz} />}</Await>
320+ <Await resolve={happened}>
321+ {(value) => <LandedCard landed={value?.landed ?? null} missing={value?.landedMissing ?? []} code={loaderData.code} now={now} tz={tz} byDay={span.key === "7d"} />}
322+ </Await>
276323 </Suspense>
277324 <Suspense fallback={null}>
278325 <Await resolve={happened}>
+11−0
252252 pub saved: bool,
253253 /// While this is in the future the item is out of the list.
254254 pub snoozed_until: Option<String>,
255+ /// Why it settled by itself, in a few words, such as `CI passed in
256+ /// run 330` or `Merged`: set when the event that left nothing to do
257+ /// marked it done. Absent while it is not done, and when it was marked
258+ /// done by hand.
259+ #[serde(default, skip_serializing_if = "Option::is_none")]
260+ pub settled_reason: Option<String>,
261+ /// Who settled it, when a person or g1t did: a username, or `g1t`.
262+ #[serde(default, skip_serializing_if = "Option::is_none")]
263+ pub settled_by: Option<String>,
255264 }
256265
257266 /// One thing that happened on a thread, as the person was told of it.
816825 done_at: None,
817826 saved: false,
818827 snoozed_until: None,
828+ settled_reason: None,
829+ settled_by: None,
819830 },
820831 activity: Vec::new(),
821832 subscription: None,
+6−1
8484 "repo.archived",
8585 ],
8686 ),
87− // Previews and production deploys (deployments/src/index.ts queue).
87+ // Previews and production deploys (deployments/src/index.ts queue),
88+ // and what each production deployment shipped (deployments/src/shipped.ts).
8889 (
8990 "SUBSCRIBER_DEPLOYMENTS",
9091 &[
9192 "git.push",
93+ "deployment_status.created",
9294 "branch.renamed",
9395 "repo.default_branch_changed",
9496 "pull.opened",
290292 assert!(!routed("SUBSCRIBER_DOCS", "pull.opened"));
291293 assert!(routed("SUBSCRIBER_PROJECTS", "deployment_status.created"));
292294 assert!(!routed("SUBSCRIBER_PROJECTS", "deployment.created"));
295+ // A production deployment's success writes down what it shipped.
296+ assert!(routed("SUBSCRIBER_DEPLOYMENTS", "deployment_status.created"));
297+ assert!(!routed("SUBSCRIBER_DEPLOYMENTS", "deployment.created"));
293298 // Every subscriber follows what moves or removes a repository.
294299 for (binding, _) in ROUTES {
295300 for kind in LIFECYCLE {
+29−0
767767 pub root: Option<Session>,
768768 }
769769
770+/// `pull_sessions`: the harness sessions tied to some of a repository's
771+/// pull requests (`numbers`, at most 100) any way a session is tied to
772+/// one, for Home's marks on what landed. Returns
773+/// `Outcome<Vec<PullSessions>>`, one entry for each pull request that has
774+/// any, its top-level sessions first: a subagent counts as the session
775+/// that started it.
776+#[derive(Debug, Serialize, Deserialize)]
777+pub struct PullSessionsArgs {
778+ pub viewer: Viewer,
779+ pub repo: RepoPath,
780+ pub numbers: Vec<u32>,
781+}
782+
783+/// The sessions tied to one pull request.
784+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
785+#[serde(rename_all = "camelCase")]
786+pub struct PullSessions {
787+ pub number: u32,
788+ pub sessions: Vec<SessionRef>,
789+}
790+
791+/// A session, as a mark on something it made: its id and harness.
792+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
793+#[serde(rename_all = "camelCase")]
794+pub struct SessionRef {
795+ pub id: String,
796+ pub harness: Harness,
797+}
798+
770799 /// `list_work_sessions`: a repository's sessions, most recently heard from
771800 /// first. Returns `Outcome<Vec<Session>>`. Subagents are left out unless
772801 /// `subagents`; with `issue` or `pull`, the sessions tied to it any way
+3−0
393393 gitAccess: (path, viewer, service) =>
394394 call("git_access", { path, viewer, service }),
395395 branches: (path, viewer) => call("branches", { path, viewer }),
396+ pathById: (id) => call("path_by_id", { id }),
396397 lastCommits: (path, viewer, ref, treePath) => call("last_commits", { path, viewer, ref, treePath }),
397398 branchDrift: (path, viewer, base, heads) => call("branch_drift", { path, viewer, base, heads }),
398399 tags: (path, viewer) => call("tags", { path, viewer }),
560561 }),
561562 getWorkSession: (repo, id, viewer, afterSeq = 0) =>
562563 call("get_work_session", { repo, id, viewer, after_seq: afterSeq }),
564+ pullSessions: (repo, viewer, numbers) => call("pull_sessions", { repo, viewer, numbers }),
563565 listDecisions: (repo, number, viewer) => call("list_decisions", { repo, number, viewer }),
564566 };
565567 }
876878 environments: (repo, viewer) => call("list_environments", { repo, viewer }),
877879 createDeployment: (actor, repo, input) => call("create_deployment", { repo, actor, ...input }),
878880 createDeploymentStatus: (actor, repo, id, input) => call("create_deployment_status", { repo, actor, id, ...input }),
881+ shipped: (workspace, viewer, repos) => call("shipped", { workspace, viewer, repos }),
879882 };
880883 }
881884
+33−0
480480 auto_inactive?: boolean | null;
481481 };
482482
483+/**
484+ * Whether a change that landed on a default branch is live in production:
485+ * shipped by a production deployment that succeeded (`live`), not yet
486+ * deployed (`waiting`), on its way (`deploying`), or the production
487+ * deployment after it failed and none has shipped it since (`failed`).
488+ */
489+export type ShipState = "live" | "waiting" | "deploying" | "failed";
490+
491+/** One change asked about: its commit on the default branch, and when it landed there. */
492+export type ShippedAsk = { sha: string; landed_at: string };
493+
494+/** A change's ship state, from what deployments recorded when production deployed. */
495+export type ShippedCommit = {
496+ sha: string;
497+ state: ShipState;
498+ /** RFC 3339: when the deployment that shipped it went live; null until then. */
499+ deployed_at: string | null;
500+ /** Where that deployment is on the site (or the failed or running one, for `failed` and `deploying`). */
501+ url: string | null;
502+ /** The environment it went to, such as `production`. */
503+ environment: string | null;
504+};
505+
506+/** A repository's answer: whether it deploys to production at all, and each change asked about. */
507+export type ShippedRepo = { repo_id: string; deploys: boolean; commits: ShippedCommit[] };
508+
483509 export interface DeploymentsApi {
484510 /** Members of the workspace only. */
485511 settings(project: ProjectRef, viewer: Viewer): Promise<Result<DeploySettings>>;
518544 createDeployment(actor: User, repo: RepoPath, input: NewDeployment): Promise<Result<DeploymentDetail>>;
519545 /** Adds a status to a reported deployment. Takes the Write role. */
520546 createDeploymentStatus(actor: User, repo: RepoPath, id: string, input: NewDeploymentStatus): Promise<Result<DeploymentStatus>>;
547+ /**
548+ * Whether changes that landed on default branches went live, and when:
549+ * read from what was recorded as each production deployment succeeded
550+ * (src/shipped.ts). Members of the workspace only; at most 20
551+ * repositories and 300 commits a call.
552+ */
553+ shipped(workspace: string, viewer: Viewer, repos: { repo_id: string; commits: ShippedAsk[] }[]): Promise<Result<ShippedRepo[]>>;
521554 }
+8−0
8585 doneAt: string | null;
8686 saved: boolean;
8787 snoozedUntil: string | null;
88+ /**
89+ * Why it settled by itself, in a few words (`CI passed in run 330`,
90+ * `Merged`): set when the event that left nothing to do marked it done.
91+ * Absent when it is not done, or was marked done by hand.
92+ */
93+ settledReason?: string | null;
94+ /** Who settled it, when a person or g1t did: a username, or `g1t`. */
95+ settledBy?: string | null;
8896 };
8997
9098 /** One thing that happened on a thread, as the person was told of it. */
+13−2
5858 /** The thread it is in, for a reply: quick replies go there. */
5959 thread_root?: string | null;
6060 /**
61+ * For an inbox item: its thread's key (events' `inbox_items.thread`).
62+ * Once the item settles by itself, events names the thread to notify
63+ * (`set_inbox` with `settled`): the notification is no longer replayed
64+ * to tabs that open later, and open tabs drop it.
65+ */
66+ inbox_thread?: string | null;
67+ /**
6168 * The chat card it is about, when that card has something to press,
6269 * such as a draft issue (File issue, Discard). The toast and the notifications panel show these
6370 * actions, and pressing one goes to the site's `card_action`, exactly as
230237 | { type: "notification"; notification: FeedNotification; toast: boolean }
231238 | ({ type: "counts" } & FeedCounts)
232239 | { type: "preferences"; preferences: NotifyPreferences }
233− /** The person's inbox count as it now is, after items arrive or are marked anywhere. */
234− | { type: "inbox"; unread: number }
235240 /**
241+ * The person's inbox count as it now is, after items arrive or are
242+ * marked anywhere; `settled`, the threads of items that settled by
243+ * themselves, whose notifications tabs drop.
244+ */
245+ | { type: "inbox"; unread: number; settled?: string[] }
246+ /**
236247 * People in a workspace: everyone known on connect (`full`), then each
237248 * one as they change.
238249 */
+3−0
331331 /** The repository's branches, default branch first. */
332332 branches(path: RepoPath, viewer: Viewer): Promise<Result<Branch[]>>;
333333
334+ /** Where a repository is now, by id; null when it is gone. For services: it checks nobody's access. */
335+ pathById(id: string): Promise<RepoPath | null>;
336+
334337 /** Which commit last changed each entry of a directory at `ref` (the default branch when null). */
335338 lastCommits(path: RepoPath, viewer: Viewer, ref: string | null, treePath: string): Promise<Result<LastCommits>>;
336339
+12−0
12391239 /** The harness a session runs in. */
12401240 export type Harness = "claude_code" | "claude_managed_agents" | "codex" | "cursor" | "copilot" | "other";
12411241
1242+/** A session as a mark on something it made. */
1243+export type SessionRef = { id: string; harness: Harness };
1244+
1245+/** The sessions tied to one pull request (`pullSessions`). */
1246+export type PullSessions = { number: number; sessions: SessionRef[] };
1247+
12421248 /** Active: heard from in 15 minutes; idle: not since; ended: ended, or quiet for 2 hours. */
12431249 export type SessionState = "active" | "idle" | "ended";
12441250
15071513 filter?: { state?: SessionState; issue?: number; pull?: number; path?: string; subagents?: boolean },
15081514 ): Promise<Result<WorkSession[]>>;
15091515 getWorkSession(repo: RepoPath, id: string, viewer: Viewer, afterSeq?: number): Promise<Result<WorkSessionDetail>>;
1516+ /**
1517+ * The harness sessions tied to some pull requests (at most 100), any way
1518+ * a session is tied to one; a subagent's as the session that started it.
1519+ * Only pull requests with any are listed.
1520+ */
1521+ pullSessions(repo: RepoPath, viewer: Viewer, numbers: number[]): Promise<Result<PullSessions[]>>;
15101522 /** An issue's (with its pull requests') or a pull request's, oldest first. */
15111523 listDecisions(repo: RepoPath, number: number, viewer: Viewer): Promise<Result<Decision[]>>;
15121524 }
+34−0
1+-- What went live, and when (src/shipped.ts): when a production deployment
2+-- succeeds (`deployment_status.created` with `success`, for a g1t.page
3+-- build or one reported by a workflow or the API), the commits it shipped
4+-- are written down: those in
5+-- its commit's history not shipped before. A commit's first production
6+-- deployment is the one that took it live, so Home can say "Live 9 min
7+-- after merge" without working anything out on page load.
8+
9+-- Each production deployment walked, once, however often its event comes.
10+-- The first a repository has is when recording began: a change that landed
11+-- before it is matched to the first production deployment after it instead.
12+CREATE TABLE shipped_walks (
13+ deployment_id TEXT PRIMARY KEY,
14+ repo_id TEXT NOT NULL,
15+ sha TEXT NOT NULL,
16+ environment TEXT NOT NULL,
17+ -- RFC 3339: when it went live.
18+ deployed_at TEXT NOT NULL,
19+ -- How many commits it shipped that no deployment had before.
20+ commits INTEGER NOT NULL DEFAULT 0
21+);
22+CREATE INDEX shipped_walks_by_repo ON shipped_walks (repo_id, deployed_at);
23+
24+-- Each commit's first production deployment.
25+CREATE TABLE shipped_commits (
26+ repo_id TEXT NOT NULL,
27+ sha TEXT NOT NULL,
28+ deployment_id TEXT NOT NULL,
29+ environment TEXT NOT NULL,
30+ deployed_at TEXT NOT NULL,
31+ -- Where the deployment is on the site: its page, or its workflow run.
32+ url TEXT,
33+ PRIMARY KEY (repo_id, sha)
34+);
+17−0
109109 import { commitMissing, MAX_IDENTICAL_FAILURES, missingCommitMessage, retryDecision, type PastBuild } from "./retries";
110110 import { appHost, appUrl, label, uniqueLabel } from "./names";
111111 import { RepoDeployments, sourceOf } from "./repo-deployments";
112+import { Shipped, shipmentOf } from "./shipped";
112113 import { telemetryTags, withOtel, type AppTelemetry } from "./telemetry";
113114 import { NEEDS_WRITE, commitOf, jobEndedMessage, jobOf, projectNamed, readOnlyAllowed, runTrusted, targetOf, type WorkflowBuild } from "./workflow-builds";
114115
403404 return new RepoDeployments(this.env);
404405 }
405406
407+ /** What each production deployment shipped, and whether a change is live (shipped.ts). */
408+ get shipped(): Shipped {
409+ return new Shipped(this.env, (slug) => this.workspaceActor(slug), (repo, actor, ref, limit) => reposClient(this.env.REPOS).log(repo, actor, ref, limit));
410+ }
411+
406412 /**
407413 * Publishes a build's change in the repository-wide model
408414 * (`deployment.created`, `deployment_status.created`), as a reported
18931899 case "repo.purged":
18941900 await this.repoPurged(event.data.repoId);
18951901 await this.repoDeployments.purge(event.data.repoId);
1902+ await this.shipped.purge(event.data.repoId);
18961903 break;
18971904 case "repo.default_branch_changed":
18981905 await this.defaultBranchChanged(event.data.repoId, event.data.to, event.actor ?? "g1t");
19351942 await this.deployProduction(project, event.data.after, event.actor ?? "g1t");
19361943 }
19371944 break;
1945+ // A production deployment went live, wherever it ran: what it shipped is written down.
1946+ case "deployment_status.created": {
1947+ const repo = await reposClient(this.env.REPOS).pathById(event.data.repoId);
1948+ const shipment = repo ? shipmentOf(event.data, repo, this.env.SITE) : null;
1949+ if (shipment) await this.shipped.record(shipment);
1950+ break;
1951+ }
19381952 }
19391953 }
19401954
29222936 // For the actions service: a run's deployment to one environment.
29232937 case "actions_deployment":
29242938 return service.repoDeployments.fromActions(args);
2939+ // For Home: whether changes that landed went live (shipped.ts).
2940+ case "shipped":
2941+ return service.shipped.answer(args);
29252942 default:
29262943 return undefined;
29272944 }
+96−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { type Attempt, shipStateOf, shipmentOf, sitePath, walkPage } from "./shipped.ts";
5+
6+const commit = (hash: string, parent: string | null = null) => ({ hash, parents: parent ? [parent] : [] });
7+
8+test("a deployment ships what no deployment shipped before, and stops at the last one's history", () => {
9+ const page = [commit("c3", "c2"), commit("c2", "c1"), commit("c1", "c0")];
10+ // Nothing shipped yet, a page not full: all of it, and no more pages.
11+ assert.deepEqual(walkPage(page, new Set(), false), { ship: ["c3", "c2", "c1"], more: false, next: "c0" });
12+ // A full page with nothing known: read on from the last commit's first parent.
13+ assert.deepEqual(walkPage(page, new Set(), true), { ship: ["c3", "c2", "c1"], more: true, next: "c0" });
14+ // The last deployment shipped c1: only what came after it, and the walk stops.
15+ assert.deepEqual(walkPage(page, new Set(["c1"]), true), { ship: ["c3", "c2"], more: false, next: "c0" });
16+ // A rollback to a commit shipped before ships nothing.
17+ assert.deepEqual(walkPage([commit("c1", "c0")], new Set(["c1", "c0"]), false).ship, []);
18+ // A merged branch's older commits after the last deployment's commit in the log still ship.
19+ assert.deepEqual(walkPage([commit("m", "c1"), commit("c1", "c0"), commit("f1", "c0")], new Set(["c1"]), false).ship, ["m", "f1"]);
20+});
21+
22+const ask = { sha: "abc", landed_at: "2026-10-14T10:00:00.000Z" };
23+const at = (minutes: number) => new Date(Date.parse(ask.landed_at) + minutes * 60_000).toISOString();
24+const attempt = (minutes: number, outcome: Attempt["outcome"], url = `/acme/web/deployments/d${minutes}`): Attempt => ({ at: at(minutes), outcome, url, environment: "production" });
25+
26+test("a change shipped is live from the deployment that first shipped it", () => {
27+ const shipped = { deployed_at: at(9), url: "/acme/web/actions/runs/run_1", environment: "production" };
28+ assert.deepEqual(shipStateOf(ask, shipped, [attempt(3, "failed"), attempt(9, "live")], at(-60)), {
29+ sha: "abc",
30+ state: "live",
31+ deployed_at: at(9),
32+ url: "/acme/web/actions/runs/run_1",
33+ environment: "production",
34+ });
35+});
36+
37+test("a change not shipped is deploying, failed or waiting, by the production attempts after it", () => {
38+ const start = at(-60);
39+ assert.equal(shipStateOf(ask, null, [attempt(2, "running")], start).state, "deploying");
40+ const failed = shipStateOf(ask, null, [attempt(2, "failed")], start);
41+ assert.equal(failed.state, "failed");
42+ assert.equal(failed.url, "/acme/web/deployments/d2");
43+ assert.equal(shipStateOf(ask, null, [], start).state, "waiting");
44+ // A failure fixed by a later deployment that did not ship it (another branch, say): waiting again.
45+ assert.equal(shipStateOf(ask, null, [attempt(2, "failed"), attempt(5, "live")], start).state, "waiting");
46+ // Attempts before it landed say nothing about it.
47+ assert.equal(shipStateOf(ask, null, [attempt(-5, "failed")], start).state, "waiting");
48+});
49+
50+test("a change that landed before recording began goes live with the first success after it", () => {
51+ const start = at(120);
52+ const live = shipStateOf(ask, null, [attempt(-5, "live"), attempt(4, "failed"), attempt(30, "live")], start);
53+ assert.equal(live.state, "live");
54+ assert.equal(live.deployed_at, at(30));
55+ // Even when the first walk wrote it down later, as of that walk.
56+ assert.equal(shipStateOf(ask, { deployed_at: start, url: null, environment: "production" }, [attempt(30, "live")], start).deployed_at, at(30));
57+ // Nothing recorded at all yet: the same.
58+ assert.equal(shipStateOf(ask, null, [attempt(30, "live")], null).deployed_at, at(30));
59+});
60+
61+test("only a production success is a shipment, linked to its page", () => {
62+ const repo = { namespace: "acme", name: "web" };
63+ const deployment = {
64+ id: "dep_1",
65+ sha: "abc",
66+ environment: "production",
67+ production_environment: true,
68+ source: "actions" as const,
69+ project: null,
70+ run_url: "https://g1t.sh/acme/web/actions/runs/run_1",
71+ updated_at: "2026-10-14T10:09:00.000Z",
72+ };
73+ const status = { state: "success" as const, created_at: "2026-10-14T10:09:00.000Z" };
74+ assert.deepEqual(shipmentOf({ repoId: "rep_1", deployment, deploymentStatus: status }, repo, "https://g1t.sh"), {
75+ deploymentId: "dep_1",
76+ repoId: "rep_1",
77+ repo,
78+ sha: "abc",
79+ environment: "production",
80+ deployedAt: "2026-10-14T10:09:00.000Z",
81+ url: "/acme/web/actions/runs/run_1",
82+ });
83+ assert.equal(shipmentOf({ repoId: "rep_1", deployment, deploymentStatus: { ...status, state: "in_progress" } }, repo, "https://g1t.sh"), null);
84+ assert.equal(shipmentOf({ repoId: "rep_1", deployment: { ...deployment, production_environment: false }, deploymentStatus: status }, repo, "https://g1t.sh"), null);
85+ const build = { ...deployment, id: "dpl_1", source: "g1t_page" as const, project: "site", run_url: null };
86+ assert.equal(shipmentOf({ repoId: "rep_1", deployment: build, deploymentStatus: status }, repo, "https://g1t.sh")?.url, "/acme/site/deployments/dpl_1");
87+ // Reported through the API with no run: the repository's deployments.
88+ assert.equal(shipmentOf({ repoId: "rep_1", deployment: { ...deployment, source: "api" as const, run_url: null }, deploymentStatus: status }, repo, "https://g1t.sh")?.url, "/acme/web/deployments");
89+});
90+
91+test("addresses stay on the site", () => {
92+ assert.equal(sitePath("https://g1t.sh/acme/web/actions/runs/1", "https://g1t.sh"), "/acme/web/actions/runs/1");
93+ assert.equal(sitePath("/acme/web", "https://g1t.sh"), "/acme/web");
94+ assert.equal(sitePath("https://elsewhere.example/x", "https://g1t.sh"), null);
95+ assert.equal(sitePath(null, "https://g1t.sh"), null);
96+});
+308−0
1+/**
2+ * What went live, and when: Home's "Live 9 min after merge".
3+ *
4+ * When a production deployment succeeds (`deployment_status.created`
5+ * with `success` in a production environment: a g1t.page build, or one a
6+ * workflow or the API reported, since builds are published into the same
7+ * model), its commit's history is read back from repos and every
8+ * commit in it that no production deployment shipped before is written
9+ * down as shipped by this one (`shipped_commits`, migration 0017). A
10+ * commit's first production deployment is the one that took it live: a
11+ * rollback to an older commit ships nothing new, and a deployment's event
12+ * delivered twice is walked once (`shipped_walks`).
13+ *
14+ * Asking (`shipped`) reads only what was written, so a page never walks
15+ * history. A change not shipped is `deploying` while a production
16+ * deployment after it is on its way, `failed` when the latest after it
17+ * failed, and `waiting` otherwise. A change that landed before recording
18+ * began for its repository is matched to the first production deployment
19+ * that succeeded after it instead.
20+ */
21+import type {
22+ Commit,
23+ DeploymentStatus,
24+ RepoDeployment,
25+ RepoPath,
26+ Result,
27+ ShipState,
28+ ShippedAsk,
29+ ShippedCommit,
30+ ShippedRepo,
31+ User,
32+ Viewer,
33+} from "@g1t/contracts";
34+
35+/** Reads a commit's history back, newest first (repos' `log`). */
36+export type ReadLog = (repo: RepoPath, actor: User, ref: string, limit: number) => Promise<Result<Pick<Commit, "hash" | "parents">[]>>;
37+
38+/** Commits read back per page of history, and the most pages one deployment walks. */
39+export const WALK_PAGE = 200;
40+export const WALK_PAGES = 5;
41+/** The most repositories, and commits in all, one `shipped` call asks about. */
42+export const MAX_REPOS = 20;
43+export const MAX_COMMITS = 300;
44+
45+/** A production deployment that went live, as its event says. */
46+export type Shipment = {
47+ deploymentId: string;
48+ repoId: string;
49+ repo: RepoPath;
50+ sha: string;
51+ environment: string;
52+ deployedAt: string;
53+ /** Its page on the site. */
54+ url: string | null;
55+};
56+
57+/**
58+ * The commits of one page of history this deployment ships: those not
59+ * known to be shipped already, each once. Whether to read the next page:
60+ * only when this page was full and held nothing shipped before, so the
61+ * walk stops at the last deployment's history.
62+ */
63+export function walkPage(page: Pick<Commit, "hash" | "parents">[], known: ReadonlySet<string>, full: boolean): { ship: string[]; more: boolean; next: string | null } {
64+ const ship = [...new Set(page.map((commit) => commit.hash).filter((hash) => !known.has(hash)))];
65+ const reachedKnown = page.some((commit) => known.has(commit.hash));
66+ const last = page.at(-1);
67+ const next = last?.parents[0] ?? null;
68+ return { ship, more: full && !reachedKnown && next != null, next };
69+}
70+
71+/** A production attempt after a change landed, as either table keeps it. */
72+export type Attempt = { at: string; outcome: "live" | "running" | "failed"; url: string | null; environment: string };
73+
74+/**
75+ * A change's state from what was recorded: shipped (its first production
76+ * deployment), else from the production attempts after it landed (oldest
77+ * first). `recordingStart` is when the repository's first walk went live:
78+ * a change that landed before it was never walked, so the first attempt
79+ * after it that went live shipped it.
80+ */
81+export function shipStateOf(
82+ ask: ShippedAsk,
83+ shipped: { deployed_at: string; url: string | null; environment: string } | null,
84+ attempts: Attempt[],
85+ recordingStart: string | null,
86+): ShippedCommit {
87+ const base = { sha: ask.sha };
88+ const after = attempts.filter((attempt) => attempt.at >= ask.landed_at);
89+ // Landed before anything was walked: the first walk wrote its history
90+ // down as of then, so the first deployment after it that went live is nearer.
91+ if (recordingStart == null || ask.landed_at < recordingStart) {
92+ const live = after.find((attempt) => attempt.outcome === "live");
93+ if (live) return { ...base, state: "live", deployed_at: live.at, url: live.url, environment: live.environment };
94+ }
95+ if (shipped) return { ...base, state: "live", deployed_at: shipped.deployed_at, url: shipped.url, environment: shipped.environment };
96+ const running = after.find((attempt) => attempt.outcome === "running");
97+ if (running) return { ...base, state: "deploying", deployed_at: null, url: running.url, environment: running.environment };
98+ const latest = after.at(-1);
99+ const state: ShipState = latest?.outcome === "failed" ? "failed" : "waiting";
100+ return { ...base, state, deployed_at: null, url: state === "failed" ? (latest?.url ?? null) : null, environment: latest?.environment ?? null };
101+}
102+
103+/** A g1t.page build's outcome. */
104+function buildOutcome(status: string): Attempt["outcome"] | null {
105+ if (status === "ready" || status === "replaced" || status === "down") return "live";
106+ if (status === "queued" || status === "building") return "running";
107+ if (status === "failed") return "failed";
108+ return null;
109+}
110+
111+/** A reported deployment's outcome: `inactive` is a success a newer one replaced. */
112+function reportedOutcome(state: string): Attempt["outcome"] | null {
113+ if (state === "success" || state === "inactive") return "live";
114+ if (state === "queued" || state === "in_progress" || state === "pending") return "running";
115+ if (state === "failure" || state === "error") return "failed";
116+ return null;
117+}
118+
119+/** A site path from an address that may be the site's own, absolute. */
120+export function sitePath(url: string | null | undefined, site: string): string | null {
121+ if (!url) return null;
122+ if (url.startsWith("/")) return url;
123+ if (site && url.startsWith(site)) return url.slice(site.length) || "/";
124+ return null;
125+}
126+
127+/**
128+ * The shipment a `deployment_status.created` event is, if it is one: a
129+ * success in a production environment. A g1t.page build links to its
130+ * page, a reported deployment to its workflow run, else to the
131+ * repository's deployments.
132+ */
133+export function shipmentOf(
134+ data: { repoId: string; deployment: Pick<RepoDeployment, "id" | "sha" | "environment" | "production_environment" | "source" | "project" | "run_url" | "updated_at">; deploymentStatus: Pick<DeploymentStatus, "state" | "created_at"> },
135+ repo: RepoPath,
136+ site: string,
137+): Shipment | null {
138+ const { deployment, deploymentStatus } = data;
139+ if (deploymentStatus?.state !== "success" || !deployment?.production_environment || !deployment.sha) return null;
140+ const own = `/${repo.namespace}/${repo.name}`;
141+ const url =
142+ deployment.source === "g1t_page" && deployment.project
143+ ? `/${repo.namespace}/${deployment.project}/deployments/${deployment.id}`
144+ : (sitePath(deployment.run_url, site) ?? `${own}/deployments`);
145+ return {
146+ deploymentId: deployment.id,
147+ repoId: data.repoId,
148+ repo,
149+ sha: deployment.sha,
150+ environment: deployment.environment,
151+ deployedAt: deploymentStatus.created_at || deployment.updated_at,
152+ url,
153+ };
154+}
155+
156+type Env = { DB: D1Database; SITE: string };
157+
158+/**
159+ * Only types are imported here, so the rules above are tested on their
160+ * own; the service hands in how to read history and as whom.
161+ */
162+export class Shipped {
163+ constructor(
164+ private readonly env: Env,
165+ /** Reads a workspace's repositories as the workspace itself. */
166+ private readonly actorFor: (workspace: string) => Promise<User | null>,
167+ private readonly readLog: ReadLog,
168+ ) {}
169+
170+ private get db(): D1Database {
171+ return this.env.DB;
172+ }
173+
174+ /** Writes down what a production deployment shipped. Once per deployment. */
175+ async record(shipment: Shipment): Promise<number> {
176+ const walked = await this.db
177+ .prepare(
178+ `INSERT INTO shipped_walks (deployment_id, repo_id, sha, environment, deployed_at) VALUES (?, ?, ?, ?, ?)
179+ ON CONFLICT DO NOTHING RETURNING deployment_id`,
180+ )
181+ .bind(shipment.deploymentId, shipment.repoId, shipment.sha, shipment.environment, shipment.deployedAt)
182+ .first<{ deployment_id: string }>();
183+ if (!walked) return 0;
184+ const actor = await this.actorFor(shipment.repo.namespace);
185+ if (!actor) return 0;
186+ let ref: string | null = shipment.sha;
187+ let shipped = 0;
188+ try {
189+ for (let page = 0; page < WALK_PAGES && ref; page++) {
190+ const history = await this.readLog(shipment.repo, actor, ref, WALK_PAGE);
191+ if (!history.ok || history.value.length === 0) break;
192+ const hashes = history.value.map((commit) => commit.hash);
193+ const known = await this.db
194+ .prepare("SELECT sha FROM shipped_commits WHERE repo_id = ? AND sha IN (SELECT value FROM json_each(?))")
195+ .bind(shipment.repoId, JSON.stringify(hashes))
196+ .all<{ sha: string }>();
197+ const { ship, more, next } = walkPage(history.value, new Set(known.results.map((row) => row.sha)), history.value.length >= WALK_PAGE);
198+ if (ship.length > 0) {
199+ await this.db.batch(
200+ ship.map((sha) =>
201+ this.db
202+ .prepare(
203+ `INSERT INTO shipped_commits (repo_id, sha, deployment_id, environment, deployed_at, url) VALUES (?, ?, ?, ?, ?, ?)
204+ ON CONFLICT DO NOTHING`,
205+ )
206+ .bind(shipment.repoId, sha, shipment.deploymentId, shipment.environment, shipment.deployedAt, shipment.url),
207+ ),
208+ );
209+ shipped += ship.length;
210+ }
211+ ref = more ? next : null;
212+ }
213+ } finally {
214+ await this.db.prepare("UPDATE shipped_walks SET commits = ? WHERE deployment_id = ?").bind(shipped, shipment.deploymentId).run();
215+ }
216+ return shipped;
217+ }
218+
219+ /** Whether each change asked about is live (see the module's notes). Members of the workspace only. */
220+ async answer(a: { workspace: string; viewer: Viewer; repos: { repo_id: string; commits: ShippedAsk[] }[] }): Promise<Result<ShippedRepo[]>> {
221+ const workspace = String(a.workspace ?? "").toLowerCase();
222+ if (!a.viewer?.workspaces?.some((membership) => membership.slug === workspace)) {
223+ return { ok: false, error: { code: "forbidden", message: "Only members can see a workspace's deployments." } };
224+ }
225+ const asked = (Array.isArray(a.repos) ? a.repos : []).slice(0, MAX_REPOS);
226+ let budget = MAX_COMMITS;
227+ const out: ShippedRepo[] = [];
228+ for (const repo of asked) {
229+ const commits = (Array.isArray(repo.commits) ? repo.commits : [])
230+ .filter((ask) => typeof ask?.sha === "string" && typeof ask?.landed_at === "string")
231+ .slice(0, Math.max(0, budget));
232+ budget -= commits.length;
233+ out.push(await this.forRepo(String(repo.repo_id), workspace, commits));
234+ }
235+ return { ok: true, value: out };
236+ }
237+
238+ private async forRepo(repoId: string, workspace: string, commits: ShippedAsk[]): Promise<ShippedRepo> {
239+ // Only this workspace's repositories: the g1t.page builds and the
240+ // reported deployments say whose they are.
241+ const oldest = commits.reduce<string | null>((min, ask) => (min == null || ask.landed_at < min ? ask.landed_at : min), null);
242+ const [shipped, start, builds, reported] = await Promise.all([
243+ commits.length
244+ ? this.db
245+ .prepare(
246+ "SELECT sha, deployed_at, url, environment FROM shipped_commits WHERE repo_id = ? AND sha IN (SELECT value FROM json_each(?))",
247+ )
248+ .bind(repoId, JSON.stringify(commits.map((ask) => ask.sha)))
249+ .all<{ sha: string; deployed_at: string; url: string | null; environment: string }>()
250+ : null,
251+ this.db.prepare("SELECT MIN(deployed_at) AS at FROM shipped_walks WHERE repo_id = ?").bind(repoId).first<{ at: string | null }>(),
252+ this.db
253+ .prepare(
254+ `SELECT id, slug, status, created_at, finished_at FROM deployments
255+ WHERE repo_id = ? AND workspace = ? AND kind = 'production' AND created_at >= ? ORDER BY created_at LIMIT 200`,
256+ )
257+ .bind(repoId, workspace, oldest ?? "9999")
258+ .all<{ id: string; slug: string; status: string; created_at: string; finished_at: string | null }>(),
259+ this.db
260+ .prepare(
261+ `SELECT id, environment, state, run_url, created_at, updated_at FROM reported_deployments
262+ WHERE repo_id = ? AND production_environment = 1 AND created_at >= ? ORDER BY created_at LIMIT 200`,
263+ )
264+ .bind(repoId, oldest ?? "9999")
265+ .all<{ id: string; environment: string; state: string; run_url: string | null; created_at: string; updated_at: string }>(),
266+ ]);
267+ const deploys =
268+ (builds.results.length > 0 || reported.results.length > 0 || (start?.at ?? null) != null) ||
269+ (await this.deploysAtAll(repoId, workspace));
270+ const attempts: Attempt[] = [
271+ ...builds.results.flatMap((row) => {
272+ const outcome = buildOutcome(row.status);
273+ return outcome
274+ ? [{ at: outcome === "running" ? row.created_at : (row.finished_at ?? row.created_at), outcome, url: `/${workspace}/${row.slug}/deployments/${row.id}`, environment: "production" }]
275+ : [];
276+ }),
277+ ...reported.results.flatMap((row) => {
278+ const outcome = reportedOutcome(row.state);
279+ // A success replaced since (`inactive`) was last changed when it was replaced: it went live nearer its start.
280+ const at = outcome === "running" || row.state === "inactive" ? row.created_at : row.updated_at;
281+ return outcome ? [{ at, outcome, url: sitePath(row.run_url, this.env.SITE), environment: row.environment }] : [];
282+ }),
283+ ].sort((x, y) => x.at.localeCompare(y.at));
284+ const byHash = new Map((shipped?.results ?? []).map((row) => [row.sha, row]));
285+ return {
286+ repo_id: repoId,
287+ deploys,
288+ commits: deploys ? commits.map((ask) => shipStateOf(ask, byHash.get(ask.sha) ?? null, attempts, start?.at ?? null)) : [],
289+ };
290+ }
291+
292+ /** Whether the repository ever deployed to production. */
293+ private async deploysAtAll(repoId: string, workspace: string): Promise<boolean> {
294+ const [build, reported] = await Promise.all([
295+ this.db.prepare("SELECT 1 AS x FROM deployments WHERE repo_id = ? AND workspace = ? AND kind = 'production' LIMIT 1").bind(repoId, workspace).first(),
296+ this.db.prepare("SELECT 1 AS x FROM reported_deployments WHERE repo_id = ? AND production_environment = 1 LIMIT 1").bind(repoId).first(),
297+ ]);
298+ return build != null || reported != null;
299+ }
300+
301+ /** A repository purged: what it shipped goes with it. */
302+ async purge(repoId: string): Promise<void> {
303+ await this.db.batch([
304+ this.db.prepare("DELETE FROM shipped_commits WHERE repo_id = ?").bind(repoId),
305+ this.db.prepare("DELETE FROM shipped_walks WHERE repo_id = ?").bind(repoId),
306+ ]);
307+ }
308+}
+7−0
1+-- Why an item settled by itself (src/inbox.rs, "Settling"), for the Done
2+-- view and Home's "While you were away": a few words on what ended it,
3+-- such as "CI passed in run 330" or "Merged", and who did, when a person
4+-- or g1t did (a username, or g1t). Both are cleared when new activity
5+-- brings the thread back, and stay empty for an item marked done by hand.
6+ALTER TABLE inbox_items ADD COLUMN settled_reason TEXT;
7+ALTER TABLE inbox_items ADD COLUMN settled_by TEXT;
+294−103
Loading the diff…
+1−1
Loading the diff…
+15−4
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…

Comments

Sign in to comment.