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.

181 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 AskProposalState, type User } from "@g1t/contracts";
23
24import type { Route } from "./+types/ask";
25import { agentSessions, ai, chat, 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 handoff = input?.handoff === true;
54 // What the card did is kept on the answer that proposed it, step by step, so
55 // the card shows it wherever the answer is read and another click carries on.
56 const kept = keeper(user, params.owner, input);
57 const before = await kept.save({});
58 if (before?.started && !handoff) {
59 return Response.json({ ...before, handoff: false, command: null, prompt: "" });
60 }
61 if (!handoff && before?.starting_at && Date.now() - before.starting_at < STARTING_MS) {
62 return Response.json({ error: "It's already starting. This card shows it in a moment.", ...before }, { status: 409 });
63 }
64 const asked = Number.isInteger(input?.issue) && (input?.issue as number) > 0 ? (input?.issue as number) : undefined;
65 // An issue a click already opened is worked on, not opened again.
66 const issue = asked ?? before?.issue;
67 const title = issue ? undefined : text("title");
68 if (!issue && !title) return Response.json({ error: "The proposal names no issue." }, { status: 400 });
69 const mode = AGENT_MODES.find((one) => one.mode === input?.mode)?.mode ?? "implement";
70 if (!handoff) await kept.save({ starting_at: Date.now() });
71 let instruction = text("instruction");
72 let built: Built | null = null;
73 if (input?.new_repository === true) {
74 const made = before?.repo_url && before.default_branch
75 ? { repo_url: before.repo_url, default_branch: before.default_branch, production_url: before.production_url ?? null, deploy_error: before.deploy_error ?? null }
76 : await newRepository(user, namespace, name, { title: (title ?? text("title")) || name, isPrivate: input.private === true, deploy: input.deploy !== false });
77 if ("error" in made) {
78 await kept.save({ starting_at: 0 });
79 return Response.json({ error: made.error }, { status: 400 });
80 }
81 built = made;
82 await kept.save(made);
83 instruction = [firstPush(made), instruction].filter(Boolean).join("\n\n");
84 }
85 const result = await agentSessions
86 .start(user, { namespace, name }, { issue, title, body: issue ? undefined : text("body"), instruction, mode, handoff })
87 .catch(() => null);
88 if (!result?.ok) {
89 if (!handoff) await kept.save({ starting_at: 0 });
90 if (!result) return Response.json({ error: "The session could not be started. Try again in a moment.", ...built }, { status: 502 });
91 return Response.json({ error: result.error.message, ...built }, { status: 400 });
92 }
93 const started = result.value;
94 const session_url = started.sessionUrl ?? (started.session ? `/${namespace}/${name}/work/sessions/${started.session.id}` : null);
95 await kept.save(
96 started.handoff
97 ? { issue: started.issue }
98 : { issue: started.issue, started: true, session_url, provider_session_url: started.providerSessionUrl ?? null },
99 );
100 return Response.json({
101 issue: started.issue,
102 session_url,
103 provider_session_url: started.providerSessionUrl,
104 handoff: started.handoff,
105 command: started.command,
106 prompt: started.prompt,
107 ...built,
108 });
109}
110
111/** How long a click that began starting a proposal holds off another. */
112const STARTING_MS = 90_000;
113
114/**
115 * Keeps what a proposal's card did on the @g1t answer it came in
116 * (`message_id` in `channel_id`, the viewer's direct message with g1t),
117 * through chat's `g1t_proposal`; `save({})` only reads it. Without a
118 * message, or when chat can't be reached, nothing is kept and null is said:
119 * the card still works, it just won't remember.
120 */
121function keeper(user: User, workspace: string, input: Record<string, unknown> | null) {
122 const id = typeof input?.id === "string" ? input.id : "";
123 const channel = typeof input?.channel_id === "string" ? input.channel_id : "";
124 const message = typeof input?.message_id === "string" ? input.message_id : "";
125 return {
126 async save(change: Omit<AskProposalState, "id">): Promise<AskProposalState | null> {
127 if (!id || !channel || !message) return null;
128 const kept = await chat.g1tProposal(workspace.toLowerCase(), channel, user, message, { ...change, id }).catch((error: unknown) => {
129 console.error("ask: proposal state not kept", id, error);
130 return null;
131 });
132 return kept?.ok ? kept.value : null;
133 },
134 };
135}
136
137/** What the card made before the session: the repository, and where it deploys or why it can't. */
138type Built = { repo_url: string; default_branch: string; production_url: string | null; deploy_error: string | null };
139
140/**
141 * The repository a new-repository proposal names, made as the person, or
142 * the one already made by an earlier click; its project described; its
143 * deployments on when asked. A deploy that can't be turned on (no plan, no
144 * Admin role) is said, not fatal: the session still builds it.
145 */
146async function newRepository(
147 user: User,
148 namespace: string,
149 name: string,
150 wanted: { title: string; isPrivate: boolean; deploy: boolean },
151): Promise<Built | { error: string }> {
152 const created = await repos.create(user, { namespace, name, isPrivate: wanted.isPrivate }).catch(() => null);
153 let repo = created?.ok ? created.value : null;
154 if (!repo) {
155 // Already made: a second click, or Copy after Start. Anything else is the reason it wasn't.
156 const found = await repos.get({ namespace, name }, user).catch(() => null);
157 if (!found?.ok) return { error: created && !created.ok ? created.error.message : "The repository could not be created. Try again in a moment." };
158 repo = found.value;
159 } else {
160 const described = await projects.describe(user, repo.id, wanted.title).catch((error: unknown) => String(error));
161 if (typeof described === "string" || !described.ok) console.error("ask: new repository without its description", repo.id, described);
162 }
163 const built: Built = { repo_url: `/${repo.namespace}/${repo.name}`, default_branch: repo.defaultBranch, production_url: null, deploy_error: null };
164 if (!wanted.deploy) return built;
165 const saved = await deployments.updateSettings(user, { workspace: repo.namespace, slug: repo.name }, { enabled: true }).catch(() => null);
166 if (saved?.ok) built.production_url = saved.value.productionUrl;
167 else built.deploy_error = saved ? saved.error.message : "Deployments could not be turned on. Try again from the project's Settings → Deployments.";
168 return built;
169}
170
171/** What the session is told about a repository the card just made. */
172function firstPush(built: Built): string {
173 const live = built.production_url
174 ? `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.`
175 : "Its deployments are off, so nothing deploys yet; say so in your report.";
176 return (
177 `The repository was just created for this and is empty. Build the first working version and push it straight to the default branch ` +
178 `(\`git push origin HEAD:${built.default_branch}\`): there is nothing to open a pull request against yet, so open none. ` +
179 `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}`
180 );
181}