flagon-io/g1t

public

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

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

541 lines22,009 bytesCodeBlame
1import { env } from "cloudflare:workers";
2import { Suspense, lazy } from "react";
3import { type ShouldRevalidateFunctionArgs, data } from "react-router";
4
5import {
6 type Lifecycle,
7 type Pull,
8 type Repo,
9 type RepoPath,
10 REPO_ROLE_LABELS,
11 isActiveRun,
12} from "@g1t/contracts";
13
14import type { Route } from "./+types/home";
15import { page } from "../lib/meta";
16import { WORKSPACE_COOKIE, chosenWorkspace } from "../lib/workspace-choice";
17import {
18 type ActivityItem,
19 type Need,
20 TIME,
21 agentHours,
22 dailyBuckets,
23 eventItem,
24 greetingFor,
25 groupActivity,
26 hourIn,
27 isAgent,
28 rankNeeds,
29 readCookie,
30 stuckMinutes,
31} from "../lib/mission";
32import {
33 type Fact,
34 type Merged,
35 type NeedRow,
36 type QuickAction,
37 RUN_LABEL,
38 dateLine,
39 dayKey,
40 landedToday,
41 needPathKey,
42 pullFacts,
43 reachesBack,
44 reasonFor,
45 summaryLine,
46 usd,
47 waitingRows,
48 weekOf,
49 who,
50 whyFor,
51} from "../lib/mission-control";
52import { Landing } from "../components/landing";
53import {
54 agents,
55 billing,
56 deployments,
57 events as eventLog,
58 identity,
59 projects as projectsApi,
60 repos as reposApi,
61 work,
62} from "../lib/services.server";
63import { getViewer } from "../lib/session.server";
64
65/** The viewer's time zone, which mission control sets, so the greeting fits their day. */
66const TZ_COOKIE = "g1t_tz";
67/** The most projects whose pull requests and events are read for the page. */
68const MAX_PROJECTS = 10;
69const EVENTS_PER_PROJECT = 80;
70/** How many pull requests work lists at once (`LIST_PAGE` in services/work). */
71const PULL_PAGE = 100;
72
73export function meta(args: Route.MetaArgs) {
74 return page(args, {
75 title: "g1t — where people and agents ship software together",
76 description:
77 "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.",
78 });
79}
80
81export function headers({ loaderHeaders }: Route.HeadersArgs) {
82 const out = new Headers();
83 const timing = loaderHeaders.get("Server-Timing");
84 if (timing) out.set("Server-Timing", timing);
85 const cookie = loaderHeaders.get("Set-Cookie");
86 if (cookie) out.set("Set-Cookie", cookie);
87 return out;
88}
89
90/**
91 * Switching tabs, the sort or the activity list changes only the address:
92 * everything is already loaded. A refresh, a form, or anything else loads
93 * again as usual.
94 */
95export function shouldRevalidate({ currentUrl, nextUrl, formMethod, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
96 if (!formMethod && currentUrl.pathname === nextUrl.pathname && currentUrl.search !== nextUrl.search) return false;
97 return defaultShouldRevalidate;
98}
99
100/** Where a need came from, so its row can say what is known about it. */
101type Extra = Partial<Pick<NeedRow, "repo" | "ref" | "by" | "for" | "facts" | "quick" | "link" | "open">>;
102
103export async function loader({ context, request }: Route.LoaderArgs) {
104 const viewer = getViewer(context);
105 if (!viewer) {
106 // The signed-out home page is about signing up; it lists no repositories.
107 return data({ signedIn: false as const });
108 }
109
110 const now = Date.now();
111 const weekAgo = now - 7 * TIME.DAY;
112 const started = Date.now();
113 const times: Record<string, number> = {};
114 // Each call is timed, and one that fails leaves its section empty rather
115 // than taking the page down.
116 const soft = <T,>(name: string, promise: Promise<T>): Promise<T | null> =>
117 promise.then(
118 (value) => ((times[name] = Date.now() - started), value),
119 (error) => {
120 console.warn(`home: ${name} failed`, error);
121 times[name] = Date.now() - started;
122 return null;
123 },
124 );
125 const okOr = <T,>(result: { ok: true; value: T } | { ok: false } | null): T | null => (result?.ok ? result.value : null);
126
127 const cookies = request.headers.get("cookie");
128 const tz = readCookie(cookies, TZ_COOKIE);
129 const memberships = viewer.workspaces ?? [];
130 // The workspace you chose, as the sidebar shows it (lib/workspace-choice.ts).
131 const slug = chosenWorkspace(memberships, readCookie(cookies, WORKSPACE_COOKIE))?.slug ?? null;
132 const username = viewer.username;
133
134 const reposP = soft("repos", reposApi.list(viewer, { memberOnly: true }));
135 // The chosen workspace's projects: their open and recently merged pull
136 // requests and recent events, read once each, all at once, as soon as
137 // the projects are known.
138 const perRepoP = reposP.then((repos) =>
139 Promise.all(
140 (repos ?? [])
141 .filter((repo) => slug != null && repo.namespace.toLowerCase() === slug.toLowerCase())
142 .slice(0, MAX_PROJECTS)
143 .map(async (repo) => {
144 const path = { namespace: repo.namespace, name: repo.name };
145 const [pulls, closed, log] = await Promise.all([
146 work.listPulls(path, viewer, "open").catch(() => null),
147 work.listPulls(path, viewer, "closed").catch(() => null),
148 eventLog.list({ repoId: repo.id, limit: EVENTS_PER_PROJECT }).catch(() => null),
149 ]);
150 return {
151 repo,
152 pulls: pulls?.ok ? pulls.value.slice(0, 60) : null,
153 closed: closed?.ok ? closed.value : null,
154 events: log,
155 };
156 }),
157 ),
158 );
159
160 const [repos, perRepo, active, models, profile, runs, overview, usage, projectList, memories, invitations] = await Promise.all([
161 reposP,
162 soft("projects", perRepoP),
163 soft("pulls", work.listActivePulls(viewer)),
164 slug ? soft("models", env.RUNNER.modelAccess(slug)) : null,
165 soft("profile", identity.profile(username)),
166 slug ? soft("runs", agents.listRuns(viewer, { workspace: slug, limit: 150 })) : null,
167 slug ? soft("deploys", deployments.overview(slug, viewer)) : null,
168 slug ? soft("usage", billing.usage(slug, viewer, new Date(weekAgo - TIME.DAY).toISOString())) : null,
169 slug ? soft("projectList", projectsApi.list(slug, viewer)) : null,
170 slug ? soft("memories", agents.listMemories(viewer, slug, null)) : null,
171 // Repositories someone has invited the viewer to.
172 soft("invitations", identity.myRepoInvitations(viewer)),
173 ]);
174
175 const repoList = repos ?? [];
176 // Each pull request is shown under its repository. Most are in the
177 // viewer's own, already listed; the rest are looked up once each.
178 const known = new Map<string, Repo>(repoList.map((repo) => [repo.id, repo]));
179 const missing = [...new Set((active ?? []).map(({ pull }) => pull.repoId))].filter((id) => !known.has(id));
180 const looked = await Promise.all(missing.map((id) => reposApi.getById(id, viewer).catch(() => null)));
181 for (const found of looked) if (found?.ok) known.set(found.value.id, found.value);
182 const pathOf = (repo: Repo): RepoPath => ({ namespace: repo.namespace, name: repo.name });
183 const inWorkspace = (repo: RepoPath) => slug != null && repo.namespace.toLowerCase() === slug.toLowerCase();
184
185 const activeList = (active ?? []).flatMap((item) => {
186 const repo = known.get(item.pull.repoId);
187 return repo ? [{ ...item, repo }] : [];
188 });
189 const runList = okOr(runs ?? null) ?? [];
190 const liveRuns = runList.filter((run) => isActiveRun(run.status));
191 const overviewList = okOr(overview ?? null);
192 const projectsOk = okOr(projectList ?? null);
193 const runsOn = (repo: RepoPath, number: number) =>
194 runList.filter(
195 (run) => run.number === number && `${run.repo.namespace}/${run.repo.name}`.toLowerCase() === `${repo.namespace}/${repo.name}`.toLowerCase(),
196 );
197
198 const lower = username.toLowerCase();
199 const openPulls = (perRepo ?? []).flatMap(({ repo, pulls }) => (pulls ?? []).map((pull) => ({ pull, repo })));
200 const reviewRequested = openPulls.filter(
201 ({ pull }) =>
202 pull.status === "open" &&
203 pull.author.username.toLowerCase() !== lower &&
204 pull.reviewers.some((name) => name.toLowerCase() === lower),
205 );
206
207 // --- Needs you ------------------------------------------------------------
208 const needs: Need[] = [];
209 const extras = new Map<string, Extra>();
210 /** What every pull request's row shares: where it is, who is on it, what is known. */
211 const pullExtra = (pull: Pull, repo: Repo, lifecycle: Lifecycle | null): Extra => {
212 const base = `/${repo.namespace}/${repo.name}`;
213 const agentWork = isAgent(pull.agent);
214 return {
215 repo: pathOf(repo),
216 ref: `#${pull.number}`,
217 by: agentWork ? who(pull.agent) : who(pull.author.username),
218 for: agentWork ? pull.author.username : null,
219 facts: pullFacts({ checkStatus: pull.checkStatus, files: pull.files, lifecycle, runs: runsOn(repo, pull.number) }),
220 open: pull.issue != null ? `${base}/issues/${pull.issue}` : `${base}/pull/${pull.number}?tab=changes`,
221 };
222 };
223 const pullAction = (repo: Repo, pull: Pull, quick: Omit<QuickAction, "to">): QuickAction => ({
224 ...quick,
225 to: `/${repo.namespace}/${repo.name}/pull/${pull.number}`,
226 });
227 const approve = (repo: Repo, pull: Pull) =>
228 pullAction(repo, pull, {
229 label: isAgent(pull.agent) ? "Approve the agent's change" : "Approve the change",
230 fields: { action: "comment", verdict: "approve", body: "" },
231 done: "Approved",
232 });
233
234 for (const invitation of invitations ?? []) {
235 if (invitation.status !== "pending") continue;
236 const key = `invitation:${invitation.id}`;
237 needs.push({
238 key,
239 kind: "invitation",
240 title: `${invitation.invited_by ?? "Someone"} invited you to ${invitation.repo}`,
241 detail: `With the ${REPO_ROLE_LABELS[invitation.role]} role. The invitation expires ${new Date(invitation.expires_at).toISOString().slice(0, 10)}.`,
242 to: `/${invitation.repo}/invitations`,
243 action: "Respond",
244 at: Date.parse(invitation.created_at),
245 where: invitation.repo,
246 });
247 const [namespace, name] = invitation.repo.split("/");
248 extras.set(key, {
249 repo: namespace && name ? { namespace, name } : null,
250 by: who(invitation.invited_by),
251 facts: [
252 { label: "Role", value: REPO_ROLE_LABELS[invitation.role], tone: null },
253 { label: "Expires", value: new Date(invitation.expires_at).toISOString().slice(0, 10), tone: null },
254 ],
255 });
256 }
257 for (const entry of overviewList ?? []) {
258 const latest = entry.latest;
259 if (latest?.kind === "production" && latest.status === "failed" && slug) {
260 const key = `deploy:${entry.slug}`;
261 const to = `/${slug}/${entry.slug}/deployments/${latest.id}`;
262 needs.push({
263 key,
264 kind: "deploy",
265 title: `Production build of ${entry.slug} failed`,
266 detail: latest.error ?? "The last build of the default branch failed. Production still serves the build before it.",
267 to,
268 action: "See the build",
269 at: Date.parse(latest.finishedAt ?? latest.createdAt),
270 where: `${slug}/${entry.slug}`,
271 });
272 const facts: Fact[] = [
273 { label: "Commit", value: latest.commit.slice(0, 7), tone: null },
274 {
275 label: "Production",
276 value: entry.production ? "Serving the build before" : "Not live yet",
277 tone: entry.production ? "good" : "warn",
278 },
279 ];
280 extras.set(key, {
281 repo: { namespace: slug, name: entry.slug },
282 by: who(latest.createdBy),
283 facts,
284 link: { label: "Deployment settings", to: `/${slug}/${entry.slug}/settings/deployments` },
285 });
286 }
287 }
288 for (const { pull, lifecycle, repo } of activeList) {
289 const where = `${repo.namespace}/${repo.name}#${pull.number}`;
290 const to = `/${repo.namespace}/${repo.name}/pull/${pull.number}`;
291 const key = `pull:${pull.id}`;
292 const extra = pullExtra(pull, repo, lifecycle);
293 if (lifecycle?.stage === "needs_you") {
294 const conflict = /conflict/i.test(lifecycle.detail);
295 const need: Need = {
296 key,
297 kind: conflict ? "conflict" : "stalled",
298 title: pull.title,
299 detail: lifecycle.detail,
300 to,
301 action: conflict ? "Resolve" : "Decide",
302 at: Date.parse(pull.updatedAt),
303 where,
304 };
305 needs.push(need);
306 const reason = reasonFor(need);
307 extras.set(key, {
308 ...extra,
309 quick:
310 reason === "needs_review"
311 ? approve(repo, pull)
312 : /could not be run/i.test(lifecycle.detail)
313 ? pullAction(repo, pull, { label: "Run the checks again", fields: { action: "recheck" }, done: "Checks started" })
314 : null,
315 link: reason === "outside_guardrails" ? { label: "Raise the cap", to: `/${repo.namespace}/${repo.name}/settings/guardrails` } : null,
316 });
317 } else if (lifecycle?.stage === "ready") {
318 needs.push({ key, 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 });
319 extras.set(key, {
320 ...extra,
321 quick: pullAction(repo, pull, {
322 label: isAgent(pull.agent) ? "Merge the agent's change" : "Merge it",
323 fields: { action: "merge" },
324 done: "Merging",
325 }),
326 });
327 } else if (!lifecycle && pull.status === "open" && pull.checkStatus === "failed") {
328 needs.push({ key, kind: "checks", title: pull.title, detail: "Its acceptance checks failed on the latest push.", to, action: "See checks", at: Date.parse(pull.updatedAt), where });
329 extras.set(key, {
330 ...extra,
331 quick: pullAction(repo, pull, { label: "Run the checks again", fields: { action: "recheck" }, done: "Checks started" }),
332 });
333 }
334 }
335 for (const { pull, repo } of reviewRequested) {
336 const key = `review:${pull.id}`;
337 needs.push({
338 key,
339 kind: "review",
340 title: pull.title,
341 detail: `${pull.author.username} asked for your review.`,
342 to: `/${repo.namespace}/${repo.name}/pull/${pull.number}`,
343 action: "Review",
344 at: Date.parse(pull.updatedAt),
345 where: `${repo.namespace}/${repo.name}#${pull.number}`,
346 });
347 extras.set(key, { ...pullExtra(pull, repo, null), quick: approve(repo, pull) });
348 }
349 for (const run of liveRuns) {
350 const minutes = stuckMinutes(run, now);
351 if (minutes == null) continue;
352 const key = `run:${run.id}`;
353 needs.push({
354 key,
355 kind: "stuck",
356 title: run.title ?? `${run.agent}'s run`,
357 detail: `${run.agent} has reported nothing for ${minutes} min${run.step ? `. Last: ${run.step}` : ""}.`,
358 to: `/${run.repo.namespace}/${run.repo.name}/agents/runs/${run.id}`,
359 action: "Look",
360 at: Date.parse(run.updatedAt),
361 where: `${run.repo.namespace}/${run.repo.name}${run.number != null ? `#${run.number}` : ""}`,
362 });
363 extras.set(key, {
364 repo: run.repo,
365 ref: run.number != null ? `#${run.number}` : null,
366 by: who(run.agent),
367 facts: [
368 { label: "Run", value: RUN_LABEL[run.kind], tone: null },
369 { label: "Quiet for", value: `${minutes} min`, tone: "warn" },
370 { label: "Steps so far", value: String(run.stepCount), tone: null },
371 ...(run.costUsd != null ? [{ label: "Cost so far", value: usd(run.costUsd), tone: null }] : []),
372 ],
373 open: run.number != null ? `/${run.repo.namespace}/${run.repo.name}/pull/${run.number}` : undefined,
374 });
375 }
376
377 const needRows: NeedRow[] = rankNeeds(needs).map((need) => {
378 const extra = extras.get(need.key) ?? {};
379 const reason = reasonFor(need);
380 return {
381 key: need.key,
382 reason,
383 repo: extra.repo ?? null,
384 ref: extra.ref ?? null,
385 title: need.title,
386 ask: need.detail,
387 by: extra.by ?? null,
388 for: extra.for ?? null,
389 at: need.at,
390 to: need.to,
391 open: extra.open ?? need.to,
392 facts: extra.facts ?? [],
393 why: whyFor(reason, need),
394 quick: extra.quick ?? null,
395 link: extra.link ?? null,
396 };
397 });
398
399 // --- Waiting on agents ----------------------------------------------------
400 const needKeys = new Set(
401 needRows.flatMap((row) => (row.repo && row.ref ? [needPathKey(row.repo, Number(row.ref.slice(1)))] : [])),
402 );
403 const waiting = waitingRows({
404 active: activeList.filter(({ repo }) => inWorkspace(repo)).map(({ pull, lifecycle, repo }) => ({ pull, lifecycle, repo: pathOf(repo) })),
405 live: liveRuns,
406 drafts: openPulls.filter(({ pull }) => pull.status === "draft").map(({ pull, repo }) => ({ ...pull, repo: pathOf(repo) })),
407 needKeys,
408 });
409
410 // --- Landed ---------------------------------------------------------------
411 const merged: Merged[] = (perRepo ?? []).flatMap(({ repo, closed }) =>
412 (closed ?? []).flatMap((pull) =>
413 pull.status === "merged" && pull.mergedAt
414 ? [{ repo: pathOf(repo), number: pull.number, title: pull.title, agent: pull.agent, mergedBy: pull.mergedBy, mergedAt: pull.mergedAt, files: pull.files }]
415 : [],
416 ),
417 );
418 const twoWeeksAgo = now - 14 * TIME.DAY;
419 const complete = perRepo != null && perRepo.every(({ closed }) => closed != null && reachesBack(closed, twoWeeksAgo, PULL_PAGE));
420 const week = weekOf(merged, now, tz, complete);
421 const landed = landedToday(merged, now, tz);
422
423 // --- Activity -------------------------------------------------------------
424 const items: ActivityItem[] = [];
425 const titles: Record<string, string> = {};
426 const titleKey = (repo: RepoPath, number: number) => `${repo.namespace}/${repo.name}#${number}`.toLowerCase();
427 for (const { repo, pulls, closed, events } of perRepo ?? []) {
428 for (const pull of [...(pulls ?? []), ...(closed ?? [])]) titles[titleKey(repo, pull.number)] = pull.title;
429 for (const event of events ?? []) {
430 if (event.type === "issue.opened") titles[titleKey(repo, event.data.number)] ??= event.data.title;
431 const item = eventItem(event, pathOf(repo));
432 if (item) items.push(item);
433 }
434 }
435 for (const entry of overviewList ?? []) {
436 const latest = entry.latest;
437 const served = latest?.status === "ready" || latest?.status === "replaced";
438 if (!slug || latest?.kind !== "production" || (!served && latest.status !== "failed")) continue;
439 items.push({
440 id: `deploy:${latest.id}`,
441 at: Date.parse(latest.finishedAt ?? latest.createdAt),
442 repo: { namespace: slug, name: entry.slug },
443 actor: latest.createdBy,
444 verb: served ? "deployed" : "deploy_failed",
445 number: null,
446 to: `/${slug}/${entry.slug}/deployments/${latest.id}`,
447 });
448 }
449 for (const memory of okOr(memories ?? null)?.workspace ?? []) {
450 const repo = memory.repo ?? memory.source.repo;
451 if (!repo || !slug) continue;
452 items.push({
453 id: `memory:${memory.id}`,
454 at: Date.parse(memory.createdAt),
455 repo,
456 actor: memory.createdBy,
457 verb: "learned",
458 number: null,
459 text: memory.text.length > 90 ? `${memory.text.slice(0, 88)}…` : memory.text,
460 to: `/${slug}/-/memory`,
461 });
462 }
463 const groups = groupActivity(items).slice(0, 40);
464 // Only the titles the feed names travel to the page.
465 const shownTitles: Record<string, string> = {};
466 for (const group of groups) {
467 for (const part of group.parts) {
468 for (const number of part.numbers) {
469 const key = titleKey(group.repo, number);
470 if (titles[key]) shownTitles[key] = titles[key];
471 }
472 }
473 }
474
475 // --- The strip ------------------------------------------------------------
476 const usageOk = okOr(usage ?? null);
477 const costPoints = usageOk
478 ? usageOk.byDay.map((slice) => ({ at: Date.parse(`${slice.key.split("/")[0]}T12:00:00Z`), value: slice.micros / 1_000_000 }))
479 : runList.filter((run) => run.costUsd != null).map((run) => ({ at: Date.parse(run.createdAt), value: run.costUsd ?? 0 }));
480 const weekCost = dailyBuckets(costPoints, 7, now).reduce((sum, v) => sum + v, 0);
481 const month = dayKey(now, tz).slice(0, 7);
482 const projectCount = projectsOk?.length ?? (perRepo != null ? perRepo.length : null);
483 const projectsThisMonth = projectsOk ? projectsOk.filter((project) => dayKey(Date.parse(project.createdAt), tz).slice(0, 7) === month).length : null;
484
485 const models_ = models ?? null;
486 times.total = Date.now() - started;
487 const serverTiming = Object.entries(times)
488 .map(([name, ms]) => `${name};dur=${ms}`)
489 .join(", ");
490 return data(
491 {
492 signedIn: true as const,
493 viewer,
494 name: profile?.name?.trim() || username,
495 greeting: greetingFor(hourIn(now, tz)),
496 date: dateLine(now, tz),
497 summary: summaryLine({ total: week.total, byAgents: week.byAgents, live: liveRuns.length, needs: needRows.length }),
498 workspace: slug,
499 repos: repoList.filter((repo) => inWorkspace(repo)).map(pathOf),
500 canRunAgents: models_ == null || models_.hosted || models_.own != null,
501 trial: models_?.own == null ? (models_?.trial ?? null) : null,
502 handedOff: activeList.length > 0 || runList.length > 0 || merged.length > 0,
503 needs: needRows,
504 waiting,
505 landed,
506 liveTotal: liveRuns.length,
507 runsLoaded: runs != null && runs.ok,
508 perRepoLoaded: perRepo != null,
509 stats: {
510 projects: projectCount,
511 projectsThisMonth,
512 agentHours: agentHours(runList, weekAgo, now),
513 weekCost,
514 },
515 week,
516 groups,
517 titles: shownTitles,
518 // A run that is going makes the page worth refreshing on its own.
519 changing:
520 liveRuns.length > 0 ||
521 activeList.some((item) => item.lifecycle && item.lifecycle.stage !== "needs_you" && item.lifecycle.stage !== "ready"),
522 },
523 {
524 headers: { "Server-Timing": serverTiming },
525 },
526 );
527}
528
529export type Loaded = Extract<Route.ComponentProps["loaderData"], { signedIn: true }>;
530
531/** Mission control, loaded only for someone signed in (components/mission-control.tsx). */
532const MissionControl = lazy(() => import("../components/mission-control"));
533
534export default function Home({ loaderData }: Route.ComponentProps) {
535 if (!loaderData.signedIn) return <Landing />;
536 return (
537 <Suspense fallback={null}>
538 <MissionControl loaderData={loaderData} />
539 </Suspense>
540 );
541}