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.

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