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