flagon-io/g1t

public

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

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

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