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

713 lines26,986 bytesCodeBlame
1import {
2 AlertTriangle,
3 Bot,
4 CheckCircle2,
5 ChevronRight,
6 Cpu,
7 Plug,
8 Siren,
9 Ticket,
10 Webhook,
11 X,
12} from "lucide-react";
13import type { ReactNode } from "react";
14import { Form, Link, useNavigation } from "react-router";
15
16import {
17 type Connection,
18 type ConnectionConfig,
19 type Delivery,
20 type Provider,
21 type ProviderKind,
22 PROVIDERS,
23} from "@g1t/contracts";
24
25import type { Route } from "./+types/integrations";
26import { Button, CopyLine, ErrorText, Field, Input, Pill, TimeAgo } from "../../components/ui";
27import { billing, integrations, repos } from "../../lib/services.server";
28import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
29
30export function meta({ params }: Route.MetaArgs) {
31 return [{ title: `Integrations · ${params.owner} · g1t` }];
32}
33
34const isProvider = (value: string | null): value is Provider => value != null && value in PROVIDERS;
35
36export async function loader({ params, context, request }: Route.LoaderArgs) {
37 const viewer = getViewer(context);
38 const role = roleIn(viewer, params.owner);
39 if (!role) throw new Response(null, { status: 404 });
40 const slug = params.owner.toLowerCase();
41 const [connections, listed, account] = await Promise.all([
42 integrations.list(slug, viewer),
43 repos.list(viewer, { namespace: slug }),
44 billing.account(slug, viewer),
45 ]);
46 const all = unwrap(connections);
47 // What each alert source has sent lately, to see it is wired up.
48 const deliveries: Record<string, Delivery[]> = {};
49 await Promise.all(
50 all
51 .filter((connection) => connection.kind === "alerts")
52 .map(async (connection) => {
53 const found = await integrations.deliveries(slug, viewer, connection.id);
54 deliveries[connection.id] = found.ok ? found.value.slice(0, 5) : [];
55 }),
56 );
57 const adding = new URL(request.url).searchParams.get("add");
58 return {
59 slug,
60 role,
61 connections: all,
62 deliveries,
63 repos: listed.map((repo) => `${repo.namespace}/${repo.name}`),
64 adding: isProvider(adding) ? adding : null,
65 feeMicros: account.ok ? account.value.orchestrationFeeMicros : 100_000,
66 marginPercent: account.ok ? account.value.marginPercent : 20,
67 };
68}
69
70function text(form: FormData, name: string): string | undefined {
71 const value = String(form.get(name) ?? "").trim();
72 return value || undefined;
73}
74
75/** The settings a form describes, for the provider it is for. */
76function configFrom(form: FormData): ConnectionConfig {
77 const keys = text(form, "keys");
78 return {
79 repo: text(form, "repo"),
80 assign: form.get("assign") === "on",
81 label: text(form, "label"),
82 writeBack: form.get("writeBack") !== "off",
83 organization: text(form, "organization"),
84 site: text(form, "site"),
85 email: text(form, "email"),
86 keys: keys ? keys.split(/[\s,]+/).filter(Boolean) : [],
87 baseUrl: text(form, "baseUrl"),
88 authHeader: text(form, "authHeader"),
89 model: text(form, "model"),
90 };
91}
92
93export async function action({ request, params, context }: Route.ActionArgs) {
94 assertSameOrigin(request);
95 const user = requireUser(context, request);
96 const slug = params.owner.toLowerCase();
97 const form = await request.formData();
98 const intent = form.get("intent");
99 const id = String(form.get("id") ?? "");
100 if (intent === "disconnect") {
101 const removed = await integrations.disconnect(user, slug, id);
102 return { error: removed.ok ? null : removed.error.message };
103 }
104 if (intent === "test") {
105 const tested = await integrations.test(user, slug, id);
106 return tested.ok ? { tested: { id, ...tested.value } } : { error: tested.error.message };
107 }
108 if (intent === "update") {
109 const updated = await integrations.update(user, slug, id, {
110 signingSecret: text(form, "signingSecret"),
111 secret: text(form, "secret"),
112 });
113 return { error: updated.ok ? null : updated.error.message };
114 }
115 const provider = String(form.get("provider") ?? "");
116 if (!isProvider(provider)) return { error: "Choose what to connect." };
117 const connected = await integrations.connect(user, slug, {
118 provider,
119 name: text(form, "name"),
120 config: configFrom(form),
121 secret: text(form, "secret"),
122 signingSecret: text(form, "signingSecret"),
123 });
124 return connected.ok ? { connected: connected.value } : { error: connected.error.message, provider };
125}
126
127const KIND_INFO: Record<ProviderKind, { title: string; icon: ReactNode; blurb: string }> = {
128 models: {
129 title: "Model provider",
130 icon: <Cpu size={16} />,
131 blurb: "Where your agents' model requests go, and who pays for them.",
132 },
133 alerts: {
134 title: "Alerts",
135 icon: <Siren size={16} />,
136 blurb: "Problems your monitoring finds become issues, and agents can start on them before anyone looks.",
137 },
138 tracker: {
139 title: "Trackers",
140 icon: <Ticket size={16} />,
141 blurb: "Tickets agents can read when work mentions them, that people can import as issues, and that hear back when the work lands.",
142 },
143};
144
145const PROVIDER_BLURB: Record<Provider, string> = {
146 anthropic: "Use your own Anthropic API key. Anthropic bills you for the models.",
147 anthropic_endpoint: "Any Anthropic-compatible endpoint: your own AI Gateway, LiteLLM, Bedrock or Vertex behind a proxy.",
148 sentry: "New errors open issues, with the stack trace. Resolved in Sentry when the fix merges.",
149 datadog: "Monitors that trigger open issues. Recoveries are noted on them.",
150 webhook: "Anything that can send signed JSON: PagerDuty, Grafana, your own scripts.",
151 jira: "Agents read TECH-1234 when work mentions it. Import tickets; they hear back.",
152 linear: "Agents read ENG-42 when work mentions it. Import issues; they hear back.",
153};
154
155/** A small mark for each provider: its initial, in a tile. */
156function ProviderMark({ provider, size = 32 }: { provider: Provider; size?: number }) {
157 const hue: Record<Provider, number> = {
158 anthropic: 40,
159 anthropic_endpoint: 280,
160 sentry: 300,
161 datadog: 290,
162 webhook: 200,
163 jira: 250,
164 linear: 265,
165 };
166 const icon =
167 provider === "webhook" ? <Webhook size={size * 0.5} /> : provider === "anthropic_endpoint" ? <Bot size={size * 0.5} /> : null;
168 return (
169 <span
170 aria-hidden="true"
171 className="inline-flex shrink-0 items-center justify-center font-semibold"
172 style={{
173 width: size,
174 height: size,
175 borderRadius: size * 0.28,
176 fontSize: size * 0.42,
177 background: `oklch(0.3 0.06 ${hue[provider]})`,
178 color: `oklch(0.9 0.08 ${hue[provider]})`,
179 }}
180 >
181 {icon ?? PROVIDERS[provider].label[0]}
182 </span>
183 );
184}
185
186function dollars(micros: number): string {
187 return `$${(micros / 1_000_000).toFixed(2)}`;
188}
189
190export default function WorkspaceIntegrations({ loaderData, actionData }: Route.ComponentProps) {
191 const { slug, role, connections, deliveries, repos: repoNames, adding, feeMicros, marginPercent } = loaderData;
192 const owner = role === "owner";
193 const busy = useNavigation().state === "submitting";
194 const model = connections.find((connection) => connection.kind === "models");
195 const justConnected = actionData && "connected" in actionData ? actionData.connected : null;
196 const tested = (actionData && "tested" in actionData ? actionData.tested : null) ?? null;
197 const error = (actionData && "error" in actionData ? actionData.error : null) ?? null;
198
199 return (
200 <div className="max-w-4xl">
201 <div className="flex items-start gap-3">
202 <span className="mt-0.5 rounded-lg bg-surface p-2 text-muted ring-1 ring-line">
203 <Plug size={18} />
204 </span>
205 <div>
206 <h2 className="text-lg font-semibold tracking-tight">Integrations</h2>
207 <p className="mt-1 max-w-2xl text-sm text-muted">
208 Connect {slug} to the systems your work already lives in. g1t keeps secrets sealed and never
209 shows them again; agents never see them at all.
210 </p>
211 </div>
212 </div>
213
214 {justConnected && <Connected connected={justConnected} />}
215 <div className="mt-4">
216 <ErrorText>{error}</ErrorText>
217 </div>
218
219 {/* Models -------------------------------------------------------------- */}
220 <Section kind="models">
221 <div className="rounded-xl border border-line bg-surface p-4">
222 {model ? (
223 <ConnectionRow connection={model} owner={owner} busy={busy} tested={tested} deliveries={[]} />
224 ) : (
225 <div className="flex items-center gap-3">
226 <span className="inline-flex size-8 items-center justify-center rounded-lg bg-merged/15 text-merged ring-1 ring-merged/30">
227 <CheckCircle2 size={16} />
228 </span>
229 <div className="min-w-0 grow">
230 <p className="text-sm font-medium">g1t's models</p>
231 <p className="text-xs text-muted">
232 The default. g1t picks the model for each kind of work and charges your credit what it
233 cost, plus {marginPercent}%.
234 </p>
235 </div>
236 <Pill>In use</Pill>
237 </div>
238 )}
239 </div>
240 <p className="mt-3 text-xs text-faint">
241 With your own provider, its bill is yours and g1t charges {dollars(feeMicros)} a run for the
242 sandbox and orchestration. Your key goes only from g1t's model proxy to your provider: the
243 agent's sandbox holds a token that dies with the run.
244 </p>
245 {!model && owner && <Choices kind="models" slug={slug} adding={adding} />}
246 {adding && PROVIDERS[adding].kind === "models" && owner && (
247 <AddForm
248 provider={adding}
249 slug={slug}
250 repos={repoNames}
251 busy={busy}
252 error={actionData && "provider" in actionData ? error : null}
253 />
254 )}
255 </Section>
256
257 {/* Alerts -------------------------------------------------------------- */}
258 <Section kind="alerts">
259 <Connections
260 list={connections.filter((c) => c.kind === "alerts")}
261 owner={owner}
262 busy={busy}
263 tested={tested}
264 deliveries={deliveries}
265 />
266 {owner && <Choices kind="alerts" slug={slug} adding={adding} />}
267 {adding && PROVIDERS[adding].kind === "alerts" && owner && (
268 <AddForm
269 provider={adding}
270 slug={slug}
271 repos={repoNames}
272 busy={busy}
273 error={actionData && "provider" in actionData ? error : null}
274 />
275 )}
276 </Section>
277
278 {/* Trackers ------------------------------------------------------------ */}
279 <Section kind="tracker">
280 <Connections
281 list={connections.filter((c) => c.kind === "tracker")}
282 owner={owner}
283 busy={busy}
284 tested={tested}
285 deliveries={deliveries}
286 />
287 {owner && <Choices kind="tracker" slug={slug} adding={adding} />}
288 {adding && PROVIDERS[adding].kind === "tracker" && owner && (
289 <AddForm
290 provider={adding}
291 slug={slug}
292 repos={repoNames}
293 busy={busy}
294 error={actionData && "provider" in actionData ? error : null}
295 />
296 )}
297 </Section>
298
299 {!owner && <p className="mt-8 text-sm text-muted">An owner of {slug} can add and remove integrations.</p>}
300 </div>
301 );
302}
303
304function Section({ kind, children }: { kind: ProviderKind; children: ReactNode }) {
305 const info = KIND_INFO[kind];
306 return (
307 <section className="mt-10">
308 <h3 className="flex items-center gap-2 text-sm font-medium">
309 <span className="text-muted">{info.icon}</span>
310 {info.title}
311 </h3>
312 <p className="mt-1 mb-4 text-sm text-muted">{info.blurb}</p>
313 {children}
314 </section>
315 );
316}
317
318function Connections({
319 list,
320 owner,
321 busy,
322 tested,
323 deliveries,
324}: {
325 list: Connection[];
326 owner: boolean;
327 busy: boolean;
328 tested: { id: string; ok: boolean; message: string } | null;
329 deliveries: Record<string, Delivery[]>;
330}) {
331 if (list.length === 0) return null;
332 return (
333 <ul className="mb-4 divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
334 {list.map((connection) => (
335 <li key={connection.id} className="p-4">
336 <ConnectionRow
337 connection={connection}
338 owner={owner}
339 busy={busy}
340 tested={tested}
341 deliveries={deliveries[connection.id] ?? []}
342 />
343 </li>
344 ))}
345 </ul>
346 );
347}
348
349function ConnectionRow({
350 connection,
351 owner,
352 busy,
353 tested,
354 deliveries,
355}: {
356 connection: Connection;
357 owner: boolean;
358 busy: boolean;
359 tested: { id: string; ok: boolean; message: string } | null;
360 deliveries: Delivery[];
361}) {
362 const { config } = connection;
363 const facts = [
364 PROVIDERS[connection.provider].label,
365 config.repo && `issues in ${config.repo}`,
366 config.assign && "agents start at once",
367 config.organization,
368 config.site?.replace(/^https:\/\//, ""),
369 config.baseUrl?.replace(/^https:\/\//, ""),
370 config.model && `model ${config.model}`,
371 config.keys?.length ? config.keys.join(", ") : null,
372 connection.secretHint && `key ${connection.secretHint}`,
373 ].filter(Boolean);
374 const waitingForSecret = connection.provider === "sentry" && !deliveries.length && !connection.lastUsedAt;
375 return (
376 <div>
377 <div className="flex flex-wrap items-center gap-3">
378 <ProviderMark provider={connection.provider} />
379 <div className="min-w-0 grow">
380 <p className="truncate text-sm font-medium">{connection.name}</p>
381 <p className="truncate text-xs text-muted">{facts.join(" · ")}</p>
382 </div>
383 {connection.lastUsedAt && (
384 <span className="text-xs text-faint">
385 Used <TimeAgo at={connection.lastUsedAt} />
386 </span>
387 )}
388 {owner && (
389 <Form method="post" className="flex gap-2">
390 <input type="hidden" name="id" value={connection.id} />
391 <Button variant="quiet" type="submit" name="intent" value="test" disabled={busy}>
392 Test
393 </Button>
394 <Button variant="quiet" type="submit" name="intent" value="disconnect" disabled={busy} aria-label="Disconnect">
395 <X size={14} />
396 </Button>
397 </Form>
398 )}
399 </div>
400 {tested?.id === connection.id && (
401 <p className={`mt-3 text-sm ${tested.ok ? "text-accent" : "text-danger"}`}>{tested.message}</p>
402 )}
403 {connection.lastError && (
404 <p className="mt-3 flex items-start gap-2 text-xs text-warn">
405 <AlertTriangle size={13} className="mt-0.5 shrink-0" />
406 {connection.lastError}
407 </p>
408 )}
409 {connection.webhookUrl && (
410 <div className="mt-3">
411 <p className="mb-1.5 text-xs text-faint">Where it sends alerts</p>
412 <CopyLine text={connection.webhookUrl} />
413 </div>
414 )}
415 {owner && waitingForSecret && (
416 <Form method="post" className="mt-3 flex flex-wrap items-end gap-2">
417 <input type="hidden" name="intent" value="update" />
418 <input type="hidden" name="id" value={connection.id} />
419 <div className="min-w-64 grow">
420 <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.">
421 <Input name="signingSecret" type="password" placeholder="Paste to replace" />
422 </Field>
423 </div>
424 <Button type="submit" variant="quiet" disabled={busy}>
425 Save
426 </Button>
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-accent"
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
463function Choices({ kind, slug, adding }: { kind: ProviderKind; slug: string; adding: Provider | null }) {
464 const providers = (Object.keys(PROVIDERS) as Provider[]).filter((p) => PROVIDERS[p].kind === kind);
465 return (
466 <div className="mt-3 grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
467 {providers.map((provider) => (
468 <Link
469 key={provider}
470 to={`/${slug}/-/integrations?add=${provider}#add`}
471 preventScrollReset
472 className={`group flex items-start gap-3 rounded-xl border p-3.5 transition-colors ${
473 adding === provider ? "border-accent-dim bg-surface" : "border-line hover:border-line-strong hover:bg-surface"
474 }`}
475 >
476 <ProviderMark provider={provider} size={28} />
477 <span className="min-w-0 grow">
478 <span className="flex items-center gap-1 text-sm font-medium">
479 {PROVIDERS[provider].label}
480 <ChevronRight size={13} className="text-faint transition-transform group-hover:translate-x-0.5" />
481 </span>
482 <span className="mt-0.5 block text-xs leading-relaxed text-muted">{PROVIDER_BLURB[provider]}</span>
483 </span>
484 </Link>
485 ))}
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 <select
494 name="repo"
495 required={required}
496 defaultValue={repos[0] ?? ""}
497 className="w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none hover:border-line-strong focus:border-accent-dim"
498 >
499 {!required && <option value="">None</option>}
500 {repos.map((repo) => (
501 <option key={repo} value={repo}>
502 {repo}
503 </option>
504 ))}
505 </select>
506 </Field>
507 );
508}
509
510function AssignField() {
511 return (
512 <label className="flex items-start gap-2.5 text-sm">
513 <input type="checkbox" name="assign" className="mt-1 accent-[var(--color-accent)]" />
514 <span>
515 <span className="font-medium">Put a g1t agent on each new issue</span>
516 <span className="block text-xs text-muted">
517 It opens a pull request, gets reviewed, and lands through your merge rules, before anyone has
518 to look. Agents run only where g1t agents are enabled.
519 </span>
520 </span>
521 </label>
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: Record<Provider, ReactNode> = {
539 anthropic: (
540 <Field label="API key" hint="From console.anthropic.com. Sealed when saved; nobody sees it again.">
541 <Input name="secret" type="password" required placeholder="sk-ant-…" />
542 </Field>
543 ),
544 anthropic_endpoint: (
545 <>
546 <Field label="Base URL" hint="Without /v1. For a Cloudflare AI Gateway: https://gateway.ai.cloudflare.com/v1/<account>/<gateway>/anthropic">
547 <Input name="baseUrl" type="url" required placeholder="https://llm.example.com" />
548 </Field>
549 <Field label="Key" hint="Optional, if the endpoint needs one.">
550 <Input name="secret" type="password" />
551 </Field>
552 <Field label="Send the key as">
553 <select name="authHeader" className="w-full rounded-md border border-line bg-bg px-3 py-2 text-sm">
554 <option value="x-api-key">x-api-key</option>
555 <option value="authorization">Authorization: Bearer</option>
556 </select>
557 </Field>
558 <Field label="Model" hint="Optional. Leave empty to use g1t's choice for each kind of work; set it if your endpoint names models its own way.">
559 <Input name="model" placeholder="claude-sonnet-5-5" />
560 </Field>
561 </>
562 ),
563 sentry: (
564 <>
565 <Field label="Organization" hint="Its slug, as in acme.sentry.io.">
566 <Input name="organization" required placeholder="acme" />
567 </Field>
568 <RepoField repos={repos} required hint="Where issues are opened." />
569 <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.">
570 <Input name="secret" type="password" />
571 </Field>
572 <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.">
573 <Input name="signingSecret" type="password" />
574 </Field>
575 <AssignField />
576 </>
577 ),
578 datadog: (
579 <>
580 <RepoField repos={repos} required hint="Where issues are opened." />
581 <AssignField />
582 </>
583 ),
584 webhook: (
585 <>
586 <RepoField repos={repos} required hint="Where issues are opened." />
587 <AssignField />
588 </>
589 ),
590 jira: (
591 <>
592 <Field label="Site" hint="Your Jira's address.">
593 <Input name="site" type="url" required placeholder="https://acme.atlassian.net" />
594 </Field>
595 <Field label="Email" hint="The account the API token belongs to.">
596 <Input name="email" type="email" required autoComplete="off" />
597 </Field>
598 <Field label="API token" hint="From id.atlassian.com → Security → API tokens.">
599 <Input name="secret" type="password" required />
600 </Field>
601 <Field label="Project keys" hint="Optional. The projects this answers for, such as TECH, OPS. Empty answers for any key.">
602 <Input name="keys" placeholder="TECH, OPS" />
603 </Field>
604 </>
605 ),
606 linear: (
607 <>
608 <Field label="API key" hint="From Linear → Settings → Security & access → Personal API keys.">
609 <Input name="secret" type="password" required placeholder="lin_api_…" />
610 </Field>
611 <Field label="Team keys" hint="Optional. Such as ENG. Empty answers for any key.">
612 <Input name="keys" placeholder="ENG" />
613 </Field>
614 </>
615 ),
616 };
617 return (
618 <section id="add" className="mt-4 scroll-mt-20 rounded-xl border border-line bg-surface p-5">
619 <div className="flex items-center gap-3">
620 <ProviderMark provider={provider} />
621 <div className="grow">
622 <h3 className="font-medium">
623 Connect {provider === "anthropic_endpoint" ? "your own endpoint" : PROVIDERS[provider].label}
624 </h3>
625 <p className="text-xs text-muted">{PROVIDER_BLURB[provider]}</p>
626 </div>
627 <Link to={`/${slug}/-/integrations`} className="rounded-md p-1.5 text-faint hover:bg-raised hover:text-fg" aria-label="Close">
628 <X size={16} />
629 </Link>
630 </div>
631 <Form method="post" className="mt-5 grid max-w-xl gap-4">
632 <input type="hidden" name="provider" value={provider} />
633 {fields[provider]}
634 <ErrorText>{error}</ErrorText>
635 <div>
636 <Button type="submit" disabled={busy}>
637 {busy ? "Connecting…" : "Connect"}
638 </Button>
639 </div>
640 </Form>
641 </section>
642 );
643}
644
645/** What to do next, right after connecting: shown once. */
646function Connected({ connected }: { connected: { connection: Connection; signingSecret: string | null } }) {
647 const { connection, signingSecret } = connected;
648 const url = connection.webhookUrl;
649 return (
650 <div className="mt-6 rounded-xl border border-accent-dim/60 bg-accent/5 p-5">
651 <p className="flex items-center gap-2 font-medium">
652 <CheckCircle2 size={16} className="text-accent" />
653 {connection.name} is connected.
654 </p>
655 {connection.provider === "sentry" && url && (
656 <ol className="mt-3 list-decimal space-y-2 pl-5 text-sm text-muted">
657 <li>
658 In Sentry, open Settings → Developer Settings → Custom Integrations, and create an internal
659 integration.
660 </li>
661 <li>
662 Set its webhook URL to <CopyLine text={url} />
663 </li>
664 <li>Turn on Alert Rule Action, and under Webhooks, tick issue.</li>
665 <li>Give it Issue &amp; Event read and write, save, and paste its client secret below, on the connection.</li>
666 </ol>
667 )}
668 {(connection.provider === "datadog" || connection.provider === "webhook") && url && signingSecret && (
669 <div className="mt-3 space-y-3 text-sm text-muted">
670 <p>This secret is shown once. Copy it now.</p>
671 <CopyLine text={signingSecret} />
672 {connection.provider === "datadog" ? (
673 <>
674 <p>
675 In Datadog, open Integrations → Webhooks and add one named <code>g1t</code> with this URL:
676 </p>
677 <CopyLine text={url} />
678 <p>Custom headers:</p>
679 <CopyLine text={`{"Authorization": "Bearer ${signingSecret}"}`} />
680 <p>Payload:</p>
681 <CopyLine
682 text={`{"id": "$ALERT_ID", "title": "$EVENT_TITLE", "body": "$EVENT_MSG", "url": "$LINK", "status": "$ALERT_TRANSITION", "priority": "$PRIORITY"}`}
683 />
684 <p>
685 Then mention <code>@webhook-g1t</code> in any monitor's message.
686 </p>
687 </>
688 ) : (
689 <>
690 <p>Send JSON with at least a title to:</p>
691 <CopyLine text={url} />
692 <p>
693 with <code>Authorization: Bearer &lt;secret&gt;</code>, or an HMAC-SHA256 of the body in{" "}
694 <code>X-G1t-Signature</code>. An <code>id</code> keeps repeats on one issue.
695 </p>
696 </>
697 )}
698 </div>
699 )}
700 {connection.kind === "tracker" && (
701 <p className="mt-2 text-sm text-muted">
702 Agents now read tickets that work mentions, and you can import one from any repository's new
703 issue page. Use Test to check the token.
704 </p>
705 )}
706 {connection.kind === "models" && (
707 <p className="mt-2 text-sm text-muted">
708 The next agent run uses it. Use Test to check the key.
709 </p>
710 )}
711 </div>
712 );
713}