Skip to content
772 linesCodeBlameRaw
1import {
2 AlertTriangle,
3 ArrowLeft,
4 CheckCircle2,
5 ChevronRight,
6 Cpu,
7 Siren,
8 Ticket,
9 X,
10} from "lucide-react";
11import { env } from "cloudflare:workers";
12import type { ReactNode } from "react";
13import { Form, Link } from "react-router";
14
15import {
16 type Connection,
17 type ConnectionConfig,
18 type Delivery,
19 MODEL_TASKS,
20 type ModelRoute,
21 type Provider,
22 type ProviderKind,
23 PROVIDERS,
24 gatewayPatterns,
25} from "@g1t/contracts";
26
27import type { Route } from "./+types/integrations";
28import { page } from "../../lib/meta";
29import { trialClosed } from "../../lib/trial";
30import { GatewayModelsField, MODEL_CATALOG, ModelCatalog, ModelProviderFields, ProviderMark, ProviderTiles, Routing } from "../../components/model-providers";
31import { parseGatewayModels } from "../../lib/gateway";
32import { integrationsSection, sectionKind } from "../../lib/integration-sections";
33import { CopyLine, ErrorText, Field, Input, SubmitButton, TimeAgo } from "../../components/ui";
34import { Avatar } from "../../components/ui/avatar";
35import { Card } from "../../components/ui/card";
36import { CheckboxOption } from "../../components/ui/checkbox";
37import { Combobox } from "../../components/ui/combobox";
38import { billing, integrations, repos } from "../../lib/services.server";
39import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
40
41export function meta({ params, ...args }: Route.MetaArgs) {
42 const kind = sectionKind(params.section);
43 return page(args, { title: `${kind ? KIND_INFO[kind].title : "Integrations"} · ${params.owner} · g1t` });
44}
45
46const isProvider = (value: string | null): value is Provider => value != null && value in PROVIDERS;
47
48export async function loader({ params, context, request }: Route.LoaderArgs) {
49 const viewer = getViewer(context);
50 const role = roleIn(viewer, params.owner);
51 // One kind of connection's page: the directory at `-/integrations` finds them.
52 const kind = sectionKind(params.section);
53 if (!role || !kind) throw new Response(null, { status: 404 });
54 const slug = params.owner.toLowerCase();
55 const [connections, listed, account, access, routes] = await Promise.all([
56 integrations.list(slug, viewer),
57 repos.list(viewer, { namespace: slug }),
58 billing.account(slug, viewer),
59 env.RUNNER.modelAccess(slug),
60 integrations.routes(slug, viewer),
61 ]);
62 const all = unwrap(connections);
63 // What each alert source has sent lately, to see it is wired up.
64 const deliveries: Record<string, Delivery[]> = {};
65 await Promise.all(
66 all
67 .filter((connection) => connection.kind === "alerts")
68 .map(async (connection) => {
69 const found = await integrations.deliveries(slug, viewer, connection.id);
70 deliveries[connection.id] = found.ok ? found.value.slice(0, 5) : [];
71 }),
72 );
73 const adding = new URL(request.url).searchParams.get("add");
74 return {
75 slug,
76 role,
77 kind,
78 connections: all,
79 deliveries,
80 repos: listed.map((repo) => `${repo.namespace}/${repo.name}`),
81 adding: isProvider(adding) ? adding : null,
82 hostedOpen: access.hosted,
83 hostedPreview: access.preview,
84 trial: access.trial,
85 routes: routes.ok ? routes.value : [],
86 free: account.ok ? Boolean(account.value.status.free) : false,
87 marginPercent: account.ok ? account.value.marginPercent : 20,
88 };
89}
90
91function text(form: FormData, name: string): string | undefined {
92 const value = String(form.get(name) ?? "").trim();
93 return value || undefined;
94}
95
96/** The settings a form describes, for the provider it is for. */
97function configFrom(form: FormData): ConnectionConfig {
98 const keys = text(form, "keys");
99 return {
100 repo: text(form, "repo"),
101 assign: form.get("assign") === "on",
102 label: text(form, "label"),
103 writeBack: form.get("writeBack") !== "off",
104 organization: text(form, "organization"),
105 site: text(form, "site"),
106 email: text(form, "email"),
107 keys: keys ? keys.split(/[\s,]+/).filter(Boolean) : [],
108 baseUrl: text(form, "baseUrl"),
109 authHeader: text(form, "authHeader"),
110 model: text(form, "model"),
111 // Only a model provider's form has the field; empty means none.
112 gatewayModels: form.has("gatewayModels") ? parseGatewayModels(String(form.get("gatewayModels"))) : undefined,
113 };
114}
115
116export async function action({ request, params, context }: Route.ActionArgs) {
117 assertSameOrigin(request);
118 const user = requireUser(context, request);
119 const slug = params.owner.toLowerCase();
120 const form = await request.formData();
121 const intent = form.get("intent");
122 const id = String(form.get("id") ?? "");
123 if (intent === "disconnect") {
124 const removed = await integrations.disconnect(user, slug, id);
125 return { error: removed.ok ? null : removed.error.message };
126 }
127 if (intent === "test") {
128 const tested = await integrations.test(user, slug, id);
129 return tested.ok ? { tested: { id, ...tested.value } } : { error: tested.error.message };
130 }
131 if (intent === "routes") {
132 const routes: ModelRoute[] = [];
133 for (const task of MODEL_TASKS) {
134 const choice = String(form.get(`route-${task}`) ?? "");
135 if (!choice) continue;
136 if (choice === "g1t") routes.push({ task, connectionId: null, model: null });
137 else if (choice.startsWith("g1t::")) routes.push({ task, connectionId: null, model: choice.slice("g1t::".length) || null });
138 else {
139 const [connectionId, model] = choice.split("::");
140 routes.push({ task, connectionId, model: model || null });
141 }
142 }
143 const saved = await integrations.setRoutes(user, slug, routes);
144 return saved.ok ? { routed: true } : { error: saved.error.message };
145 }
146 if (intent === "gateway") {
147 // A model provider's AI Gateway models, and a new key if one was given:
148 // its other settings as they are.
149 const listed = await integrations.list(slug, user);
150 const connection = listed.ok ? listed.value.find((c) => c.id === id) : undefined;
151 if (!connection) return { error: listed.ok ? "No such integration." : listed.error.message };
152 const updated = await integrations.update(user, slug, id, {
153 config: { ...connection.config, gatewayModels: parseGatewayModels(String(form.get("gatewayModels") ?? "")) },
154 secret: text(form, "secret"),
155 });
156 return updated.ok ? { updated: id } : { error: updated.error.message };
157 }
158 if (intent === "update") {
159 const updated = await integrations.update(user, slug, id, {
160 signingSecret: text(form, "signingSecret"),
161 secret: text(form, "secret"),
162 });
163 return updated.ok ? { updated: id } : { error: updated.error.message };
164 }
165 const provider = String(form.get("provider") ?? "");
166 if (!isProvider(provider)) return { error: "Choose what to connect." };
167 const connected = await integrations.connect(user, slug, {
168 provider,
169 name: text(form, "name"),
170 config: configFrom(form),
171 secret: text(form, "secret"),
172 signingSecret: text(form, "signingSecret"),
173 });
174 return connected.ok ? { connected: connected.value } : { error: connected.error.message, provider };
175}
176
177const KIND_INFO: Record<ProviderKind, { title: string; icon: ReactNode; blurb: string }> = {
178 models: {
179 title: "Model providers",
180 icon: <Cpu size={16} />,
181 blurb: "Connect as many as you use, then choose which model does which work, and so who pays for it.",
182 },
183 alerts: {
184 title: "Alerts",
185 icon: <Siren size={16} />,
186 blurb: "Problems your monitoring finds become issues, and agents can start on them before anyone looks.",
187 },
188 tracker: {
189 title: "Trackers",
190 icon: <Ticket size={16} />,
191 blurb: "Tickets agents can read when work mentions them, that people can import as issues, and that hear back when the work lands.",
192 },
193};
194
195const PROVIDER_BLURB: Partial<Record<Provider, string>> = {
196 sentry: "New errors open issues, with the stack trace. Resolved in Sentry when the fix merges.",
197 datadog: "Monitors that trigger open issues. Recoveries are noted on them.",
198 webhook: "Anything that can send signed JSON: PagerDuty, Grafana, your own scripts.",
199 jira: "Agents read TECH-1234 when work mentions it. Import tickets; they hear back.",
200 linear: "Agents read ENG-42 when work mentions it. Import issues; they hear back.",
201};
202
203function dollars(micros: number): string {
204 return `$${(micros / 1_000_000).toFixed(2)}`;
205}
206
207export default function WorkspaceIntegrations({ loaderData, actionData }: Route.ComponentProps) {
208 const { slug, role, kind, connections, deliveries, repos: repoNames, adding, free, marginPercent, hostedOpen, hostedPreview, trial, routes } =
209 loaderData;
210 const owner = role === "owner";
211 const modelConnections = connections.filter((connection) => connection.kind === "models");
212 const justConnected = actionData && "connected" in actionData ? actionData.connected : null;
213 const tested = (actionData && "tested" in actionData ? actionData.tested : null) ?? null;
214 const error = (actionData && "error" in actionData ? actionData.error : null) ?? null;
215 const updated = (actionData && "updated" in actionData ? actionData.updated : null) ?? null;
216
217 return (
218 <div>
219 <Link to={`/${slug}/-/integrations`} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
220 <ArrowLeft size={14} /> All integrations
221 </Link>
222 {justConnected && <Connected connected={justConnected} />}
223 <div className="mt-4">
224 <ErrorText>{error}</ErrorText>
225 </div>
226
227 {/* Models -------------------------------------------------------------- */}
228 {kind === "models" && (
229 <Section kind="models">
230 {!hostedOpen && modelConnections.length === 0 && (
231 <div className="mb-4 flex items-center gap-3 rounded-xl border border-warn/30 bg-warn/5 p-4">
232 <span className="inline-flex size-8 shrink-0 items-center justify-center rounded-lg bg-warn/10 text-warn ring-1 ring-warn/30">
233 <AlertTriangle size={16} />
234 </span>
235 <p className="text-sm text-muted">
236 <span className="font-medium text-fg">Choose how your agents reach a model.</span>{" "}
237 {hostedPreview
238 ? `During the preview, g1t's hosted models run only for a few invited workspaces, and ${slug} is not one of them yet.`
239 : (trialClosed(trial, slug) ?? `g1t's hosted models are not open to ${slug}.`)}{" "}
240 Connect a provider of your own below and your agents start at once, billed by that provider.
241 </p>
242 </div>
243 )}
244 <Connections list={modelConnections} owner={owner} tested={tested} updated={updated} deliveries={deliveries} />
245 {modelConnections.length === 0 && hostedOpen && (
246 <Card asChild className="flex items-center gap-2 px-4 py-3 text-sm text-muted">
247 <p>
248 <CheckCircle2 size={15} className="shrink-0 text-accent" />
249 {trial?.open
250 ? trial.granted
251 ? `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.`
252 : `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.`
253 : free
254 ? "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."
255 : "All work runs on g1t's models, charged to your AI credit at the provider's price plus the agent rate. Auto picks the model for each job; choose one per kind of work below, or connect a provider of your own and pay for its models there."}
256 </p>
257 </Card>
258 )}
259 {(modelConnections.length > 0 || hostedOpen) && (
260 <Routing
261 // Started again from what is saved whenever that changes, such as a provider disconnected.
262 key={JSON.stringify([routes, modelConnections.map((c) => c.id)])}
263 connections={modelConnections}
264 routes={routes}
265 hostedOpen={hostedOpen}
266 owner={owner}
267 saved={actionData != null && "routed" in actionData}
268 />
269 )}
270 <p className="mt-3 text-xs text-faint">
271 On your own providers, their bills are yours.{" "}
272 {free
273 ? `g1t charges nothing while it is being built out; once pricing starts, each run's sandbox time at cost plus ${marginPercent}%, and the agent rate on the tokens it used.`
274 : `g1t charges each run's sandbox time at cost plus ${marginPercent}%, and the agent rate on the tokens it used, shown on Usage as "Agent rate, your own model key".`}{" "}
275 Keys go only from g1t's model proxy to the provider: the agent's
276 sandbox holds a token that dies with the run.
277 </p>
278 {owner && <ModelCatalog slug={slug} adding={adding} />}
279 {adding && PROVIDERS[adding].kind === "models" && owner && (
280 <AddForm
281 provider={adding}
282 slug={slug}
283 repos={repoNames}
284 error={actionData && "provider" in actionData ? error : null}
285 />
286 )}
287 </Section>
288 )}
289
290 {/* Alerts -------------------------------------------------------------- */}
291 {kind === "alerts" && (
292 <Section kind="alerts">
293 <Connections
294 list={connections.filter((c) => c.kind === "alerts")}
295 owner={owner}
296 tested={tested}
297 updated={updated}
298 deliveries={deliveries}
299 />
300 {owner && <Choices kind="alerts" slug={slug} adding={adding} />}
301 {adding && PROVIDERS[adding].kind === "alerts" && owner && (
302 <AddForm
303 provider={adding}
304 slug={slug}
305 repos={repoNames}
306 error={actionData && "provider" in actionData ? error : null}
307 />
308 )}
309 </Section>
310 )}
311
312 {/* Trackers ------------------------------------------------------------ */}
313 {kind === "tracker" && (
314 <Section kind="tracker">
315 <Connections
316 list={connections.filter((c) => c.kind === "tracker")}
317 owner={owner}
318 tested={tested}
319 updated={updated}
320 deliveries={deliveries}
321 />
322 {owner && <Choices kind="tracker" slug={slug} adding={adding} />}
323 {adding && PROVIDERS[adding].kind === "tracker" && owner && (
324 <AddForm
325 provider={adding}
326 slug={slug}
327 repos={repoNames}
328 error={actionData && "provider" in actionData ? error : null}
329 />
330 )}
331 </Section>
332 )}
333
334 {!owner && <p className="mt-8 text-sm text-muted">An owner of {slug} can add and remove integrations.</p>}
335 </div>
336 );
337}
338
339/** One kind of connection's page: its heading, then what is connected and what to connect. */
340function Section({ kind, children }: { kind: ProviderKind; children: ReactNode }) {
341 const info = KIND_INFO[kind];
342 return (
343 <section className="mt-4">
344 <h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight [&_svg]:size-5">
345 <span className="text-muted">{info.icon}</span>
346 {info.title}
347 </h1>
348 <p className="mt-1.5 mb-6 border-b border-line pb-6 text-sm text-muted">{info.blurb}</p>
349 {children}
350 </section>
351 );
352}
353
354function Connections({
355 list,
356 owner,
357 tested,
358 updated,
359 deliveries,
360}: {
361 list: Connection[];
362 owner: boolean;
363 tested: { id: string; ok: boolean; message: string } | null;
364 /** The connection whose secret was just saved. */
365 updated: string | null;
366 deliveries: Record<string, Delivery[]>;
367}) {
368 if (list.length === 0) return null;
369 return (
370 <Card asChild divided className="mb-4 overflow-hidden">
371 <ul>
372 {list.map((connection) => (
373 <li key={connection.id} className="p-4">
374 <ConnectionRow
375 connection={connection}
376 owner={owner}
377 tested={tested}
378 updated={updated === connection.id}
379 deliveries={deliveries[connection.id] ?? []}
380 />
381 </li>
382 ))}
383 </ul>
384 </Card>
385 );
386}
387
388/** Just the host of an address, which is what tells connections apart. */
389function host(url: string | undefined): string | undefined {
390 if (!url) return undefined;
391 try {
392 return new URL(url).host;
393 } catch {
394 return url;
395 }
396}
397
398function ConnectionRow({
399 connection,
400 owner,
401 tested,
402 updated,
403 deliveries,
404}: {
405 connection: Connection;
406 owner: boolean;
407 tested: { id: string; ok: boolean; message: string } | null;
408 updated: boolean;
409 deliveries: Delivery[];
410}) {
411 const { config } = connection;
412 const facts = [
413 PROVIDERS[connection.provider].label,
414 config.repo && `issues in ${config.repo}`,
415 config.assign && "agents start at once",
416 config.organization,
417 host(config.site),
418 host(config.baseUrl),
419 config.model && `default ${config.model}`,
420 connection.models.length > 0 && `${connection.models.length} models`,
421 config.keys?.length ? config.keys.join(", ") : null,
422 connection.secretHint && `key ${connection.secretHint}`,
423 ].filter(Boolean);
424 const gateway = connection.kind === "models" ? gatewayPatterns(connection.provider, config) : null;
425 const waitingForSecret = connection.provider === "sentry" && !deliveries.length && !connection.lastUsedAt;
426 return (
427 <div>
428 <div className="flex items-center gap-3">
429 <ProviderMark provider={connection.provider} />
430 <div className="min-w-0 grow">
431 <p className="truncate text-sm font-medium">{connection.name}</p>
432 <p className="truncate text-xs text-muted">{facts.join(" · ")}</p>
433 </div>
434 {connection.lastUsedAt && (
435 <span className="hidden shrink-0 text-xs text-faint sm:inline">
436 Used <TimeAgo at={connection.lastUsedAt} />
437 </span>
438 )}
439 {owner && (
440 <Form method="post" className="flex shrink-0 gap-2">
441 <input type="hidden" name="id" value={connection.id} />
442 <SubmitButton variant="outline" name="intent" value="test" match={{ id: connection.id }} pending="Testing…">
443 Test
444 </SubmitButton>
445 <SubmitButton variant="outline" name="intent" value="disconnect" match={{ id: connection.id }} pending="Disconnecting…" aria-label="Disconnect">
446 <X size={14} />
447 </SubmitButton>
448 </Form>
449 )}
450 </div>
451 {tested?.id === connection.id && (
452 <p className={`mt-3 text-sm ${tested.ok ? "text-success" : "text-danger"}`}>{tested.message}</p>
453 )}
454 {connection.lastError && (
455 <p className="mt-3 flex items-start gap-2 text-xs text-warn">
456 <AlertTriangle size={13} className="mt-0.5 shrink-0" />
457 {connection.lastError}
458 </p>
459 )}
460 {gateway && (
461 <div className="mt-3 flex flex-wrap items-center gap-1.5 text-xs text-muted">
462 <span className="text-faint">AI Gateway</span>
463 {gateway.length === 0 ? (
464 <span>no models</span>
465 ) : (
466 gateway.map((pattern) => (
467 <code key={pattern} className="rounded border border-line bg-bg px-1.5 py-0.5 font-mono text-[0.75rem] text-fg">
468 {pattern}
469 </code>
470 ))
471 )}
472 </div>
473 )}
474 {owner && gateway && (
475 <details className="group mt-3" open={updated || undefined}>
476 <summary className="inline-flex cursor-pointer list-none items-center gap-1 text-xs text-muted hover:text-fg">
477 <ChevronRight size={12} className="transition-transform group-open:rotate-90" />
478 Change its AI Gateway models or key
479 </summary>
480 <Form method="post" className="mt-3 max-w-2xl space-y-3">
481 <input type="hidden" name="intent" value="gateway" />
482 <input type="hidden" name="id" value={connection.id} />
483 <GatewayModelsField provider={connection.provider} value={gateway} />
484 <Field label="Replace the key" hint="Write-only: g1t never shows it again. Leave it empty to keep the key you have.">
485 <Input name="secret" type="password" placeholder={connection.secretHint ? `Now ${connection.secretHint}` : "Paste a key"} autoComplete="new-password" className="max-w-sm" />
486 </Field>
487 <div className="flex items-center gap-3">
488 <SubmitButton variant="outline" match={{ intent: "gateway", id: connection.id }} pending="Saving…">
489 Save
490 </SubmitButton>
491 {updated && <span className="text-sm text-success">Saved.</span>}
492 </div>
493 </Form>
494 </details>
495 )}
496 {connection.webhookUrl && (
497 <div className="mt-3">
498 <p className="mb-1.5 text-xs text-faint">Where it sends alerts</p>
499 <CopyLine text={connection.webhookUrl} />
500 </div>
501 )}
502 {owner && waitingForSecret && (
503 <Form method="post" className="mt-3 flex flex-wrap items-end gap-2">
504 <input type="hidden" name="intent" value="update" />
505 <input type="hidden" name="id" value={connection.id} />
506 <div className="min-w-64 grow">
507 <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.">
508 <Input name="signingSecret" type="password" placeholder="Paste to replace" />
509 </Field>
510 </div>
511 <SubmitButton variant="outline" match={{ intent: "update", id: connection.id }} pending="Saving…">
512 Save
513 </SubmitButton>
514 {updated && <span className="pb-2 text-sm text-muted">Saved.</span>}
515 </Form>
516 )}
517 {deliveries.length > 0 && (
518 <ul className="mt-3 space-y-1 text-xs">
519 {deliveries.map((delivery) => (
520 <li key={delivery.id} className="flex items-center gap-2 text-muted">
521 <span
522 className={`size-1.5 shrink-0 rounded-full ${
523 delivery.outcome === "refused"
524 ? "bg-danger"
525 : delivery.outcome === "ignored"
526 ? "bg-faint"
527 : "bg-success"
528 }`}
529 />
530 <span className="font-mono text-faint">{delivery.event}</span>
531 <span className="min-w-0 truncate">{delivery.detail}</span>
532 {delivery.issue && (
533 <Link
534 to={`/${delivery.issue.replace("#", "/issues/")}`}
535 className="shrink-0 font-mono text-fg hover:underline"
536 >
537 {delivery.issue}
538 </Link>
539 )}
540 <span className="ml-auto shrink-0 text-faint">
541 <TimeAgo at={delivery.receivedAt} />
542 </span>
543 </li>
544 ))}
545 </ul>
546 )}
547 </div>
548 );
549}
550
551/** What a provider is for, in a line. */
552function blurb(provider: Provider): string {
553 return PROVIDER_BLURB[provider] ?? MODEL_CATALOG[provider]?.blurb ?? "";
554}
555
556function Choices({ kind, slug, adding }: { kind: ProviderKind; slug: string; adding: Provider | null }) {
557 const providers = (Object.keys(PROVIDERS) as Provider[]).filter((p) => PROVIDERS[p].kind === kind);
558 return (
559 <div className="mt-3">
560 <ProviderTiles providers={providers} slug={slug} adding={adding} blurb={blurb} />
561 </div>
562 );
563}
564
565function RepoField({ repos, required, hint }: { repos: string[]; required?: boolean; hint: string }) {
566 return (
567 <Field label="Repository" hint={hint}>
568 <Combobox
569 name="repo"
570 defaultValue={repos[0] ?? ""}
571 aria-label="Repository"
572 placeholder={repos.length === 0 ? "No repositories yet" : "Choose a repository"}
573 searchPlaceholder="Find a repository"
574 emptyText="No repository by that name."
575 options={[
576 ...(required ? [] : [{ value: "", label: "None" }]),
577 ...repos.map((repo) => ({ value: repo, label: repo, icon: <Avatar name={repo.split("/").pop() ?? repo} size={16} square /> })),
578 ]}
579 className="font-mono text-[0.8125rem]"
580 />
581 </Field>
582 );
583}
584
585function AssignField() {
586 return (
587 <CheckboxOption
588 name="assign"
589 label={<span className="font-medium">Assign each new issue to g1t</span>}
590 description={
591 <span className="text-muted">
592 It opens a pull request, gets reviewed, and lands through your merge rules, before anyone has to look.
593 Agents run only where g1t is enabled.
594 </span>
595 }
596 />
597 );
598}
599
600function AddForm({
601 provider,
602 slug,
603 repos,
604 error,
605}: {
606 provider: Provider;
607 slug: string;
608 repos: string[];
609 error: string | null;
610}) {
611 const fields: Partial<Record<Provider, ReactNode>> = {
612 sentry: (
613 <>
614 <Field label="Organization" hint="Its slug, as in acme.sentry.io.">
615 <Input name="organization" required placeholder="acme" />
616 </Field>
617 <RepoField repos={repos} required hint="Where issues are opened." />
618 <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.">
619 <Input name="secret" type="password" />
620 </Field>
621 <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.">
622 <Input name="signingSecret" type="password" />
623 </Field>
624 <AssignField />
625 </>
626 ),
627 datadog: (
628 <>
629 <RepoField repos={repos} required hint="Where issues are opened." />
630 <AssignField />
631 </>
632 ),
633 webhook: (
634 <>
635 <RepoField repos={repos} required hint="Where issues are opened." />
636 <AssignField />
637 </>
638 ),
639 jira: (
640 <>
641 <Field label="Site" hint="Your Jira's address.">
642 <Input name="site" type="url" required placeholder="https://acme.atlassian.net" />
643 </Field>
644 <Field label="Email" hint="The account the API token belongs to.">
645 <Input name="email" type="email" required autoComplete="off" />
646 </Field>
647 <Field label="API token" hint="From id.atlassian.com → Security → API tokens.">
648 <Input name="secret" type="password" required />
649 </Field>
650 <Field label="Project keys" hint="Optional. The projects this answers for, such as TECH, OPS. Empty answers for any key.">
651 <Input name="keys" placeholder="TECH, OPS" />
652 </Field>
653 </>
654 ),
655 linear: (
656 <>
657 <Field label="API key" hint="From Linear → Settings → Security & access → Personal API keys.">
658 <Input name="secret" type="password" required placeholder="lin_api_…" />
659 </Field>
660 <Field label="Team keys" hint="Optional. Such as ENG. Empty answers for any key.">
661 <Input name="keys" placeholder="ENG" />
662 </Field>
663 </>
664 ),
665 };
666 return (
667 <Card asChild className="mt-4 scroll-mt-20 p-5">
668 <section id="add">
669 <div className="flex items-center gap-3">
670 <ProviderMark provider={provider} />
671 <div className="grow">
672 <h3 className="font-medium">
673 Connect {provider.endsWith("_endpoint") ? `an ${PROVIDERS[provider].label}` : PROVIDERS[provider].label}
674 </h3>
675 <p className="text-xs text-muted">{blurb(provider)}</p>
676 </div>
677 <Link
678 to={`/${slug}/-/integrations/${integrationsSection(PROVIDERS[provider].kind)}`}
679 className="rounded-md p-1.5 text-faint hover:bg-raised hover:text-fg"
680 aria-label="Close"
681 >
682 <X size={16} />
683 </Link>
684 </div>
685 <Form method="post" className="mt-5 grid max-w-xl gap-4">
686 <input type="hidden" name="provider" value={provider} />
687 {fields[provider] ?? <ModelProviderFields provider={provider} />}
688 <ErrorText>{error}</ErrorText>
689 <div>
690 <SubmitButton match={{ provider }} pending="Connecting…">
691 Connect
692 </SubmitButton>
693 </div>
694 </Form>
695 </section>
696 </Card>
697 );
698}
699
700/** What to do next, right after connecting: shown once. */
701function Connected({ connected }: { connected: { connection: Connection; signingSecret: string | null } }) {
702 const { connection, signingSecret } = connected;
703 const url = connection.webhookUrl;
704 return (
705 <div className="mt-6 rounded-xl border border-success-dim/60 bg-success/5 p-5">
706 <p className="flex items-center gap-2 font-medium">
707 <CheckCircle2 size={16} className="text-success" />
708 {connection.name} is connected.
709 </p>
710 {connection.provider === "sentry" && url && (
711 <ol className="mt-3 list-decimal space-y-2 pl-5 text-sm text-muted">
712 <li>
713 In Sentry, open Settings → Developer Settings → Custom Integrations, and create an internal
714 integration.
715 </li>
716 <li>
717 Set its webhook URL to <CopyLine text={url} />
718 </li>
719 <li>Turn on Alert Rule Action, and under Webhooks, tick issue.</li>
720 <li>Give it Issue &amp; Event read and write, save, and paste its client secret below, on the connection.</li>
721 </ol>
722 )}
723 {(connection.provider === "datadog" || connection.provider === "webhook") && url && signingSecret && (
724 <div className="mt-3 space-y-3 text-sm text-muted">
725 <p>This secret is shown once. Copy it now.</p>
726 <CopyLine text={signingSecret} />
727 {connection.provider === "datadog" ? (
728 <>
729 <p>
730 In Datadog, open Integrations → Webhooks and add one named <code>g1t</code> with this URL:
731 </p>
732 <CopyLine text={url} />
733 <p>Custom headers:</p>
734 <CopyLine text={`{"Authorization": "Bearer ${signingSecret}"}`} />
735 <p>Payload:</p>
736 <CopyLine
737 text={`{"id": "$ALERT_ID", "title": "$EVENT_TITLE", "body": "$EVENT_MSG", "url": "$LINK", "status": "$ALERT_TRANSITION", "priority": "$PRIORITY"}`}
738 />
739 <p>
740 Then mention <code>@webhook-g1t</code> in any monitor's message.
741 </p>
742 </>
743 ) : (
744 <>
745 <p>Send JSON with at least a title to:</p>
746 <CopyLine text={url} />
747 <p>
748 with <code>Authorization: Bearer &lt;secret&gt;</code>, or an HMAC-SHA256 of the body in{" "}
749 <code>X-G1t-Signature</code>. An <code>id</code> keeps repeats on one issue.
750 </p>
751 </>
752 )}
753 </div>
754 )}
755 {connection.kind === "tracker" && (
756 <p className="mt-2 text-sm text-muted">
757 Agents now read tickets that work mentions, and you can import one from any repository's new
758 issue page. Use Test to check the token.
759 </p>
760 )}
761 {connection.kind === "models" && (
762 <p className="mt-2 text-sm text-muted">
763 {connection.lastError
764 ? `It was saved, but the check failed: ${connection.lastError}`
765 : connection.models.length > 0
766 ? `It offers ${connection.models.length} models. Choose which work goes to it under “Which model does which work”.`
767 : "Choose which work goes to it under “Which model does which work”."}
768 </p>
769 )}
770 </div>
771 );
772}