g1t/apps/web/app/routes/workspace/integrations.tsx

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