Skip to content
92 linesCodeBlameRaw
1import { redirect } from "react-router";
2
3import { type Provider, PROVIDERS } from "@g1t/contracts";
4import { CONNECTORS, connectorsFor } from "@g1t/contracts/connectors";
5
6import type { Route } from "./+types/integrations-directory";
7import { ConnectorDirectory, ScopeNote } from "../../components/connectors";
8import type { ConnectedState } from "../../lib/connectors";
9import { githubApp } from "../../lib/github.server";
10import { integrationsSection } from "../../lib/integration-sections";
11import { page } from "../../lib/meta";
12import { integrations, webhooks } from "../../lib/services.server";
13import { getViewer, roleIn } from "../../lib/session.server";
14
15export function meta({ params, ...args }: Route.MetaArgs) {
16 return page(args, { title: `Integrations · ${params.owner} · g1t` });
17}
18
19/**
20 * The workspace's integrations: every connector g1t has or will have, by
21 * category, with what this workspace has connected first. Each one is set
22 * up on its own page (the model providers, alerts and trackers pages, the
23 * GitHub import, webhooks); this page only finds them.
24 */
25export async function loader({ params, context, request }: Route.LoaderArgs) {
26 const viewer = getViewer(context);
27 const role = roleIn(viewer, params.owner);
28 if (!role || !viewer) throw new Response(null, { status: 404 });
29 const slug = params.owner.toLowerCase();
30
31 // An older link to one provider's form (`?add=sentry`): its own page now.
32 const add = new URL(request.url).searchParams.get("add");
33 if (add && add in PROVIDERS) {
34 throw redirect(`/${slug}/-/integrations/${integrationsSection(PROVIDERS[add as Provider].kind)}?add=${add}#add`);
35 }
36
37 const [connections, github, hooks] = await Promise.all([
38 integrations.list(slug, viewer),
39 githubApp.status(viewer, slug).catch(() => null),
40 webhooks.list(viewer, { workspace: slug }).catch(() => null),
41 ]);
42
43 const connected: Record<string, ConnectedState> = {};
44 if (connections.ok) {
45 for (const connector of CONNECTORS) {
46 if (!connector.provider) continue;
47 const mine = connections.value.filter((connection) => connection.provider === connector.provider);
48 if (mine.length === 0) continue;
49 const broken = mine.find((connection) => connection.lastError);
50 connected[connector.id] = {
51 detail: mine.length === 1 ? mine[0]!.name : `${mine.length} connections: ${mine.map((c) => c.name).join(", ")}`,
52 problem: broken ? `${broken.name}: ${broken.lastError}` : null,
53 manage: `/${slug}/-/integrations/${integrationsSection(PROVIDERS[connector.provider].kind)}`,
54 };
55 }
56 }
57 if (github?.ok && github.value.installations.length > 0) {
58 const suspended = github.value.installations.find((installation) => installation.suspended);
59 connected.github = {
60 detail: `On ${github.value.installations.map((installation) => installation.account).join(", ")}`,
61 problem: suspended ? `The installation on ${suspended.account} is suspended on GitHub.` : null,
62 manage: null,
63 };
64 }
65 const ours = hooks?.ok ? hooks.value.filter((hook) => hook.scope === "workspace") : [];
66 if (ours.length > 0) {
67 const failing = ours.filter((hook) => hook.lastStatus === "failed");
68 connected.webhooks = {
69 detail: ours.length === 1 ? ours[0]!.url : `${ours.length} webhooks`,
70 problem: failing.length > 0 ? `The last delivery to ${failing[0]!.url} failed.` : null,
71 manage: null,
72 };
73 }
74
75 return { slug, owner: role === "owner", connected };
76}
77
78export default function WorkspaceIntegrationsDirectory({ loaderData }: Route.ComponentProps) {
79 const { slug, owner, connected } = loaderData;
80 return (
81 <div>
82 <ScopeNote scope="workspace" workspace={slug} />
83 <ConnectorDirectory
84 scope="workspace"
85 views={connectorsFor("workspace")}
86 connected={connected}
87 workspace={slug}
88 canManage={owner}
89 />
90 </div>
91 );
92}