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.

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