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.

126 linesCodeBlameRaw
1/**
2 * Ask g1t (components/ask-composer): `GET -/ask` says what Ask answers with
3 * in the workspace, or why it cannot yet, and whether the viewer is an
4 * owner who can choose; with `?sessions`, whether sessions can start on the
5 * workspace's Anthropic account. The conversation itself is the viewer's
6 * direct message with @g1t in chat (`-/chat/api`, intents `g1t_dm`, `post`,
7 * `g1t_stop`, `g1t_reset`, `g1t_retry`, `g1t_clear`): the chat service has g1t answer
8 * there, with the provider an owner chose, on the workspace's own key.
9 *
10 * `POST -/ask` starts a session g1t proposed in an answer (the proposal
11 * card in components/ask-steps): the person's click is the confirmation.
12 * `handoff` asks for the Claude Code command instead of starting one on
13 * the workspace's Anthropic account. A proposal with `new_repository`
14 * first creates the repository as the person (its project described with
15 * the issue's title), turns on its deployments when `deploy`, and tells
16 * the session to push the first version to the default branch, which
17 * deploys it. Sent again (a retry, or Copy after Start), a repository
18 * already made is used as it is.
19 */
20import { data } from "react-router";
21
22import { AGENT_MODES, type User } from "@g1t/contracts";
23
24import type { Route } from "./+types/ask";
25import { agentSessions, ai, deployments, projects, repos } from "../../lib/services.server";
26import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server";
27
28export async function loader({ params, context, request }: Route.LoaderArgs) {
29 const viewer = getViewer(context);
30 const role = roleIn(viewer, params.owner);
31 if (!role) throw data(null, { status: 404 });
32 const workspace = params.owner.toLowerCase();
33 const headers = { "cache-control": "no-store" };
34 if (new URL(request.url).searchParams.has("sessions")) {
35 const setup = await agentSessions.setup(workspace, viewer).catch(() => null);
36 return Response.json({ available: Boolean(setup?.ok && setup.value.available), model: setup?.ok ? setup.value.model : null }, { headers });
37 }
38 const setup = await ai.askSettings(workspace, viewer);
39 return Response.json({ ...setup, owner: role === "owner" }, { headers });
40}
41
42export async function action({ request, params, context }: Route.ActionArgs) {
43 assertSameOrigin(request);
44 const user = requireUser(context, request);
45 if (!roleIn(getViewer(context), params.owner)) throw data(null, { status: 404 });
46 const input = (await request.json().catch(() => null)) as Record<string, unknown> | null;
47 const [namespace, name, extra] = String(input?.repo ?? "").split("/");
48 // A proposal is for a repository in this workspace only.
49 if (!namespace || !name || extra !== undefined || namespace.toLowerCase() !== params.owner.toLowerCase()) {
50 return Response.json({ error: "That repository isn't in this workspace." }, { status: 400 });
51 }
52 const text = (key: string) => (typeof input?.[key] === "string" && (input[key] as string).trim()) || undefined;
53 const issue = Number.isInteger(input?.issue) && (input?.issue as number) > 0 ? (input?.issue as number) : undefined;
54 const title = issue ? undefined : text("title");
55 if (!issue && !title) return Response.json({ error: "The proposal names no issue." }, { status: 400 });
56 const mode = AGENT_MODES.find((one) => one.mode === input?.mode)?.mode ?? "implement";
57 let instruction = text("instruction");
58 let built: Built | null = null;
59 if (input?.new_repository === true) {
60 const made = await newRepository(user, namespace, name, { title: title ?? name, isPrivate: input.private === true, deploy: input.deploy !== false });
61 if ("error" in made) return Response.json({ error: made.error }, { status: 400 });
62 built = made;
63 instruction = [firstPush(made), instruction].filter(Boolean).join("\n\n");
64 }
65 const result = await agentSessions
66 .start(user, { namespace, name }, { issue, title, body: issue ? undefined : text("body"), instruction, mode, handoff: input?.handoff === true })
67 .catch(() => null);
68 if (!result) return Response.json({ error: "The session could not be started. Try again in a moment.", ...built }, { status: 502 });
69 if (!result.ok) return Response.json({ error: result.error.message, ...built }, { status: 400 });
70 const started = result.value;
71 return Response.json({
72 issue: started.issue,
73 session_url: started.sessionUrl ?? (started.session ? `/${namespace}/${name}/work/sessions/${started.session.id}` : null),
74 provider_session_url: started.providerSessionUrl,
75 handoff: started.handoff,
76 command: started.command,
77 prompt: started.prompt,
78 ...built,
79 });
80}
81
82/** What the card made before the session: the repository, and where it deploys or why it can't. */
83type Built = { repo_url: string; default_branch: string; production_url: string | null; deploy_error: string | null };
84
85/**
86 * The repository a new-repository proposal names, made as the person, or
87 * the one already made by an earlier click; its project described; its
88 * deployments on when asked. A deploy that can't be turned on (no plan, no
89 * Admin role) is said, not fatal: the session still builds it.
90 */
91async function newRepository(
92 user: User,
93 namespace: string,
94 name: string,
95 wanted: { title: string; isPrivate: boolean; deploy: boolean },
96): Promise<Built | { error: string }> {
97 const created = await repos.create(user, { namespace, name, isPrivate: wanted.isPrivate }).catch(() => null);
98 let repo = created?.ok ? created.value : null;
99 if (!repo) {
100 // Already made: a second click, or Copy after Start. Anything else is the reason it wasn't.
101 const found = await repos.get({ namespace, name }, user).catch(() => null);
102 if (!found?.ok) return { error: created && !created.ok ? created.error.message : "The repository could not be created. Try again in a moment." };
103 repo = found.value;
104 } else {
105 const described = await projects.describe(user, repo.id, wanted.title).catch((error: unknown) => String(error));
106 if (typeof described === "string" || !described.ok) console.error("ask: new repository without its description", repo.id, described);
107 }
108 const built: Built = { repo_url: `/${repo.namespace}/${repo.name}`, default_branch: repo.defaultBranch, production_url: null, deploy_error: null };
109 if (!wanted.deploy) return built;
110 const saved = await deployments.updateSettings(user, { workspace: repo.namespace, slug: repo.name }, { enabled: true }).catch(() => null);
111 if (saved?.ok) built.production_url = saved.value.productionUrl;
112 else built.deploy_error = saved ? saved.error.message : "Deployments could not be turned on. Try again from the project's Settings → Deployments.";
113 return built;
114}
115
116/** What the session is told about a repository the card just made. */
117function firstPush(built: Built): string {
118 const live = built.production_url
119 ? `Its deployments are on: that push deploys production to ${built.production_url}. Watch it with \`g1t deploy list\` until it is live; if it fails, read why (\`g1t deploy view ID\`), fix it and push again. End your report with the address.`
120 : "Its deployments are off, so nothing deploys yet; say so in your report.";
121 return (
122 `The repository was just created for this and is empty. Build the first working version and push it straight to the default branch ` +
123 `(\`git push origin HEAD:${built.default_branch}\`): there is nothing to open a pull request against yet, so open none. ` +
124 `Keep it small and make it build and serve from the repository's root. Put \`Closes #\` and the issue's number in the commit message, so the push closes it. ${live}`
125 );
126}