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.

g1t.sh
100 linesCodeBlameRaw
1/**
2 * Ask g1t (components/ask-composer): `GET -/ask` says what Ask answers with
3 * in the workspace, or why it cannot yet; `POST -/ask` with
4 * `{ messages, page }` streams one answer back as newline-delimited JSON
5 * (`AskEvent`s), from the integrations service, which calls the AI provider
6 * an owner chose with the workspace's own key. The key never reaches the
7 * browser.
8 */
9import { env } from "cloudflare:workers";
10import { data } from "react-router";
11
12import {
13 ASK_MAX_MESSAGES,
14 ASK_MAX_TOTAL_CHARS,
15 type AskEvent,
16 type AskMessage,
17 type AskPage,
18 checkLimit,
19 integrationsClient,
20} from "@g1t/contracts";
21
22import type { Route } from "./+types/ask";
23import { integrations } from "../../lib/services.server";
24import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server";
25
26/** The most a request may carry: the conversation's cap, with room for JSON. */
27const MAX_BODY_BYTES = ASK_MAX_TOTAL_CHARS * 4 + 16_384;
28
29const NDJSON = { "content-type": "application/x-ndjson; charset=utf-8", "cache-control": "no-store" };
30
31export async function loader({ params, context }: Route.LoaderArgs) {
32 const viewer = getViewer(context);
33 const role = roleIn(viewer, params.owner);
34 if (!role) throw data(null, { status: 404 });
35 const setup = await integrations.askSettings(params.owner.toLowerCase(), viewer);
36 return Response.json({ ...setup, owner: role === "owner" }, { headers: { "cache-control": "no-store" } });
37}
38
39/** An answer that never started: one `error` line, with the status that says why. */
40function refuse(message: string, status: number, headers: Record<string, string> = {}): Response {
41 const event: AskEvent = { type: "error", message };
42 return new Response(`${JSON.stringify(event)}\n`, { status, headers: { ...NDJSON, ...headers } });
43}
44
45function isMessage(value: unknown): value is AskMessage {
46 if (!value || typeof value !== "object") return false;
47 const { role, content } = value as AskMessage;
48 return (role === "user" || role === "assistant") && typeof content === "string";
49}
50
51/** The page as sent, with only what Ask reads, of the shapes it reads. */
52function pageFrom(value: unknown): AskPage {
53 if (!value || typeof value !== "object") return {};
54 const sent = value as Record<string, unknown>;
55 const number = (n: unknown) => (typeof n === "number" && Number.isInteger(n) && n > 0 && n < 2 ** 31 ? n : null);
56 const repo = sent.repo as { namespace?: unknown; name?: unknown } | null | undefined;
57 return {
58 path: typeof sent.path === "string" ? sent.path.slice(0, 500) : null,
59 repo:
60 repo && typeof repo.namespace === "string" && typeof repo.name === "string" && repo.namespace && repo.name
61 ? { namespace: repo.namespace.slice(0, 100), name: repo.name.slice(0, 100) }
62 : null,
63 issue: number(sent.issue),
64 pull: number(sent.pull),
65 };
66}
67
68export async function action({ params, context, request }: Route.ActionArgs) {
69 if (request.method !== "POST") throw data(null, { status: 405 });
70 assertSameOrigin(request);
71 const viewer = requireUser(context, request);
72 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
73 const slug = params.owner.toLowerCase();
74 // Each question spends the workspace's own provider and key: a few a
75 // minute per person is plenty for a conversation.
76 if ((await checkLimit(env.WEB_ASK_LIMIT, `ask:${viewer.id}`)) === "limited") {
77 return refuse("You have asked a lot in the last minute. Wait a moment, then ask again.", 429, { "retry-after": "60" });
78 }
79 const length = Number(request.headers.get("content-length") ?? 0);
80 if (length > MAX_BODY_BYTES) return refuse("The conversation is too long. Start a new one.", 413);
81 const text = await request.text();
82 if (text.length > MAX_BODY_BYTES) return refuse("The conversation is too long. Start a new one.", 413);
83 let sent: { messages?: unknown; page?: unknown };
84 try {
85 sent = JSON.parse(text) as typeof sent;
86 } catch {
87 return refuse("The question did not arrive whole. Ask again.", 400);
88 }
89 const messages = Array.isArray(sent.messages) ? sent.messages.filter(isMessage).slice(-ASK_MAX_MESSAGES) : [];
90 if (messages.length === 0) return refuse("Write a question to ask.", 400);
91 // The binding itself: the timed one (lib/perf.server.ts) reads the whole
92 // answer before handing it on, and this one streams. The person closing
93 // the tab or pressing Stop ends the provider's answer too.
94 const answer = await integrationsClient(env.INTEGRATIONS)
95 .ask(viewer, slug, messages, pageFrom(sent.page), request.signal)
96 .catch(() => null);
97 if (!answer) return refuse("Ask could not reach g1t's integrations. Try again in a moment.", 502);
98 if (!answer.ok || !answer.body) return refuse("Ask could not reach g1t's integrations. Try again in a moment.", 502);
99 return new Response(answer.body, { headers: NDJSON });
100}