pr_01m47d15m3e54sn21z27rpy5n9/apps/web/app/routes/home.tsx

523 lines20,350 bytesCodeBlame
1import { env } from "cloudflare:workers";
2import { Suspense, lazy } from "react";
3import { data } from "react-router";
4
5import {
6 type AgentRun,
7 type AuthoredItem,
8 type CheckStatus,
9 type Deployment,
10 type G1tEvent,
11 type Issue,
12 type Lifecycle,
13 type LiveApp,
14 type Pull,
15 type PullStatus,
16 type Repo,
17 type RepoPath,
18 type Stage,
19 REPO_ROLE_LABELS,
20 isActiveRun,
21} from "@g1t/contracts";
22
23import type { Route } from "./+types/home";
24import { page } from "../lib/meta";
25import { WORKSPACE_COOKIE, chosenWorkspace } from "../lib/workspace-choice";
26import {
27 type ActivityItem,
28 type Need,
29 SEEN_COOKIE,
30 TIME,
31 agentHours,
32 dailyBuckets,
33 digestParts,
34 eventItem,
35 firstPassRate,
36 greetingFor,
37 groupActivity,
38 hourIn,
39 issueToMerge,
40 median,
41 nextSeen,
42 rankNeeds,
43 readCookie,
44 stuckMinutes,
45} from "../lib/mission";
46import { Landing } from "../components/landing";
47import {
48 agents,
49 billing,
50 deployments,
51 events as eventLog,
52 identity,
53 projects as projectsApi,
54 repos as reposApi,
55 work,
56} from "../lib/services.server";
57import { getViewer } from "../lib/session.server";
58
59/** The viewer's time zone, which mission control sets, so the greeting fits their day. */
60const TZ_COOKIE = "g1t_tz";
61/** The most projects whose pull requests and events are read for the page. */
62const MAX_PROJECTS = 10;
63const EVENTS_PER_PROJECT = 80;
64
65export function meta(args: Route.MetaArgs) {
66 return page(args, {
67 title: "g1t — where people and agents ship software together",
68 description:
69 "The open-source git platform for people and agents: issues, pull requests and review, agents you assign like teammates, a merge queue that keeps main green, and deployments to the edge. Priced at cost plus 20%, never per seat.",
70 });
71}
72
73export function headers({ loaderHeaders }: Route.HeadersArgs) {
74 const out = new Headers();
75 const timing = loaderHeaders.get("Server-Timing");
76 if (timing) out.set("Server-Timing", timing);
77 const cookie = loaderHeaders.get("Set-Cookie");
78 if (cookie) out.set("Set-Cookie", cookie);
79 return out;
80}
81
82/** A row of the viewer's pull requests, whichever list it comes from. */
83type PullRow = {
84 key: string;
85 repo: RepoPath;
86 number: number;
87 title: string;
88 status: PullStatus;
89 agent: string | null;
90 stage: Stage | null;
91 detail: string | null;
92 checkStatus: CheckStatus | null;
93 updatedAt: string;
94};
95
96/** A row of the viewer's issues. */
97type IssueRow = {
98 key: string;
99 repo: RepoPath;
100 number: number;
101 title: string;
102 agent: string | null;
103 queued: boolean;
104 updatedAt: string;
105};
106
107type ProjectHealth = {
108 slug: string;
109 name: string;
110 private: boolean;
111 repo: RepoPath | null;
112 deploys: boolean;
113 production: LiveApp | null;
114 latest: Deployment | null;
115 openPulls: number | null;
116 agents: number;
117 passRate: number | null;
118 checks: number;
119};
120
121const pullRow = (pull: Pull, repo: RepoPath, lifecycle: Lifecycle | null): PullRow => ({
122 key: pull.id,
123 repo,
124 number: pull.number,
125 title: pull.title,
126 status: pull.status,
127 agent: pull.agent,
128 stage: lifecycle?.stage ?? null,
129 detail: lifecycle?.detail ?? null,
130 checkStatus: pull.checkStatus,
131 updatedAt: pull.updatedAt,
132});
133
134const authoredPull = (item: AuthoredItem): PullRow => ({
135 key: `${item.repo.namespace}/${item.repo.name}#${item.number}`,
136 repo: item.repo,
137 number: item.number,
138 title: item.title,
139 status: item.status ?? (item.draft ? "draft" : "open"),
140 agent: null,
141 stage: null,
142 detail: null,
143 checkStatus: null,
144 updatedAt: item.updatedAt,
145});
146
147export async function loader({ context, request }: Route.LoaderArgs) {
148 const viewer = getViewer(context);
149 if (!viewer) {
150 // The signed-out home page is about signing up; it lists no repositories.
151 return data({ signedIn: false as const });
152 }
153
154 const now = Date.now();
155 const weekAgo = now - 7 * TIME.DAY;
156 const started = Date.now();
157 const times: Record<string, number> = {};
158 // Each call is timed, and one that fails leaves its section empty rather
159 // than taking the page down.
160 const soft = <T,>(name: string, promise: Promise<T>): Promise<T | null> =>
161 promise.then(
162 (value) => ((times[name] = Date.now() - started), value),
163 (error) => {
164 console.warn(`home: ${name} failed`, error);
165 times[name] = Date.now() - started;
166 return null;
167 },
168 );
169 const okOr = <T,>(result: { ok: true; value: T } | { ok: false } | null): T | null => (result?.ok ? result.value : null);
170
171 const cookies = request.headers.get("cookie");
172 const seen = nextSeen(readCookie(cookies, SEEN_COOKIE), now);
173 const tz = readCookie(cookies, TZ_COOKIE);
174 const memberships = viewer.workspaces ?? [];
175 // The workspace you chose, as the sidebar shows it (lib/workspace-choice.ts).
176 const slug = chosenWorkspace(memberships, readCookie(cookies, WORKSPACE_COOKIE))?.slug ?? null;
177 const mine = new Set(memberships.map((m) => m.slug.toLowerCase()));
178 const username = viewer.username;
179
180 const reposP = soft("repos", reposApi.list(viewer, { memberOnly: true }));
181 // The workspace's projects' open pull requests and recent events, read
182 // once each, all at once, as soon as the projects are known.
183 const perRepoP = reposP.then((repos) =>
184 Promise.all(
185 (repos ?? [])
186 .filter((repo) => mine.has(repo.namespace.toLowerCase()))
187 .slice(0, MAX_PROJECTS)
188 .map(async (repo) => {
189 const path = { namespace: repo.namespace, name: repo.name };
190 const [pulls, log] = await Promise.all([
191 work.listPulls(path, viewer, "open").catch(() => null),
192 eventLog.list({ repoId: repo.id, limit: EVENTS_PER_PROJECT }).catch(() => null),
193 ]);
194 return { repo, pulls: pulls?.ok ? pulls.value.slice(0, 60) : null, events: log };
195 }),
196 ),
197 );
198
199 const [repos, perRepo, active, assigned, models, profile, runs, overview, usage, authoredPulls, authoredIssues, projectList, memories, invitations] =
200 await Promise.all([
201 reposP,
202 soft("projects", perRepoP),
203 soft("pulls", work.listActivePulls(viewer)),
204 soft("assigned", work.listAssignedIssues(viewer)),
205 slug ? soft("models", env.RUNNER.modelAccess(slug)) : null,
206 soft("profile", identity.profile(username)),
207 slug ? soft("runs", agents.listRuns(viewer, { workspace: slug, limit: 150 })) : null,
208 slug ? soft("deploys", deployments.overview(slug, viewer)) : null,
209 slug ? soft("usage", billing.usage(slug, viewer, new Date(weekAgo - TIME.DAY).toISOString())) : null,
210 soft("authoredPulls", work.byAuthor(username, viewer, { kind: "pull", state: "open", sort: "updated", limit: 10 })),
211 soft("authoredIssues", work.byAuthor(username, viewer, { kind: "issue", state: "open", sort: "updated", limit: 10 })),
212 slug ? soft("projectList", projectsApi.list(slug, viewer)) : null,
213 slug ? soft("memories", agents.listMemories(viewer, slug, null)) : null,
214 // Repositories someone has invited the viewer to.
215 soft("invitations", identity.myRepoInvitations(viewer)),
216 ]);
217
218 const repoList = repos ?? [];
219 // Each issue and pull request is shown under its repository. Most are in
220 // the viewer's own, already listed; the rest are looked up once each.
221 const known = new Map<string, Repo>(repoList.map((repo) => [repo.id, repo]));
222 const missing = [
223 ...new Set([...(assigned ?? []).map((issue) => issue.repoId), ...(active ?? []).map(({ pull }) => pull.repoId)]),
224 ].filter((id) => !known.has(id));
225 const looked = await Promise.all(missing.map((id) => reposApi.getById(id, viewer).catch(() => null)));
226 for (const found of looked) if (found?.ok) known.set(found.value.id, found.value);
227 const pathOf = (repo: Repo): RepoPath => ({ namespace: repo.namespace, name: repo.name });
228
229 const activeList = (active ?? []).flatMap((item) => {
230 const repo = known.get(item.pull.repoId);
231 return repo ? [{ ...item, repo }] : [];
232 });
233 const runList = okOr(runs ?? null) ?? [];
234 const liveRuns = runList.filter((run) => isActiveRun(run.status));
235 const overviewList = okOr(overview ?? null);
236 const projectsOk = okOr(projectList ?? null);
237
238 // --- What the viewer's pull requests and issues are ----------------------
239 const lower = username.toLowerCase();
240 const openPulls = (perRepo ?? []).flatMap(({ repo, pulls }) => (pulls ?? []).map((pull) => ({ pull, repo })));
241 const reviewRequested = openPulls.filter(
242 ({ pull }) =>
243 pull.status === "open" &&
244 pull.author.username.toLowerCase() !== lower &&
245 pull.reviewers.some((name) => name.toLowerCase() === lower),
246 );
247 const assignedPulls = openPulls.filter(({ pull }) => pull.assignees.some((name) => name.toLowerCase() === lower));
248 const authoredRows =
249 activeList.length > 0
250 ? activeList.map(({ pull, lifecycle, repo }) => pullRow(pull, pathOf(repo), lifecycle))
251 : (okOr(authoredPulls)?.items ?? []).map(authoredPull);
252 const pullsTabs = {
253 authored: authoredRows.slice(0, 8),
254 review: reviewRequested.slice(0, 8).map(({ pull, repo }) => pullRow(pull, pathOf(repo), null)),
255 assigned: assignedPulls.slice(0, 8).map(({ pull, repo }) => pullRow(pull, pathOf(repo), null)),
256 };
257 const issueRow = (issue: Issue, repo: RepoPath): IssueRow => ({
258 key: issue.id,
259 repo,
260 number: issue.number,
261 title: issue.title,
262 agent: issue.agent,
263 queued: issue.queued,
264 updatedAt: issue.updatedAt,
265 });
266 const issuesTabs = {
267 assigned: (assigned ?? []).flatMap((issue) => {
268 const repo = known.get(issue.repoId);
269 return repo ? [issueRow(issue, pathOf(repo))] : [];
270 }).slice(0, 8),
271 authored: (okOr(authoredIssues)?.items ?? []).slice(0, 8).map((item) => ({
272 key: `${item.repo.namespace}/${item.repo.name}#${item.number}`,
273 repo: item.repo,
274 number: item.number,
275 title: item.title,
276 agent: null,
277 queued: false,
278 updatedAt: item.updatedAt,
279 })),
280 };
281
282 // --- Needs you ------------------------------------------------------------
283 const needs: Need[] = [];
284 for (const invitation of invitations ?? []) {
285 if (invitation.status !== "pending") continue;
286 needs.push({
287 key: `invitation:${invitation.id}`,
288 kind: "invitation",
289 title: `${invitation.invited_by ?? "Someone"} invited you to ${invitation.repo}`,
290 detail: `With the ${REPO_ROLE_LABELS[invitation.role]} role. The invitation expires ${new Date(invitation.expires_at).toISOString().slice(0, 10)}.`,
291 to: `/${invitation.repo}/invitations`,
292 action: "Respond",
293 at: Date.parse(invitation.created_at),
294 where: invitation.repo,
295 });
296 }
297 for (const entry of overviewList ?? []) {
298 const latest = entry.latest;
299 if (latest?.kind === "production" && latest.status === "failed" && slug) {
300 needs.push({
301 key: `deploy:${entry.slug}`,
302 kind: "deploy",
303 title: `Production build of ${entry.slug} failed`,
304 detail: latest.error ?? "The last build of the default branch failed. Production still serves the build before it.",
305 to: `/${slug}/${entry.slug}/deployments/${latest.id}`,
306 action: "See the build",
307 at: Date.parse(latest.finishedAt ?? latest.createdAt),
308 where: `${slug}/${entry.slug}`,
309 });
310 }
311 }
312 for (const { pull, lifecycle, repo } of activeList) {
313 const where = `${repo.namespace}/${repo.name}#${pull.number}`;
314 const to = `/${repo.namespace}/${repo.name}/pull/${pull.number}`;
315 if (lifecycle?.stage === "needs_you") {
316 const conflict = /conflict/i.test(lifecycle.detail);
317 needs.push({
318 key: `pull:${pull.id}`,
319 kind: conflict ? "conflict" : "stalled",
320 title: pull.title,
321 detail: lifecycle.detail,
322 to,
323 action: conflict ? "Resolve" : "Decide",
324 at: Date.parse(pull.updatedAt),
325 where,
326 });
327 } else if (lifecycle?.stage === "ready") {
328 needs.push({ key: `pull:${pull.id}`, kind: "ready", title: pull.title, detail: "Checks passed and it was approved. It lands when you merge it.", to, action: "Merge", at: Date.parse(pull.updatedAt), where });
329 } else if (!lifecycle && pull.status === "open" && pull.checkStatus === "failed") {
330 needs.push({ key: `pull:${pull.id}`, kind: "checks", title: pull.title, detail: "Its acceptance checks failed on the latest push.", to, action: "See checks", at: Date.parse(pull.updatedAt), where });
331 }
332 }
333 for (const { pull, repo } of reviewRequested) {
334 needs.push({
335 key: `review:${pull.id}`,
336 kind: "review",
337 title: pull.title,
338 detail: `${pull.author.username} asked for your review.`,
339 to: `/${repo.namespace}/${repo.name}/pull/${pull.number}`,
340 action: "Review",
341 at: Date.parse(pull.updatedAt),
342 where: `${repo.namespace}/${repo.name}#${pull.number}`,
343 });
344 }
345 let quietest: number | null = null;
346 for (const run of liveRuns) {
347 const minutes = stuckMinutes(run, now);
348 if (minutes == null) continue;
349 quietest = Math.max(quietest ?? 0, minutes);
350 needs.push({
351 key: `run:${run.id}`,
352 kind: "stuck",
353 title: run.title ?? `${run.agent}'s run`,
354 detail: `${run.agent} has reported nothing for ${minutes} min${run.step ? `. Last: ${run.step}` : ""}.`,
355 to: `/${run.repo.namespace}/${run.repo.name}/agents/runs/${run.id}`,
356 action: "Look",
357 at: Date.parse(run.updatedAt),
358 where: `${run.repo.namespace}/${run.repo.name}${run.number != null ? `#${run.number}` : ""}`,
359 });
360 }
361
362 // --- Activity -------------------------------------------------------------
363 const items: ActivityItem[] = [];
364 const allEvents: { repo: string; event: G1tEvent }[] = [];
365 for (const { repo, events } of perRepo ?? []) {
366 for (const event of events ?? []) {
367 allEvents.push({ repo: repo.id, event });
368 const item = eventItem(event, pathOf(repo));
369 if (item) items.push(item);
370 }
371 }
372 for (const entry of overviewList ?? []) {
373 const latest = entry.latest;
374 const served = latest?.status === "ready" || latest?.status === "replaced";
375 if (!slug || latest?.kind !== "production" || (!served && latest.status !== "failed")) continue;
376 items.push({
377 id: `deploy:${latest.id}`,
378 at: Date.parse(latest.finishedAt ?? latest.createdAt),
379 repo: { namespace: slug, name: entry.slug },
380 actor: latest.createdBy,
381 verb: served ? "deployed" : "deploy_failed",
382 number: null,
383 to: `/${slug}/${entry.slug}/deployments/${latest.id}`,
384 });
385 }
386 for (const memory of okOr(memories ?? null)?.workspace ?? []) {
387 const repo = memory.repo ?? memory.source.repo;
388 if (!repo || !slug) continue;
389 items.push({
390 id: `memory:${memory.id}`,
391 at: Date.parse(memory.createdAt),
392 repo,
393 actor: memory.createdBy,
394 verb: "learned",
395 number: null,
396 text: memory.text.length > 90 ? `${memory.text.slice(0, 88)}…` : memory.text,
397 to: `/${slug}/-/memory`,
398 });
399 }
400 const groups = groupActivity(items).slice(0, 60);
401
402 // --- Since you were last here --------------------------------------------
403 const since = seen.since ?? now - TIME.DAY;
404 const after = (verb: ActivityItem["verb"]) => items.filter((item) => item.verb === verb && item.at > since).length;
405 const digest = digestParts({
406 landed: after("landed"),
407 reviews: reviewRequested.length,
408 opened: after("opened_issue"),
409 deploys: after("deployed"),
410 failedDeploys: after("deploy_failed"),
411 stuck: quietest,
412 });
413
414 // --- Pulse ----------------------------------------------------------------
415 const week = allEvents.filter(({ event }) => Date.parse(event.time) >= weekAgo);
416 const mergedWeek = week.filter(({ event }) => event.type === "pull.merged");
417 const spans = issueToMerge(
418 allEvents.flatMap(({ repo, event }) => (event.type === "issue.opened" ? [{ repo, number: event.data.number, at: Date.parse(event.time) }] : [])),
419 mergedWeek.flatMap(({ repo, event }) => (event.type === "pull.merged" ? [{ repo, issue: event.data.issue ?? null, at: Date.parse(event.time) }] : [])),
420 );
421 const checkEvents = allEvents.flatMap(({ repo, event }) =>
422 event.type === "checks.completed" ? [{ repo, number: event.data.number, at: Date.parse(event.time), passed: event.data.status === "passed" }] : [],
423 );
424 const firstPass = firstPassRate(checkEvents.filter((c) => c.at >= weekAgo));
425 const usageOk = okOr(usage ?? null);
426 const costPoints = usageOk
427 ? usageOk.byDay.map((slice) => ({ at: Date.parse(`${slice.key.split("/")[0]}T12:00:00Z`), value: slice.micros / 1_000_000 }))
428 : runList.filter((run) => run.costUsd != null).map((run) => ({ at: Date.parse(run.createdAt), value: run.costUsd ?? 0 }));
429 const costDays = dailyBuckets(costPoints, 7, now);
430 const pulse = {
431 merged: mergedWeek.length,
432 mergedDays: dailyBuckets(mergedWeek.map(({ event }) => ({ at: Date.parse(event.time) })), 7, now),
433 hours: agentHours(runList, weekAgo, now),
434 hoursDays: dailyBuckets(
435 runList
436 .filter((run) => run.startedAt)
437 .map((run) => ({ at: Date.parse(run.startedAt!), value: agentHours([run], weekAgo, now) })),
438 7,
439 now,
440 ),
441 cost: costDays.reduce((sum, v) => sum + v, 0),
442 costDays,
443 issueToMerge: median(spans),
444 mergedWithIssue: spans.length,
445 firstPass: firstPass.rate,
446 firstPassOf: firstPass.of,
447 };
448
449 // --- Projects -------------------------------------------------------------
450 const byRepoName = new Map((perRepo ?? []).map((entry) => [`${entry.repo.namespace}/${entry.repo.name}`.toLowerCase(), entry]));
451 const projectsHealth: ProjectHealth[] = (projectsOk ?? []).slice(0, 12).map((project) => {
452 const repo = project.source.kind === "hosted" ? project.source.repo : null;
453 const key = repo ? `${repo.namespace}/${repo.name}`.toLowerCase() : "";
454 const entry = byRepoName.get(key);
455 const deploy = overviewList?.find((d) => d.slug === project.slug) ?? null;
456 const checks = (entry?.events ?? []).flatMap((event) => (event.type === "checks.completed" ? [event.data.status === "passed"] : []));
457 return {
458 slug: project.slug,
459 name: project.name,
460 private: project.private,
461 repo,
462 deploys: deploy?.enabled ?? false,
463 production: deploy?.production ?? null,
464 latest: deploy?.latest ?? null,
465 openPulls: entry?.pulls ? entry.pulls.length : null,
466 agents: liveRuns.filter((run) => repo && `${run.repo.namespace}/${run.repo.name}`.toLowerCase() === key).length,
467 passRate: checks.length ? checks.filter(Boolean).length / checks.length : null,
468 checks: checks.length,
469 };
470 });
471
472 const models_ = models ?? null;
473 times.total = Date.now() - started;
474 const serverTiming = Object.entries(times)
475 .map(([name, ms]) => `${name};dur=${ms}`)
476 .join(", ");
477 const secure = new URL(request.url).protocol === "https:" ? "; Secure" : "";
478 return data(
479 {
480 signedIn: true as const,
481 viewer,
482 name: profile?.name?.trim() || username,
483 greeting: greetingFor(hourIn(now, tz)),
484 seenBefore: seen.since,
485 repos: repoList,
486 canRunAgents: models_ == null || models_.hosted || models_.own != null,
487 trial: models_?.own == null ? (models_?.trial ?? null) : null,
488 active: activeList.map(({ pull, lifecycle, repo }) => ({ pull, lifecycle, repo })),
489 needs: rankNeeds(needs),
490 live: liveRuns.slice(0, 12) as AgentRun[],
491 liveTotal: liveRuns.length,
492 runsLoaded: runs != null && runs.ok,
493 pullsTabs,
494 issuesTabs,
495 perRepoLoaded: perRepo != null,
496 groups,
497 digest,
498 pulse,
499 projects: projectsHealth,
500 projectsLoaded: projectsOk != null,
501 },
502 {
503 headers: {
504 "Server-Timing": serverTiming,
505 "Set-Cookie": `${SEEN_COOKIE}=${seen.value}; Path=/; Max-Age=31536000; HttpOnly; SameSite=Lax${secure}`,
506 },
507 },
508 );
509}
510
511export type Loaded = Extract<Route.ComponentProps["loaderData"], { signedIn: true }>;
512
513/** Mission control, loaded only for someone signed in (components/mission-control.tsx). */
514const MissionControl = lazy(() => import("../components/mission-control"));
515
516export default function Home({ loaderData }: Route.ComponentProps) {
517 if (!loaderData.signedIn) return <Landing />;
518 return (
519 <Suspense fallback={null}>
520 <MissionControl loaderData={loaderData} />
521 </Suspense>
522 );
523}