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.

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