Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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