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.

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