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

677 lines25,766 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";
A catalogue of model providers, and settings that feel like settings26import { MODEL_CATALOG, ModelCatalog, ModelProviderFields, ProviderMark, ProviderTiles, Routing } from "../../components/model-providers";
27import { Button, CopyLine, ErrorText, Field, Input, TimeAgo } from "../../components/ui";
Integrations: your own model provider, alerts that open issues, tickets agents read28import { billing, integrations, repos } from "../../lib/services.server";
29import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
30
31export function meta({ params }: Route.MetaArgs) {
32 return [{ title: `Integrations · ${params.owner} · g1t` }];
33}
34
35const isProvider = (value: string | null): value is Provider => value != null && value in PROVIDERS;
36
37export async function loader({ params, context, request }: Route.LoaderArgs) {
38 const viewer = getViewer(context);
39 const role = roleIn(viewer, params.owner);
40 if (!role) throw new Response(null, { status: 404 });
41 const slug = params.owner.toLowerCase();
Models per workspace: several providers, routed by kind of work42 const [connections, listed, account, access, routes] = await Promise.all([
Integrations: your own model provider, alerts that open issues, tickets agents read43 integrations.list(slug, viewer),
44 repos.list(viewer, { namespace: slug }),
45 billing.account(slug, viewer),
Models per workspace: several providers, routed by kind of work46 env.RUNNER.modelAccess(slug),
47 integrations.routes(slug, viewer),
Integrations: your own model provider, alerts that open issues, tickets agents read48 ]);
49 const all = unwrap(connections);
50 // What each alert source has sent lately, to see it is wired up.
51 const deliveries: Record<string, Delivery[]> = {};
52 await Promise.all(
53 all
54 .filter((connection) => connection.kind === "alerts")
55 .map(async (connection) => {
56 const found = await integrations.deliveries(slug, viewer, connection.id);
57 deliveries[connection.id] = found.ok ? found.value.slice(0, 5) : [];
58 }),
59 );
60 const adding = new URL(request.url).searchParams.get("add");
61 return {
62 slug,
63 role,
64 connections: all,
65 deliveries,
66 repos: listed.map((repo) => `${repo.namespace}/${repo.name}`),
67 adding: isProvider(adding) ? adding : null,
Models per workspace: several providers, routed by kind of work68 hostedOpen: access.hosted,
69 routes: routes.ok ? routes.value : [],
Integrations: your own model provider, alerts that open issues, tickets agents read70 feeMicros: account.ok ? account.value.orchestrationFeeMicros : 100_000,
71 marginPercent: account.ok ? account.value.marginPercent : 20,
72 };
73}
74
75function text(form: FormData, name: string): string | undefined {
76 const value = String(form.get(name) ?? "").trim();
77 return value || undefined;
78}
79
80/** The settings a form describes, for the provider it is for. */
81function configFrom(form: FormData): ConnectionConfig {
82 const keys = text(form, "keys");
83 return {
84 repo: text(form, "repo"),
85 assign: form.get("assign") === "on",
86 label: text(form, "label"),
87 writeBack: form.get("writeBack") !== "off",
88 organization: text(form, "organization"),
89 site: text(form, "site"),
90 email: text(form, "email"),
91 keys: keys ? keys.split(/[\s,]+/).filter(Boolean) : [],
92 baseUrl: text(form, "baseUrl"),
93 authHeader: text(form, "authHeader"),
94 model: text(form, "model"),
95 };
96}
97
98export async function action({ request, params, context }: Route.ActionArgs) {
99 assertSameOrigin(request);
100 const user = requireUser(context, request);
101 const slug = params.owner.toLowerCase();
102 const form = await request.formData();
103 const intent = form.get("intent");
104 const id = String(form.get("id") ?? "");
105 if (intent === "disconnect") {
106 const removed = await integrations.disconnect(user, slug, id);
107 return { error: removed.ok ? null : removed.error.message };
108 }
109 if (intent === "test") {
110 const tested = await integrations.test(user, slug, id);
111 return tested.ok ? { tested: { id, ...tested.value } } : { error: tested.error.message };
112 }
Models per workspace: several providers, routed by kind of work113 if (intent === "routes") {
114 const routes: ModelRoute[] = [];
115 for (const task of MODEL_TASKS) {
116 const choice = String(form.get(`route-${task}`) ?? "");
117 if (!choice) continue;
118 if (choice === "g1t") routes.push({ task, connectionId: null, model: null });
119 else {
120 const [connectionId, model] = choice.split("::");
121 routes.push({ task, connectionId, model: model || null });
122 }
123 }
124 const saved = await integrations.setRoutes(user, slug, routes);
125 return saved.ok ? { routed: true } : { error: saved.error.message };
126 }
Integrations: your own model provider, alerts that open issues, tickets agents read127 if (intent === "update") {
128 const updated = await integrations.update(user, slug, id, {
129 signingSecret: text(form, "signingSecret"),
130 secret: text(form, "secret"),
131 });
132 return { error: updated.ok ? null : updated.error.message };
133 }
134 const provider = String(form.get("provider") ?? "");
135 if (!isProvider(provider)) return { error: "Choose what to connect." };
136 const connected = await integrations.connect(user, slug, {
137 provider,
138 name: text(form, "name"),
139 config: configFrom(form),
140 secret: text(form, "secret"),
141 signingSecret: text(form, "signingSecret"),
142 });
143 return connected.ok ? { connected: connected.value } : { error: connected.error.message, provider };
144}
145
146const KIND_INFO: Record<ProviderKind, { title: string; icon: ReactNode; blurb: string }> = {
147 models: {
Model providers: gateway tokens for endpoints, tidier rows, and the docs148 title: "Model providers",
Integrations: your own model provider, alerts that open issues, tickets agents read149 icon: <Cpu size={16} />,
Model providers: gateway tokens for endpoints, tidier rows, and the docs150 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 read151 },
152 alerts: {
153 title: "Alerts",
154 icon: <Siren size={16} />,
155 blurb: "Problems your monitoring finds become issues, and agents can start on them before anyone looks.",
156 },
157 tracker: {
158 title: "Trackers",
159 icon: <Ticket size={16} />,
160 blurb: "Tickets agents can read when work mentions them, that people can import as issues, and that hear back when the work lands.",
161 },
162};
163
A catalogue of model providers, and settings that feel like settings164const PROVIDER_BLURB: Partial<Record<Provider, string>> = {
Integrations: your own model provider, alerts that open issues, tickets agents read165 sentry: "New errors open issues, with the stack trace. Resolved in Sentry when the fix merges.",
166 datadog: "Monitors that trigger open issues. Recoveries are noted on them.",
167 webhook: "Anything that can send signed JSON: PagerDuty, Grafana, your own scripts.",
168 jira: "Agents read TECH-1234 when work mentions it. Import tickets; they hear back.",
169 linear: "Agents read ENG-42 when work mentions it. Import issues; they hear back.",
170};
171
172function dollars(micros: number): string {
173 return `$${(micros / 1_000_000).toFixed(2)}`;
174}
175
176export default function WorkspaceIntegrations({ loaderData, actionData }: Route.ComponentProps) {
Models per workspace: several providers, routed by kind of work177 const { slug, role, connections, deliveries, repos: repoNames, adding, feeMicros, marginPercent, hostedOpen, routes } =
178 loaderData;
Integrations: your own model provider, alerts that open issues, tickets agents read179 const owner = role === "owner";
180 const busy = useNavigation().state === "submitting";
Models per workspace: several providers, routed by kind of work181 const modelConnections = connections.filter((connection) => connection.kind === "models");
Integrations: your own model provider, alerts that open issues, tickets agents read182 const justConnected = actionData && "connected" in actionData ? actionData.connected : null;
183 const tested = (actionData && "tested" in actionData ? actionData.tested : null) ?? null;
184 const error = (actionData && "error" in actionData ? actionData.error : null) ?? null;
185
186 return (
A catalogue of model providers, and settings that feel like settings187 <div>
Integrations: your own model provider, alerts that open issues, tickets agents read188 {justConnected && <Connected connected={justConnected} />}
189 <div className="mt-4">
190 <ErrorText>{error}</ErrorText>
191 </div>
192
193 {/* Models -------------------------------------------------------------- */}
A catalogue of model providers, and settings that feel like settings194 <Section kind="models" first>
Models per workspace: several providers, routed by kind of work195 {!hostedOpen && modelConnections.length === 0 && (
196 <div className="mb-4 flex items-center gap-3 rounded-xl border border-warn/30 bg-warn/5 p-4">
197 <span className="inline-flex size-8 shrink-0 items-center justify-center rounded-lg bg-warn/10 text-warn ring-1 ring-warn/30">
198 <AlertTriangle size={16} />
199 </span>
200 <p className="text-sm text-muted">
201 <span className="font-medium text-fg">Choose how your agents reach a model.</span> g1t's hosted models
202 are not open to {slug} yet. Connect a provider of your own below and your agents start at once,
203 billed by that provider.
204 </p>
205 </div>
206 )}
207 <Connections list={modelConnections} owner={owner} busy={busy} tested={tested} deliveries={deliveries} />
A catalogue of model providers, and settings that feel like settings208 {modelConnections.length === 0 && hostedOpen && (
209 <p className="flex items-center gap-2 rounded-xl border border-line bg-surface px-4 py-3 text-sm text-muted">
210 <CheckCircle2 size={15} className="shrink-0 text-merged" />
211 All work runs on g1t's models, charged to your credit at cost plus {marginPercent}%. Connect a provider of
212 your own to choose models and pay for them there.
213 </p>
214 )}
215 {modelConnections.length > 0 && (
Models per workspace: several providers, routed by kind of work216 <Routing
217 connections={modelConnections}
218 routes={routes}
219 hostedOpen={hostedOpen}
220 marginPercent={marginPercent}
221 owner={owner}
222 busy={busy}
223 saved={actionData != null && "routed" in actionData}
224 />
A catalogue of model providers, and settings that feel like settings225 )}
Integrations: your own model provider, alerts that open issues, tickets agents read226 <p className="mt-3 text-xs text-faint">
Models per workspace: several providers, routed by kind of work227 On your own providers, their bills are yours and g1t charges {dollars(feeMicros)} a run for the
228 sandbox and orchestration. Keys go only from g1t's model proxy to the provider: the agent's
229 sandbox holds a token that dies with the run.
Integrations: your own model provider, alerts that open issues, tickets agents read230 </p>
A catalogue of model providers, and settings that feel like settings231 {owner && <ModelCatalog slug={slug} adding={adding} />}
Integrations: connect forms open under the tile that was chosen232 {adding && PROVIDERS[adding].kind === "models" && owner && (
233 <AddForm
234 provider={adding}
235 slug={slug}
236 repos={repoNames}
237 busy={busy}
238 error={actionData && "provider" in actionData ? error : null}
239 />
240 )}
Integrations: your own model provider, alerts that open issues, tickets agents read241 </Section>
242
243 {/* Alerts -------------------------------------------------------------- */}
244 <Section kind="alerts">
245 <Connections
246 list={connections.filter((c) => c.kind === "alerts")}
247 owner={owner}
248 busy={busy}
249 tested={tested}
250 deliveries={deliveries}
251 />
252 {owner && <Choices kind="alerts" slug={slug} adding={adding} />}
Integrations: connect forms open under the tile that was chosen253 {adding && PROVIDERS[adding].kind === "alerts" && owner && (
254 <AddForm
255 provider={adding}
256 slug={slug}
257 repos={repoNames}
258 busy={busy}
259 error={actionData && "provider" in actionData ? error : null}
260 />
261 )}
Integrations: your own model provider, alerts that open issues, tickets agents read262 </Section>
263
264 {/* Trackers ------------------------------------------------------------ */}
265 <Section kind="tracker">
266 <Connections
267 list={connections.filter((c) => c.kind === "tracker")}
268 owner={owner}
269 busy={busy}
270 tested={tested}
271 deliveries={deliveries}
272 />
273 {owner && <Choices kind="tracker" slug={slug} adding={adding} />}
Integrations: connect forms open under the tile that was chosen274 {adding && PROVIDERS[adding].kind === "tracker" && owner && (
275 <AddForm
276 provider={adding}
277 slug={slug}
278 repos={repoNames}
279 busy={busy}
280 error={actionData && "provider" in actionData ? error : null}
281 />
282 )}
Integrations: your own model provider, alerts that open issues, tickets agents read283 </Section>
284
285 {!owner && <p className="mt-8 text-sm text-muted">An owner of {slug} can add and remove integrations.</p>}
286 </div>
287 );
Models per workspace: several providers, routed by kind of work288}
289
A catalogue of model providers, and settings that feel like settings290function Section({ kind, first, children }: { kind: ProviderKind; first?: boolean; children: ReactNode }) {
Integrations: your own model provider, alerts that open issues, tickets agents read291 const info = KIND_INFO[kind];
292 return (
A catalogue of model providers, and settings that feel like settings293 <section className={first ? "" : "mt-12"}>
Integrations: your own model provider, alerts that open issues, tickets agents read294 <h3 className="flex items-center gap-2 text-sm font-medium">
295 <span className="text-muted">{info.icon}</span>
296 {info.title}
297 </h3>
298 <p className="mt-1 mb-4 text-sm text-muted">{info.blurb}</p>
299 {children}
300 </section>
301 );
302}
303
304function Connections({
305 list,
306 owner,
307 busy,
308 tested,
309 deliveries,
310}: {
311 list: Connection[];
312 owner: boolean;
313 busy: boolean;
314 tested: { id: string; ok: boolean; message: string } | null;
315 deliveries: Record<string, Delivery[]>;
316}) {
317 if (list.length === 0) return null;
318 return (
319 <ul className="mb-4 divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
320 {list.map((connection) => (
321 <li key={connection.id} className="p-4">
322 <ConnectionRow
323 connection={connection}
324 owner={owner}
325 busy={busy}
326 tested={tested}
327 deliveries={deliveries[connection.id] ?? []}
328 />
329 </li>
330 ))}
331 </ul>
332 );
333}
334
Model providers: gateway tokens for endpoints, tidier rows, and the docs335/** Just the host of an address, which is what tells connections apart. */
336function host(url: string | undefined): string | undefined {
337 if (!url) return undefined;
338 try {
339 return new URL(url).host;
340 } catch {
341 return url;
342 }
343}
344
Integrations: your own model provider, alerts that open issues, tickets agents read345function ConnectionRow({
346 connection,
347 owner,
348 busy,
349 tested,
350 deliveries,
351}: {
352 connection: Connection;
353 owner: boolean;
354 busy: boolean;
355 tested: { id: string; ok: boolean; message: string } | null;
356 deliveries: Delivery[];
357}) {
358 const { config } = connection;
359 const facts = [
360 PROVIDERS[connection.provider].label,
361 config.repo && `issues in ${config.repo}`,
362 config.assign && "agents start at once",
363 config.organization,
Model providers: gateway tokens for endpoints, tidier rows, and the docs364 host(config.site),
365 host(config.baseUrl),
Models per workspace: several providers, routed by kind of work366 config.model && `default ${config.model}`,
367 connection.models.length > 0 && `${connection.models.length} models`,
Integrations: your own model provider, alerts that open issues, tickets agents read368 config.keys?.length ? config.keys.join(", ") : null,
369 connection.secretHint && `key ${connection.secretHint}`,
370 ].filter(Boolean);
371 const waitingForSecret = connection.provider === "sentry" && !deliveries.length && !connection.lastUsedAt;
372 return (
373 <div>
Model providers: gateway tokens for endpoints, tidier rows, and the docs374 <div className="flex items-center gap-3">
Integrations: your own model provider, alerts that open issues, tickets agents read375 <ProviderMark provider={connection.provider} />
376 <div className="min-w-0 grow">
377 <p className="truncate text-sm font-medium">{connection.name}</p>
378 <p className="truncate text-xs text-muted">{facts.join(" · ")}</p>
379 </div>
380 {connection.lastUsedAt && (
Model providers: gateway tokens for endpoints, tidier rows, and the docs381 <span className="hidden shrink-0 text-xs text-faint sm:inline">
Integrations: your own model provider, alerts that open issues, tickets agents read382 Used <TimeAgo at={connection.lastUsedAt} />
383 </span>
384 )}
385 {owner && (
Model providers: gateway tokens for endpoints, tidier rows, and the docs386 <Form method="post" className="flex shrink-0 gap-2">
Integrations: your own model provider, alerts that open issues, tickets agents read387 <input type="hidden" name="id" value={connection.id} />
388 <Button variant="quiet" type="submit" name="intent" value="test" disabled={busy}>
389 Test
390 </Button>
391 <Button variant="quiet" type="submit" name="intent" value="disconnect" disabled={busy} aria-label="Disconnect">
392 <X size={14} />
393 </Button>
394 </Form>
395 )}
396 </div>
397 {tested?.id === connection.id && (
398 <p className={`mt-3 text-sm ${tested.ok ? "text-accent" : "text-danger"}`}>{tested.message}</p>
399 )}
400 {connection.lastError && (
401 <p className="mt-3 flex items-start gap-2 text-xs text-warn">
402 <AlertTriangle size={13} className="mt-0.5 shrink-0" />
403 {connection.lastError}
404 </p>
405 )}
406 {connection.webhookUrl && (
407 <div className="mt-3">
408 <p className="mb-1.5 text-xs text-faint">Where it sends alerts</p>
409 <CopyLine text={connection.webhookUrl} />
410 </div>
411 )}
412 {owner && waitingForSecret && (
413 <Form method="post" className="mt-3 flex flex-wrap items-end gap-2">
414 <input type="hidden" name="intent" value="update" />
415 <input type="hidden" name="id" value={connection.id} />
416 <div className="min-w-64 grow">
417 <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.">
418 <Input name="signingSecret" type="password" placeholder="Paste to replace" />
419 </Field>
420 </div>
421 <Button type="submit" variant="quiet" disabled={busy}>
422 Save
423 </Button>
424 </Form>
425 )}
426 {deliveries.length > 0 && (
427 <ul className="mt-3 space-y-1 text-xs">
428 {deliveries.map((delivery) => (
429 <li key={delivery.id} className="flex items-center gap-2 text-muted">
430 <span
431 className={`size-1.5 shrink-0 rounded-full ${
432 delivery.outcome === "refused"
433 ? "bg-danger"
434 : delivery.outcome === "ignored"
435 ? "bg-faint"
436 : "bg-accent"
437 }`}
438 />
439 <span className="font-mono text-faint">{delivery.event}</span>
440 <span className="min-w-0 truncate">{delivery.detail}</span>
441 {delivery.issue && (
442 <Link
443 to={`/${delivery.issue.replace("#", "/issues/")}`}
444 className="shrink-0 font-mono text-fg hover:underline"
445 >
446 {delivery.issue}
447 </Link>
448 )}
449 <span className="ml-auto shrink-0 text-faint">
450 <TimeAgo at={delivery.receivedAt} />
451 </span>
452 </li>
453 ))}
454 </ul>
455 )}
456 </div>
457 );
458}
459
A catalogue of model providers, and settings that feel like settings460/** What a provider is for, in a line. */
461function blurb(provider: Provider): string {
462 return PROVIDER_BLURB[provider] ?? MODEL_CATALOG[provider]?.blurb ?? "";
463}
464
Integrations: your own model provider, alerts that open issues, tickets agents read465function Choices({ kind, slug, adding }: { kind: ProviderKind; slug: string; adding: Provider | null }) {
466 const providers = (Object.keys(PROVIDERS) as Provider[]).filter((p) => PROVIDERS[p].kind === kind);
467 return (
A catalogue of model providers, and settings that feel like settings468 <div className="mt-3">
469 <ProviderTiles providers={providers} slug={slug} adding={adding} blurb={blurb} />
Integrations: your own model provider, alerts that open issues, tickets agents read470 </div>
471 );
472}
473
474function RepoField({ repos, required, hint }: { repos: string[]; required?: boolean; hint: string }) {
475 return (
476 <Field label="Repository" hint={hint}>
477 <select
478 name="repo"
479 required={required}
480 defaultValue={repos[0] ?? ""}
481 className="w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none hover:border-line-strong focus:border-accent-dim"
482 >
483 {!required && <option value="">None</option>}
484 {repos.map((repo) => (
485 <option key={repo} value={repo}>
486 {repo}
487 </option>
488 ))}
489 </select>
490 </Field>
491 );
492}
493
494function AssignField() {
495 return (
496 <label className="flex items-start gap-2.5 text-sm">
497 <input type="checkbox" name="assign" className="mt-1 accent-[var(--color-accent)]" />
498 <span>
499 <span className="font-medium">Put a g1t agent on each new issue</span>
500 <span className="block text-xs text-muted">
501 It opens a pull request, gets reviewed, and lands through your merge rules, before anyone has
502 to look. Agents run only where g1t agents are enabled.
503 </span>
504 </span>
505 </label>
506 );
507}
508
509function AddForm({
510 provider,
511 slug,
512 repos,
513 busy,
514 error,
515}: {
516 provider: Provider;
517 slug: string;
518 repos: string[];
519 busy: boolean;
520 error: string | null;
521}) {
A catalogue of model providers, and settings that feel like settings522 const fields: Partial<Record<Provider, ReactNode>> = {
Integrations: your own model provider, alerts that open issues, tickets agents read523 sentry: (
524 <>
525 <Field label="Organization" hint="Its slug, as in acme.sentry.io.">
526 <Input name="organization" required placeholder="acme" />
527 </Field>
528 <RepoField repos={repos} required hint="Where issues are opened." />
529 <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.">
530 <Input name="secret" type="password" />
531 </Field>
532 <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.">
533 <Input name="signingSecret" type="password" />
534 </Field>
535 <AssignField />
536 </>
537 ),
538 datadog: (
539 <>
540 <RepoField repos={repos} required hint="Where issues are opened." />
541 <AssignField />
542 </>
543 ),
544 webhook: (
545 <>
546 <RepoField repos={repos} required hint="Where issues are opened." />
547 <AssignField />
548 </>
549 ),
550 jira: (
551 <>
552 <Field label="Site" hint="Your Jira's address.">
553 <Input name="site" type="url" required placeholder="https://acme.atlassian.net" />
554 </Field>
555 <Field label="Email" hint="The account the API token belongs to.">
556 <Input name="email" type="email" required autoComplete="off" />
557 </Field>
558 <Field label="API token" hint="From id.atlassian.com → Security → API tokens.">
559 <Input name="secret" type="password" required />
560 </Field>
561 <Field label="Project keys" hint="Optional. The projects this answers for, such as TECH, OPS. Empty answers for any key.">
562 <Input name="keys" placeholder="TECH, OPS" />
563 </Field>
564 </>
565 ),
566 linear: (
567 <>
568 <Field label="API key" hint="From Linear → Settings → Security & access → Personal API keys.">
569 <Input name="secret" type="password" required placeholder="lin_api_…" />
570 </Field>
571 <Field label="Team keys" hint="Optional. Such as ENG. Empty answers for any key.">
572 <Input name="keys" placeholder="ENG" />
573 </Field>
574 </>
575 ),
576 };
577 return (
Integrations: connect forms open under the tile that was chosen578 <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 read579 <div className="flex items-center gap-3">
580 <ProviderMark provider={provider} />
581 <div className="grow">
Integrations: connect forms open under the tile that was chosen582 <h3 className="font-medium">
Models per workspace: several providers, routed by kind of work583 Connect {provider.endsWith("_endpoint") ? `an ${PROVIDERS[provider].label}` : PROVIDERS[provider].label}
Integrations: connect forms open under the tile that was chosen584 </h3>
A catalogue of model providers, and settings that feel like settings585 <p className="text-xs text-muted">{blurb(provider)}</p>
Integrations: your own model provider, alerts that open issues, tickets agents read586 </div>
587 <Link to={`/${slug}/-/integrations`} className="rounded-md p-1.5 text-faint hover:bg-raised hover:text-fg" aria-label="Close">
588 <X size={16} />
589 </Link>
590 </div>
591 <Form method="post" className="mt-5 grid max-w-xl gap-4">
592 <input type="hidden" name="provider" value={provider} />
A catalogue of model providers, and settings that feel like settings593 {fields[provider] ?? <ModelProviderFields provider={provider} />}
Integrations: your own model provider, alerts that open issues, tickets agents read594 <ErrorText>{error}</ErrorText>
595 <div>
596 <Button type="submit" disabled={busy}>
597 {busy ? "Connecting…" : "Connect"}
598 </Button>
599 </div>
600 </Form>
601 </section>
602 );
603}
604
605/** What to do next, right after connecting: shown once. */
606function Connected({ connected }: { connected: { connection: Connection; signingSecret: string | null } }) {
607 const { connection, signingSecret } = connected;
608 const url = connection.webhookUrl;
609 return (
610 <div className="mt-6 rounded-xl border border-accent-dim/60 bg-accent/5 p-5">
611 <p className="flex items-center gap-2 font-medium">
612 <CheckCircle2 size={16} className="text-accent" />
613 {connection.name} is connected.
614 </p>
615 {connection.provider === "sentry" && url && (
616 <ol className="mt-3 list-decimal space-y-2 pl-5 text-sm text-muted">
617 <li>
618 In Sentry, open Settings → Developer Settings → Custom Integrations, and create an internal
619 integration.
620 </li>
621 <li>
622 Set its webhook URL to <CopyLine text={url} />
623 </li>
624 <li>Turn on Alert Rule Action, and under Webhooks, tick issue.</li>
625 <li>Give it Issue &amp; Event read and write, save, and paste its client secret below, on the connection.</li>
626 </ol>
627 )}
628 {(connection.provider === "datadog" || connection.provider === "webhook") && url && signingSecret && (
629 <div className="mt-3 space-y-3 text-sm text-muted">
630 <p>This secret is shown once. Copy it now.</p>
631 <CopyLine text={signingSecret} />
632 {connection.provider === "datadog" ? (
633 <>
634 <p>
635 In Datadog, open Integrations → Webhooks and add one named <code>g1t</code> with this URL:
636 </p>
637 <CopyLine text={url} />
638 <p>Custom headers:</p>
639 <CopyLine text={`{"Authorization": "Bearer ${signingSecret}"}`} />
640 <p>Payload:</p>
641 <CopyLine
642 text={`{"id": "$ALERT_ID", "title": "$EVENT_TITLE", "body": "$EVENT_MSG", "url": "$LINK", "status": "$ALERT_TRANSITION", "priority": "$PRIORITY"}`}
643 />
644 <p>
645 Then mention <code>@webhook-g1t</code> in any monitor's message.
646 </p>
647 </>
648 ) : (
649 <>
650 <p>Send JSON with at least a title to:</p>
651 <CopyLine text={url} />
652 <p>
653 with <code>Authorization: Bearer &lt;secret&gt;</code>, or an HMAC-SHA256 of the body in{" "}
654 <code>X-G1t-Signature</code>. An <code>id</code> keeps repeats on one issue.
655 </p>
656 </>
657 )}
658 </div>
659 )}
660 {connection.kind === "tracker" && (
661 <p className="mt-2 text-sm text-muted">
662 Agents now read tickets that work mentions, and you can import one from any repository's new
663 issue page. Use Test to check the token.
664 </p>
665 )}
666 {connection.kind === "models" && (
667 <p className="mt-2 text-sm text-muted">
Models per workspace: several providers, routed by kind of work668 {connection.lastError
669 ? `It was saved, but the check failed: ${connection.lastError}`
670 : connection.models.length > 0
671 ? `It offers ${connection.models.length} models. Choose which work goes to it under “Which model does which work”.`
672 : "Choose which work goes to it under “Which model does which work”."}
Integrations: your own model provider, alerts that open issues, tickets agents read673 </p>
674 )}
675 </div>
676 );
677}