| 1 | import { env } from "cloudflare:workers"; |
| 2 | import { data, redirect } from "react-router"; |
| 3 | |
| 4 | import type { Route } from "./+types/home"; |
| 5 | import { page } from "../lib/meta"; |
| 6 | import { WORKSPACE_COOKIE, chosenWorkspace } from "../lib/workspace-choice"; |
| 7 | import { readCookie } from "../lib/mission"; |
| 8 | import { type NotStarted, chosenRepo, delegateForm, issuePath, notStarted } from "../lib/delegate"; |
| 9 | import { Landing } from "../components/landing"; |
| 10 | import { repos as reposApi } from "../lib/services.server"; |
| 11 | import { assertSameOrigin, getViewer, requireUser } from "../lib/session.server"; |
| 12 | import { homePath } from "../lib/workspace-nav"; |
| 13 | |
| 14 | export function meta(args: Route.MetaArgs) { |
| 15 | return page(args, { |
| 16 | title: "g1t · Your team and its agents, working in one place", |
| 17 | description: |
| 18 | "Chat with your team and your agents in channels and DMs. Agents are teammates with a role, a personality and a budget, and their code lands through pull requests, checks and a merge queue. Chat is free on every plan, never per seat.", |
| 19 | }); |
| 20 | } |
| 21 | |
| 22 | /** |
| 23 | * g1t.sh: the landing page for a visitor. Someone signed in has a Home in |
| 24 | * their workspace (routes/workspace/home.tsx); Mission control's code |
| 25 | * panels are Code's Overview. `/?agent=new` and the rest of the query go |
| 26 | * with them. |
| 27 | */ |
| 28 | export async function loader({ context, request }: Route.LoaderArgs) { |
| 29 | const viewer = getViewer(context); |
| 30 | if (!viewer) return data({ signedIn: false as const }); |
| 31 | const url = new URL(request.url); |
| 32 | const slug = chosenWorkspace(viewer.workspaces ?? [], readCookie(request.headers.get("cookie"), WORKSPACE_COOKIE))?.slug ?? null; |
| 33 | // A workspace-less account is sent to make one by the session (lib/session.server.ts). |
| 34 | if (!slug) throw redirect("/workspaces/new"); |
| 35 | throw redirect(homePath(slug, url.search)); |
| 36 | } |
| 37 | |
| 38 | /** What the composer said back, when the issue opened but the agent did not start, or nothing opened. */ |
| 39 | export type DelegateResult = { error: string; notStarted: null } | { error: null; notStarted: NotStarted }; |
| 40 | |
| 41 | /** |
| 42 | * "Put an agent on it": opens an issue in one of the viewer's projects and |
| 43 | * puts g1t on it, then lands on the issue. When the agent could not |
| 44 | * start, the issue is still open, and the composer says why and where to |
| 45 | * fix it. |
| 46 | */ |
| 47 | export async function action({ request, context }: Route.ActionArgs) { |
| 48 | assertSameOrigin(request); |
| 49 | const user = requireUser(context, request); |
| 50 | const form = await request.formData(); |
| 51 | if (form.get("intent") !== "delegate") return data<DelegateResult>({ error: "Nothing to do.", notStarted: null }, { status: 400 }); |
| 52 | const projects = await reposApi.list(user, { memberOnly: true }).catch(() => []); |
| 53 | const repo = chosenRepo(form.get("repo"), projects.map((repo) => ({ namespace: repo.namespace, name: repo.name }))); |
| 54 | if (!repo) return data<DelegateResult>({ error: "Choose one of your projects.", notStarted: null }, { status: 400 }); |
| 55 | const result = await env.RUNNER.delegate(user, repo, delegateForm(form)); |
| 56 | if (!result.ok) return data<DelegateResult>({ error: result.error.message, notStarted: null }, { status: 400 }); |
| 57 | const { issue, agent } = result.value; |
| 58 | const refused = notStarted(agent, repo, issue.number); |
| 59 | if (!refused) throw redirect(issuePath(repo, issue.number)); |
| 60 | return data<DelegateResult>({ error: null, notStarted: refused }); |
| 61 | } |
| 62 | |
| 63 | |
| 64 | export default function Home() { |
| 65 | return <Landing />; |
| 66 | } |