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.

Stacked branches merge and get CI cleanly; a job's log report is constant work #369

Mergedsyntaqx merged 365-stacked-ci-and-log-bytes into main
23 files+1925−1660/23 viewed
+1−1
319319
320320 Signed in without any workspace (you declined an invitation, or left the
321321 only workspace you were in), g1t shows **Create your workspace or ask to
322−join one** in place of Home: the invitations waiting for you, if
322+join one** in place of Mission control: the invitations waiting for you, if
323323 any, and the form to create a workspace. To join a team already on g1t,
324324 ask one of its owners to invite you by your username.
325325
+36−20
1616 someone else's workspace; see
1717 [your first workspace](/guides/authentication/#your-first-workspace).
1818 Signed in without a workspace, g1t shows **Create your workspace or ask to
19−join one** in place of Home: the invitations waiting for you,
19+join one** in place of Mission control: the invitations waiting for you,
2020 and the form below.
2121
2222 To create another:
627627
628628 ## The workspace's page
629629
630−A workspace's own page, `g1t.sh/<workspace>`, is Code's front page, as an
631−organization's page is on GitHub, for members and visitors alike. It has
632−the workspace's [profile](#the-workspaces-profile) at the top: its
633−picture, name, address, description, location and links. Then how many
634−repositories it has, how many are in production, its open work items and
635−the pull requests in progress; your
630+A workspace's own page, `g1t.sh/<workspace>`, has its icon, name, address
631+and description at the top, then its overview: your
636632 [pinned repositories](#pinned-and-recent-repositories), then the most active ones,
637−and **All repositories**; the pull requests in progress across them; and,
638−beside them, its members. **Code** in the app bar opens it. Where you are
639−needed and what happened while you were away are on [Home](/guides/home/).
633+the pull requests in progress across them, and **All repositories**. Members
634+also see a **Usage** card with this month's spend, and who belongs.
640635
641636 The workspace's other pages each have a heading of their own and a row in
642637 [the sidebar](#the-sidebar), lit while you are on them. The trail in the
10061001 **Explore**, **Docs**, **Sign in** and **Sign up**; a repository's pages run in
10071002 the row under its name.
10081003
1009−## Code's front page
1004+## Mission control
1005+
1006+Mission control is Code's **Overview**, at `g1t.sh/<workspace>/-/overview`.
1007+It shows where you are needed in the workspace's code and what landed,
1008+then the week and the activity. What the workspace's work says is on
1009+[Home](#the-workspace-overview).
1010+Signed in, `g1t.sh` itself opens [Home](/guides/home/) in the workspace
1011+you are in.
1012+
1013+**Review N that need you** jumps to the list, and **New work item** opens
1014+a new [work item](/work/work-items/) of the workspace, filed from the
1015+repository you pick.
1016+
1017+| Tab | What it lists |
1018+| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1019+| **Needs you** | Reviews asked of you, changes ready for you to merge, failed checks, failed production builds, repository invitations and a usage limit that is close or reached. |
1020+| **Landed today** | Pull requests merged today in your time zone, and whether a person merged them. |
1021+
1022+Each row in **Needs you** carries the reason it needs you:
1023+
1024+| Reason | Means |
1025+| ---------------- | ------------------------------------------------------------------------------------------------------------- |
1026+| `BLOCKING` | Nothing moves until a person acts: a failed production build, a merge g1t could not make, or the usage limit. |
1027+| `ASKED FOR YOU` | A review or an invitation addressed to you by name. |
1028+| `CHECKS FAILING` | A required check fails. |
1029+| `NEEDS REVIEW` | The repository wants a person's approval. |
1030+| `READY TO MERGE` | Checks passed and it was approved; the repository lands changes only when a person merges them. |
10101031
1011−Code opens on [the workspace's page](#the-workspaces-page),
1012−`g1t.sh/<workspace>`: the workspace as an organization, its repositories
1013−and its people. What needs you in the workspace's code (reviews asked of
1014−you, changes ready to merge, failing checks, failed production builds,
1015−repository invitations and a usage limit that is close or reached) is in
1016−[Home's Your move](/guides/home/#your-move), with everything else waiting
1017−on you; what landed is under
1018−[Since you were last here](/guides/home/). Signed in, `g1t.sh` itself
1019−opens [Home](/guides/home/) in the workspace you are in.
1032+From a row, **Review and respond** opens it, and where it can be done
1033+without leaving the page you can approve the change, merge it or re-run
1034+its failed jobs. **By impact** puts the most urgent first; **Newest** sorts
1035+by time.
10201036
10211037 ## Workspace access tokens
10221038
+1−1
188188 ))}
189189 </ul>
190190 )}
191− <Link to={`/${slug}/-/home#needed`} className="mt-3 inline-flex items-center gap-1 text-xs text-muted hover:text-fg">
191+ <Link to={`/${slug}/-/overview?tab=needs#work`} className="mt-3 inline-flex items-center gap-1 text-xs text-muted hover:text-fg">
192192 The review queue <ArrowRight size={11} />
193193 </Link>
194194 </div>
+836−0
1+import { ArrowDownWideNarrow, ArrowRight, ArrowUpRight, Check, ChevronDown, ChevronRight, Plus } from "lucide-react";
2+import { type ReactNode, useEffect, useState } from "react";
3+import { Link, useFetcher, useLocation, useRouteLoaderData, useSearchParams } from "react-router";
4+
5+import { type ActivityGroup, type Verb, isAgent } from "../lib/mission";
6+import {
7+ BLOCKING,
8+ type Fact,
9+ type LandedRow,
10+ type NeedRow,
11+ type QuickAction,
12+ REASON_LABEL,
13+ type Reason,
14+ type Sort,
15+ type Tab,
16+ type Week,
17+ type Who,
18+ change,
19+ dateLine,
20+ parseSort,
21+ parseTab,
22+ signedPercent,
23+ sortRows,
24+} from "../lib/mission-control";
25+import { cn } from "../lib/cn";
26+import { money } from "../lib/money";
27+import { InboxNeedsCard } from "./inbox";
28+import type { ShellData } from "./shell";
29+import { Unavailable } from "./mission";
30+import { SubmitButton, TimeAgo } from "./ui";
31+import { Avatar } from "./ui/avatar";
32+import { UserAvatar } from "./user-card";
33+import { PersonLink } from "./work";
34+import { Card } from "./ui/card";
35+import { Hint } from "./ui/hint";
36+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from "./ui/dropdown-menu";
37+import type { Loaded } from "../lib/mission-control.server";
38+import { itemPath, newItemPath } from "../lib/work-items";
39+
40+/*
41+ * Code's Overview: where the viewer is needed in the workspace's code,
42+ * what landed, and the week. It is a chunk of its own, loaded only for
43+ * those signed in. routes/workspace/code-overview.tsx loads its data;
44+ * lib/mission-control.ts shapes it.
45+ */
46+
47+/** The viewer's time zone, set by the page itself, so the date fits their day. */
48+const TZ_COOKIE = "g1t_tz";
49+/** Rows a tab shows before "See all". */
50+const ROWS = 6;
51+
52+const dollars = (micros: number) => money(Math.max(0, micros));
53+const plural = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString("en-US")} ${n === 1 ? one : many}`;
54+
55+// --- Small pieces -----------------------------------------------------------------
56+
57+const CHIP_TONE: Record<Reason, string> = {
58+ blocking: "border-danger/35 bg-danger/10 text-danger",
59+ checks_failing: "border-danger/35 bg-danger/10 text-danger",
60+ stalled: "border-warn/35 bg-warn/10 text-warn",
61+ invited: "border-accent/35 bg-accent/10 text-accent",
62+ needs_review: "border-info/35 bg-info/10 text-info",
63+ ready: "border-success/35 bg-success/10 text-success",
64+};
65+
66+function Chip({ children, tone }: { children: ReactNode; tone: string }) {
67+ return (
68+ <span
69+ className={cn(
70+ "inline-flex shrink-0 items-center rounded border px-1.5 py-px font-mono text-[0.625rem] font-medium tracking-wider whitespace-nowrap uppercase",
71+ tone,
72+ )}
73+ >
74+ {children}
75+ </span>
76+ );
77+}
78+
79+/** Someone, by name, with their avatar. */
80+function Person({ who, size = 16, className }: { who: Who; size?: number; className?: string }) {
81+ return (
82+ <span className={cn("inline-flex min-w-0 items-center gap-1.5", className)}>
83+ <Avatar name={who.name} size={size} />
84+ <span className="truncate">{who.name}</span>
85+ </span>
86+ );
87+}
88+
89+function Eyebrow({ children }: { children: ReactNode }) {
90+ return <p className="font-mono text-[0.625rem] font-medium tracking-[0.12em] text-faint uppercase">{children}</p>;
91+}
92+
93+const FACT_TONE = { good: "text-success", warn: "text-warn", bad: "text-danger" } as const;
94+
95+function Facts({ facts }: { facts: Fact[] }) {
96+ if (facts.length === 0) return <p className="text-sm text-muted">Nothing more is known about it yet.</p>;
97+ return (
98+ <dl className="grid grid-cols-2 gap-x-4 gap-y-2.5">
99+ {facts.map((fact) => (
100+ <div key={fact.label} className={cn("min-w-0", fact.wide && "col-span-2")}>
101+ <dt className="text-xs text-faint">{fact.label}</dt>
102+ <dd
103+ className={cn("mt-0.5 text-sm font-medium tabular-nums", fact.wide ? "leading-5" : "truncate", fact.tone ? FACT_TONE[fact.tone] : "text-fg-soft")}
104+ >
105+ {fact.value}
106+ </dd>
107+ </div>
108+ ))}
109+ </dl>
110+ );
111+}
112+
113+/**
114+ * A form that acts on a pull request from here: working until the action
115+ * has answered and the page has reloaded what it changed, then done, or
116+ * the error beside it.
117+ */
118+function QuickForm({ quick, variant = "outline" }: { quick: QuickAction; variant?: "outline" | "accent" }) {
119+ const fetcher = useFetcher<{ error?: string } | null>();
120+ const [sent, setSent] = useState(false);
121+ const done = sent && fetcher.state === "idle" && !fetcher.data?.error;
122+ return (
123+ <fetcher.Form method="post" action={quick.to} onSubmit={() => setSent(true)} className="contents">
124+ {Object.entries(quick.fields).map(([name, value]) => (
125+ <input key={name} type="hidden" name={name} value={value} />
126+ ))}
127+ <SubmitButton
128+ fetcher={fetcher}
129+ disabled={done}
130+ className={cn(
131+ "inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors disabled:cursor-default",
132+ variant === "accent" ? "bg-accent text-bg hover:bg-accent-hover" : "border border-line-strong text-fg/90 hover:bg-raised hover:text-fg",
133+ done && "border-success/40 text-success",
134+ )}
135+ >
136+ {done ? <Check size={14} /> : null}
137+ {done ? quick.done : quick.label}
138+ </SubmitButton>
139+ {fetcher.data?.error && <span className="basis-full text-xs text-danger">{fetcher.data.error}</span>}
140+ </fetcher.Form>
141+ );
142+}
143+
144+function ProjectMark({ repo }: { repo: { namespace: string; name: string } | null }) {
145+ return <Avatar name={repo ? repo.name : "g1t"} size={32} square />;
146+}
147+
148+/** A row that opens to what is known about it; the first one starts open. */
149+function Row({
150+ open,
151+ repo,
152+ by,
153+ title,
154+ sub,
155+ chip,
156+ at,
157+ children,
158+}: {
159+ open: boolean;
160+ repo: { namespace: string; name: string } | null;
161+ by: Who | null;
162+ title: ReactNode;
163+ sub: ReactNode;
164+ chip: ReactNode;
165+ at: number;
166+ children: ReactNode;
167+}) {
168+ return (
169+ <li>
170+ <details open={open} className="group">
171+ <summary className="flex cursor-pointer list-none items-center gap-3 px-4 py-3 transition-colors select-none hover:bg-raised/60 sm:px-5 [&::-webkit-details-marker]:hidden">
172+ <ProjectMark repo={repo} />
173+ <span className="min-w-0 grow">
174+ <span className="block truncate text-sm">{title}</span>
175+ <span className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted">
176+ <span className="shrink-0 font-medium text-fg-soft">{repo ? repo.name : "Workspace"}</span>
177+ {by && (
178+ <>
179+ <span className="text-faint">·</span>
180+ <Person who={by} size={14} className={cn("shrink-0", by.agent ? "text-accent" : "")} />
181+ </>
182+ )}
183+ {sub && (
184+ <>
185+ <span className="text-faint">—</span>
186+ <span className="truncate">{sub}</span>
187+ </>
188+ )}
189+ </span>
190+ </span>
191+ <span className="hidden sm:inline-flex">{chip}</span>
192+ <span className="hidden w-16 shrink-0 text-right text-xs text-faint sm:block">
193+ <TimeAgo at={at} />
194+ </span>
195+ <ChevronDown size={16} className="shrink-0 text-faint transition-transform group-open:rotate-180" />
196+ </summary>
197+ <div className="px-4 pb-4 sm:px-5 sm:pb-5">
198+ <div className="mb-3 flex items-center gap-2 sm:hidden">
199+ {chip}
200+ <span className="text-xs text-faint">
201+ <TimeAgo at={at} />
202+ </span>
203+ </div>
204+ {children}
205+ </div>
206+ </details>
207+ </li>
208+ );
209+}
210+
211+function NeedCard({ row, first }: { row: NeedRow; first: boolean }) {
212+ const isPull = row.to.includes("/pull/");
213+ return (
214+ <Row
215+ open={first}
216+ repo={row.repo}
217+ by={row.by}
218+ title={
219+ <>
220+ <span className="font-medium">{row.title}</span>
221+ {row.ref && <span className="ml-1 font-mono text-xs text-faint">{row.ref}</span>}
222+ </>
223+ }
224+ sub={row.ask}
225+ chip={<Chip tone={CHIP_TONE[row.reason]}>{REASON_LABEL[row.reason]}</Chip>}
226+ at={row.at}
227+ >
228+ <Card tone="plain" className="grid gap-4 bg-bg/50 p-4 md:grid-cols-2 md:gap-x-6 2xl:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)_minmax(0,0.95fr)]">
229+ <div className="min-w-0">
230+ <Eyebrow>The ask</Eyebrow>
231+ <p className="mt-2 text-sm leading-6 text-fg-soft">{row.ask}</p>
232+ <p className="mt-2 text-xs leading-5 text-muted">
233+ <Link to={row.to} className="font-medium text-fg hover:underline">
234+ {row.repo ? `${row.repo.name}${row.ref ?? ""}` : row.title}
235+ </Link>
236+ </p>
237+ </div>
238+ <div className="min-w-0">
239+ <Eyebrow>What is known</Eyebrow>
240+ <div className="mt-2">
241+ <Facts facts={row.facts} />
242+ </div>
243+ </div>
244+ <div className="rounded-lg border border-warn/20 bg-warn/6 p-3.5 md:col-span-2 2xl:col-span-1">
245+ <p className="text-xs font-semibold text-warn">Why this needs you</p>
246+ <p className="mt-1.5 text-sm leading-6 text-fg-soft">{row.why}</p>
247+ <p className="mt-2 text-xs text-faint">
248+ Started waiting <TimeAgo at={row.at} />
249+ </p>
250+ </div>
251+ </Card>
252+ <div className="mt-3 flex flex-wrap items-center gap-2">
253+ <Link
254+ to={row.to}
255+ prefetch="intent"
256+ className="inline-flex items-center gap-1.5 rounded-md bg-fg px-3 py-1.5 text-sm font-medium text-bg transition-colors hover:bg-fg-hover"
257+ >
258+ Review and respond <ArrowUpRight size={14} />
259+ </Link>
260+ {row.quick && <QuickForm quick={row.quick} />}
261+ {row.link && (
262+ <Link
263+ to={row.link.to}
264+ className="inline-flex items-center rounded-md border border-line-strong px-3 py-1.5 text-sm font-medium text-fg/90 hover:bg-raised hover:text-fg"
265+ >
266+ {row.link.label}
267+ </Link>
268+ )}
269+ {row.open !== row.to && (
270+ <Link to={row.open} prefetch="intent" className="px-2 py-1.5 text-sm text-muted hover:text-fg">
271+ {row.open.includes("/-/work/") ? "Open work item" : isPull ? "See the changes" : "Open pull request"}
272+ </Link>
273+ )}
274+ </div>
275+ </Row>
276+ );
277+}
278+
279+function LandedCard({ row, first }: { row: LandedRow; first: boolean }) {
280+ return (
281+ <Row
282+ open={first}
283+ repo={row.repo}
284+ by={row.by}
285+ title={
286+ <>
287+ <span className="font-medium">{row.title}</span>
288+ <span className="ml-1 font-mono text-xs text-faint">{row.ref}</span>
289+ </>
290+ }
291+ sub={row.automatic ? "merged automatically" : `merged by ${row.by?.name ?? "a person"}`}
292+ chip={
293+ row.automatic ? (
294+ <Chip tone="border-accent/35 bg-accent/10 text-accent">Auto-merged</Chip>
295+ ) : (
296+ <Chip tone="border-line-strong bg-raised text-muted">Merged</Chip>
297+ )
298+ }
299+ at={row.at}
300+ >
301+ <Card tone="plain" className="grid gap-4 bg-bg/50 p-4 md:grid-cols-2 md:gap-6">
302+ <div className="min-w-0">
303+ <Eyebrow>How it landed</Eyebrow>
304+ <p className="mt-2 text-sm leading-6 text-fg-soft">
305+ {row.automatic
306+ ? "g1t merged it once everything the repository asks for was met, with no one pressing merge."
307+ : `${row.by?.name ?? "A person"} merged it.`}
308+ </p>
309+ </div>
310+ <div className="min-w-0">
311+ <Eyebrow>What changed</Eyebrow>
312+ <div className="mt-2">
313+ <Facts facts={row.facts} />
314+ </div>
315+ </div>
316+ </Card>
317+ <div className="mt-3">
318+ <Link
319+ to={row.to}
320+ prefetch="intent"
321+ className="inline-flex items-center gap-1.5 rounded-md border border-line-strong px-3 py-1.5 text-sm font-medium text-fg/90 hover:bg-raised hover:text-fg"
322+ >
323+ Open pull request <ArrowUpRight size={14} />
324+ </Link>
325+ </div>
326+ </Row>
327+ );
328+}
329+
330+function List({ children }: { children: ReactNode }) {
331+ return <ul className="divide-y divide-line">{children}</ul>;
332+}
333+
334+function Empty({ children, action }: { children: ReactNode; action?: ReactNode }) {
335+ return (
336+ <div className="px-5 py-10 text-center">
337+ <div className="mx-auto max-w-md text-sm leading-6 text-muted">{children}</div>
338+ {action && <div className="mt-4 flex flex-wrap justify-center gap-2">{action}</div>}
339+ </div>
340+ );
341+}
342+
343+// --- The stat strip -----------------------------------------------------------------
344+
345+function Stat({ label, value, hint, dot, title }: { label: string; value: string; hint: ReactNode; dot?: string; title?: string }) {
346+ return (
347+ <Hint label={title}>
348+ <div className="min-w-0 bg-surface px-4 py-3.5 sm:px-5 sm:py-4">
349+ <p className="flex items-center gap-1.5 truncate text-xs text-muted">
350+ {dot && <span className={cn("size-1.5 shrink-0 rounded-full", dot)} />}
351+ {label}
352+ </p>
353+ <p className="mt-1 font-display text-2xl font-semibold tracking-tight tabular-nums sm:text-[1.75rem]">{value}</p>
354+ <p className="mt-0.5 truncate text-xs text-faint">{hint}</p>
355+ </div>
356+ </Hint>
357+ );
358+}
359+
360+// --- This week ----------------------------------------------------------------------
361+
362+/** Seven days of landed changes: merged pull requests and commits pushed straight to a default branch. */
363+function WeekChart({ week }: { week: Week }) {
364+ const max = Math.max(1, ...week.days.map((d) => d.count));
365+ const height = 112;
366+ const delta = change(week.total, week.previous);
367+ return (
368+ <div>
369+ <div className="flex items-end justify-between gap-3">
370+ <div>
371+ <p className="font-display text-3xl font-semibold tracking-tight tabular-nums">{week.total.toLocaleString("en-US")}</p>
372+ <p className="text-xs text-muted">changes landed in 7 days</p>
373+ </div>
374+ {delta != null ? (
375+ <span
376+ className={cn(
377+ "rounded-full px-2 py-0.5 text-xs font-medium tabular-nums ring-1",
378+ delta >= 0 ? "text-success ring-success/30" : "text-warn ring-warn/30",
379+ )}
380+ >
381+ {signedPercent(delta)} vs last week
382+ </span>
383+ ) : week.previous === 0 && week.total > 0 ? (
384+ <span className="text-xs text-faint">none the week before</span>
385+ ) : null}
386+ </div>
387+ {week.total === 0 ? (
388+ <Card asChild tone="plain" radius="lg" className="mt-4 border-dashed px-4 py-6 text-center text-sm leading-6 text-muted">
389+ <p>Nothing landed in the last 7 days. Each merged pull request and each push to a default branch shows here, by day.</p>
390+ </Card>
391+ ) : (
392+ <div className="relative mt-5" style={{ height: height + 20 }}>
393+ <div className="absolute inset-x-0 border-t border-line" style={{ top: height }} />
394+ <div className="absolute inset-x-0 top-0 flex items-end justify-between gap-1" style={{ height }}>
395+ {week.days.map((day, index) => {
396+ const today = index === week.days.length - 1;
397+ return (
398+ <div key={day.key} className="group relative flex h-full flex-1 flex-col items-center justify-end">
399+ {day.count > 0 && (
400+ <span
401+ className={cn("mb-1 text-[0.625rem] tabular-nums", today ? "text-fg-soft" : "text-faint opacity-0 group-hover:opacity-100 max-md:hidden")}
402+ >
403+ {day.count}
404+ </span>
405+ )}
406+ <div className="flex w-full max-w-6 flex-col items-stretch">
407+ {day.count > 0 ? (
408+ <span className="block rounded-t bg-accent" style={{ height: Math.max(3, Math.round((day.count / max) * (height - 4))) }} />
409+ ) : (
410+ <span className="block h-0.5 rounded-full bg-line-strong" />
411+ )}
412+ </div>
413+ <div className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-1 hidden -translate-x-1/2 rounded-md border border-line-strong bg-raised px-2.5 py-1.5 text-xs whitespace-nowrap shadow-lg shadow-black/40 md:group-hover:block">
414+ <p className="font-medium text-fg">{day.label}</p>
415+ <p className="mt-0.5 text-muted">{plural(day.count, "change")} landed</p>
416+ </div>
417+ </div>
418+ );
419+ })}
420+ </div>
421+ <div className="absolute inset-x-0 bottom-0 flex justify-between gap-1">
422+ {week.days.map((day, index) => (
423+ <span
424+ key={day.key}
425+ className={cn("flex-1 text-center text-[0.6875rem]", index === week.days.length - 1 ? "font-medium text-fg-soft" : "text-faint")}
426+ >
427+ {day.label}
428+ </span>
429+ ))}
430+ </div>
431+ </div>
432+ )}
433+ {/* A table does not shrink to sr-only's 1px; its wrapper does. */}
434+ <div className="sr-only">
435+ <table>
436+ <caption>Changes landed each day</caption>
437+ <thead>
438+ <tr>
439+ <th>Day</th>
440+ <th>Changes</th>
441+ </tr>
442+ </thead>
443+ <tbody>
444+ {week.days.map((day) => (
445+ <tr key={day.key}>
446+ <td>{day.key}</td>
447+ <td>{day.count}</td>
448+ </tr>
449+ ))}
450+ </tbody>
451+ </table>
452+ </div>
453+ </div>
454+ );
455+}
456+
457+// --- Activity -----------------------------------------------------------------------
458+
459+const VERB: Record<Verb, string> = {
460+ landed: "landed",
461+ opened_item: "opened",
462+ closed_item: "closed",
463+ started: "started on",
464+ ready: "marked ready",
465+ checks_passed: "checks passed on",
466+ checks_failed: "checks failed on",
467+ approved: "approved",
468+ changes_requested: "asked for changes on",
469+ commented: "commented on",
470+ deployed: "deployed production",
471+ deploy_failed: "production build failed",
472+};
473+
474+/** One line of the feed: who, what, and the one thing it was about, by name. */
475+function FeedLine({ group, titles }: { group: ActivityGroup; titles: Record<string, string> }) {
476+ const part = group.parts[0];
477+ const base = `/${group.repo.namespace}/${group.repo.name}`;
478+ const actor = group.actor ?? "g1t";
479+ const agent = group.actor == null || isAgent(group.actor);
480+ const number = part.numbers[0];
481+ const title = number != null ? titles[`${group.repo.namespace}/${group.repo.name}#${number}`.toLowerCase()] : undefined;
482+ const more = group.parts.length - 1 + Math.max(0, part.numbers.length - 1);
483+ return (
484+ <li className="flex gap-2.5 py-2">
485+ <span className="mt-px">{agent ? <Avatar name={actor} image={null} size={22} /> : <UserAvatar username={actor} size={22} />}</span>
486+ <span className="min-w-0 grow text-[0.8125rem] leading-5 text-muted">
487+ {agent ? <span className="font-medium text-accent">{actor}</span> : <PersonLink name={actor} className="font-medium text-fg hover:text-accent" />}{" "}
488+ {VERB[part.verb]}{" "}
489+ {number != null ? (
490+ <Link
491+ to={part.verb === "opened_item" || part.verb === "closed_item" ? itemPath(group.repo.namespace, number) : `${base}/pull/${number}`}
492+ prefetch="intent"
493+ className="text-fg-soft hover:text-fg"
494+ >
495+ <span className="font-mono text-xs">#{number}</span>
496+ {title && <> {title}</>}
497+ </Link>
498+ ) : part.to ? (
499+ <Link to={part.to} className="text-fg-soft hover:text-fg">
500+ {group.repo.name}
501+ </Link>
502+ ) : null}
503+ {more > 0 && <span className="text-faint"> and {more} more</span>}
504+ <span className="block text-xs text-faint">
505+ <span className="font-mono">{group.repo.name}</span> · <TimeAgo at={group.at} />
506+ </span>
507+ </span>
508+ </li>
509+ );
510+}
511+
512+// --- The page -----------------------------------------------------------------------
513+
514+const TAB_LABEL: Record<Tab, string> = { needs: "Needs you", landed: "Landed today" };
515+const TAB_SHORT: Record<Tab, string> = { needs: "Needs you", landed: "Today" };
516+const SORT_LABEL: Record<Sort, string> = { impact: "By impact", newest: "Newest" };
517+
518+export default function MissionControl({ loaderData }: { loaderData: Loaded }) {
519+ const { pathname } = useLocation();
520+ const shell = useRouteLoaderData("root")?.shell as ShellData | null | undefined;
521+ const loaded: Loaded = loaderData;
522+ const [params] = useSearchParams();
523+ const [date, setDate] = useState(loaded.date);
524+ useEffect(() => {
525+ // The date follows the viewer's own clock, and the server learns their zone for next time.
526+ try {
527+ const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
528+ setDate(dateLine(Date.now(), zone ?? null));
529+ if (zone) document.cookie = `${TZ_COOKIE}=${encodeURIComponent(zone)}; Path=/; Max-Age=31536000; SameSite=Lax`;
530+ } catch {
531+ // Nothing to remember.
532+ }
533+ }, []);
534+
535+ const { viewer, repos, landed, week, groups, titles, stats } = loaded;
536+ const workspace = shell?.workspace?.slug ?? loaded.workspace ?? viewer.workspaces?.[0]?.slug ?? null;
537+
538+ // The usage limit, as the sidebar knows it, heads the list when it bites.
539+ const limit = shell?.limit && !shell.limit.comped && shell.limit.state !== "ok" && workspace ? shell.limit : null;
540+ const limitRow: NeedRow[] = limit
541+ ? [
542+ (() => {
543+ const detail =
544+ limit.state === "stopped"
545+ ? "Paid usage is paused until the limit is raised or a card is added."
546+ : `${dollars(limit.exposureMicros)} of ${dollars(limit.ceilingMicros ?? 0)} used. Add a card so work keeps going.`;
547+ return {
548+ key: "limit",
549+ reason: "blocking" as const,
550+ repo: null,
551+ ref: null,
552+ title: limit.state === "stopped" ? "The usage limit was reached" : "Usage is nearing its limit",
553+ ask: detail,
554+ by: null,
555+ at: Date.now(),
556+ to: `/${workspace}/-/settings/billing`,
557+ open: `/${workspace}/-/settings/billing`,
558+ facts: [{ label: "Used", value: `${dollars(limit.exposureMicros)} of ${dollars(limit.ceilingMicros ?? 0)}`, tone: "warn" as const }],
559+ why: "Workflows, checks and deployments that are paid for stop at the workspace's usage limit. Only an owner can raise it or add a card.",
560+ quick: null,
561+ link: null,
562+ };
563+ })(),
564+ ]
565+ : [];
566+ const needs = [...limitRow, ...loaded.needs];
567+ const blocking = needs.filter((row) => BLOCKING.has(row.reason)).length;
568+
569+ const counts: Record<Tab, number> = { needs: needs.length, landed: landed.length };
570+ const tab = parseTab(params.get("tab")) ?? "needs";
571+ const sort = parseSort(params.get("sort"));
572+ const all = params.get("all") === "1";
573+ const everyActivity = params.get("activity") === "all";
574+ const link = (changes: Record<string, string | null>) => {
575+ const next = new URLSearchParams(params);
576+ for (const [key, value] of Object.entries(changes)) {
577+ if (value == null) next.delete(key);
578+ else next.set(key, value);
579+ }
580+ const text = next.toString();
581+ return text ? `?${text}` : pathname;
582+ };
583+
584+ const noProjects = repos.length === 0;
585+
586+ const rows = tab === "needs" ? sortRows(needs, sort) : sortRows(landed, sort);
587+ const shown = all ? rows : rows.slice(0, ROWS);
588+ const delta = change(week.total, week.previous);
589+ const feed = everyActivity ? groups : groups.slice(0, 8);
590+
591+ const newItem =
592+ repos.length > 0 ? (
593+ <DropdownMenu>
594+ <DropdownMenuTrigger className="inline-flex items-center gap-1.5 rounded-md border border-line-strong px-3 py-2 text-sm font-medium text-fg/90 transition-colors hover:bg-raised hover:text-fg">
595+ <Plus size={14} /> New work item
596+ </DropdownMenuTrigger>
597+ <DropdownMenuContent align="start" className="max-h-80 overflow-y-auto">
598+ <DropdownMenuLabel>Filed from which repository?</DropdownMenuLabel>
599+ {repos.map((repo) => (
600+ <DropdownMenuItem key={repo.name} asChild>
601+ <Link to={newItemPath(repo.namespace, repo.name)}>
602+ <Avatar name={repo.name} size={18} square />
603+ {repo.name}
604+ </Link>
605+ </DropdownMenuItem>
606+ ))}
607+ </DropdownMenuContent>
608+ </DropdownMenu>
609+ ) : (
610+ <Link
611+ to={"/new"}
612+ className="inline-flex items-center gap-1.5 rounded-md border border-line-strong px-3 py-2 text-sm font-medium text-fg/90 hover:bg-raised hover:text-fg"
613+ >
614+ <Plus size={14} /> New repository
615+ </Link>
616+ );
617+
618+ return (
619+ <main className="mx-auto max-w-7xl space-y-6 px-4 py-8 sm:px-6 sm:py-10">
620+ <header className="flex flex-wrap items-end justify-between gap-x-6 gap-y-4">
621+ <div className="min-w-0">
622+ <h1 className="text-2xl leading-tight font-semibold tracking-tight">Overview</h1>
623+ <p className="mt-1.5 text-sm text-muted" suppressHydrationWarning>
624+ <span className="text-fg-soft">{date}</span> · {loaded.summary}
625+ </p>
626+ </div>
627+ </header>
628+
629+ <section aria-label="Start work" className="flex flex-wrap items-center gap-2">
630+ {newItem}
631+ {needs.length > 0 && (
632+ <Link
633+ to={`${link({ tab: "needs", all: null })}#work`}
634+ preventScrollReset
635+ className="inline-flex items-center gap-1.5 rounded-md bg-accent px-3.5 py-2 text-sm font-semibold text-bg transition-colors hover:bg-accent-hover"
636+ >
637+ Review {needs.length} that need{needs.length === 1 ? "s" : ""} you <ArrowRight size={14} />
638+ </Link>
639+ )}
640+ </section>
641+
642+ <Card asChild tone="plain" className="grid grid-cols-2 gap-px overflow-hidden bg-line lg:grid-cols-4">
643+ <section aria-label="At a glance">
644+ <Stat
645+ label="Repositories"
646+ value={stats.projects == null ? "—" : String(stats.projects)}
647+ hint={
648+ stats.projectsThisMonth == null
649+ ? "in this workspace"
650+ : stats.projectsThisMonth > 0
651+ ? `+${stats.projectsThisMonth} this month`
652+ : "none new this month"
653+ }
654+ />
655+ <Stat label="Open pull requests" value={stats.openPulls == null ? "—" : String(stats.openPulls)} hint="drafts included" />
656+ <Stat
657+ label="Changes this week"
658+ value={loaded.perRepoLoaded ? String(week.total) : "—"}
659+ hint={delta != null ? `${signedPercent(delta)} vs last week` : week.previous === 0 ? "none the week before" : "merges and direct pushes"}
660+ />
661+ <Stat
662+ label="Needs you"
663+ dot="bg-warn"
664+ value={String(needs.length)}
665+ hint={blocking > 0 ? `${blocking} blocking` : needs.length > 0 ? "nothing blocking" : "all clear"}
666+ />
667+ </section>
668+ </Card>
669+
670+ <div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_21rem] xl:grid-cols-[minmax(0,1fr)_23rem]">
671+ <Card asChild className="min-w-0 scroll-mt-[calc(var(--topbar-h)+1rem)] self-start overflow-hidden">
672+ <section id="work">
673+ <div className="flex items-center gap-2 border-b border-line px-2 sm:px-3">
674+ <nav className="-mb-px flex min-w-0 grow gap-1 overflow-x-auto scrollbar-none" aria-label="Overview">
675+ {(["needs", "landed"] as const).map((value) => (
676+ <Link
677+ key={value}
678+ to={link({ tab: value, all: null })}
679+ preventScrollReset
680+ aria-current={tab === value ? "page" : undefined}
681+ className={cn(
682+ "flex shrink-0 items-center gap-1.5 border-b-2 px-2 py-3 text-sm whitespace-nowrap transition-colors sm:px-2.5",
683+ tab === value ? "border-accent font-medium text-fg" : "border-transparent text-muted hover:text-fg",
684+ )}
685+ >
686+ <span className="sm:hidden">{TAB_SHORT[value]}</span>
687+ <span className="hidden sm:inline">{TAB_LABEL[value]}</span>
688+ <span
689+ className={cn(
690+ "rounded-full px-1.5 text-[0.6875rem] tabular-nums",
691+ tab === value && value === "needs" && counts.needs > 0 ? "bg-warn/15 text-warn" : "bg-line text-muted",
692+ )}
693+ >
694+ {counts[value]}
695+ </span>
696+ </Link>
697+ ))}
698+ </nav>
699+ <DropdownMenu>
700+ <DropdownMenuTrigger
701+ className="inline-flex shrink-0 items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-muted hover:bg-raised hover:text-fg"
702+ aria-label="Sort"
703+ >
704+ <ArrowDownWideNarrow size={14} />
705+ <span className="hidden sm:inline">{SORT_LABEL[sort]}</span>
706+ </DropdownMenuTrigger>
707+ <DropdownMenuContent align="end">
708+ {(["impact", "newest"] as const).map((value) => (
709+ <DropdownMenuItem key={value} asChild>
710+ <Link to={link({ sort: value === "impact" ? null : value })} preventScrollReset>
711+ {sort === value ? <Check /> : <span className="size-4" />}
712+ {SORT_LABEL[value]}
713+ </Link>
714+ </DropdownMenuItem>
715+ ))}
716+ </DropdownMenuContent>
717+ </DropdownMenu>
718+ </div>
719+
720+ {!loaded.perRepoLoaded ? (
721+ <div className="p-4">
722+ <Unavailable what={tab === "needs" ? "Some of what needs you" : "What landed"} />
723+ </div>
724+ ) : null}
725+
726+ {tab === "needs" &&
727+ (needs.length === 0 ? (
728+ noProjects ? (
729+ <Empty
730+ action={
731+ <Link to={"/new"} className="rounded-md bg-fg px-3 py-1.5 text-sm font-medium text-bg hover:bg-fg-hover">
732+ Create or import a repository
733+ </Link>
734+ }
735+ >
736+ <p className="font-medium text-fg">No repositories yet</p>
737+ <p className="mt-1">Create a repository or import one. Reviews, failed deploys and invitations for you show up here.</p>
738+ </Empty>
739+ ) : (
740+ <>
741+ <Empty>
742+ <p className="font-medium text-fg">Nothing needs you</p>
743+ <p className="mt-1">{landed.length > 0 ? "Here's what landed today." : "Reviews, failed deploys and invitations show up here first."}</p>
744+ </Empty>
745+ {landed.length > 0 && (
746+ <div className="border-t border-line">
747+ <List>
748+ {landed.slice(0, ROWS).map((row) => (
749+ <LandedCard key={row.key} row={row} first={false} />
750+ ))}
751+ </List>
752+ </div>
753+ )}
754+ </>
755+ )
756+ ) : (
757+ <List>
758+ {(shown as NeedRow[]).map((row, index) => (
759+ <NeedCard key={row.key} row={row} first={index === 0} />
760+ ))}
761+ </List>
762+ ))}
763+
764+ {tab === "landed" &&
765+ loaded.perRepoLoaded &&
766+ (landed.length === 0 ? (
767+ <Empty>
768+ <p className="font-medium text-fg">Nothing has landed today yet</p>
769+ <p className="mt-1">
770+ {week.total > 0 ? `${plural(week.total, "change")} landed in the last 7 days.` : "Merged pull requests show up here the moment they land."}
771+ </p>
772+ </Empty>
773+ ) : (
774+ <List>
775+ {(shown as LandedRow[]).map((row, index) => (
776+ <LandedCard key={row.key} row={row} first={index === 0} />
777+ ))}
778+ </List>
779+ ))}
780+
781+ {rows.length > ROWS && (
782+ <div className="flex items-center justify-between border-t border-line px-4 py-2.5 text-xs text-muted sm:px-5">
783+ <span>
784+ Showing {shown.length} of {rows.length}
785+ </span>
786+ <Link to={link({ all: all ? null : "1" })} preventScrollReset className="inline-flex items-center gap-1 font-medium text-fg-soft hover:text-fg">
787+ {all ? "Show fewer" : "See all"} <ChevronRight size={12} />
788+ </Link>
789+ </div>
790+ )}
791+ </section>
792+ </Card>
793+
794+ <aside className="min-w-0 space-y-6">
795+ <InboxNeedsCard items={loaderData.inboxNeeds.items} total={loaderData.inboxNeeds.total} />
796+ <Card asChild className="p-5">
797+ <section>
798+ <h2 className="text-base font-semibold tracking-tight">This week</h2>
799+ <div className="mt-4">{loaded.perRepoLoaded ? <WeekChart week={week} /> : <Unavailable what="The week" />}</div>
800+ </section>
801+ </Card>
802+
803+ <Card asChild className="scroll-mt-[calc(var(--topbar-h)+1rem)] p-5">
804+ <section id="activity">
805+ <div className="flex items-center justify-between">
806+ <h2 className="text-base font-semibold tracking-tight">Activity</h2>
807+ {groups.length > 8 && (
808+ <Link
809+ to={`${link({ activity: everyActivity ? null : "all" })}#activity`}
810+ preventScrollReset
811+ className="inline-flex items-center gap-0.5 text-xs text-muted hover:text-fg"
812+ >
813+ {everyActivity ? "Less" : "All activity"} <ChevronRight size={12} />
814+ </Link>
815+ )}
816+ </div>
817+ {!loaded.perRepoLoaded ? (
818+ <div className="mt-3">
819+ <Unavailable what="Activity" />
820+ </div>
821+ ) : feed.length === 0 ? (
822+ <p className="mt-3 text-sm leading-6 text-muted">Nothing has moved yet. Merges, deploys, checks and reviews show up here.</p>
823+ ) : (
824+ <ol className="mt-2 divide-y divide-line/70">
825+ {feed.map((group) => (
826+ <FeedLine key={group.id} group={group} titles={titles} />
827+ ))}
828+ </ol>
829+ )}
830+ </section>
831+ </Card>
832+ </aside>
833+ </div>
834+ </main>
835+ );
836+}
+3−3
12281228 ? { to: repositoriesPath(home.slug), label: "All repositories" }
12291229 : browsing
12301230 ? { to: repositoriesPath(menuRepo.namespace), label: "Repositories", context: menuRepo.namespace }
1231− : { to: ws ? `/${ws.slug}` : "/explore", label: "Code" };
1231+ : { to: ws ? `/${ws.slug}/-/overview` : "/explore", label: "Code" };
12321232 trail.push({
12331233 key,
12341234 node: (
14261426 return (
14271427 <nav aria-label="Code" className={PANEL}>
14281428 <NavRows>
1429− <SidebarLink to={`/${slug}`} icon={<LayoutDashboard size={15} />} current={path === `/${slug}`}>
1429+ <SidebarLink to={`/${slug}/-/overview`} icon={<LayoutDashboard size={15} />} current={at("overview")}>
14301430 Overview
14311431 </SidebarLink>
14321432 </NavRows>
20772077 { label: "Labels", hint: "Work", to: workPath(ws.slug, "labels"), icon: <ListTodo size={15} /> },
20782078 { label: "Milestones", hint: "Work", to: workPath(ws.slug, "milestones"), icon: <ListTodo size={15} /> },
20792079 { label: "Chat", to: `/${ws.slug}/-/chat`, icon: <MessagesSquare size={15} /> },
2080− ...(hasCodeAccess(ws) ? [{ label: "Code overview", to: `/${ws.slug}`, icon: <Code2 size={15} /> }] : []),
2080+ ...(hasCodeAccess(ws) ? [{ label: "Code overview", to: `/${ws.slug}/-/overview`, icon: <Code2 size={15} /> }] : []),
20812081 { label: "Library", to: `/${ws.slug}/-/library/artifacts`, icon: <LibraryBig size={15} /> },
20822082 { label: "Routines", hint: "Standing work for @g1t (soon)", to: `/${ws.slug}/-/routines`, icon: <Repeat size={15} /> },
20832083 { label: "Apps", hint: "Everything installed that you can use", to: `/${ws.slug}/-/apps`, icon: <LayoutGrid size={15} /> },
+1−2
4545 { key: "work", name: "Work", path: under("work") },
4646 { key: "chat", name: "Chat", path: under("chat") },
4747 { key: "notifications", name: "Notifications", path: () => "/notifications" },
48− // The workspace's own page, as an organization's is on GitHub.
49− { key: "code", name: "Code", path: (slug) => `/${slug}`, code: true },
48+ { key: "code", name: "Code", path: under("overview"), code: true },
5049 // Artifacts are the Library's: docs now, and slides, designs and dashboards next.
5150 { key: "artifacts", name: "Library", path: under("library/artifacts") },
5251 { key: "routines", name: "Routines", path: under("routines") },
+4−4
323323 series: weeks.map(agentChanges),
324324 points: weeks.map((week, i) => `${labels[i]}: ${week.agentMerged} merged, ${week.agentCommits ?? 0} pushed directly, of ${allChanges(week)} changes`),
325325 hint: "Agents' merged pull requests, and the commits their sessions pushed outside one",
326− to: `/${slug}/-/home?window=7d#since`,
326+ to: `/${slug}/-/overview?tab=landed`,
327327 },
328328 {
329329 key: "spend",
343343 series: weeks.map((week) => week.stalled),
344344 points: weeks.map((week, i) => `${labels[i]}: ${week.stalled} with nothing for three days`),
345345 hint: "Open pull requests with no push, comment, review, check or session for three days",
346− to: `/${slug}/-/home#needed`,
346+ to: `/${slug}/-/overview?tab=needs#work`,
347347 },
348348 {
349349 key: "rework",
453453 caption: "Time to first review",
454454 range: `${short(first)} → ${short(wait)} over ${weeks.length} weeks`,
455455 };
456− const link = { label: "See the review queue", to: `/${ctx.slug}/-/home#needed` };
456+ const link = { label: "See the review queue", to: `/${ctx.slug}/-/overview?tab=needs#work` };
457457 if (draft != null && wait >= 4 * draft && wait >= 4 * 3600) {
458458 return {
459459 key: "review",
611611 key: "stalled",
612612 headline: `${countWord(now.stalled)} open changes have gone three days with nothing happening, ${more} more than last week.`,
613613 facts: [`${before.stalled} last week`, `${now.stalled} now`],
614− link: { label: "See what's stalled", to: `/${ctx.slug}/-/home#needed` },
614+ link: { label: "See what's stalled", to: `/${ctx.slug}/-/overview?tab=needs#work` },
615615 trend: {
616616 values: ctx.weeks.map((week) => week.stalled),
617617 points: ctx.weeks.map((week) => `Week of ${weekLabel(week.start)}: ${week.stalled}`),
+3−4
8484 test("the rail's mode follows the address", async () => {
8585 const { modeOf, modeHome, homePath } = await import("./workspace-nav.ts");
8686 assert.equal(modeOf("/", "acme"), "home");
87− // The workspace's own page is Code's front page.
88− assert.equal(modeOf("/acme", "acme"), "code");
87+ assert.equal(modeOf("/acme", "acme"), "home");
8988 assert.equal(modeOf("/acme/-/home", "acme"), "home");
9089 // g1t's own public pages light no mode.
9190 assert.equal(modeOf("/explore", "acme"), "site");
128127 assert.equal(modeHome("notifications", "acme"), "/notifications");
129128 assert.equal(modeHome("people", "acme"), "/acme/-/people");
130129 assert.equal(modeHome("apps", "acme"), "/acme/-/apps");
131− assert.equal(modeHome("code", "acme"), "/acme");
130+ assert.equal(modeHome("code", "acme"), "/acme/-/overview");
132131 assert.equal(modeHome("workspace", "acme"), "/acme/-/settings/profile");
133132 assert.equal(homePath("acme"), "/acme/-/home");
134− assert.equal(homePath("acme", "?tab=landed&_routes=x"), "/acme/-/home");
133+ assert.equal(homePath("acme", "?tab=landed&_routes=x"), "/acme/-/overview?tab=landed");
135134 });
136135
137136 test("Workspace mode's sidebar knows which group and page an address is", async () => {
+4−4
347347 const parts = path.split("/").filter(Boolean);
348348 if (SITE_PAGES.has(parts[0] ?? "")) return "site";
349349 if (slug && parts[0]?.toLowerCase() === slug.toLowerCase()) {
350− // The workspace's own page is Code's front page, as an organization's is on GitHub.
351− if (parts.length === 1) return "code";
350+ if (parts.length === 1) return "home";
352351 if (parts[1] === "-") {
353352 // The workspace's integrations are its apps: Apps is lit on them, beside the settings' sidebar.
354353 if (parts[2] === "settings" && parts[3] === "integrations") return "apps";
382381 case "routines":
383382 return `/${slug}/-/routines`;
384383 case "code":
385− return `/${slug}`;
384+ return `/${slug}/-/overview`;
386385 case "people":
387386 return `/${slug}/-/people`;
388387 case "workspace":
516515 params.delete("_routes");
517516 params.delete("index");
518517 const query = params.toString();
518+ if (params.has("tab") || params.has("sort")) return `/${slug}/-/overview?${query}`;
519519 return homePagePath(slug);
520520 }
521521
528528 const WORK_REDIRECTS = new Set(["issues", "labels", "milestones", "ref"]);
529529
530530 /** The workspace pages under `-/` that are Code's: closed to a member without Code access. */
531−const CODE_PAGES = new Set(["code", "projects", "repositories", "packages", "security", "rules", "runners", "actions", "insights", "soon"]);
531+const CODE_PAGES = new Set(["overview", "code", "projects", "repositories", "packages", "security", "rules", "runners", "actions", "insights", "soon"]);
532532
533533 /** The workspace's settings pages (under `-/settings/`) that are Code's, closed as Code's pages are. */
534534 const CODE_SETTINGS = new Set(["repositories", "rules", "runners", "actions", "security"]);
+1−0
143143 // it or skipping a step (components/onboarding.tsx).
144144 route("-/onboarding", "routes/workspace/onboarding.ts"),
145145 // Code's Overview: Mission control's code panels, for this workspace.
146+ route("-/overview", "routes/workspace/code-overview.tsx"),
146147 // The workspace itself, at a glance: members, plan and spend.
147148 // The workspace's settings, every page under `-/settings/`, open on its Profile.
148149 route("-/settings", "routes/workspace/settings-index.ts"),
+122−0
1+import { Suspense, lazy } from "react";
2+import { type ShouldRevalidateFunctionArgs, data } from "react-router";
3+
4+import type { Route } from "./+types/code-overview";
5+import { page } from "../../lib/meta";
6+import { loadMissionControl } from "../../lib/mission-control.server";
7+import { requireUser, roleIn } from "../../lib/session.server";
8+import { Card } from "../../components/ui/card";
9+import { Skeleton, SkeletonCard, SkeletonLine, SkeletonStat } from "../../components/ui/skeleton";
10+
11+export function meta({ params, ...args }: Route.MetaArgs) {
12+ return page(args, { title: `Overview · Code · ${params.owner} · g1t` });
13+}
14+
15+export function headers({ loaderHeaders }: Route.HeadersArgs) {
16+ const out = new Headers();
17+ const timing = loaderHeaders.get("Server-Timing");
18+ if (timing) out.set("Server-Timing", timing);
19+ return out;
20+}
21+
22+/**
23+ * Code's Overview, for this workspace: what needs you and what landed,
24+ * the week's numbers, activity, and a way to open a work item.
25+ */
26+export async function loader({ params, context, request }: Route.LoaderArgs) {
27+ const viewer = requireUser(context, request);
28+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
29+ const { value, timing } = await loadMissionControl(viewer, request, params.owner);
30+ return data(value, { headers: { "Server-Timing": timing } });
31+}
32+
33+/**
34+ * Switching tabs, the sort or the activity list changes only the address:
35+ * everything is already loaded. Marking an inbox item read changes nothing
36+ * else here.
37+ */
38+export function shouldRevalidate({ currentUrl, nextUrl, formMethod, formAction, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
39+ if (!formMethod && currentUrl.pathname === nextUrl.pathname && currentUrl.search !== nextUrl.search) return false;
40+ if (formAction === "/notifications") return false;
41+ return defaultShouldRevalidate;
42+}
43+
44+/** Loaded once the browser is idle (components/shell.tsx), so the skeleton below is rarely seen. */
45+const MissionControl = lazy(() => import("../../components/mission-control"));
46+
47+export default function CodeOverview({ loaderData }: Route.ComponentProps) {
48+ return (
49+ <Suspense fallback={<MissionControlSkeleton />}>
50+ <MissionControl loaderData={loaderData} />
51+ </Suspense>
52+ );
53+}
54+
55+/**
56+ * The Overview's outline while its code arrives, piece for piece as
57+ * components/mission-control.tsx lays it out: the title, the actions
58+ * under it, the stats, then the work list beside the week. Each
59+ * piece has the padding and line heights of the real one, so nothing moves
60+ * when it arrives.
61+ */
62+export function MissionControlSkeleton() {
63+ return (
64+ <main aria-busy="true" className="mx-auto max-w-7xl space-y-6 px-4 py-8 sm:px-6 sm:py-10">
65+ <span role="status" className="sr-only">
66+ Loading…
67+ </span>
68+ <header>
69+ <SkeletonLine className="w-72 max-w-full text-[1.75rem] leading-tight sm:text-[2rem]" barClassName="h-8" />
70+ {/* The date and the week's summary: one line wide, three on a phone. */}
71+ <div className="mt-1.5 text-sm">
72+ <SkeletonLine className="w-xl max-w-full" barClassName="h-3.5" />
73+ <SkeletonLine className="w-full sm:hidden" barClassName="h-3.5" />
74+ <SkeletonLine className="w-2/3 sm:hidden" barClassName="h-3.5" />
75+ </div>
76+ </header>
77+ <div className="flex flex-wrap items-center gap-2">
78+ <Skeleton className="h-9.5 w-28 rounded-md" />
79+ <Skeleton className="h-9.5 w-44 rounded-md" />
80+ </div>
81+ <Card tone="plain" className="grid grid-cols-2 gap-px overflow-hidden bg-line lg:grid-cols-4">
82+ {Array.from({ length: 4 }, (_, index) => (
83+ <SkeletonStat key={index} />
84+ ))}
85+ </Card>
86+ <div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_21rem] xl:grid-cols-[minmax(0,1fr)_23rem]">
87+ <Card className="self-start overflow-hidden">
88+ <div className="flex items-center gap-4 border-b border-line px-4 py-3 text-sm sm:px-5">
89+ <SkeletonLine className="w-24" />
90+ <SkeletonLine className="w-32" />
91+ <SkeletonLine className="hidden w-28 sm:flex" />
92+ </div>
93+ <div className="divide-y divide-line">
94+ {Array.from({ length: 5 }, (_, index) => (
95+ <div key={index}>
96+ <div className="flex items-center gap-3 px-4 py-3 sm:px-5">
97+ <Skeleton className="size-8 shrink-0 rounded-md" />
98+ <span className="min-w-0 grow">
99+ <SkeletonLine className="text-sm" width={`${70 - ((index * 17) % 30)}%`} />
100+ <SkeletonLine className="mt-0.5 text-xs" width={`${50 - ((index * 11) % 20)}%`} />
101+ </span>
102+ <Skeleton className="hidden h-4 w-24 shrink-0 sm:block" />
103+ </div>
104+ {/* The first row stands open, as the real list's does. */}
105+ {index === 0 && (
106+ <div className="px-4 pb-4 sm:px-5 sm:pb-5">
107+ <Skeleton className="h-68 rounded-xl" />
108+ <Skeleton className="mt-3 h-8 w-44 rounded-md" />
109+ </div>
110+ )}
111+ </div>
112+ ))}
113+ </div>
114+ </Card>
115+ <div className="space-y-6">
116+ <SkeletonCard lines={0} className="h-80 p-5" />
117+ <SkeletonCard lines={6} className="p-5" />
118+ </div>
119+ </div>
120+ </main>
121+ );
122+}
+2−0
246246 // are columns of their own (components/chat/channel.tsx). Its sessions
247247 // are pages like any other, with the page's margins.
248248 if (parts[0] === "chat" && parts[1] !== "sessions" && !next) return <Outlet />;
249+ // Code's Overview lays itself out, as Mission control did.
250+ if (parts[0] === "overview" && parts.length === 1 && !next) return <Outlet />;
249251 // An open artifact, or a repository's doc, fills the panel: its header runs edge to edge right under
250252 // the top bar (components/folios/shell.tsx), and its page sets its own width.
251253 if (isArtifactPage(parts) && !next) {
+68−16
1−import { ArrowRight, ArrowUpRight, Box, CircleDot, GitBranch, GitPullRequest, Lock, Pin, Plus, Rocket } from "lucide-react";
2−import { Link } from "react-router";
1+import { ArrowRight, ArrowUpRight, Box, CircleDot, GitBranch, GitPullRequest, KeyRound, Lock, Pin, Plus, Rocket } from "lucide-react";
2+import { Link, redirect } from "react-router";
33
44 import type { PackageSummary, Project, ProjectDeploys } from "@g1t/contracts";
55
66 import type { Route } from "./+types/overview";
77 import { DeployLink, host, StatusDot } from "../../components/deploy";
88 import { ButtonLink, EmptyState, TimeAgo } from "../../components/ui";
9+import { Avatar } from "../../components/ui/avatar";
910 import { Badge } from "../../components/ui/badge";
1011 import { Card } from "../../components/ui/card";
12+import { UsageCard } from "../../components/usage-card";
1113 import { PullIcon } from "../../components/work-icons";
1214 import { openedBy } from "../../lib/opened-by";
1315 import { kindLabel, libraryPackages, packageLine, packagePath, primaryLink } from "../../lib/project-kind";
1416 import { PinButton } from "../../components/pin-button";
1517 import { sortProjects } from "../../lib/project-list";
18+import { usageFor } from "../../lib/workspace-usage.server";
1619 import { deployments, identity, packages, projects as projectsApi, work } from "../../lib/services.server";
1720 import { getViewer, roleIn } from "../../lib/session.server";
1821 import { workspaceProjects } from "../../lib/workspace-projects.server";
1922 import { repositoriesPath } from "../../lib/workspace-nav";
2023 import { Unavailable } from "../../components/boundary";
21−import { MembersCard } from "../../components/members-card";
2224 import { isFailed, part } from "../../lib/partial";
2325
2426 /** Projects whose open work items and pull requests are counted: the most active. */
2830 /** Projects whose pull requests are listed under "In progress". */
2931 const MAX_LISTED = 8;
3032 const MAX_PULLS = 8;
31−
32−/**
33− * The workspace's own page, and Code's front page, as an organization's is
34− * on GitHub: under its profile (the layout's header), how many
35− * repositories it has, what is live, open and in progress; then the
36− * viewer's pinned repositories, the recently active ones and every one;
37− * the pull requests in progress; and who is in it. The same page for a
38− * member and a visitor, who sees what is public and nothing to pin.
39− * Where you're needed and what happened while you were away are Home's.
40− */
33+const MAX_FACES = 8;
4134 export async function loader({ params, context }: Route.LoaderArgs) {
4235 const viewer = getViewer(context);
4336 const slug = params.owner.toLowerCase();
4437 const role = roleIn(viewer, slug);
45− const [listed, members, deploys, published, shortcuts, items] = await Promise.all([
38+ // A member's way in is Home; this page
39+ // is the workspace as a visitor sees it.
40+ if (role) throw redirect(`/${slug}/-/home`);
41+ const [listed, members, deploys, usage, published, shortcuts, items] = await Promise.all([
4642 // The listing the sidebar and the Projects tab share.
4743 workspaceProjects(slug, viewer),
4844 role ? identity.listMembers(slug, viewer) : null,
4945 role ? deployments.overview(slug, viewer) : null,
46+ role ? usageFor(slug, viewer) : null,
5047 // One listing for every card: a library's card shows its package where an app's shows production.
5148 packages.list(slug, viewer).catch(() => null),
5249 // The viewer's pins lead the page, as they lead the sidebar.
121118 openItems: !isFailed(items) && items.ok ? items.value.length : 0,
122119 pullsFailed,
123120 members: members?.ok ? members.value : null,
121+ usage,
124122 };
125123 }
126124
247245 }
248246
249247 export default function WorkspaceOverview({ loaderData }: Route.ComponentProps) {
250− const { slug, role, total, pinned, active, open, deploys, pulls, members } = loaderData;
248+ const { slug, role, total, pinned, active, open, deploys, pulls, members, usage } = loaderData;
251249 const card = (project: Project, isPinned: boolean) => (
252250 <ProjectCard
253251 key={project.id}
372370 </div>
373371
374372 <aside className="space-y-6">
373+ {role && usage && <UsageCard slug={slug} glance={usage} owner={role === "owner"} />}
374+
375375 {members ? (
376− <MembersCard slug={slug} owner={role === "owner"} members={members} />
376+ <section>
377+ <div className="flex items-center justify-between">
378+ <h2 className="text-sm font-medium">Members</h2>
379+ <Link to={role === "owner" ? `/${slug}/-/settings/members` : `/${slug}/-/people`} className="text-xs text-muted hover:text-fg">
380+ {role === "owner" ? "Manage" : "See all"}
381+ </Link>
382+ </div>
383+ <ul className="mt-3 space-y-1.5">
384+ {members.slice(0, MAX_FACES).map((member) => (
385+ <li key={member.username} className="flex items-center gap-2 text-sm">
386+ <Avatar name={member.username} />
387+ <span className="grow truncate font-mono">{member.username}</span>
388+ {member.role === "owner" && <Badge size="md">owner</Badge>}
389+ </li>
390+ ))}
391+ </ul>
392+ {members.length > MAX_FACES && <p className="mt-2 text-xs text-faint">and {members.length - MAX_FACES} more</p>}
393+ </section>
377394 ) : (
378395 <p className="text-sm text-muted">
379396 Members of this workspace build its repositories. Anyone can file a work item or open a pull request on a public one.
380397 </p>
381398 )}
399+
400+ {role && (
401+ <Card asChild className="p-5">
402+ <section>
403+ <h2 className="flex items-center gap-2 font-medium">
404+ <Rocket size={15} className="text-faint" />
405+ Deploy on g1t.page
406+ </h2>
407+ <p className="mt-1.5 text-sm text-muted">
408+ Off until you turn them on for a repository, under its Settings. Then production builds from its default branch, with a live preview for every
409+ pull request. Apps cost nothing while no one visits.
410+ </p>
411+ <Link prefetch="intent" to={`/${slug}/-/settings/billing`} className="mt-3 inline-flex items-center gap-1 text-sm text-accent hover:underline">
412+ Billing and plans <ArrowRight size={13} />
413+ </Link>
414+ </section>
415+ </Card>
416+ )}
417+
418+ {role && (
419+ <Card asChild className="p-5">
420+ <section>
421+ <h2 className="flex items-center gap-2 font-medium">
422+ <KeyRound size={15} className="text-faint" />
423+ Automate without a service account
424+ </h2>
425+ <p className="mt-1.5 text-sm text-muted">
426+ A workspace has access tokens of its own for CI, integrations and agents. They act as the workspace, not as a person.
427+ </p>
428+ <Link prefetch="intent" to={`/${slug}/-/settings/tokens`} className="mt-3 inline-flex items-center gap-1 text-sm text-accent hover:underline">
429+ Access tokens <ArrowRight size={13} />
430+ </Link>
431+ </section>
432+ </Card>
433+ )}
382434 </aside>
383435 </div>
384436 );
+10−0
1+-- How many bytes of log a job has kept so far, added to as each batch is
2+-- kept (src/plan.rs `LOG_COUNTED`), so a report checks the 4 MB limit
3+-- against this rather than adding up the whole log again each time. A job
4+-- run again starts from 0 (its log moves aside).
5+--
6+-- Only jobs still to finish are counted here: a finished job is sent no
7+-- more log, and counting every finished job's would read every log kept.
8+ALTER TABLE jobs ADD COLUMN log_bytes INTEGER NOT NULL DEFAULT 0;
9+UPDATE jobs SET log_bytes = (SELECT COALESCE(SUM(LENGTH(CAST(text AS BLOB))), 0) FROM logs WHERE logs.job_id = jobs.id)
10+ WHERE finished_at IS NULL;
+17−4
3030 use worker::Result;
3131 use worker::wasm_bindgen::JsValue;
3232
33−use crate::plan::LOG_INSERT;
33+use crate::plan::{LOG_COUNTED, LOG_INSERT};
3434 use crate::{Actions, Count, check, fail};
3535
3636 /// The most hosts one brokered secret names.
202202 }
203203
204204 /// Adds g1t's own lines to a job's log, under `step` (0 for its setup).
205+ /// They count toward its log's size, though they are kept past it.
205206 pub(crate) async fn note_log(&self, job_id: &str, step: u32, text: &str) -> Result<()> {
207+ let text = stamped(text);
206208 self.db
207− .prepare(LOG_INSERT)
208− .bind(&[job_id.into(), step.into(), stamped(text).into(), JsValue::NULL])?
209− .run()
209+ .batch(vec![
210+ self.db.prepare(LOG_COUNTED).bind(&[
211+ job_id.into(),
212+ (text.len() as u32).into(),
213+ JsValue::NULL,
214+ JsValue::NULL,
215+ ])?,
216+ self.db.prepare(LOG_INSERT).bind(&[
217+ job_id.into(),
218+ step.into(),
219+ text.into(),
220+ JsValue::NULL,
221+ ])?,
222+ ])
210223 .await?;
211224 Ok(())
212225 }
+177−24
5151 pub(crate) const LOG_INSERT: &str = "INSERT INTO logs (job_id, seq, step, text, chunk)
5252 SELECT ?1, COALESCE(MAX(seq), 0) + 1, ?2, ?3, ?4 FROM logs WHERE job_id = ?1
5353 ON CONFLICT (job_id, chunk) DO NOTHING";
54+/// Adds a batch of `?2` bytes to the job `?1`'s count of its log (migration
55+/// 0027), and with `?3` marks it seen. Sent in the same batch as, and just
56+/// ahead of, [`LOG_INSERT`] with the same `?4`: a batch sent again, which
57+/// the insert does not keep twice, is not counted twice either.
58+pub(crate) const LOG_COUNTED: &str = "UPDATE jobs SET seen_at = COALESCE(?3, seen_at),
59+ log_bytes = log_bytes + CASE WHEN ?4 IS NOT NULL AND EXISTS (SELECT 1 FROM logs WHERE job_id = ?1 AND chunk = ?4) THEN 0 ELSE ?2 END
60+ WHERE id = ?1";
61+
62+/// Whether a batch of log is kept by a job that has `used` bytes of it
63+/// already: the one that reaches [`MAX_LOG_BYTES`] is kept whole, with a
64+/// line to say the rest is not, and none after it.
65+fn log_room(used: usize, text: &mut String) -> bool {
66+ if used >= MAX_LOG_BYTES {
67+ return false;
68+ }
69+ if used + text.len() >= MAX_LOG_BYTES {
70+ text.push_str("\n… The log reached its limit of 4 MB; the rest is not kept.\n");
71+ }
72+ true
73+}
5474 /// The most one step's job summary keeps, in bytes, and how many steps of a
5575 /// job may have one, as on GitHub.
5676 pub(crate) const MAX_SUMMARY_BYTES: usize = 1024 * 1024;
257277 /// (migration 0025).
258278 #[serde(default)]
259279 pub waiting_since: Option<String>,
280+ /// How many bytes of log it has kept so far (migration 0027).
281+ #[serde(default)]
282+ pub log_bytes: u32,
260283 }
261284
262285 /// How long a cancelled job has to run its `if: always()` and `cancelled()`
23582381 "UPDATE jobs SET status = 'waiting', conclusion = NULL, steps = '[]', annotations = '[]', outputs = '{}', reason = NULL,
23592382 matrix = NULL, call = NULL, token_hash = NULL, first_token_hash = NULL, seen_at = NULL, started_at = NULL, finished_at = NULL,
23602383 labels = NULL, queued_at = NULL, runner_id = NULL, runner_name = NULL, environment = NULL,
2361− concurrency_group = NULL, cancel_in_progress = 0, cancel_requested_at = NULL WHERE run_id = ? AND key = ?",
2384+ concurrency_group = NULL, cancel_in_progress = 0, cancel_requested_at = NULL, log_bytes = 0 WHERE run_id = ? AND key = ?",
23622385 )
23632386 .bind(&[run.id.as_str().into(), key.as_str().into()])?,
23642387 );
27902813 }
27912814 text.truncate(cut);
27922815 }
2793− #[derive(Deserialize)]
2794− struct Size {
2795− n: Option<f64>,
2796− }
2797− let size = self
2798− .db
2799− .prepare("SELECT SUM(LENGTH(text)) AS n FROM logs WHERE job_id = ?")
2800− .bind(&[job.id.as_str().into()])?
2801− .first::<Size>(None)
2802− .await?;
2803− let used = size.and_then(|s| s.n).unwrap_or(0.0) as usize;
2804− if used < MAX_LOG_BYTES {
2805− if used + text.len() >= MAX_LOG_BYTES {
2806− text.push_str("\n… The log reached its limit of 4 MB; the rest is not kept.\n");
2807− }
2816+ // Checked against the count kept on the job, read with it
2817+ // just now, rather than the log added up again: the count
2818+ // and the batch are written together, so the count is
2819+ // always the log's own size. Two reports at once may both
2820+ // find room, as before, and take the log one batch past.
2821+ if log_room(job.log_bytes as usize, &mut text) {
2822+ let chunk = report["chunk"]
2823+ .as_u64()
2824+ .map_or(worker::wasm_bindgen::JsValue::NULL, |n| (n as f64).into());
28082825 // The next `seq` is taken in the insert itself, so two
28092826 // batches at once (a report sent again while the first
28102827 // was still being written) never reach for the same one
28112828 // and lose a chunk to the primary key.
28122829 self.db
2813− .prepare(LOG_INSERT)
2814− .bind(&[
2815− job.id.as_str().into(),
2816− (report["step"].as_u64().unwrap_or(0) as u32).into(),
2817− text.into(),
2818− report["chunk"].as_u64().map_or(worker::wasm_bindgen::JsValue::NULL, |n| (n as f64).into()),
2819− ])?
2830+ .batch(vec![
2831+ self.db.prepare(LOG_COUNTED).bind(&[
2832+ job.id.as_str().into(),
2833+ (text.len() as u32).into(),
2834+ at.as_str().into(),
2835+ chunk.clone(),
2836+ ])?,
2837+ self.db.prepare(LOG_INSERT).bind(&[
2838+ job.id.as_str().into(),
2839+ (report["step"].as_u64().unwrap_or(0) as u32).into(),
2840+ text.into(),
2841+ chunk,
2842+ ])?,
2843+ ])
2844+ .await?;
2845+ } else {
2846+ self.db
2847+ .prepare("UPDATE jobs SET seen_at = ? WHERE id = ?")
2848+ .bind(&[at.into(), job.id.as_str().into()])?
28202849 .run()
28212850 .await?;
28222851 }
2823− self.db.prepare("UPDATE jobs SET seen_at = ? WHERE id = ?").bind(&[at.into(), job.id.as_str().into()])?.run().await?;
28242852 }
28252853 "summary" => {
28262854 // `$GITHUB_STEP_SUMMARY`, masked by the runner: added to the
30843112 }
30853113
30863114 #[cfg(test)]
3115+mod log_bytes {
3116+ use rusqlite::{Connection, params};
3117+
3118+ use super::{LOG_COUNTED, LOG_INSERT, MAX_LOG_BYTES, log_room};
3119+
3120+ fn migrate(db: &Connection, names: &[&str]) {
3121+ for name in names {
3122+ let path = format!("{}/migrations/{name}", env!("CARGO_MANIFEST_DIR"));
3123+ db.execute_batch(&std::fs::read_to_string(path).unwrap()).unwrap();
3124+ }
3125+ }
3126+
3127+ fn add_job(db: &Connection, id: &str, finished: Option<&str>) {
3128+ db.execute(
3129+ "INSERT INTO jobs (id, run_id, repo_id, namespace, key, name, status, finished_at) VALUES (?1, 'run_1', 'repo_1', 'acme', ?1, ?1, 'in_progress', ?2)",
3130+ params![id, finished],
3131+ )
3132+ .unwrap();
3133+ }
3134+
3135+ fn bytes(db: &Connection, job: &str) -> i64 {
3136+ db.query_row("SELECT log_bytes FROM jobs WHERE id = ?", [job], |row| {
3137+ row.get(0)
3138+ })
3139+ .unwrap()
3140+ }
3141+
3142+ /// A report's batch as `job_report` writes it: counted, then kept.
3143+ fn report(db: &Connection, job: &str, text: &str, chunk: Option<i64>, at: Option<&str>) {
3144+ let tx = db.unchecked_transaction().unwrap();
3145+ tx.execute(LOG_COUNTED, params![job, text.len() as i64, at, chunk])
3146+ .unwrap();
3147+ tx.execute(LOG_INSERT, params![job, 0, text, chunk])
3148+ .unwrap();
3149+ tx.commit().unwrap();
3150+ }
3151+
3152+ #[test]
3153+ fn the_migration_counts_the_jobs_still_running_in_bytes() {
3154+ let db = Connection::open_in_memory().unwrap();
3155+ migrate(&db, &["0001_init.sql", "0016_log_chunks.sql"]);
3156+ add_job(&db, "running", None);
3157+ add_job(&db, "quiet", None);
3158+ add_job(&db, "done", Some("2026-01-01T00:00:00Z"));
3159+ for (job, text) in [
3160+ ("running", "héllo\n"),
3161+ ("running", "two\n"),
3162+ ("done", "old\n"),
3163+ ] {
3164+ db.execute(LOG_INSERT, params![job, 0, text, None::<i64>])
3165+ .unwrap();
3166+ }
3167+ migrate(&db, &["0027_log_bytes.sql"]);
3168+ // Bytes, as the limit is: é is two.
3169+ assert_eq!(
3170+ bytes(&db, "running"),
3171+ ("héllo\n".len() + "two\n".len()) as i64
3172+ );
3173+ assert_eq!(bytes(&db, "quiet"), 0);
3174+ // A finished job is sent no more log, so is not counted.
3175+ assert_eq!(bytes(&db, "done"), 0);
3176+ }
3177+
3178+ #[test]
3179+ fn each_batch_kept_adds_to_the_count_once() {
3180+ let db = Connection::open_in_memory().unwrap();
3181+ migrate(
3182+ &db,
3183+ &["0001_init.sql", "0016_log_chunks.sql", "0027_log_bytes.sql"],
3184+ );
3185+ add_job(&db, "job_1", None);
3186+ report(&db, "job_1", "one\n", Some(1), Some("2026-01-01T00:00:01Z"));
3187+ assert_eq!(bytes(&db, "job_1"), 4);
3188+ // Sent again after an answer the runner did not hear: neither kept
3189+ // nor counted twice, though the job was still heard from.
3190+ report(&db, "job_1", "one\n", Some(1), Some("2026-01-01T00:00:02Z"));
3191+ assert_eq!(bytes(&db, "job_1"), 4);
3192+ report(
3193+ &db,
3194+ "job_1",
3195+ "three\n",
3196+ Some(2),
3197+ Some("2026-01-01T00:00:03Z"),
3198+ );
3199+ // g1t's own notes number nothing, are always kept, and leave when
3200+ // it was last heard from alone.
3201+ report(&db, "job_1", "note\n", None, None);
3202+ report(&db, "job_1", "note\n", None, None);
3203+ let (counted, seen): (i64, String) = db
3204+ .query_row(
3205+ "SELECT log_bytes, seen_at FROM jobs WHERE id = 'job_1'",
3206+ [],
3207+ |row| Ok((row.get(0)?, row.get(1)?)),
3208+ )
3209+ .unwrap();
3210+ let kept: i64 = db
3211+ .query_row(
3212+ "SELECT SUM(LENGTH(CAST(text AS BLOB))) FROM logs WHERE job_id = 'job_1'",
3213+ [],
3214+ |row| row.get(0),
3215+ )
3216+ .unwrap();
3217+ assert_eq!(counted, kept);
3218+ assert_eq!(counted, 4 + 6 + 5 + 5);
3219+ assert_eq!(seen, "2026-01-01T00:00:03Z");
3220+ }
3221+
3222+ #[test]
3223+ fn the_batch_that_reaches_the_limit_is_the_last_kept() {
3224+ let mut text = "a".repeat(10);
3225+ assert!(log_room(0, &mut text));
3226+ assert_eq!(text, "a".repeat(10));
3227+ let mut text = "a".repeat(10);
3228+ assert!(log_room(MAX_LOG_BYTES - 10, &mut text));
3229+ assert!(
3230+ text.starts_with(&"a".repeat(10)) && text.contains("limit of 4 MB"),
3231+ "{text}"
3232+ );
3233+ let mut text = "a".repeat(10);
3234+ assert!(!log_room(MAX_LOG_BYTES, &mut text));
3235+ assert!(!log_room(MAX_LOG_BYTES + 100, &mut text));
3236+ }
3237+}
3238+
3239+#[cfg(test)]
30873240 mod token_rotation {
30883241 use g1t_secrets::sha256_hex;
30893242 use rusqlite::{Connection, OptionalExtension, params};
+50−11
99 use g1t_contracts::events::{Event, caused_by_job};
1010 use g1t_contracts::identity::{AGENT_ID, AGENT_NAME, UsernamesArgs};
1111 use g1t_contracts::repos::{Commit, CompareArgs, Comparison, FileStatus, LogArgs, PullMergeRef, PullMergeRefArgs, Repo, RepoPath};
12−use g1t_contracts::work::{Label, PullDetail, ViewArgs, WorkItemDetail, WorkItemViewArgs, WorkspaceViewArgs};
12+use g1t_contracts::work::{Label, Mergeable, PullDetail, ViewArgs, WorkItemDetail, WorkItemViewArgs, WorkspaceViewArgs};
1313 use g1t_contracts::{FailureCode, Outcome, User, new_id};
1414 use g1t_contracts::time::rfc3339;
1515 use g1t_kit::now_ms;
116116 Nothing,
117117 }
118118
119−fn pull_build(event_name: &str, action: Option<&str>, made: &PullMergeRef) -> PullBuild {
119+/// What a pull request's run builds, given the test merge `made` and
120+/// whether the pull request is known to conflict (`mergeable`, worked out
121+/// by work's mergeability check, which merges with git itself).
122+///
123+/// A conflict only the test merge found is not shown on the pull request,
124+/// which then waits on its required checks with nothing saying why they
125+/// never report: it builds the head instead. Until 2026-10-22 it ran
126+/// nothing, and a branch made from a pull request that merged with a merge
127+/// commit, whose test merge went wrong (repos merge_base.rs), sat at
128+/// "expected" (flagon-io/g1t#365).
129+fn pull_build(
130+ event_name: &str,
131+ action: Option<&str>,
132+ made: &PullMergeRef,
133+ mergeable: Mergeable,
134+) -> PullBuild {
120135 match made {
121136 PullMergeRef::Merged { merge, .. } => PullBuild::Merge(merge.clone()),
122− PullMergeRef::Conflict { .. } if event_name == "pull_request" && action != Some("closed") => PullBuild::Nothing,
137+ PullMergeRef::Conflict { .. }
138+ if event_name == "pull_request"
139+ && action != Some("closed")
140+ && mergeable == Mergeable::Conflicting =>
141+ {
142+ PullBuild::Nothing
143+ }
123144 PullMergeRef::Conflict { .. } | PullMergeRef::Unavailable { .. } => PullBuild::Head,
124145 }
125146 }
524545 Some(_) => None,
525546 None => {
526547 let made = self.test_merge(repo, pull, &sha, &base_ref).await;
527− match pull_build(event_name, action, &made) {
548+ match pull_build(event_name, action, &made, detail.mergeable) {
528549 PullBuild::Merge(merge) => Some(merge),
529550 PullBuild::Nothing => {
530551 worker::console_log!("actions: pull request #{} conflicts with {base_ref}: {made:?}; no pull_request workflows run", pull.number);
531552 return Ok(None);
532553 }
533554 PullBuild::Head => {
534− worker::console_log!("actions: no merge commit for pull request #{}: {made:?}; its head is built", pull.number);
555+ worker::console_warn!("actions: no merge commit for pull request #{}: {made:?}; its head is built", pull.number);
535556 None
536557 }
537558 }
15941615 let merged = PullMergeRef::Merged { head: "h".into(), base: "b".into(), merge: "m".into() };
15951616 let conflict = PullMergeRef::Conflict { head: "h".into(), base: "b".into(), paths: vec!["a".into()] };
15961617 let unavailable = PullMergeRef::Unavailable { reason: "too large".into() };
1597− assert_eq!(pull_build("pull_request", Some("synchronize"), &merged), PullBuild::Merge("m".into()));
1598− assert_eq!(pull_build("pull_request_review", Some("submitted"), &merged), PullBuild::Merge("m".into()));
1618+ let conflicting = Mergeable::Conflicting;
1619+ assert_eq!(pull_build("pull_request", Some("synchronize"), &merged, Mergeable::Clean), PullBuild::Merge("m".into()));
1620+ assert_eq!(pull_build("pull_request_review", Some("submitted"), &merged, Mergeable::Clean), PullBuild::Merge("m".into()));
15991621 // GitHub runs no pull_request workflow while it conflicts.
16001622 for action in ["opened", "synchronize", "reopened", "edited"] {
1601− assert_eq!(pull_build("pull_request", Some(action), &conflict), PullBuild::Nothing);
1623+ assert_eq!(pull_build("pull_request", Some(action), &conflict, conflicting), PullBuild::Nothing);
16021624 }
16031625 // Closing it, or reviewing it, still runs, on the head.
1604− assert_eq!(pull_build("pull_request", Some("closed"), &conflict), PullBuild::Head);
1605− assert_eq!(pull_build("pull_request_review", Some("submitted"), &conflict), PullBuild::Head);
1626+ assert_eq!(pull_build("pull_request", Some("closed"), &conflict, conflicting), PullBuild::Head);
1627+ assert_eq!(pull_build("pull_request_review", Some("submitted"), &conflict, conflicting), PullBuild::Head);
16061628 // What g1t could not merge itself runs on the head.
1607− assert_eq!(pull_build("pull_request", Some("opened"), &unavailable), PullBuild::Head);
1629+ assert_eq!(pull_build("pull_request", Some("opened"), &unavailable, Mergeable::Unknown), PullBuild::Head);
1630+ }
1631+
1632+ #[test]
1633+ fn a_conflict_the_pull_request_does_not_show_still_builds_its_head() {
1634+ // flagon-io/g1t#362: the test merge conflicted where git did not, the
1635+ // pull request showed none, and its required check waited for good.
1636+ let conflict = PullMergeRef::Conflict {
1637+ head: "h".into(),
1638+ base: "b".into(),
1639+ paths: vec!["a".into()],
1640+ };
1641+ for mergeable in [Mergeable::Clean, Mergeable::Checking, Mergeable::Unknown] {
1642+ assert_eq!(
1643+ pull_build("pull_request", Some("synchronize"), &conflict, mergeable),
1644+ PullBuild::Head
1645+ );
1646+ }
16081647 }
16091648
16101649 fn repo() -> Repo {
+4−8
3333 use crate::registry::{can_read, can_write, store_key};
3434 use crate::store::{GitRepo, GitStore, Scope};
3535 use crate::merge_methods::{Applied, Reader, apply};
36−use crate::{MAX_ANCESTRY, Repos, UNVERIFIED, descends_from, land, nearest_ancestor_in, not_found};
36+use crate::merge_base::{MergeBase, merge_base};
37+use crate::{MAX_ANCESTRY, Repos, UNVERIFIED, descends_from, land, not_found};
3738
3839 /// The mode git writes for an entry of each kind.
3940 fn mode(kind: EntryKind) -> &'static str {
488489 }));
489490 }
490491 let shared: HashSet<String> = target_history.iter().map(|commit| commit.hash.clone()).collect();
491− let merge_base = nearest_ancestor_in(&source_git, &history, &shared).await?;
492− let Some((merge_base, merge_base_tree)) = merge_base.and_then(|hash| {
493− target_history
494− .iter()
495− .find(|commit| commit.hash == hash)
496− .map(|commit| (hash, commit.tree_hash.clone()))
497− }) else {
492+ let found = merge_base(&source_git, &history, &target_git, &target_history, &shared).await?;
493+ let Some(MergeBase { hash: merge_base, tree: merge_base_tree, .. }) = found else {
498494 return Ok(needs_agent(
499495 NeedsAgentReason::Unsupported,
500496 format!("g1t could not find where this pull request left {base_branch}."),
+20−49
3030 mod lifecycle;
3131 mod limits;
3232 mod listing;
33+mod merge_base;
3334 mod merge_methods;
3435 mod meters;
3536 mod moves;
6970 use g1t_contracts::time::rfc3339;
7071 use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, User, Viewer, is_valid_repo_name, new_id};
7172 use g1t_kit::{args, now_ms, reply, rpc_method};
72−use std::collections::{HashMap, HashSet, VecDeque};
73+use std::collections::{HashMap, HashSet};
7374 use std::rc::Rc;
7475
7576 use serde::Serialize;
171172 }
172173 }
173174 Ok(false)
174−}
175−
176−/// The commit closest to the newest in `history` that is also in `shared`:
177−/// where a fork and the repository it came from last agreed.
178−async fn nearest_ancestor_in<R: GitRepo>(
179− repo: &R,
180− history: &[Commit],
181− shared: &HashSet<String>,
182−) -> Result<Option<String>> {
183− let known: HashMap<&str, &[String]> = history
184− .iter()
185− .map(|commit| (commit.hash.as_str(), commit.parents.as_slice()))
186− .collect();
187− let mut seen = HashSet::new();
188− let mut queue: VecDeque<String> = history
189− .first()
190− .map(|c| c.hash.clone())
191− .into_iter()
192− .collect();
193− while let Some(hash) = queue.pop_front() {
194− if shared.contains(&hash) {
195− return Ok(Some(hash));
196− }
197− if !seen.insert(hash.clone()) || seen.len() > MAX_ANCESTRY as usize {
198− continue;
199− }
200− match known.get(hash.as_str()) {
201− Some(parents) => queue.extend(parents.iter().cloned()),
202− None => queue.extend(repo.parents(&hash).await?.unwrap_or_default()),
203− }
204− }
205− Ok(None)
206175 }
207176
208177 thread_local! {
13561325 return Ok(None);
13571326 };
13581327 let behind = !descends_from(&source_git, &history, &base.hash).await?;
1359− let merge_base = nearest_ancestor_in(&source_git, &history, &side.shared).await?;
1328+ let found = merge_base::merge_base(
1329+ &source_git,
1330+ &history,
1331+ &target_git,
1332+ target_history,
1333+ &side.shared,
1334+ )
1335+ .await?;
13601336 let mut divergence = Divergence {
13611337 head: head.hash.clone(),
13621338 base: base.hash.clone(),
1363− merge_base: merge_base.clone(),
1339+ merge_base: found.as_ref().map(|found| found.hash.clone()),
13641340 behind,
13651341 ..Divergence::default()
13661342 };
1367− let merge_base_tree = match &merge_base {
1368− Some(hash) => target_history
1369− .iter()
1370− .find(|commit| commit.hash == *hash)
1371− .map(|commit| commit.tree_hash.clone()),
1372− None => None,
1373− };
1374− let Some(merge_base_tree) = merge_base_tree else {
1343+ let Some(merge_base_tree) = found.map(|found| found.tree) else {
13751344 // No common history to compare from: say nothing is known.
13761345 divergence.truncated = true;
13771346 return Ok(Some(divergence));
18811850 let shared_with = async |against: &Repo| -> Result<Option<String>> {
18821851 let against_git = self.read_git(against).await?;
18831852 let branch = base_branch.as_deref().unwrap_or(&against.default_branch);
1884− let shared: HashSet<String> = against_git
1885− .log(branch, MAX_ANCESTRY)
1886− .await?
1887− .into_iter()
1888− .map(|commit| commit.hash)
1853+ let against_history = against_git.log(branch, MAX_ANCESTRY).await?;
1854+ let shared: HashSet<String> = against_history
1855+ .iter()
1856+ .map(|commit| commit.hash.clone())
18891857 .collect();
1890− nearest_ancestor_in(&git, &history, &shared).await
1858+ let found =
1859+ merge_base::merge_base(&git, &history, &against_git, &against_history, &shared)
1860+ .await?;
1861+ Ok(found.map(|found| found.hash))
18911862 };
18921863 // A fork's branch, compared from the repository it would merge into.
18931864 let target = match a.target_id.as_deref().filter(|id| a.base.is_none() && *id != repo.id) {
+425−0
Loading the diff…
+104−8
Loading the diff…
+3−2
Loading the diff…
+33−5
Loading the diff…