flagon-io/g1t

public

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/apps/web/app/routes/workspace/integrations.tsx

690 lines26,940 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Integrations: your own model provider, alerts that open issues, tickets agents read1import {
2 AlertTriangle,
3 CheckCircle2,
4 ChevronRight,
5 Cpu,
6 Siren,
7 Ticket,
8 X,
9} from "lucide-react";
Models per workspace: several providers, routed by kind of work10import { env } from "cloudflare:workers";
Integrations: your own model provider, alerts that open issues, tickets agents read11import type { ReactNode } from "react";
12import { Form, Link, useNavigation } from "react-router";
13
14import {
15 type Connection,
16 type ConnectionConfig,
17 type Delivery,
Models per workspace: several providers, routed by kind of work18 MODEL_TASKS,
19 type ModelRoute,
Integrations: your own model provider, alerts that open issues, tickets agents read20 type Provider,
21 type ProviderKind,
22 PROVIDERS,
23} from "@g1t/contracts";
24
25import type { Route } from "./+types/integrations";
Social cards for every page: og.g1t.sh26import { page } from "../../lib/meta";
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put27import { trialClosed } from "../../lib/trial";
A catalogue of model providers, and settings that feel like settings28import { MODEL_CATALOG, ModelCatalog, ModelProviderFields, ProviderMark, ProviderTiles, Routing } from "../../components/model-providers";
Workspace names and icons, and a component kit for every control29import { Avatar, Button, CopyLine, ErrorText, Field, Input, TimeAgo } from "../../components/ui";
30import { CheckboxOption } from "../../components/ui/checkbox";
31import { Combobox } from "../../components/ui/combobox";
Integrations: your own model provider, alerts that open issues, tickets agents read32import { billing, integrations, repos } from "../../lib/services.server";
33import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
34
Social cards for every page: og.g1t.sh35export function meta({ params, ...args }: Route.MetaArgs) {
36 return page(args, { title: `Integrations · ${params.owner} · g1t` });
Integrations: your own model provider, alerts that open issues, tickets agents read37}
38
39const isProvider = (value: string | null): value is Provider => value != null && value in PROVIDERS;
40
41export async function loader({ params, context, request }: Route.LoaderArgs) {
42 const viewer = getViewer(context);
43 const role = roleIn(viewer, params.owner);
44 if (!role) throw new Response(null, { status: 404 });
45 const slug = params.owner.toLowerCase();
Models per workspace: several providers, routed by kind of work46 const [connections, listed, account, access, routes] = await Promise.all([
Integrations: your own model provider, alerts that open issues, tickets agents read47 integrations.list(slug, viewer),
48 repos.list(viewer, { namespace: slug }),
49 billing.account(slug, viewer),
Models per workspace: several providers, routed by kind of work50 env.RUNNER.modelAccess(slug),
51 integrations.routes(slug, viewer),
Integrations: your own model provider, alerts that open issues, tickets agents read52 ]);
53 const all = unwrap(connections);
54 // What each alert source has sent lately, to see it is wired up.
55 const deliveries: Record<string, Delivery[]> = {};
56 await Promise.all(
57 all
58 .filter((connection) => connection.kind === "alerts")
59 .map(async (connection) => {
60 const found = await integrations.deliveries(slug, viewer, connection.id);
61 deliveries[connection.id] = found.ok ? found.value.slice(0, 5) : [];
62 }),
63 );
64 const adding = new URL(request.url).searchParams.get("add");
65 return {
66 slug,
67 role,
68 connections: all,
69 deliveries,
70 repos: listed.map((repo) => `${repo.namespace}/${repo.name}`),
71 adding: isProvider(adding) ? adding : null,
Models per workspace: several providers, routed by kind of work72 hostedOpen: access.hosted,
A free allowance on g1t's models, so anyone can try its agents73 trial: access.trial,
Models per workspace: several providers, routed by kind of work74 routes: routes.ok ? routes.value : [],
New workspaces are told to connect a model, before anything fails75 free: account.ok ? Boolean(account.value.status.free) : false,
Integrations: your own model provider, alerts that open issues, tickets agents read76 marginPercent: account.ok ? account.value.marginPercent : 20,
77 };
78}
79
80function text(form: FormData, name: string): string | undefined {
81 const value = String(form.get(name) ?? "").trim();
82 return value || undefined;
83}
84
85/** The settings a form describes, for the provider it is for. */
86function configFrom(form: FormData): ConnectionConfig {
87 const keys = text(form, "keys");
88 return {
89 repo: text(form, "repo"),
90 assign: form.get("assign") === "on",
91 label: text(form, "label"),
92 writeBack: form.get("writeBack") !== "off",
93 organization: text(form, "organization"),
94 site: text(form, "site"),
95 email: text(form, "email"),
96 keys: keys ? keys.split(/[\s,]+/).filter(Boolean) : [],
97 baseUrl: text(form, "baseUrl"),
98 authHeader: text(form, "authHeader"),
99 model: text(form, "model"),
100 };
101}
102
103export async function action({ request, params, context }: Route.ActionArgs) {
104 assertSameOrigin(request);
105 const user = requireUser(context, request);
106 const slug = params.owner.toLowerCase();
107 const form = await request.formData();
108 const intent = form.get("intent");
109 const id = String(form.get("id") ?? "");
110 if (intent === "disconnect") {
111 const removed = await integrations.disconnect(user, slug, id);
112 return { error: removed.ok ? null : removed.error.message };
113 }
114 if (intent === "test") {
115 const tested = await integrations.test(user, slug, id);
116 return tested.ok ? { tested: { id, ...tested.value } } : { error: tested.error.message };
117 }
Models per workspace: several providers, routed by kind of work118 if (intent === "routes") {
119 const routes: ModelRoute[] = [];
120 for (const task of MODEL_TASKS) {
121 const choice = String(form.get(`route-${task}`) ?? "");
122 if (!choice) continue;
123 if (choice === "g1t") routes.push({ task, connectionId: null, model: null });
124 else {
125 const [connectionId, model] = choice.split("::");
126 routes.push({ task, connectionId, model: model || null });
127 }
128 }
129 const saved = await integrations.setRoutes(user, slug, routes);
130 return saved.ok ? { routed: true } : { error: saved.error.message };
131 }
Integrations: your own model provider, alerts that open issues, tickets agents read132 if (intent === "update") {
133 const updated = await integrations.update(user, slug, id, {
134 signingSecret: text(form, "signingSecret"),
135 secret: text(form, "secret"),
136 });
137 return { error: updated.ok ? null : updated.error.message };
138 }
139 const provider = String(form.get("provider") ?? "");
140 if (!isProvider(provider)) return { error: "Choose what to connect." };
141 const connected = await integrations.connect(user, slug, {
142 provider,
143 name: text(form, "name"),
144 config: configFrom(form),
145 secret: text(form, "secret"),
146 signingSecret: text(form, "signingSecret"),
147 });
148 return connected.ok ? { connected: connected.value } : { error: connected.error.message, provider };
149}
150
151const KIND_INFO: Record<ProviderKind, { title: string; icon: ReactNode; blurb: string }> = {
152 models: {
Model providers: gateway tokens for endpoints, tidier rows, and the docs153 title: "Model providers",
Integrations: your own model provider, alerts that open issues, tickets agents read154 icon: <Cpu size={16} />,
Model providers: gateway tokens for endpoints, tidier rows, and the docs155 blurb: "Connect as many as you use, then choose which model does which work, and so who pays for it.",
Integrations: your own model provider, alerts that open issues, tickets agents read156 },
157 alerts: {
158 title: "Alerts",
159 icon: <Siren size={16} />,
160 blurb: "Problems your monitoring finds become issues, and agents can start on them before anyone looks.",
161 },
162 tracker: {
163 title: "Trackers",
164 icon: <Ticket size={16} />,
165 blurb: "Tickets agents can read when work mentions them, that people can import as issues, and that hear back when the work lands.",
166 },
167};
168
A catalogue of model providers, and settings that feel like settings169const PROVIDER_BLURB: Partial<Record<Provider, string>> = {
Integrations: your own model provider, alerts that open issues, tickets agents read170 sentry: "New errors open issues, with the stack trace. Resolved in Sentry when the fix merges.",
171 datadog: "Monitors that trigger open issues. Recoveries are noted on them.",
172 webhook: "Anything that can send signed JSON: PagerDuty, Grafana, your own scripts.",
173 jira: "Agents read TECH-1234 when work mentions it. Import tickets; they hear back.",
174 linear: "Agents read ENG-42 when work mentions it. Import issues; they hear back.",
175};
176
177function dollars(micros: number): string {
178 return `$${(micros / 1_000_000).toFixed(2)}`;
179}
180
181export default function WorkspaceIntegrations({ loaderData, actionData }: Route.ComponentProps) {
Prices are what g1t pays plus 20%, from the first second182 const { slug, role, connections, deliveries, repos: repoNames, adding, free, marginPercent, hostedOpen, trial, routes } =
Models per workspace: several providers, routed by kind of work183 loaderData;
Integrations: your own model provider, alerts that open issues, tickets agents read184 const owner = role === "owner";
185 const busy = useNavigation().state === "submitting";
Models per workspace: several providers, routed by kind of work186 const modelConnections = connections.filter((connection) => connection.kind === "models");
Integrations: your own model provider, alerts that open issues, tickets agents read187 const justConnected = actionData && "connected" in actionData ? actionData.connected : null;
188 const tested = (actionData && "tested" in actionData ? actionData.tested : null) ?? null;
189 const error = (actionData && "error" in actionData ? actionData.error : null) ?? null;
190
191 return (
A catalogue of model providers, and settings that feel like settings192 <div>
Integrations: your own model provider, alerts that open issues, tickets agents read193 {justConnected && <Connected connected={justConnected} />}
194 <div className="mt-4">
195 <ErrorText>{error}</ErrorText>
196 </div>
197
198 {/* Models -------------------------------------------------------------- */}
A catalogue of model providers, and settings that feel like settings199 <Section kind="models" first>
Models per workspace: several providers, routed by kind of work200 {!hostedOpen && modelConnections.length === 0 && (
201 <div className="mb-4 flex items-center gap-3 rounded-xl border border-warn/30 bg-warn/5 p-4">
202 <span className="inline-flex size-8 shrink-0 items-center justify-center rounded-lg bg-warn/10 text-warn ring-1 ring-warn/30">
203 <AlertTriangle size={16} />
204 </span>
205 <p className="text-sm text-muted">
A free allowance on g1t's models, so anyone can try its agents206 <span className="font-medium text-fg">Choose how your agents reach a model.</span>{" "}
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put207 {trialClosed(trial, slug) ?? `g1t's hosted models are not open to ${slug}.`}{" "}
A free allowance on g1t's models, so anyone can try its agents208 Connect a provider of your own below and your agents start at once, billed by that provider.
Models per workspace: several providers, routed by kind of work209 </p>
210 </div>
211 )}
212 <Connections list={modelConnections} owner={owner} busy={busy} tested={tested} deliveries={deliveries} />
A catalogue of model providers, and settings that feel like settings213 {modelConnections.length === 0 && hostedOpen && (
214 <p className="flex items-center gap-2 rounded-xl border border-line bg-surface px-4 py-3 text-sm text-muted">
215 <CheckCircle2 size={15} className="shrink-0 text-merged" />
A free allowance on g1t's models, so anyone can try its agents216 {trial?.open
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put217 ? trial.granted
218 ? `All work runs on g1t's models, paid by ${slug}'s trial credit first: ${dollars(Math.max(0, trial.limitMicros - trial.usedMicros))} of ${dollars(trial.limitMicros)} left. Connect a provider of your own for more, or to choose models.`
219 : `All work runs on g1t's models. ${slug} gets ${dollars(trial.limitMicros)} of trial credit the first time its agents work. Connect a provider of your own for more, or to choose models.`
A free allowance on g1t's models, so anyone can try its agents220 : free
221 ? "All work runs on g1t's models, free while g1t is being built out. Connect a provider of your own to choose models and pay for them there."
222 : `All work runs on g1t's models, charged to your credit at cost plus ${marginPercent}%. Connect a provider of your own to choose models and pay for them there.`}
A catalogue of model providers, and settings that feel like settings223 </p>
224 )}
225 {modelConnections.length > 0 && (
Models per workspace: several providers, routed by kind of work226 <Routing
227 connections={modelConnections}
228 routes={routes}
229 hostedOpen={hostedOpen}
230 marginPercent={marginPercent}
231 owner={owner}
232 busy={busy}
233 saved={actionData != null && "routed" in actionData}
234 />
A catalogue of model providers, and settings that feel like settings235 )}
Integrations: your own model provider, alerts that open issues, tickets agents read236 <p className="mt-3 text-xs text-faint">
New workspaces are told to connect a model, before anything fails237 On your own providers, their bills are yours.{" "}
238 {free
Prices are what g1t pays plus 20%, from the first second239 ? `g1t charges nothing while it is being built out; once pricing starts, only each run's sandbox time, at cost plus ${marginPercent}%.`
240 : `g1t charges only each run's sandbox time, at cost plus ${marginPercent}%.`}{" "}
New workspaces are told to connect a model, before anything fails241 Keys go only from g1t's model proxy to the provider: the agent's
Models per workspace: several providers, routed by kind of work242 sandbox holds a token that dies with the run.
Integrations: your own model provider, alerts that open issues, tickets agents read243 </p>
A catalogue of model providers, and settings that feel like settings244 {owner && <ModelCatalog slug={slug} adding={adding} />}
Integrations: connect forms open under the tile that was chosen245 {adding && PROVIDERS[adding].kind === "models" && owner && (
246 <AddForm
247 provider={adding}
248 slug={slug}
249 repos={repoNames}
250 busy={busy}
251 error={actionData && "provider" in actionData ? error : null}
252 />
253 )}
Integrations: your own model provider, alerts that open issues, tickets agents read254 </Section>
255
256 {/* Alerts -------------------------------------------------------------- */}
257 <Section kind="alerts">
258 <Connections
259 list={connections.filter((c) => c.kind === "alerts")}
260 owner={owner}
261 busy={busy}
262 tested={tested}
263 deliveries={deliveries}
264 />
265 {owner && <Choices kind="alerts" slug={slug} adding={adding} />}
Integrations: connect forms open under the tile that was chosen266 {adding && PROVIDERS[adding].kind === "alerts" && owner && (
267 <AddForm
268 provider={adding}
269 slug={slug}
270 repos={repoNames}
271 busy={busy}
272 error={actionData && "provider" in actionData ? error : null}
273 />
274 )}
Integrations: your own model provider, alerts that open issues, tickets agents read275 </Section>
276
277 {/* Trackers ------------------------------------------------------------ */}
278 <Section kind="tracker">
279 <Connections
280 list={connections.filter((c) => c.kind === "tracker")}
281 owner={owner}
282 busy={busy}
283 tested={tested}
284 deliveries={deliveries}
285 />
286 {owner && <Choices kind="tracker" slug={slug} adding={adding} />}
Integrations: connect forms open under the tile that was chosen287 {adding && PROVIDERS[adding].kind === "tracker" && owner && (
288 <AddForm
289 provider={adding}
290 slug={slug}
291 repos={repoNames}
292 busy={busy}
293 error={actionData && "provider" in actionData ? error : null}
294 />
295 )}
Integrations: your own model provider, alerts that open issues, tickets agents read296 </Section>
297
298 {!owner && <p className="mt-8 text-sm text-muted">An owner of {slug} can add and remove integrations.</p>}
299 </div>
300 );
Models per workspace: several providers, routed by kind of work301}
302
A catalogue of model providers, and settings that feel like settings303function Section({ kind, first, children }: { kind: ProviderKind; first?: boolean; children: ReactNode }) {
Integrations: your own model provider, alerts that open issues, tickets agents read304 const info = KIND_INFO[kind];
305 return (
A catalogue of model providers, and settings that feel like settings306 <section className={first ? "" : "mt-12"}>
Integrations: your own model provider, alerts that open issues, tickets agents read307 <h3 className="flex items-center gap-2 text-sm font-medium">
308 <span className="text-muted">{info.icon}</span>
309 {info.title}
310 </h3>
311 <p className="mt-1 mb-4 text-sm text-muted">{info.blurb}</p>
312 {children}
313 </section>
314 );
315}
316
317function Connections({
318 list,
319 owner,
320 busy,
321 tested,
322 deliveries,
323}: {
324 list: Connection[];
325 owner: boolean;
326 busy: boolean;
327 tested: { id: string; ok: boolean; message: string } | null;
328 deliveries: Record<string, Delivery[]>;
329}) {
330 if (list.length === 0) return null;
331 return (
332 <ul className="mb-4 divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
333 {list.map((connection) => (
334 <li key={connection.id} className="p-4">
335 <ConnectionRow
336 connection={connection}
337 owner={owner}
338 busy={busy}
339 tested={tested}
340 deliveries={deliveries[connection.id] ?? []}
341 />
342 </li>
343 ))}
344 </ul>
345 );
346}
347
Model providers: gateway tokens for endpoints, tidier rows, and the docs348/** Just the host of an address, which is what tells connections apart. */
349function host(url: string | undefined): string | undefined {
350 if (!url) return undefined;
351 try {
352 return new URL(url).host;
353 } catch {
354 return url;
355 }
356}
357
Integrations: your own model provider, alerts that open issues, tickets agents read358function ConnectionRow({
359 connection,
360 owner,
361 busy,
362 tested,
363 deliveries,
364}: {
365 connection: Connection;
366 owner: boolean;
367 busy: boolean;
368 tested: { id: string; ok: boolean; message: string } | null;
369 deliveries: Delivery[];
370}) {
371 const { config } = connection;
372 const facts = [
373 PROVIDERS[connection.provider].label,
374 config.repo && `issues in ${config.repo}`,
375 config.assign && "agents start at once",
376 config.organization,
Model providers: gateway tokens for endpoints, tidier rows, and the docs377 host(config.site),
378 host(config.baseUrl),
Models per workspace: several providers, routed by kind of work379 config.model && `default ${config.model}`,
380 connection.models.length > 0 && `${connection.models.length} models`,
Integrations: your own model provider, alerts that open issues, tickets agents read381 config.keys?.length ? config.keys.join(", ") : null,
382 connection.secretHint && `key ${connection.secretHint}`,
383 ].filter(Boolean);
384 const waitingForSecret = connection.provider === "sentry" && !deliveries.length && !connection.lastUsedAt;
385 return (
386 <div>
Model providers: gateway tokens for endpoints, tidier rows, and the docs387 <div className="flex items-center gap-3">
Integrations: your own model provider, alerts that open issues, tickets agents read388 <ProviderMark provider={connection.provider} />
389 <div className="min-w-0 grow">
390 <p className="truncate text-sm font-medium">{connection.name}</p>
391 <p className="truncate text-xs text-muted">{facts.join(" · ")}</p>
392 </div>
393 {connection.lastUsedAt && (
Model providers: gateway tokens for endpoints, tidier rows, and the docs394 <span className="hidden shrink-0 text-xs text-faint sm:inline">
Integrations: your own model provider, alerts that open issues, tickets agents read395 Used <TimeAgo at={connection.lastUsedAt} />
396 </span>
397 )}
398 {owner && (
Model providers: gateway tokens for endpoints, tidier rows, and the docs399 <Form method="post" className="flex shrink-0 gap-2">
Integrations: your own model provider, alerts that open issues, tickets agents read400 <input type="hidden" name="id" value={connection.id} />
401 <Button variant="quiet" type="submit" name="intent" value="test" disabled={busy}>
402 Test
403 </Button>
404 <Button variant="quiet" type="submit" name="intent" value="disconnect" disabled={busy} aria-label="Disconnect">
405 <X size={14} />
406 </Button>
407 </Form>
408 )}
409 </div>
410 {tested?.id === connection.id && (
411 <p className={`mt-3 text-sm ${tested.ok ? "text-accent" : "text-danger"}`}>{tested.message}</p>
412 )}
413 {connection.lastError && (
414 <p className="mt-3 flex items-start gap-2 text-xs text-warn">
415 <AlertTriangle size={13} className="mt-0.5 shrink-0" />
416 {connection.lastError}
417 </p>
418 )}
419 {connection.webhookUrl && (
420 <div className="mt-3">
421 <p className="mb-1.5 text-xs text-faint">Where it sends alerts</p>
422 <CopyLine text={connection.webhookUrl} />
423 </div>
424 )}
425 {owner && waitingForSecret && (
426 <Form method="post" className="mt-3 flex flex-wrap items-end gap-2">
427 <input type="hidden" name="intent" value="update" />
428 <input type="hidden" name="id" value={connection.id} />
429 <div className="min-w-64 grow">
430 <Field label="Client secret" hint="From the internal integration's page in Sentry, once it has this address. Leave it if it is already saved.">
431 <Input name="signingSecret" type="password" placeholder="Paste to replace" />
432 </Field>
433 </div>
434 <Button type="submit" variant="quiet" disabled={busy}>
435 Save
436 </Button>
437 </Form>
438 )}
439 {deliveries.length > 0 && (
440 <ul className="mt-3 space-y-1 text-xs">
441 {deliveries.map((delivery) => (
442 <li key={delivery.id} className="flex items-center gap-2 text-muted">
443 <span
444 className={`size-1.5 shrink-0 rounded-full ${
445 delivery.outcome === "refused"
446 ? "bg-danger"
447 : delivery.outcome === "ignored"
448 ? "bg-faint"
449 : "bg-accent"
450 }`}
451 />
452 <span className="font-mono text-faint">{delivery.event}</span>
453 <span className="min-w-0 truncate">{delivery.detail}</span>
454 {delivery.issue && (
455 <Link
456 to={`/${delivery.issue.replace("#", "/issues/")}`}
457 className="shrink-0 font-mono text-fg hover:underline"
458 >
459 {delivery.issue}
460 </Link>
461 )}
462 <span className="ml-auto shrink-0 text-faint">
463 <TimeAgo at={delivery.receivedAt} />
464 </span>
465 </li>
466 ))}
467 </ul>
468 )}
469 </div>
470 );
471}
472
A catalogue of model providers, and settings that feel like settings473/** What a provider is for, in a line. */
474function blurb(provider: Provider): string {
475 return PROVIDER_BLURB[provider] ?? MODEL_CATALOG[provider]?.blurb ?? "";
476}
477
Integrations: your own model provider, alerts that open issues, tickets agents read478function Choices({ kind, slug, adding }: { kind: ProviderKind; slug: string; adding: Provider | null }) {
479 const providers = (Object.keys(PROVIDERS) as Provider[]).filter((p) => PROVIDERS[p].kind === kind);
480 return (
A catalogue of model providers, and settings that feel like settings481 <div className="mt-3">
482 <ProviderTiles providers={providers} slug={slug} adding={adding} blurb={blurb} />
Integrations: your own model provider, alerts that open issues, tickets agents read483 </div>
484 );
485}
486
487function RepoField({ repos, required, hint }: { repos: string[]; required?: boolean; hint: string }) {
488 return (
489 <Field label="Repository" hint={hint}>
Workspace names and icons, and a component kit for every control490 <Combobox
Integrations: your own model provider, alerts that open issues, tickets agents read491 name="repo"
492 defaultValue={repos[0] ?? ""}
Workspace names and icons, and a component kit for every control493 aria-label="Repository"
494 placeholder={repos.length === 0 ? "No repositories yet" : "Choose a repository"}
495 searchPlaceholder="Find a repository"
496 emptyText="No repository by that name."
497 options={[
498 ...(required ? [] : [{ value: "", label: "None" }]),
499 ...repos.map((repo) => ({ value: repo, label: repo, icon: <Avatar name={repo.split("/").pop() ?? repo} size={16} square /> })),
500 ]}
501 className="font-mono text-[0.8125rem]"
502 />
Integrations: your own model provider, alerts that open issues, tickets agents read503 </Field>
504 );
505}
506
507function AssignField() {
508 return (
Workspace names and icons, and a component kit for every control509 <CheckboxOption
510 name="assign"
511 label={<span className="font-medium">Put a g1t agent on each new issue</span>}
512 description={
513 <span className="text-muted">
514 It opens a pull request, gets reviewed, and lands through your merge rules, before anyone has to look.
515 Agents run only where g1t agents are enabled.
Integrations: your own model provider, alerts that open issues, tickets agents read516 </span>
Workspace names and icons, and a component kit for every control517 }
518 />
Integrations: your own model provider, alerts that open issues, tickets agents read519 );
520}
521
522function AddForm({
523 provider,
524 slug,
525 repos,
526 busy,
527 error,
528}: {
529 provider: Provider;
530 slug: string;
531 repos: string[];
532 busy: boolean;
533 error: string | null;
534}) {
A catalogue of model providers, and settings that feel like settings535 const fields: Partial<Record<Provider, ReactNode>> = {
Integrations: your own model provider, alerts that open issues, tickets agents read536 sentry: (
537 <>
538 <Field label="Organization" hint="Its slug, as in acme.sentry.io.">
539 <Input name="organization" required placeholder="acme" />
540 </Field>
541 <RepoField repos={repos} required hint="Where issues are opened." />
542 <Field label="Auth token" hint="The internal integration's token, with Issue & Event read and write. Lets g1t read stack traces and resolve issues. Optional, but recommended.">
543 <Input name="secret" type="password" />
544 </Field>
545 <Field label="Client secret" hint="You get this from Sentry after giving it this connection's address; you can add it on the next step.">
546 <Input name="signingSecret" type="password" />
547 </Field>
548 <AssignField />
549 </>
550 ),
551 datadog: (
552 <>
553 <RepoField repos={repos} required hint="Where issues are opened." />
554 <AssignField />
555 </>
556 ),
557 webhook: (
558 <>
559 <RepoField repos={repos} required hint="Where issues are opened." />
560 <AssignField />
561 </>
562 ),
563 jira: (
564 <>
565 <Field label="Site" hint="Your Jira's address.">
566 <Input name="site" type="url" required placeholder="https://acme.atlassian.net" />
567 </Field>
568 <Field label="Email" hint="The account the API token belongs to.">
569 <Input name="email" type="email" required autoComplete="off" />
570 </Field>
571 <Field label="API token" hint="From id.atlassian.com → Security → API tokens.">
572 <Input name="secret" type="password" required />
573 </Field>
574 <Field label="Project keys" hint="Optional. The projects this answers for, such as TECH, OPS. Empty answers for any key.">
575 <Input name="keys" placeholder="TECH, OPS" />
576 </Field>
577 </>
578 ),
579 linear: (
580 <>
581 <Field label="API key" hint="From Linear → Settings → Security & access → Personal API keys.">
582 <Input name="secret" type="password" required placeholder="lin_api_…" />
583 </Field>
584 <Field label="Team keys" hint="Optional. Such as ENG. Empty answers for any key.">
585 <Input name="keys" placeholder="ENG" />
586 </Field>
587 </>
588 ),
589 };
590 return (
Integrations: connect forms open under the tile that was chosen591 <section id="add" className="mt-4 scroll-mt-20 rounded-xl border border-line bg-surface p-5">
Integrations: your own model provider, alerts that open issues, tickets agents read592 <div className="flex items-center gap-3">
593 <ProviderMark provider={provider} />
594 <div className="grow">
Integrations: connect forms open under the tile that was chosen595 <h3 className="font-medium">
Models per workspace: several providers, routed by kind of work596 Connect {provider.endsWith("_endpoint") ? `an ${PROVIDERS[provider].label}` : PROVIDERS[provider].label}
Integrations: connect forms open under the tile that was chosen597 </h3>
A catalogue of model providers, and settings that feel like settings598 <p className="text-xs text-muted">{blurb(provider)}</p>
Integrations: your own model provider, alerts that open issues, tickets agents read599 </div>
600 <Link to={`/${slug}/-/integrations`} className="rounded-md p-1.5 text-faint hover:bg-raised hover:text-fg" aria-label="Close">
601 <X size={16} />
602 </Link>
603 </div>
604 <Form method="post" className="mt-5 grid max-w-xl gap-4">
605 <input type="hidden" name="provider" value={provider} />
A catalogue of model providers, and settings that feel like settings606 {fields[provider] ?? <ModelProviderFields provider={provider} />}
Integrations: your own model provider, alerts that open issues, tickets agents read607 <ErrorText>{error}</ErrorText>
608 <div>
609 <Button type="submit" disabled={busy}>
610 {busy ? "Connecting…" : "Connect"}
611 </Button>
612 </div>
613 </Form>
614 </section>
615 );
616}
617
618/** What to do next, right after connecting: shown once. */
619function Connected({ connected }: { connected: { connection: Connection; signingSecret: string | null } }) {
620 const { connection, signingSecret } = connected;
621 const url = connection.webhookUrl;
622 return (
623 <div className="mt-6 rounded-xl border border-accent-dim/60 bg-accent/5 p-5">
624 <p className="flex items-center gap-2 font-medium">
625 <CheckCircle2 size={16} className="text-accent" />
626 {connection.name} is connected.
627 </p>
628 {connection.provider === "sentry" && url && (
629 <ol className="mt-3 list-decimal space-y-2 pl-5 text-sm text-muted">
630 <li>
631 In Sentry, open Settings → Developer Settings → Custom Integrations, and create an internal
632 integration.
633 </li>
634 <li>
635 Set its webhook URL to <CopyLine text={url} />
636 </li>
637 <li>Turn on Alert Rule Action, and under Webhooks, tick issue.</li>
638 <li>Give it Issue &amp; Event read and write, save, and paste its client secret below, on the connection.</li>
639 </ol>
640 )}
641 {(connection.provider === "datadog" || connection.provider === "webhook") && url && signingSecret && (
642 <div className="mt-3 space-y-3 text-sm text-muted">
643 <p>This secret is shown once. Copy it now.</p>
644 <CopyLine text={signingSecret} />
645 {connection.provider === "datadog" ? (
646 <>
647 <p>
648 In Datadog, open Integrations → Webhooks and add one named <code>g1t</code> with this URL:
649 </p>
650 <CopyLine text={url} />
651 <p>Custom headers:</p>
652 <CopyLine text={`{"Authorization": "Bearer ${signingSecret}"}`} />
653 <p>Payload:</p>
654 <CopyLine
655 text={`{"id": "$ALERT_ID", "title": "$EVENT_TITLE", "body": "$EVENT_MSG", "url": "$LINK", "status": "$ALERT_TRANSITION", "priority": "$PRIORITY"}`}
656 />
657 <p>
658 Then mention <code>@webhook-g1t</code> in any monitor's message.
659 </p>
660 </>
661 ) : (
662 <>
663 <p>Send JSON with at least a title to:</p>
664 <CopyLine text={url} />
665 <p>
666 with <code>Authorization: Bearer &lt;secret&gt;</code>, or an HMAC-SHA256 of the body in{" "}
667 <code>X-G1t-Signature</code>. An <code>id</code> keeps repeats on one issue.
668 </p>
669 </>
670 )}
671 </div>
672 )}
673 {connection.kind === "tracker" && (
674 <p className="mt-2 text-sm text-muted">
675 Agents now read tickets that work mentions, and you can import one from any repository's new
676 issue page. Use Test to check the token.
677 </p>
678 )}
679 {connection.kind === "models" && (
680 <p className="mt-2 text-sm text-muted">
Models per workspace: several providers, routed by kind of work681 {connection.lastError
682 ? `It was saved, but the check failed: ${connection.lastError}`
683 : connection.models.length > 0
684 ? `It offers ${connection.models.length} models. Choose which work goes to it under “Which model does which work”.`
685 : "Choose which work goes to it under “Which model does which work”."}
Integrations: your own model provider, alerts that open issues, tickets agents read686 </p>
687 )}
688 </div>
689 );
690}