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.

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