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