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.

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