diff --git a/apps/webservice/src/app/[workspaceSlug]/(targets)/target-providers/integrations/google/GoogleDialog.tsx b/apps/webservice/src/app/[workspaceSlug]/(targets)/target-providers/integrations/google/GoogleDialog.tsx
index f880b5ec..5981a4bd 100644
--- a/apps/webservice/src/app/[workspaceSlug]/(targets)/target-providers/integrations/google/GoogleDialog.tsx
+++ b/apps/webservice/src/app/[workspaceSlug]/(targets)/target-providers/integrations/google/GoogleDialog.tsx
@@ -10,6 +10,7 @@ import { useCopyToClipboard } from "react-use";
import { z } from "zod";
import { cn } from "@ctrlplane/ui";
+import { Alert, AlertDescription, AlertTitle } from "@ctrlplane/ui/alert";
import { Button } from "@ctrlplane/ui/button";
import {
Dialog,
@@ -91,25 +92,25 @@ export const GoogleDialog: React.FC<{ children: React.ReactNode }> = ({
from google.
-
-
-
- To use the Google provider, you will need to invite our
- service account to your project and configure the necessary
- permissions. Read more{" "}
-
- here
-
- .
-
-
+
+
+ Google Provider
+
+
+ To use the Google provider, you will need to invite our
+ service account to your project and configure the necessary
+ permissions. Read more{" "}
+
+ here
+
+ .
+
+
+
diff --git a/apps/webservice/src/app/[workspaceSlug]/(targets)/target-providers/integrations/google/UpdateGoogleProviderDialog.tsx b/apps/webservice/src/app/[workspaceSlug]/(targets)/target-providers/integrations/google/UpdateGoogleProviderDialog.tsx
index 8f75c4eb..bbb7cc62 100644
--- a/apps/webservice/src/app/[workspaceSlug]/(targets)/target-providers/integrations/google/UpdateGoogleProviderDialog.tsx
+++ b/apps/webservice/src/app/[workspaceSlug]/(targets)/target-providers/integrations/google/UpdateGoogleProviderDialog.tsx
@@ -7,6 +7,7 @@ import { TbBulb, TbCheck, TbCopy, TbX } from "react-icons/tb";
import { useCopyToClipboard } from "react-use";
import { cn } from "@ctrlplane/ui";
+import { Alert, AlertDescription, AlertTitle } from "@ctrlplane/ui/alert";
import { Button } from "@ctrlplane/ui/button";
import {
Dialog,
@@ -101,12 +102,10 @@ export const UpdateGoogleProviderDialog: React.FC<{
from google.
-
-
-
+
+
+ Google Provider
+
To use the Google provider, you will need to invite our
service account to your project and configure the necessary
permissions. Read more{" "}
@@ -118,8 +117,8 @@ export const UpdateGoogleProviderDialog: React.FC<{
here
.
-
-
+
+
diff --git a/apps/webservice/src/app/[workspaceSlug]/settings/(settings)/workspace/integrations/(integration)/github/DeleteGithubUserButton.tsx b/apps/webservice/src/app/[workspaceSlug]/settings/(settings)/workspace/integrations/(integration)/github/DeleteGithubUserButton.tsx
new file mode 100644
index 00000000..9a301d0f
--- /dev/null
+++ b/apps/webservice/src/app/[workspaceSlug]/settings/(settings)/workspace/integrations/(integration)/github/DeleteGithubUserButton.tsx
@@ -0,0 +1,23 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+
+import { Button } from "@ctrlplane/ui/button";
+
+import { api } from "~/trpc/react";
+
+export const DeleteGithubUserButton: React.FC<{ githubUserId: string }> = ({
+ githubUserId,
+}) => {
+ const deleteGithubUser = api.github.user.delete.useMutation();
+ const router = useRouter();
+
+ const handleDelete = () =>
+ deleteGithubUser.mutateAsync(githubUserId).then(() => router.refresh());
+
+ return (
+
+ Disconnect
+
+ );
+};
diff --git a/apps/webservice/src/app/[workspaceSlug]/settings/(settings)/workspace/integrations/(integration)/github/DisconnectDropdownActionButton.tsx b/apps/webservice/src/app/[workspaceSlug]/settings/(settings)/workspace/integrations/(integration)/github/DisconnectDropdownActionButton.tsx
new file mode 100644
index 00000000..3364846f
--- /dev/null
+++ b/apps/webservice/src/app/[workspaceSlug]/settings/(settings)/workspace/integrations/(integration)/github/DisconnectDropdownActionButton.tsx
@@ -0,0 +1,16 @@
+"use client";
+
+import React from "react";
+
+import { DropdownMenuItem } from "@ctrlplane/ui/dropdown-menu";
+
+export const DisconnectDropdownActionButton = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentPropsWithoutRef
+>((props, ref) => {
+ return (
+ e.preventDefault()}>
+ Disconnect
+
+ );
+});
diff --git a/apps/webservice/src/app/[workspaceSlug]/settings/(settings)/workspace/integrations/(integration)/github/GithubAddOrgDialog.tsx b/apps/webservice/src/app/[workspaceSlug]/settings/(settings)/workspace/integrations/(integration)/github/GithubAddOrgDialog.tsx
new file mode 100644
index 00000000..fc0ac8bc
--- /dev/null
+++ b/apps/webservice/src/app/[workspaceSlug]/settings/(settings)/workspace/integrations/(integration)/github/GithubAddOrgDialog.tsx
@@ -0,0 +1,132 @@
+"use client";
+
+import type { GithubUser } from "@ctrlplane/db/schema";
+import { useState } from "react";
+import Link from "next/link";
+import { TbBulb } from "react-icons/tb";
+
+import { Alert, AlertDescription, AlertTitle } from "@ctrlplane/ui/alert";
+import { Button } from "@ctrlplane/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@ctrlplane/ui/dialog";
+
+import type { GithubOrg } from "./SelectPreconnectedOrgDialogContent";
+import { SelectPreconnectedOrgDialogContent } from "./SelectPreconnectedOrgDialogContent";
+
+type GithubAddOrgDialogProps = {
+ githubUser: GithubUser;
+ children: React.ReactNode;
+ githubConfig: {
+ url: string;
+ botName: string;
+ clientId: string;
+ };
+ validOrgsToAdd: GithubOrg[];
+ workspaceId: string;
+};
+
+export const GithubAddOrgDialog: React.FC = ({
+ githubUser,
+ children,
+ githubConfig,
+ validOrgsToAdd,
+ workspaceId,
+}) => {
+ const [dialogStep, setDialogStep] = useState<"choose-org" | "pre-connected">(
+ "choose-org",
+ );
+ const [open, setOpen] = useState(false);
+
+ return (
+
+ {children}
+
+ {dialogStep === "choose-org" && (
+ <>
+
+ Connect a new Organization
+ {validOrgsToAdd.length === 0 && (
+
+ Install the ctrlplane Github app on an organization to connect
+ it to your workspace.
+
+ )}
+
+
+ {validOrgsToAdd.length > 0 && (
+
+
+ Connect an organization
+
+ You have two options for connecting an organization:
+
+
+ Connect a new organization: Install the
+ ctrlplane Github app on a new organization.
+
+
+ Select pre-connected: Select a
+ pre-connected organization where the ctrlplane app is
+ installed.
+
+
+
+ Read more{" "}
+
+ here
+
+ .
+
+
+
+ )}
+
+
+
+ Connect new organization
+
+
+ {validOrgsToAdd.length > 0 && (
+
+ setDialogStep("pre-connected")}
+ >
+ Select pre-connected
+
+
+ )}
+
+ >
+ )}
+
+ {dialogStep === "pre-connected" && (
+ setDialogStep("choose-org")}
+ onSave={() => {
+ setOpen(false);
+ setDialogStep("choose-org");
+ }}
+ />
+ )}
+
+
+ );
+};
diff --git a/apps/webservice/src/app/[workspaceSlug]/settings/(settings)/workspace/integrations/(integration)/github/GithubConnectedOrgs.tsx b/apps/webservice/src/app/[workspaceSlug]/settings/(settings)/workspace/integrations/(integration)/github/GithubConnectedOrgs.tsx
new file mode 100644
index 00000000..258e13bb
--- /dev/null
+++ b/apps/webservice/src/app/[workspaceSlug]/settings/(settings)/workspace/integrations/(integration)/github/GithubConnectedOrgs.tsx
@@ -0,0 +1,132 @@
+import type { GithubUser } from "@ctrlplane/db/schema";
+import { SiGithub } from "react-icons/si";
+import { TbPlus } from "react-icons/tb";
+
+import { Avatar, AvatarFallback, AvatarImage } from "@ctrlplane/ui/avatar";
+import { Button } from "@ctrlplane/ui/button";
+import {
+ Card,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@ctrlplane/ui/card";
+import { Separator } from "@ctrlplane/ui/separator";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@ctrlplane/ui/tooltip";
+
+import { api } from "~/trpc/server";
+import { GithubAddOrgDialog } from "./GithubAddOrgDialog";
+import { OrgActionDropdown } from "./OrgActionDropdown";
+
+type GithubConnectedOrgsProps = {
+ githubUser?: GithubUser | null;
+ workspaceId: string;
+ loading: boolean;
+ githubConfig: {
+ url: string;
+ botName: string;
+ clientId: string;
+ };
+};
+
+export const GithubConnectedOrgs: React.FC = async ({
+ githubUser,
+ workspaceId,
+ githubConfig,
+}) => {
+ const githubOrgsUserCanAccess =
+ githubUser != null
+ ? await api.github.organizations.byGithubUserId({
+ workspaceId,
+ githubUserId: githubUser.githubUserId,
+ })
+ : [];
+ const githubOrgsInstalled = await api.github.organizations.list(workspaceId);
+ const validOrgsToAdd = githubOrgsUserCanAccess.filter(
+ (org) =>
+ !githubOrgsInstalled.some(
+ (installedOrg) => installedOrg.organizationName === org.login,
+ ),
+ );
+
+ return (
+
+
+
+
+ Connected Github organizations
+
+
+ You can configure job agents and sync config files for these
+ organizations
+
+
+ {githubUser != null ? (
+
+
+
+
+
+ ) : (
+
+
+
+
+
+
+
+
+ Connect your Github account to add organizations
+
+
+
+ )}
+
+
+ {githubOrgsInstalled.length > 0 && (
+ <>
+
+
+ {githubOrgsInstalled.map((org) => (
+
+
+
+
+
+
+
+
+
+
+ {org.organizationName}
+
+ {org.addedByUser != null && (
+
+ Enabled by {org.addedByUser.githubUsername} on{" "}
+ {org.createdAt.toLocaleDateString()}
+
+ )}
+
+
+
+
+ ))}
+
+ >
+ )}
+
+ );
+};
diff --git a/apps/webservice/src/app/[workspaceSlug]/settings/(settings)/workspace/integrations/(integration)/github/GithubOrgConfig.tsx b/apps/webservice/src/app/[workspaceSlug]/settings/(settings)/workspace/integrations/(integration)/github/GithubOrgConfig.tsx
deleted file mode 100644
index 710ace02..00000000
--- a/apps/webservice/src/app/[workspaceSlug]/settings/(settings)/workspace/integrations/(integration)/github/GithubOrgConfig.tsx
+++ /dev/null
@@ -1,307 +0,0 @@
-import type { GithubUser } from "@ctrlplane/db/schema";
-import { useState } from "react";
-import _ from "lodash";
-import { SiGithub } from "react-icons/si";
-import { TbChevronDown, TbPlus } from "react-icons/tb";
-
-import { cn } from "@ctrlplane/ui";
-import { Avatar, AvatarFallback, AvatarImage } from "@ctrlplane/ui/avatar";
-import { Button } from "@ctrlplane/ui/button";
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from "@ctrlplane/ui/card";
-import {
- Command,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
-} from "@ctrlplane/ui/command";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "@ctrlplane/ui/dropdown-menu";
-import { Popover, PopoverContent, PopoverTrigger } from "@ctrlplane/ui/popover";
-import { Separator } from "@ctrlplane/ui/separator";
-import { Skeleton } from "@ctrlplane/ui/skeleton";
-
-import { api } from "~/trpc/react";
-
-interface GithubOrgConfigProps {
- githubUser?: GithubUser | null;
- workspaceSlug?: string;
- workspaceId?: string;
- loading: boolean;
- githubConfig: {
- url: string;
- botName: string;
- clientId: string;
- };
-}
-
-export const GithubOrgConfig: React.FC = ({
- githubUser,
- workspaceSlug,
- workspaceId,
- loading,
- githubConfig,
-}) => {
- const githubOrgs = api.github.organizations.byGithubUserId.useQuery(
- githubUser?.githubUserId ?? 0,
- { enabled: !loading && githubUser != null },
- );
- const githubOrgCreate = api.github.organizations.create.useMutation();
- const githubOrgsInstalled = api.github.organizations.list.useQuery(
- workspaceId ?? "",
- { enabled: !loading && workspaceId != null },
- );
- const githubOrgUpdate = api.github.organizations.update.useMutation();
- const jobAgentCreate = api.job.agent.create.useMutation();
-
- const utils = api.useUtils();
-
- const [open, setOpen] = useState(false);
- const [value, setValue] = useState(null);
- const [image, setImage] = useState(null);
-
- const baseUrl = api.runtime.baseUrl.useQuery();
-
- return (
-
-
-
-
- Connect an organization
-
-
- Select an organization to integrate with Ctrlplane.
-
-
-
-
-
-
-
-
- {image !== null && (
-
-
- {value?.slice(0, 2)}
-
- )}
-
-
- {value ?? "Select organization..."}
-
-
-
-
-
-
-
-
-
- {githubOrgs.data
- ?.filter(
- (org) =>
- !githubOrgsInstalled.data?.some(
- (o) =>
- o.github_organization.organizationName ===
- org.login &&
- o.github_organization.connected === true,
- ),
- )
- .map(({ id, login, avatar_url }) => (
- {
- setValue(currentValue);
- setImage(avatar_url);
- setOpen(false);
- }}
- >
-
-
-
-
- {login.slice(0, 2)}
-
-
- {login}
-
-
- ))}
-
-
-
-
- Add new organization
-
-
-
-
-
-
-
-
-
{
- const existingOrg = githubOrgsInstalled.data?.find(
- (o) => o.github_organization.organizationName === value,
- );
-
- if (existingOrg != null)
- await githubOrgUpdate.mutateAsync({
- id: existingOrg.github_organization.id,
- data: {
- connected: true,
- },
- });
-
- const org = githubOrgs.data?.find((o) => o.login === value);
-
- if (org == null) return;
-
- await githubOrgCreate.mutateAsync({
- installationId: org.installationId,
- workspaceId: workspaceId ?? "",
- organizationName: org.login,
- addedByUserId: githubUser?.userId ?? "",
- avatarUrl: org.avatar_url,
- });
-
- await jobAgentCreate.mutateAsync({
- workspaceId: workspaceId ?? "",
- type: "github-app",
- name: org.login,
- config: {
- installationId: org.installationId,
- owner: org.login,
- },
- });
-
- await utils.github.organizations.list.invalidate(
- workspaceId ?? "",
- );
- await utils.github.configFile.list.invalidate(
- workspaceId ?? "",
- );
- }}
- >
- Save
-
-
-
-
-
-
- {(loading || githubOrgsInstalled.isLoading) && (
-
- {_.range(3).map((i) => (
-
- ))}
-
- )}
- {!loading && !githubOrgsInstalled.isLoading && (
-
- {githubOrgsInstalled.data?.map(
- ({ github_organization, github_user }) => (
-
- ),
- )}
-
- )}
-
- );
-};
diff --git a/apps/webservice/src/app/[workspaceSlug]/settings/(settings)/workspace/integrations/(integration)/github/GithubRemoveOrgDialog.tsx b/apps/webservice/src/app/[workspaceSlug]/settings/(settings)/workspace/integrations/(integration)/github/GithubRemoveOrgDialog.tsx
new file mode 100644
index 00000000..13a78a12
--- /dev/null
+++ b/apps/webservice/src/app/[workspaceSlug]/settings/(settings)/workspace/integrations/(integration)/github/GithubRemoveOrgDialog.tsx
@@ -0,0 +1,114 @@
+"use client";
+
+import type {
+ Deployment,
+ GithubConfigFile,
+ GithubOrganization,
+} from "@ctrlplane/db/schema";
+import { useRouter } from "next/navigation";
+
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@ctrlplane/ui/alert-dialog";
+import { Badge } from "@ctrlplane/ui/badge";
+import { buttonVariants } from "@ctrlplane/ui/button";
+
+import { api } from "~/trpc/react";
+
+type GithubConfigFileWithDeployments = GithubConfigFile & {
+ deployments: Deployment[];
+};
+
+export type GithubOrganizationWithConfigFiles = GithubOrganization & {
+ configFiles: GithubConfigFileWithDeployments[];
+};
+
+type GithubRemoveOrgDialogProps = {
+ githubOrganization: GithubOrganizationWithConfigFiles;
+ children: React.ReactNode;
+};
+
+export const GithubRemoveOrgDialog: React.FC = ({
+ githubOrganization,
+ children,
+}) => {
+ const router = useRouter();
+ const githubOrgDelete = api.github.organizations.delete.useMutation();
+
+ const handleDelete = (deleteResources: boolean) => {
+ githubOrgDelete
+ .mutateAsync({
+ id: githubOrganization.id,
+ workspaceId: githubOrganization.workspaceId,
+ deleteDeployments: deleteResources,
+ })
+ .then(() => router.refresh());
+ };
+
+ return (
+
+ {children}
+
+
+ Are you sure?
+ {githubOrganization.configFiles.length > 0 ? (
+
+ You have two options for deletion:
+
+
+ Disconnect only the organization: Any
+ resources generated from a{" "}
+
+ ctrlplane.yaml
+ {" "}
+ config file associated with this organization will remain, but
+ will no longer be synced with changes to the source file.
+
+
+ Disconnect and delete all resources: This
+ action is irreversible and will permanently remove the
+ organization along with all associated resources. This
+ includes all resources generated from a{" "}
+
+ ctrlplane.yaml
+ {" "}
+ config file in a repo within your Github organization.
+
+
+
+ ) : (
+
+ Disconnecting the organization will remove the connection between
+ Ctrlplane and your Github organization for this workspace.
+
+ )}
+
+
+ Cancel
+ handleDelete(false)}
+ >
+ Disconnect {githubOrganization.configFiles.length > 0 && "only"}
+
+ {githubOrganization.configFiles.length > 0 && (
+ handleDelete(true)}
+ >
+ Disconnect and delete
+
+ )}
+
+
+
+ );
+};
diff --git a/apps/webservice/src/app/[workspaceSlug]/settings/(settings)/workspace/integrations/(integration)/github/OrgActionDropdown.tsx b/apps/webservice/src/app/[workspaceSlug]/settings/(settings)/workspace/integrations/(integration)/github/OrgActionDropdown.tsx
new file mode 100644
index 00000000..b4b55c5c
--- /dev/null
+++ b/apps/webservice/src/app/[workspaceSlug]/settings/(settings)/workspace/integrations/(integration)/github/OrgActionDropdown.tsx
@@ -0,0 +1,55 @@
+import Link from "next/link";
+import { TbChevronDown, TbExternalLink } from "react-icons/tb";
+
+import { Button } from "@ctrlplane/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@ctrlplane/ui/dropdown-menu";
+
+import type { GithubOrganizationWithConfigFiles } from "./GithubRemoveOrgDialog";
+import { DisconnectDropdownActionButton } from "./DisconnectDropdownActionButton";
+import { GithubRemoveOrgDialog } from "./GithubRemoveOrgDialog";
+
+type OrgActionDropdownProps = {
+ githubConfig: {
+ url: string;
+ botName: string;
+ clientId: string;
+ };
+ org: GithubOrganizationWithConfigFiles;
+};
+
+export const OrgActionDropdown: React.FC = ({
+ githubConfig,
+ org,
+}) => {
+ return (
+
+
+
+
+ Connected
+
+
+
+
+
+
+ Configure
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/webservice/src/app/[workspaceSlug]/settings/(settings)/workspace/integrations/(integration)/github/SelectPreconnectedOrgDialogContent.tsx b/apps/webservice/src/app/[workspaceSlug]/settings/(settings)/workspace/integrations/(integration)/github/SelectPreconnectedOrgDialogContent.tsx
new file mode 100644
index 00000000..5410cf5b
--- /dev/null
+++ b/apps/webservice/src/app/[workspaceSlug]/settings/(settings)/workspace/integrations/(integration)/github/SelectPreconnectedOrgDialogContent.tsx
@@ -0,0 +1,136 @@
+import type { GithubUser } from "@ctrlplane/db/schema";
+import type { RestEndpointMethodTypes } from "@octokit/rest";
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+
+import { Avatar, AvatarFallback, AvatarImage } from "@ctrlplane/ui/avatar";
+import { Button } from "@ctrlplane/ui/button";
+import {
+ Command,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@ctrlplane/ui/command";
+import { DialogFooter, DialogHeader, DialogTitle } from "@ctrlplane/ui/dialog";
+import { Popover, PopoverContent, PopoverTrigger } from "@ctrlplane/ui/popover";
+
+import { api } from "~/trpc/react";
+
+export type GithubOrg =
+ RestEndpointMethodTypes["orgs"]["get"]["response"]["data"] & {
+ installationId: number;
+ };
+
+type PreconnectedOrgsComboboxProps = {
+ githubOrgs: GithubOrg[];
+ githubUser: GithubUser;
+ workspaceId: string;
+ onNavigateBack: () => void;
+ onSave: () => void;
+};
+
+export const SelectPreconnectedOrgDialogContent: React.FC<
+ PreconnectedOrgsComboboxProps
+> = ({ githubOrgs, githubUser, workspaceId, onNavigateBack, onSave }) => {
+ const [open, setOpen] = useState(false);
+ const [value, setValue] = useState(null);
+ const [image, setImage] = useState(null);
+ const router = useRouter();
+
+ const githubOrgCreate = api.github.organizations.create.useMutation();
+
+ const handleSave = () => {
+ if (value == null) return;
+ const org = githubOrgs.find((o) => o.login === value);
+ if (org == null) return;
+
+ githubOrgCreate
+ .mutateAsync({
+ installationId: org.installationId,
+ workspaceId,
+ organizationName: org.login,
+ addedByUserId: githubUser.userId,
+ avatarUrl: org.avatar_url,
+ })
+ .then(() => {
+ onSave();
+ router.refresh();
+ });
+ };
+
+ return (
+ <>
+
+ Select a pre-connected organization
+
+
+
+
+
+ {image !== null && (
+
+
+ {value?.slice(0, 2)}
+
+ )}
+
+
+ {value ?? "Select organization..."}
+
+
+
+
+
+
+
+
+
+ {githubOrgs.map(({ id, login, avatar_url }) => (
+ {
+ setValue(currentValue);
+ setImage(avatar_url);
+ setOpen(false);
+ }}
+ className="w-full cursor-pointer"
+ >
+
+
+
+ {login.slice(0, 2)}
+
+ {login}
+
+
+ ))}
+
+
+
+
+
+
+
+
+ Back
+
+
+
+ Connect
+
+
+
+ >
+ );
+};
diff --git a/apps/webservice/src/app/[workspaceSlug]/settings/(settings)/workspace/integrations/(integration)/github/page.tsx b/apps/webservice/src/app/[workspaceSlug]/settings/(settings)/workspace/integrations/(integration)/github/page.tsx
index b40cf572..3aa7b79b 100644
--- a/apps/webservice/src/app/[workspaceSlug]/settings/(settings)/workspace/integrations/(integration)/github/page.tsx
+++ b/apps/webservice/src/app/[workspaceSlug]/settings/(settings)/workspace/integrations/(integration)/github/page.tsx
@@ -1,15 +1,16 @@
-"use client";
-
-import { useRouter } from "next/navigation";
-import { useSession } from "next-auth/react";
+import Link from "next/link";
+import { notFound, redirect } from "next/navigation";
import { SiGithub } from "react-icons/si";
+import { auth } from "@ctrlplane/auth";
import { Button } from "@ctrlplane/ui/button";
import { Card } from "@ctrlplane/ui/card";
-import { api } from "~/trpc/react";
+import { env } from "~/env";
+import { api } from "~/trpc/server";
+import { DeleteGithubUserButton } from "./DeleteGithubUserButton";
import { GithubConfigFileSync } from "./GithubConfigFile";
-import { GithubOrgConfig } from "./GithubOrgConfig";
+import { GithubConnectedOrgs } from "./GithubConnectedOrgs";
const githubAuthUrl = (
baseUrl: string,
@@ -20,33 +21,28 @@ const githubAuthUrl = (
) =>
`${githubUrl}/login/oauth/authorize?response_type=code&client_id=${clientId}&redirect_uri=${baseUrl}/api/github/${userId}/${workspaceSlug}&state=sLtHqpxQ6FiUtBWJ&scope=repo%2Cread%3Auser`;
-export default function GitHubIntegrationPage({
+export default async function GitHubIntegrationPage({
params,
}: {
params: { workspaceSlug: string };
}) {
const { workspaceSlug } = params;
- const workspace = api.workspace.bySlug.useQuery(workspaceSlug);
- const session = useSession();
- const router = useRouter();
- const baseUrl = api.runtime.baseUrl.useQuery();
+ const workspace = await api.workspace.bySlug(workspaceSlug);
+ if (workspace == null) return notFound();
+ const session = await auth();
+ if (session == null) redirect("/login");
+
+ const baseUrl = env.BASE_URL;
- const githubUrl = api.runtime.github.url.useQuery();
- const githubBotName = api.runtime.github.botName.useQuery();
- const githubBotClientId = api.runtime.github.clientId.useQuery();
+ const githubUrl = env.GITHUB_URL;
+ const githubBotName = env.GITHUB_BOT_NAME;
+ const githubBotClientId = env.GITHUB_BOT_CLIENT_ID;
const isGithubConfigured =
- githubUrl.data != null &&
- githubBotName.data != null &&
- githubBotClientId.data != null;
+ githubUrl != null && githubBotName != null && githubBotClientId != null;
- const githubUser = api.github.user.byUserId.useQuery(session.data!.user.id, {
- enabled: session.status === "authenticated",
- });
+ const githubUser = await api.github.user.byUserId(session.user.id);
- const configFiles = api.github.configFile.list.useQuery(
- workspace.data?.id ?? "",
- { enabled: workspace.data != null },
- );
+ const configFiles = await api.github.configFile.list(workspace.id);
return (
@@ -61,57 +57,53 @@ export default function GitHubIntegrationPage({
-
-
-
- {githubUser.data != null
- ? "Personal account connected"
- : "Connect your personal account"}
-
-
- {githubUser.data != null
- ? "Your GitHub account is connected to Ctrlplane"
- : "Connect your GitHub account to Ctrlplane"}
-
-
- {githubUser.data == null && (
-
- router.push(
- githubAuthUrl(
- baseUrl.data ?? "",
- githubUrl.data ?? "",
- githubBotClientId.data ?? "",
- session.data!.user.id,
- workspaceSlug,
- ),
- )
- }
- >
- Connect
-
- )}
- {githubUser.data != null && (
- Disconnect
- )}
-
+
+
+
+
+ {githubUser != null
+ ? "Personal account connected"
+ : "Connect your personal account"}
+
+
+ {githubUser != null
+ ? "Your GitHub account is connected to Ctrlplane"
+ : "Connect your GitHub account to Ctrlplane to add Github organizations to your workspaces"}
+
+
+ {githubUser == null && (
+
+ Connect
+
+ )}
+ {githubUser != null && (
+
+ )}
+
- {isGithubConfigured && (
-
- )}
+ {isGithubConfigured && (
+
+ )}
-
+
+
);
}
diff --git a/apps/webservice/src/app/[workspaceSlug]/settings/(settings)/workspace/integrations/(integration)/layout.tsx b/apps/webservice/src/app/[workspaceSlug]/settings/(settings)/workspace/integrations/(integration)/layout.tsx
index 075237de..4cac65c5 100644
--- a/apps/webservice/src/app/[workspaceSlug]/settings/(settings)/workspace/integrations/(integration)/layout.tsx
+++ b/apps/webservice/src/app/[workspaceSlug]/settings/(settings)/workspace/integrations/(integration)/layout.tsx
@@ -1,6 +1,4 @@
-"use client";
-
-import { useRouter } from "next/navigation";
+import Link from "next/link";
import { TbArrowLeft } from "react-icons/tb";
import { Button } from "@ctrlplane/ui/button";
@@ -12,20 +10,16 @@ export default function IntegrationLayout({
children: React.ReactNode;
params: { workspaceSlug: string };
}) {
- const router = useRouter();
const { workspaceSlug } = params;
return (
-
- router.push(`/${workspaceSlug}/settings/workspace/integrations`)
- }
- className="flex w-fit items-center gap-2"
- >
- Integrations
-
+
+
+ Integrations
+
+
+
{children}
diff --git a/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/configure/job-agent/ConfigureJobAgentGithub.tsx b/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/configure/job-agent/ConfigureJobAgentGithub.tsx
index e9f0f1a8..7585c365 100644
--- a/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/configure/job-agent/ConfigureJobAgentGithub.tsx
+++ b/apps/webservice/src/app/[workspaceSlug]/systems/[systemSlug]/deployments/[deploymentSlug]/configure/job-agent/ConfigureJobAgentGithub.tsx
@@ -26,11 +26,13 @@ import { api } from "~/trpc/react";
export const ConfigureJobAgentGithub: React.FC<{
value: Record;
jobAgent: JobAgent;
+ workspaceId: string;
onChange: (v: Record) => void;
-}> = ({ value, jobAgent, onChange }) => {
+}> = ({ value, jobAgent, workspaceId, onChange }) => {
const repos = api.github.organizations.repos.list.useQuery({
- login: jobAgent.config.login,
+ owner: jobAgent.config.owner,
installationId: jobAgent.config.installationId,
+ workspaceId,
});
const [repoOpen, setRepoOpen] = useState(false);
@@ -40,7 +42,8 @@ export const ConfigureJobAgentGithub: React.FC<{
{
installationId: jobAgent.config.installationId,
repo: repo ?? "",
- login: jobAgent.config.login,
+ owner: jobAgent.config.owner,
+ workspaceId,
},
{ enabled: repo != null },
);
@@ -59,7 +62,7 @@ export const ConfigureJobAgentGithub: React.FC<{
onChange({
installationId: jobAgent.config.installationId,
- login: jobAgent.config.login,
+ owner: jobAgent.config.owner,
repo,
workflowId,
});
@@ -95,7 +98,7 @@ export const ConfigureJobAgentGithub: React.FC<{
- {repos.data?.data.map((repo) => (
+ {repos.data?.map((repo) => (
)}
- {jobAgent?.type === "github-app" && (
-
- )}
+ {jobAgent?.type === "github-app" &&
+ workspace.data != null && (
+
+ )}
diff --git a/apps/webservice/src/app/api/github/[userId]/[workspaceSlug]/route.ts b/apps/webservice/src/app/api/github/[userId]/[workspaceSlug]/route.ts
index d504b7c6..61ff344e 100644
--- a/apps/webservice/src/app/api/github/[userId]/[workspaceSlug]/route.ts
+++ b/apps/webservice/src/app/api/github/[userId]/[workspaceSlug]/route.ts
@@ -14,8 +14,8 @@ export const GET = async (
const code = searchParams.get("code");
const { userId, workspaceSlug } = params;
- const baseUrl = await api.runtime.baseUrl();
- const githubUrl = await api.runtime.github.url();
+ const baseUrl = env.BASE_URL;
+ const githubUrl = env.GITHUB_URL;
const tokenResponse = await fetch(`${githubUrl}/login/oauth/access_token`, {
method: "POST",
diff --git a/apps/webservice/src/app/api/github/installation/route.ts b/apps/webservice/src/app/api/github/installation/route.ts
new file mode 100644
index 00000000..73da120d
--- /dev/null
+++ b/apps/webservice/src/app/api/github/installation/route.ts
@@ -0,0 +1,167 @@
+import type { NextRequest } from "next/server";
+import { NextResponse } from "next/server";
+import { createAppAuth } from "@octokit/auth-app";
+import { Octokit } from "@octokit/rest";
+import {
+ BAD_REQUEST,
+ INTERNAL_SERVER_ERROR,
+ NOT_FOUND,
+ UNAUTHORIZED,
+} from "http-status";
+
+import { auth } from "@ctrlplane/auth";
+import { eq, takeFirstOrNull } from "@ctrlplane/db";
+import { db } from "@ctrlplane/db/client";
+import { user, workspace } from "@ctrlplane/db/schema";
+
+import { env } from "~/env";
+import { api } from "~/trpc/server";
+
+const isValidGithubAppConfiguration =
+ env.GITHUB_BOT_APP_ID != null &&
+ env.GITHUB_BOT_PRIVATE_KEY != null &&
+ env.GITHUB_BOT_CLIENT_ID != null &&
+ env.GITHUB_BOT_CLIENT_SECRET != null;
+
+const octokit = isValidGithubAppConfiguration
+ ? new Octokit({
+ authStrategy: createAppAuth,
+ auth: {
+ appId: env.GITHUB_BOT_APP_ID,
+ privateKey: env.GITHUB_BOT_PRIVATE_KEY,
+ clientId: env.GITHUB_BOT_CLIENT_ID,
+ clientSecret: env.GITHUB_BOT_CLIENT_SECRET,
+ },
+ })
+ : null;
+
+const getOctokitInstallation = (installationId: number) =>
+ isValidGithubAppConfiguration
+ ? new Octokit({
+ authStrategy: createAppAuth,
+ auth: {
+ appId: env.GITHUB_BOT_APP_ID,
+ privateKey: env.GITHUB_BOT_PRIVATE_KEY,
+ clientId: env.GITHUB_BOT_CLIENT_ID,
+ clientSecret: env.GITHUB_BOT_CLIENT_SECRET,
+ installationId,
+ },
+ })
+ : null;
+
+export const GET = async (req: NextRequest) => {
+ if (octokit == null)
+ return NextResponse.json(
+ { error: "GitHub app not configured" },
+ { status: INTERNAL_SERVER_ERROR },
+ );
+
+ const { searchParams } = new URL(req.url);
+ const installationId = searchParams.get("installation_id");
+ const setupAction = searchParams.get("setup_action");
+
+ if (installationId == null || setupAction == null)
+ return NextResponse.json(
+ { error: "Invalid request from GitHub" },
+ { status: BAD_REQUEST },
+ );
+
+ if (setupAction !== "install")
+ return NextResponse.json(
+ { error: "Invalid setup action" },
+ { status: BAD_REQUEST },
+ );
+
+ const session = await auth();
+ if (session == null)
+ return NextResponse.json(
+ { error: "Authentication required" },
+ { status: UNAUTHORIZED },
+ );
+
+ const u = await db
+ .select()
+ .from(user)
+ .where(eq(user.id, session.user.id))
+ .then(takeFirstOrNull);
+ if (u == null)
+ return NextResponse.json(
+ { error: "User not found" },
+ { status: UNAUTHORIZED },
+ );
+
+ if (u.activeWorkspaceId == null)
+ return NextResponse.json(
+ { error: "Workspace not found" },
+ { status: NOT_FOUND },
+ );
+
+ const activeWorkspace = await db
+ .select()
+ .from(workspace)
+ .where(eq(workspace.id, u.activeWorkspaceId))
+ .then(takeFirstOrNull);
+
+ if (activeWorkspace == null)
+ return NextResponse.json(
+ { error: "Workspace not found" },
+ { status: NOT_FOUND },
+ );
+
+ const installation = await octokit.apps.getInstallation({
+ installation_id: Number(installationId),
+ headers: {
+ "X-GitHub-Api-Version": "2022-11-28",
+ },
+ });
+
+ if (
+ installation.data.target_type !== "Organization" ||
+ installation.data.account == null
+ ) {
+ return NextResponse.json(
+ { error: "Invalid installation type" },
+ { status: BAD_REQUEST },
+ );
+ }
+
+ const installationOctokit = getOctokitInstallation(installation.data.id);
+ if (installationOctokit == null)
+ return NextResponse.json(
+ { error: "Failed to get authenticated Github client" },
+ { status: INTERNAL_SERVER_ERROR },
+ );
+
+ const targetId = installation.data.target_id;
+ const orgData = await installationOctokit.orgs.get({
+ org: String(targetId),
+ headers: {
+ "X-GitHub-Api-Version": "2022-11-28",
+ },
+ });
+
+ await api.github.organizations.create({
+ workspaceId: activeWorkspace.id,
+ installationId: installation.data.id,
+ organizationName: orgData.data.login,
+ avatarUrl: orgData.data.avatar_url,
+ addedByUserId: u.id,
+ });
+
+ await api.job.agent.create({
+ workspaceId: activeWorkspace.id,
+ type: "github-app",
+ name: orgData.data.login,
+ config: {
+ installationId: installation.data.id,
+ owner: orgData.data.login,
+ },
+ });
+
+ const baseUrl = env.BASE_URL;
+ const workspaceSlug = activeWorkspace.slug;
+
+ return NextResponse.redirect(
+ `${baseUrl}/${workspaceSlug}/settings/workspace/integrations/github`,
+ );
+};
diff --git a/apps/webservice/src/env.ts b/apps/webservice/src/env.ts
index d87122fb..9c51439c 100644
--- a/apps/webservice/src/env.ts
+++ b/apps/webservice/src/env.ts
@@ -16,10 +16,13 @@ export const env = createEnv({
* This way you can ensure the app isn't built with invalid env vars.
*/
server: {
+ GITHUB_URL: z.string().optional(),
+ GITHUB_BOT_NAME: z.string().optional(),
GITHUB_BOT_CLIENT_ID: z.string().optional(),
GITHUB_BOT_CLIENT_SECRET: z.string().optional(),
GITHUB_BOT_APP_ID: z.string().optional(),
GITHUB_BOT_PRIVATE_KEY: z.string().optional(),
+ BASE_URL: z.string(),
},
/**
diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts
index 0907cb0d..0e173866 100644
--- a/packages/api/src/root.ts
+++ b/packages/api/src/root.ts
@@ -1,7 +1,7 @@
import { dashboardRouter } from "./router/dashboard";
import { deploymentRouter } from "./router/deployment";
import { environmentRouter } from "./router/environment";
-import { githubRouter } from "./router/github";
+import { githubRouter } from "./router/github/github";
import { jobRouter } from "./router/job";
import { releaseRouter } from "./router/release";
import { runtimeRouter } from "./router/runtime";
diff --git a/packages/api/src/router/github.ts b/packages/api/src/router/github.ts
deleted file mode 100644
index 0e12b968..00000000
--- a/packages/api/src/router/github.ts
+++ /dev/null
@@ -1,423 +0,0 @@
-import { createAppAuth } from "@octokit/auth-app";
-import { Octokit } from "@octokit/rest";
-import { TRPCError } from "@trpc/server";
-import * as yaml from "js-yaml";
-import _ from "lodash";
-import { isPresent } from "ts-is-present";
-import { z } from "zod";
-
-import { and, eq, inArray, takeFirst, takeFirstOrNull } from "@ctrlplane/db";
-import {
- deployment,
- githubConfigFile,
- githubOrganization,
- githubOrganizationInsert,
- githubUser,
- system,
- workspace,
-} from "@ctrlplane/db/schema";
-import { configFile } from "@ctrlplane/validators";
-
-import { env } from "../config";
-import { createTRPCRouter, protectedProcedure } from "../trpc";
-
-const octokit =
- env.GITHUB_BOT_APP_ID == null
- ? null
- : new Octokit({
- authStrategy: createAppAuth,
- auth: {
- appId: env.GITHUB_BOT_APP_ID,
- privateKey: env.GITHUB_BOT_PRIVATE_KEY,
- clientId: env.GITHUB_BOT_CLIENT_ID,
- clientSecret: env.GITHUB_BOT_CLIENT_SECRET,
- },
- });
-
-const getOctokitInstallation = (installationId: number) =>
- new Octokit({
- authStrategy: createAppAuth,
- auth: {
- appId: env.GITHUB_BOT_APP_ID,
- privateKey: env.GITHUB_BOT_PRIVATE_KEY,
- clientId: env.GITHUB_BOT_CLIENT_ID,
- clientSecret: env.GITHUB_BOT_CLIENT_SECRET,
- installationId,
- },
- });
-
-const getOctokit = () => {
- if (octokit == null)
- throw new TRPCError({
- code: "INTERNAL_SERVER_ERROR",
- message: "GitHub bot not configured",
- });
- return octokit;
-};
-
-const userRouter = createTRPCRouter({
- byUserId: protectedProcedure
- .input(z.string())
- .query(({ ctx, input }) =>
- ctx.db
- .select()
- .from(githubUser)
- .where(eq(githubUser.userId, input))
- .then(takeFirstOrNull),
- ),
-
- create: protectedProcedure
- .input(
- z.object({
- userId: z.string(),
- githubUserId: z.number(),
- githubUsername: z.string(),
- }),
- )
- .mutation(({ ctx, input }) =>
- ctx.db
- .insert(githubUser)
- .values(input)
- .returning()
- .then((data) => data[0]),
- ),
-});
-
-const reposRouter = createTRPCRouter({
- list: protectedProcedure
- .input(z.object({ installationId: z.number(), login: z.string() }))
- .query(({ input }) =>
- octokit?.apps
- .getInstallation({
- installation_id: input.installationId,
- })
- .then(async ({ data: installation }) => {
- const installationOctokit = getOctokitInstallation(installation.id);
- const installationToken = (await installationOctokit.auth({
- type: "installation",
- installationId: installation.id,
- })) as { token: string };
-
- return installationOctokit.repos.listForOrg({
- org: input.login,
- headers: {
- "X-GitHub-Api-Version": "2022-11-28",
- authorization: `Bearer ${installationToken.token}`,
- },
- });
- }),
- ),
-
- workflows: createTRPCRouter({
- list: protectedProcedure
- .input(
- z.object({
- installationId: z.number(),
- login: z.string(),
- repo: z.string(),
- }),
- )
- .query(async ({ input }) => {
- const installationOctokit = getOctokitInstallation(
- input.installationId,
- );
-
- const installationToken = (await installationOctokit.auth({
- type: "installation",
- installationId: input.installationId,
- })) as { token: string };
-
- return installationOctokit.actions.listRepoWorkflows({
- owner: input.login,
- repo: input.repo,
- headers: {
- "X-GitHub-Api-Version": "2022-11-28",
- authorization: `Bearer ${installationToken.token}`,
- },
- });
- }),
- }),
-});
-
-const configFileRouter = createTRPCRouter({
- list: protectedProcedure
- .input(z.string().uuid())
- .query(({ ctx, input }) =>
- ctx.db
- .select()
- .from(githubConfigFile)
- .where(eq(githubConfigFile.workspaceId, input)),
- ),
-});
-
-export const githubRouter = createTRPCRouter({
- user: userRouter,
-
- configFile: configFileRouter,
-
- organizations: createTRPCRouter({
- byGithubUserId: protectedProcedure.input(z.number()).query(({ input }) =>
- getOctokit()
- .apps.listInstallations({
- headers: {
- "X-GitHub-Api-Version": "2022-11-28",
- },
- })
- .then(({ data: installations }) =>
- Promise.all(
- installations
- .filter((i) => i.target_type === "Organization")
- .map(async (i) => {
- const installationOctokit = getOctokitInstallation(i.id);
-
- const installationToken = (await installationOctokit.auth({
- type: "installation",
- installationId: i.id,
- })) as { token: string };
-
- const members = await installationOctokit.orgs.listMembers({
- org: i.account?.login ?? "",
- headers: {
- "X-GitHub-Api-Version": "2022-11-28",
- authorization: `Bearer ${installationToken.token}`,
- },
- });
-
- const isUserInGithubOrg =
- members.data.find((m) => m.id === input) != null;
- if (!isUserInGithubOrg) return null;
-
- const orgData = await installationOctokit.orgs.get({
- org: i.account?.login ?? "",
- headers: {
- "X-GitHub-Api-Version": "2022-11-28",
- },
- });
- return _.merge(orgData.data, { installationId: i.id });
- }),
- ).then((orgs) => orgs.filter(isPresent)),
- ),
- ),
-
- byWorkspaceId: protectedProcedure
- .input(z.string().uuid())
- .query(async ({ ctx, input }) => {
- const internalOrgs = await ctx.db
- .select()
- .from(githubOrganization)
- .where(eq(githubOrganization.workspaceId, input));
-
- return getOctokit()
- .apps.listInstallations({
- headers: {
- "X-GitHub-Api-Version": "2022-11-28",
- },
- })
- .then(({ data: installations }) =>
- Promise.all(
- installations.filter(
- (i) =>
- i.target_type === "Organization" &&
- internalOrgs.find((org) => org.installationId === i.id) !=
- null,
- ),
- ),
- );
- }),
-
- list: protectedProcedure
- .input(z.string().uuid())
- .query(({ ctx, input }) =>
- ctx.db
- .select()
- .from(githubOrganization)
- .leftJoin(
- githubUser,
- eq(githubOrganization.addedByUserId, githubUser.userId),
- )
- .where(eq(githubOrganization.workspaceId, input)),
- ),
-
- create: protectedProcedure
- .input(githubOrganizationInsert)
- .mutation(({ ctx, input }) =>
- ctx.db.transaction((db) =>
- db
- .insert(githubOrganization)
- .values(input)
- .returning()
- .then(takeFirst)
- .then((org) =>
- getOctokit()
- .apps.getInstallation({
- installation_id: org.installationId,
- })
- .then(async ({ data: installation }) => {
- const installationOctokit = getOctokitInstallation(
- installation.id,
- );
-
- const installationToken = (await installationOctokit.auth({
- type: "installation",
- installationId: installation.id,
- })) as { token: string };
-
- const configFiles = await Promise.all([
- installationOctokit.search.code({
- q: `org:${org.organizationName} filename:ctrlplane.yaml`,
- per_page: 100,
- headers: {
- "X-GitHub-Api-Version": "2022-11-28",
- authorization: `Bearer ${installationToken.token}`,
- },
- }),
- installationOctokit.search.code({
- q: `org:${org.organizationName} filename:ctrlplane.yml`,
- per_page: 100,
- headers: {
- "X-GitHub-Api-Version": "2022-11-28",
- authorization: `Bearer ${installationToken.token}`,
- },
- }),
- ]).then((responses) => {
- return [
- ...responses[0].data.items,
- ...responses[1].data.items,
- ];
- });
-
- if (configFiles.length === 0) return [];
-
- const parsedConfigFiles = await Promise.allSettled(
- configFiles.map(async (cf) => {
- const content = await installationOctokit.repos
- .getContent({
- owner: org.organizationName,
- repo: cf.repository.name,
- path: cf.path,
- ref: org.branch,
- })
- .then(({ data }) => {
- if (!("content" in data))
- throw new Error("Invalid response data");
- return Buffer.from(data.content, "base64").toString(
- "utf-8",
- );
- });
-
- const yamlContent = yaml.load(content);
- const parsed = configFile.safeParse(yamlContent);
- if (!parsed.success)
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: "Invalid config file",
- });
-
- return {
- ...cf,
- content: parsed.data,
- };
- }),
- ).then((results) =>
- results
- .map((r) => (r.status === "fulfilled" ? r.value : null))
- .filter(isPresent),
- );
-
- const deploymentInfo = await db
- .select()
- .from(system)
- .innerJoin(workspace, eq(system.workspaceId, workspace.id))
- .where(
- and(
- inArray(
- system.slug,
- parsedConfigFiles
- .map((d) =>
- d.content.deployments.map((d) => d.system),
- )
- .flat(),
- ),
- inArray(
- workspace.slug,
- parsedConfigFiles
- .map((d) =>
- d.content.deployments.map((d) => d.workspace),
- )
- .flat(),
- ),
- ),
- );
-
- const insertedConfigFiles = await db
- .insert(githubConfigFile)
- .values(
- parsedConfigFiles.map((d) => ({
- ...d,
- workspaceId: org.workspaceId,
- organizationId: org.id,
- repositoryName: d.repository.name,
- })),
- )
- .returning();
-
- const deployments = parsedConfigFiles
- .map((cf) =>
- cf.content.deployments.map((d) => {
- const info = deploymentInfo.find(
- (i) =>
- i.system.slug === d.system &&
- i.workspace.slug === d.workspace,
- );
- if (info == null)
- throw new TRPCError({
- code: "NOT_FOUND",
- message: "Deployment info not found",
- });
- const { system, workspace } = info;
-
- return {
- ...d,
- systemId: system.id,
- workspaceId: workspace.id,
- description: d.description ?? "",
- githubConfigFileId: insertedConfigFiles.find(
- (icf) =>
- icf.path === cf.path &&
- icf.repositoryName === cf.repository.name,
- )?.id,
- };
- }),
- )
- .flat();
-
- return db.insert(deployment).values(deployments);
- }),
- ),
- ),
- ),
-
- update: protectedProcedure
- .input(
- z.object({
- id: z.string().uuid(),
- data: z.object({
- connected: z.boolean().optional(),
- installationId: z.number().optional(),
- organizationName: z.string().optional(),
- organizationId: z.string().optional(),
- addedByUserId: z.string().optional(),
- workspaceId: z.string().optional(),
- }),
- }),
- )
- .mutation(({ ctx, input }) =>
- ctx.db
- .update(githubOrganization)
- .set(input.data)
- .where(eq(githubOrganization.id, input.id)),
- ),
-
- repos: reposRouter,
- }),
-});
diff --git a/packages/api/src/router/github/create-github-org.ts b/packages/api/src/router/github/create-github-org.ts
new file mode 100644
index 00000000..c8c07f63
--- /dev/null
+++ b/packages/api/src/router/github/create-github-org.ts
@@ -0,0 +1,229 @@
+import type { Tx } from "@ctrlplane/db";
+import type {
+ GithubOrganization,
+ GithubOrganizationInsert,
+} from "@ctrlplane/db/schema";
+import type { RestEndpointMethodTypes } from "@octokit/rest";
+import { createAppAuth } from "@octokit/auth-app";
+import { Octokit } from "@octokit/rest";
+import * as yaml from "js-yaml";
+import { isPresent } from "ts-is-present";
+
+import { and, eq, inArray, sql, takeFirst } from "@ctrlplane/db";
+import {
+ deployment,
+ githubConfigFile,
+ githubOrganization,
+ system,
+ workspace,
+} from "@ctrlplane/db/schema";
+import { configFile } from "@ctrlplane/validators";
+
+import { env } from "../../config";
+
+type ConfigFile =
+ RestEndpointMethodTypes["search"]["code"]["response"]["data"]["items"][number];
+
+type ParsedConfigFile = ConfigFile & {
+ content: {
+ deployments: {
+ name: string;
+ slug: string;
+ system: string;
+ workspace: string;
+ description?: string | undefined;
+ }[];
+ };
+};
+
+const isValidGithubAppConfiguration =
+ env.GITHUB_BOT_APP_ID != null &&
+ env.GITHUB_BOT_PRIVATE_KEY != null &&
+ env.GITHUB_BOT_CLIENT_ID != null &&
+ env.GITHUB_BOT_CLIENT_SECRET != null;
+
+const octokit = isValidGithubAppConfiguration
+ ? new Octokit({
+ authStrategy: createAppAuth,
+ auth: {
+ appId: env.GITHUB_BOT_APP_ID,
+ privateKey: env.GITHUB_BOT_PRIVATE_KEY,
+ clientId: env.GITHUB_BOT_CLIENT_ID,
+ clientSecret: env.GITHUB_BOT_CLIENT_SECRET,
+ },
+ })
+ : null;
+
+const getOctokitInstallation = (installationId: number) =>
+ isValidGithubAppConfiguration
+ ? new Octokit({
+ authStrategy: createAppAuth,
+ auth: {
+ appId: env.GITHUB_BOT_APP_ID,
+ privateKey: env.GITHUB_BOT_PRIVATE_KEY,
+ clientId: env.GITHUB_BOT_CLIENT_ID,
+ clientSecret: env.GITHUB_BOT_CLIENT_SECRET,
+ installationId,
+ },
+ })
+ : null;
+
+const parseConfigFile = async (
+ cf: ConfigFile,
+ orgName: string,
+ branch: string,
+ installationOctokit: Octokit,
+) => {
+ const content = await installationOctokit.repos
+ .getContent({
+ owner: orgName,
+ repo: cf.repository.name,
+ path: cf.path,
+ ref: branch,
+ })
+ .then(({ data }) => {
+ if (!("content" in data)) throw new Error("Invalid response data");
+ return Buffer.from(data.content, "base64").toString("utf-8");
+ });
+
+ const yamlContent = yaml.load(content);
+ const parsed = configFile.safeParse(yamlContent);
+ if (!parsed.success) throw new Error("Invalid config file");
+ return { ...cf, content: parsed.data };
+};
+
+const processParsedConfigFiles = async (
+ db: Tx,
+ parsedConfigFiles: ParsedConfigFile[],
+ org: GithubOrganization,
+) => {
+ const deploymentInfo = await db
+ .select()
+ .from(system)
+ .innerJoin(workspace, eq(system.workspaceId, workspace.id))
+ .where(
+ and(
+ inArray(
+ system.slug,
+ parsedConfigFiles
+ .map((d) => d.content.deployments.map((d) => d.system))
+ .flat(),
+ ),
+ inArray(
+ workspace.slug,
+ parsedConfigFiles.flatMap((d) =>
+ d.content.deployments.map((d) => d.workspace),
+ ),
+ ),
+ ),
+ );
+
+ const insertedConfigFiles = await db
+ .insert(githubConfigFile)
+ .values(
+ parsedConfigFiles.map((d) => ({
+ ...d,
+ workspaceId: org.workspaceId,
+ organizationId: org.id,
+ repositoryName: d.repository.name,
+ })),
+ )
+ .returning();
+
+ const deployments = parsedConfigFiles.flatMap((cf) =>
+ cf.content.deployments.map((d) => {
+ const info = deploymentInfo.find(
+ (i) => i.system.slug === d.system && i.workspace.slug === d.workspace,
+ );
+ if (info == null) throw new Error("Deployment info not found");
+ const { system, workspace } = info;
+
+ return {
+ ...d,
+ systemId: system.id,
+ workspaceId: workspace.id,
+ description: d.description ?? "",
+ githubConfigFileId: insertedConfigFiles.find(
+ (icf) =>
+ icf.path === cf.path && icf.repositoryName === cf.repository.name,
+ )?.id,
+ };
+ }),
+ );
+
+ await db
+ .insert(deployment)
+ .values(deployments)
+ .onConflictDoUpdate({
+ target: [deployment.systemId, deployment.slug],
+ set: {
+ githubConfigFileId: sql`excluded.github_config_file_id`,
+ },
+ });
+};
+
+export const createNewGithubOrganization = async (
+ db: Tx,
+ githubOrganizationConfig: GithubOrganizationInsert,
+) =>
+ db.transaction(async (db) => {
+ const org = await db
+ .insert(githubOrganization)
+ .values(githubOrganizationConfig)
+ .returning()
+ .then(takeFirst);
+
+ const installation = await octokit?.apps.getInstallation({
+ installation_id: org.installationId,
+ });
+ if (installation == null) throw new Error("Failed to get installation");
+
+ const installationOctokit = getOctokitInstallation(installation.data.id);
+ if (installationOctokit == null)
+ throw new Error("Failed to get authenticated Github client");
+ const installationToken = (await installationOctokit.auth({
+ type: "installation",
+ installationId: installation.data.id,
+ })) as { token: string };
+
+ const configFiles = await Promise.all([
+ installationOctokit.search.code({
+ q: `org:${org.organizationName} filename:ctrlplane.yaml`,
+ per_page: 100,
+ headers: {
+ "X-GitHub-Api-Version": "2022-11-28",
+ authorization: `Bearer ${installationToken.token}`,
+ },
+ }),
+ installationOctokit.search.code({
+ q: `org:${org.organizationName} filename:ctrlplane.yml`,
+ per_page: 100,
+ headers: {
+ "X-GitHub-Api-Version": "2022-11-28",
+ authorization: `Bearer ${installationToken.token}`,
+ },
+ }),
+ ]).then(([yamlFiles, ymlFiles]) => [
+ ...yamlFiles.data.items,
+ ...ymlFiles.data.items,
+ ]);
+
+ if (configFiles.length === 0) return;
+
+ const parsedConfigFiles = await Promise.allSettled(
+ configFiles.map((cf) =>
+ parseConfigFile(
+ cf,
+ org.organizationName,
+ org.branch,
+ installationOctokit,
+ ),
+ ),
+ ).then((results) =>
+ results
+ .map((r) => (r.status === "fulfilled" ? r.value : null))
+ .filter(isPresent),
+ );
+
+ await processParsedConfigFiles(db, parsedConfigFiles, org);
+ });
diff --git a/packages/api/src/router/github/github.ts b/packages/api/src/router/github/github.ts
new file mode 100644
index 00000000..5ebb75aa
--- /dev/null
+++ b/packages/api/src/router/github/github.ts
@@ -0,0 +1,420 @@
+import { createAppAuth } from "@octokit/auth-app";
+import { Octokit } from "@octokit/rest";
+import { TRPCError } from "@trpc/server";
+import _ from "lodash";
+import { isPresent } from "ts-is-present";
+import { z } from "zod";
+
+import { and, eq, inArray, takeFirst, takeFirstOrNull } from "@ctrlplane/db";
+import {
+ deployment,
+ githubConfigFile,
+ githubOrganization,
+ githubOrganizationInsert,
+ githubUser,
+ jobAgent,
+} from "@ctrlplane/db/schema";
+import { Permission } from "@ctrlplane/validators/auth";
+
+import { env } from "../../config";
+import { createTRPCRouter, protectedProcedure } from "../../trpc";
+import { createNewGithubOrganization } from "./create-github-org";
+
+const octokit =
+ env.GITHUB_BOT_APP_ID == null
+ ? null
+ : new Octokit({
+ authStrategy: createAppAuth,
+ auth: {
+ appId: env.GITHUB_BOT_APP_ID,
+ privateKey: env.GITHUB_BOT_PRIVATE_KEY,
+ clientId: env.GITHUB_BOT_CLIENT_ID,
+ clientSecret: env.GITHUB_BOT_CLIENT_SECRET,
+ },
+ });
+
+const getOctokitInstallation = (installationId: number) =>
+ new Octokit({
+ authStrategy: createAppAuth,
+ auth: {
+ appId: env.GITHUB_BOT_APP_ID,
+ privateKey: env.GITHUB_BOT_PRIVATE_KEY,
+ clientId: env.GITHUB_BOT_CLIENT_ID,
+ clientSecret: env.GITHUB_BOT_CLIENT_SECRET,
+ installationId,
+ },
+ });
+
+const getOctokit = () => {
+ if (octokit == null)
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "GitHub bot not configured",
+ });
+ return octokit;
+};
+
+const userRouter = createTRPCRouter({
+ byUserId: protectedProcedure
+ .input(z.string())
+ .query(({ ctx, input }) =>
+ ctx.db
+ .select()
+ .from(githubUser)
+ .where(eq(githubUser.userId, input))
+ .then(takeFirstOrNull),
+ ),
+
+ delete: protectedProcedure
+ .input(z.string())
+ .mutation(({ ctx, input }) =>
+ ctx.db.delete(githubUser).where(eq(githubUser.userId, input)),
+ ),
+
+ create: protectedProcedure
+ .input(
+ z.object({
+ userId: z.string(),
+ githubUserId: z.number(),
+ githubUsername: z.string(),
+ }),
+ )
+ .mutation(({ ctx, input }) =>
+ ctx.db.insert(githubUser).values(input).returning().then(takeFirst),
+ ),
+});
+
+const reposRouter = createTRPCRouter({
+ list: protectedProcedure
+ .meta({
+ authorizationCheck: ({ canUser, input }) =>
+ canUser.perform(Permission.WorkspaceListIntegrations).on({
+ type: "workspace",
+ id: input.workspaceId,
+ }),
+ })
+ .input(
+ z.object({
+ installationId: z.number(),
+ owner: z.string(),
+ workspaceId: z.string().uuid(),
+ }),
+ )
+ .query(({ input }) =>
+ octokit?.apps
+ .getInstallation({
+ installation_id: input.installationId,
+ })
+ .then(async ({ data: installation }) => {
+ const installationOctokit = getOctokitInstallation(installation.id);
+ const installationToken = (await installationOctokit.auth({
+ type: "installation",
+ installationId: installation.id,
+ })) as { token: string };
+
+ const { data } = await installationOctokit.repos.listForOrg({
+ org: input.owner,
+ headers: {
+ "X-GitHub-Api-Version": "2022-11-28",
+ authorization: `Bearer ${installationToken.token}`,
+ },
+ });
+ return data;
+ }),
+ ),
+
+ workflows: createTRPCRouter({
+ list: protectedProcedure
+ .meta({
+ authorizationCheck: ({ canUser, input }) =>
+ canUser.perform(Permission.WorkspaceListIntegrations).on({
+ type: "workspace",
+ id: input.workspaceId,
+ }),
+ })
+ .input(
+ z.object({
+ installationId: z.number(),
+ owner: z.string(),
+ repo: z.string(),
+ workspaceId: z.string().uuid(),
+ }),
+ )
+ .query(async ({ input }) => {
+ const installationOctokit = getOctokitInstallation(
+ input.installationId,
+ );
+
+ const installationToken = (await installationOctokit.auth({
+ type: "installation",
+ installationId: input.installationId,
+ })) as { token: string };
+
+ return installationOctokit.actions.listRepoWorkflows({
+ ...input,
+ headers: {
+ "X-GitHub-Api-Version": "2022-11-28",
+ authorization: `Bearer ${installationToken.token}`,
+ },
+ });
+ }),
+ }),
+});
+
+const configFileRouter = createTRPCRouter({
+ list: protectedProcedure
+ .meta({
+ authorizationCheck: ({ canUser, input }) =>
+ canUser.perform(Permission.WorkspaceListIntegrations).on({
+ type: "workspace",
+ id: input,
+ }),
+ })
+ .input(z.string().uuid())
+ .query(({ ctx, input }) =>
+ ctx.db
+ .select()
+ .from(githubConfigFile)
+ .where(eq(githubConfigFile.workspaceId, input)),
+ ),
+});
+
+export const githubRouter = createTRPCRouter({
+ user: userRouter,
+
+ configFile: configFileRouter,
+
+ organizations: createTRPCRouter({
+ byGithubUserId: protectedProcedure
+ .meta({
+ authorizationCheck: ({ canUser, input }) =>
+ canUser.perform(Permission.WorkspaceListIntegrations).on({
+ type: "workspace",
+ id: input.workspaceId,
+ }),
+ })
+ .input(
+ z.object({
+ githubUserId: z.number(),
+ workspaceId: z.string().uuid(),
+ }),
+ )
+ .query(({ input }) =>
+ getOctokit()
+ .apps.listInstallations({
+ headers: {
+ "X-GitHub-Api-Version": "2022-11-28",
+ },
+ })
+ .then(({ data: installations }) =>
+ Promise.all(
+ installations
+ .filter((i) => i.target_type === "Organization")
+ .map(async (i) => {
+ const installationOctokit = getOctokitInstallation(i.id);
+
+ const installationToken = (await installationOctokit.auth({
+ type: "installation",
+ installationId: i.id,
+ })) as { token: string };
+
+ const members = await installationOctokit.orgs.listMembers({
+ org: i.account?.login ?? "",
+ headers: {
+ "X-GitHub-Api-Version": "2022-11-28",
+ authorization: `Bearer ${installationToken.token}`,
+ },
+ });
+
+ const isUserInGithubOrg =
+ members.data.find((m) => m.id === input.githubUserId) !=
+ null;
+ if (!isUserInGithubOrg) return null;
+
+ const orgData = await installationOctokit.orgs.get({
+ org: i.account?.login ?? "",
+ headers: {
+ "X-GitHub-Api-Version": "2022-11-28",
+ },
+ });
+ return _.merge(orgData.data, { installationId: i.id });
+ }),
+ ).then((orgs) => orgs.filter(isPresent)),
+ ),
+ ),
+
+ byWorkspaceId: protectedProcedure
+ .meta({
+ authorizationCheck: ({ canUser, input }) =>
+ canUser.perform(Permission.WorkspaceListIntegrations).on({
+ type: "workspace",
+ id: input,
+ }),
+ })
+ .input(z.string().uuid())
+ .query(async ({ ctx, input }) => {
+ const internalOrgs = await ctx.db
+ .select()
+ .from(githubOrganization)
+ .where(eq(githubOrganization.workspaceId, input));
+
+ return getOctokit()
+ .apps.listInstallations({
+ headers: {
+ "X-GitHub-Api-Version": "2022-11-28",
+ },
+ })
+ .then(({ data: installations }) =>
+ Promise.all(
+ installations.filter(
+ (i) =>
+ i.target_type === "Organization" &&
+ internalOrgs.find((org) => org.installationId === i.id) !=
+ null,
+ ),
+ ),
+ );
+ }),
+
+ list: protectedProcedure
+ .meta({
+ authorizationCheck: ({ canUser, input }) =>
+ canUser.perform(Permission.WorkspaceListIntegrations).on({
+ type: "workspace",
+ id: input,
+ }),
+ })
+ .input(z.string().uuid())
+ .query(({ ctx, input }) =>
+ ctx.db
+ .select()
+ .from(githubOrganization)
+ .leftJoin(
+ githubUser,
+ eq(githubOrganization.addedByUserId, githubUser.userId),
+ )
+ .leftJoin(
+ githubConfigFile,
+ eq(githubConfigFile.organizationId, githubOrganization.id),
+ )
+ .leftJoin(
+ deployment,
+ eq(deployment.githubConfigFileId, githubConfigFile.id),
+ )
+ .where(eq(githubOrganization.workspaceId, input))
+ .then((rows) =>
+ _.chain(rows)
+ .groupBy("github_organization.id")
+ .map((v) => ({
+ ...v[0]!.github_organization,
+ addedByUser: v[0]!.github_user,
+ configFiles: v
+ .map((v) => v.github_config_file)
+ .filter(isPresent)
+ .map((cf) => ({
+ ...cf,
+ deployments: v
+ .map((v) => v.deployment)
+ .filter(isPresent)
+ .filter((d) => d.githubConfigFileId === cf.id),
+ })),
+ }))
+ .value(),
+ ),
+ ),
+
+ create: protectedProcedure
+ .meta({
+ authorizationCheck: ({ canUser, input }) =>
+ canUser.perform(Permission.WorkspaceUpdate).on({
+ type: "workspace",
+ id: input.workspaceId,
+ }),
+ })
+ .input(githubOrganizationInsert)
+ .mutation(({ ctx, input }) => createNewGithubOrganization(ctx.db, input)),
+
+ update: protectedProcedure
+ .meta({
+ authorizationCheck: ({ canUser, input }) =>
+ canUser.perform(Permission.WorkspaceUpdate).on({
+ type: "workspace",
+ id: input.data.workspaceId,
+ }),
+ })
+ .input(
+ z.object({
+ id: z.string().uuid(),
+ data: z.object({
+ connected: z.boolean().optional(),
+ installationId: z.number().optional(),
+ organizationName: z.string().optional(),
+ organizationId: z.string().optional(),
+ addedByUserId: z.string().optional(),
+ workspaceId: z.string().optional(),
+ }),
+ }),
+ )
+ .mutation(({ ctx, input }) =>
+ ctx.db
+ .update(githubOrganization)
+ .set(input.data)
+ .where(eq(githubOrganization.id, input.id)),
+ ),
+
+ delete: protectedProcedure
+ .meta({
+ authorizationCheck: ({ canUser, input }) =>
+ canUser.perform(Permission.WorkspaceUpdate).on({
+ type: "workspace",
+ id: input.workspaceId,
+ }),
+ })
+ .input(
+ z.object({
+ id: z.string().uuid(),
+ workspaceId: z.string().uuid(),
+ deleteDeployments: z.boolean(),
+ }),
+ )
+ .mutation(({ ctx, input }) =>
+ ctx.db.transaction(async (db) => {
+ const configFiles = await db
+ .select()
+ .from(githubConfigFile)
+ .where(eq(githubConfigFile.organizationId, input.id));
+
+ const deletedOrg = await db
+ .delete(githubOrganization)
+ .where(eq(githubOrganization.id, input.id))
+ .returning()
+ .then(takeFirstOrNull);
+
+ if (deletedOrg == null)
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Organization not found",
+ });
+
+ await db
+ .delete(jobAgent)
+ .where(
+ and(
+ eq(jobAgent.type, "github-app"),
+ eq(jobAgent.name, deletedOrg.organizationName),
+ eq(jobAgent.workspaceId, deletedOrg.workspaceId),
+ ),
+ );
+
+ if (input.deleteDeployments)
+ await db.delete(deployment).where(
+ inArray(
+ deployment.githubConfigFileId,
+ configFiles.map((c) => c.id),
+ ),
+ );
+ }),
+ ),
+ repos: reposRouter,
+ }),
+});
diff --git a/packages/api/src/router/runtime.ts b/packages/api/src/router/runtime.ts
index 55d16ee4..3428cc99 100644
--- a/packages/api/src/router/runtime.ts
+++ b/packages/api/src/router/runtime.ts
@@ -1,13 +1,6 @@
import { env } from "../config";
import { createTRPCRouter, protectedProcedure } from "../trpc";
-const githubRouter = createTRPCRouter({
- url: protectedProcedure.query(() => env.GITHUB_URL),
- botName: protectedProcedure.query(() => env.GITHUB_BOT_NAME),
- clientId: protectedProcedure.query(() => env.GITHUB_BOT_CLIENT_ID),
-});
-
export const runtimeRouter = createTRPCRouter({
baseUrl: protectedProcedure.query(() => env.BASE_URL),
- github: githubRouter,
});
diff --git a/packages/db/drizzle/0012_shocking_ultimatum.sql b/packages/db/drizzle/0012_shocking_ultimatum.sql
new file mode 100644
index 00000000..aa4e40d3
--- /dev/null
+++ b/packages/db/drizzle/0012_shocking_ultimatum.sql
@@ -0,0 +1,10 @@
+ALTER TABLE "deployment" DROP CONSTRAINT "deployment_job_agent_id_job_agent_id_fk";
+--> statement-breakpoint
+DO $$ BEGIN
+ ALTER TABLE "deployment" ADD CONSTRAINT "deployment_job_agent_id_job_agent_id_fk" FOREIGN KEY ("job_agent_id") REFERENCES "public"."job_agent"("id") ON DELETE set null ON UPDATE no action;
+EXCEPTION
+ WHEN duplicate_object THEN null;
+END $$;
+--> statement-breakpoint
+CREATE UNIQUE INDEX IF NOT EXISTS "unique_installation_workspace" ON "github_organization" ("installation_id","workspace_id");--> statement-breakpoint
+ALTER TABLE "github_organization" DROP COLUMN IF EXISTS "connected";
\ No newline at end of file
diff --git a/packages/db/drizzle/meta/0006_snapshot.json b/packages/db/drizzle/meta/0012_snapshot.json
similarity index 90%
rename from packages/db/drizzle/meta/0006_snapshot.json
rename to packages/db/drizzle/meta/0012_snapshot.json
index 4dfded5a..c609d9bb 100644
--- a/packages/db/drizzle/meta/0006_snapshot.json
+++ b/packages/db/drizzle/meta/0012_snapshot.json
@@ -1,6 +1,6 @@
{
- "id": "20958308-7a35-45b3-bbfd-91b66127eb35",
- "prevId": "c7e691ad-b60b-4452-a967-28024d710694",
+ "id": "5073064a-baad-4066-9a2c-41d58077ddc2",
+ "prevId": "ebce54a8-1ab0-45ba-96f6-4d5e56b495c1",
"version": "6",
"dialect": "postgresql",
"tables": {
@@ -650,7 +650,7 @@
"tableTo": "job_agent",
"columnsFrom": ["job_agent_id"],
"columnsTo": ["id"],
- "onDelete": "no action",
+ "onDelete": "set null",
"onUpdate": "no action"
},
"deployment_github_config_file_id_github_config_file_id_fk": {
@@ -659,7 +659,7 @@
"tableTo": "github_config_file",
"columnsFrom": ["github_config_file_id"],
"columnsTo": ["id"],
- "onDelete": "cascade",
+ "onDelete": "set null",
"onUpdate": "no action"
}
},
@@ -1195,13 +1195,6 @@
"notNull": true,
"default": "now()"
},
- "connected": {
- "name": "connected",
- "type": "boolean",
- "primaryKey": false,
- "notNull": true,
- "default": true
- },
"branch": {
"name": "branch",
"type": "text",
@@ -1210,7 +1203,13 @@
"default": "'main'"
}
},
- "indexes": {},
+ "indexes": {
+ "unique_installation_workspace": {
+ "name": "unique_installation_workspace",
+ "columns": ["installation_id", "workspace_id"],
+ "isUnique": true
+ }
+ },
"foreignKeys": {
"github_organization_added_by_user_id_user_id_fk": {
"name": "github_organization_added_by_user_id_user_id_fk",
@@ -1753,6 +1752,12 @@
"primaryKey": false,
"notNull": false
},
+ "system_id": {
+ "name": "system_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
"description": {
"name": "description",
"type": "text",
@@ -1767,20 +1772,30 @@
},
"job_agent_config": {
"name": "job_agent_config",
- "type": "text",
+ "type": "jsonb",
"primaryKey": false,
- "notNull": false
+ "notNull": true,
+ "default": "'{}'"
}
},
"indexes": {},
"foreignKeys": {
+ "runbook_system_id_system_id_fk": {
+ "name": "runbook_system_id_system_id_fk",
+ "tableFrom": "runbook",
+ "tableTo": "system",
+ "columnsFrom": ["system_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
"runbook_job_agent_id_job_agent_id_fk": {
"name": "runbook_job_agent_id_job_agent_id_fk",
"tableFrom": "runbook",
"tableTo": "job_agent",
"columnsFrom": ["job_agent_id"],
"columnsTo": ["id"],
- "onDelete": "no action",
+ "onDelete": "set null",
"onUpdate": "no action"
}
},
@@ -2183,8 +2198,8 @@
}
}
},
- "public.workspace_member": {
- "name": "workspace_member",
+ "public.variable_set": {
+ "name": "variable_set",
"schema": "",
"columns": {
"id": {
@@ -2194,51 +2209,42 @@
"notNull": true,
"default": "gen_random_uuid()"
},
- "workspace_id": {
- "name": "workspace_id",
- "type": "uuid",
+ "name": {
+ "name": "name",
+ "type": "text",
"primaryKey": false,
"notNull": true
},
- "user_id": {
- "name": "user_id",
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "system_id": {
+ "name": "system_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
}
},
- "indexes": {
- "workspace_member_workspace_id_user_id_index": {
- "name": "workspace_member_workspace_id_user_id_index",
- "columns": ["workspace_id", "user_id"],
- "isUnique": true
- }
- },
+ "indexes": {},
"foreignKeys": {
- "workspace_member_workspace_id_workspace_id_fk": {
- "name": "workspace_member_workspace_id_workspace_id_fk",
- "tableFrom": "workspace_member",
- "tableTo": "workspace",
- "columnsFrom": ["workspace_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- },
- "workspace_member_user_id_user_id_fk": {
- "name": "workspace_member_user_id_user_id_fk",
- "tableFrom": "workspace_member",
- "tableTo": "user",
- "columnsFrom": ["user_id"],
+ "variable_set_system_id_system_id_fk": {
+ "name": "variable_set_system_id_system_id_fk",
+ "tableFrom": "variable_set",
+ "tableTo": "system",
+ "columnsFrom": ["system_id"],
"columnsTo": ["id"],
- "onDelete": "cascade",
+ "onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
- "public.value": {
- "name": "value",
+ "public.variable_set_value": {
+ "name": "variable_set_value",
"schema": "",
"columns": {
"id": {
@@ -2248,18 +2254,18 @@
"notNull": true,
"default": "gen_random_uuid()"
},
- "value_set_id": {
- "name": "value_set_id",
- "type": "uuid",
- "primaryKey": false,
- "notNull": true
- },
"key": {
"name": "key",
"type": "text",
"primaryKey": false,
"notNull": false
},
+ "variable_set_id": {
+ "name": "variable_set_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
"value": {
"name": "value",
"type": "text",
@@ -2268,18 +2274,18 @@
}
},
"indexes": {
- "value_value_set_id_key_value_index": {
- "name": "value_value_set_id_key_value_index",
- "columns": ["value_set_id", "key", "value"],
+ "variable_set_value_variable_set_id_key_value_index": {
+ "name": "variable_set_value_variable_set_id_key_value_index",
+ "columns": ["variable_set_id", "key", "value"],
"isUnique": true
}
},
"foreignKeys": {
- "value_value_set_id_value_set_id_fk": {
- "name": "value_value_set_id_value_set_id_fk",
- "tableFrom": "value",
- "tableTo": "value_set",
- "columnsFrom": ["value_set_id"],
+ "variable_set_value_variable_set_id_variable_set_id_fk": {
+ "name": "variable_set_value_variable_set_id_variable_set_id_fk",
+ "tableFrom": "variable_set_value",
+ "tableTo": "variable_set",
+ "columnsFrom": ["variable_set_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
@@ -2288,8 +2294,8 @@
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
- "public.value_set": {
- "name": "value_set",
+ "public.workspace_invite_token": {
+ "name": "workspace_invite_token",
"schema": "",
"columns": {
"id": {
@@ -2299,53 +2305,20 @@
"notNull": true,
"default": "gen_random_uuid()"
},
- "name": {
- "name": "name",
- "type": "text",
+ "role_id": {
+ "name": "role_id",
+ "type": "uuid",
"primaryKey": false,
"notNull": true
},
- "description": {
- "name": "description",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "system_id": {
- "name": "system_id",
+ "workspace_id": {
+ "name": "workspace_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
- }
- },
- "indexes": {},
- "foreignKeys": {
- "value_set_system_id_system_id_fk": {
- "name": "value_set_system_id_system_id_fk",
- "tableFrom": "value_set",
- "tableTo": "system",
- "columnsFrom": ["system_id"],
- "columnsTo": ["id"],
- "onDelete": "no action",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {}
- },
- "public.workspace_invite_link": {
- "name": "workspace_invite_link",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "uuid",
- "primaryKey": true,
- "notNull": true,
- "default": "gen_random_uuid()"
},
- "workspace_member_id": {
- "name": "workspace_member_id",
+ "created_by": {
+ "name": "created_by",
"type": "uuid",
"primaryKey": false,
"notNull": true
@@ -2366,11 +2339,29 @@
},
"indexes": {},
"foreignKeys": {
- "workspace_invite_link_workspace_member_id_workspace_member_id_fk": {
- "name": "workspace_invite_link_workspace_member_id_workspace_member_id_fk",
- "tableFrom": "workspace_invite_link",
- "tableTo": "workspace_member",
- "columnsFrom": ["workspace_member_id"],
+ "workspace_invite_token_role_id_role_id_fk": {
+ "name": "workspace_invite_token_role_id_role_id_fk",
+ "tableFrom": "workspace_invite_token",
+ "tableTo": "role",
+ "columnsFrom": ["role_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "workspace_invite_token_workspace_id_workspace_id_fk": {
+ "name": "workspace_invite_token_workspace_id_workspace_id_fk",
+ "tableFrom": "workspace_invite_token",
+ "tableTo": "workspace",
+ "columnsFrom": ["workspace_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "workspace_invite_token_created_by_user_id_fk": {
+ "name": "workspace_invite_token_created_by_user_id_fk",
+ "tableFrom": "workspace_invite_token",
+ "tableTo": "user",
+ "columnsFrom": ["created_by"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
@@ -2378,8 +2369,8 @@
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
- "workspace_invite_link_token_unique": {
- "name": "workspace_invite_link_token_unique",
+ "workspace_invite_token_token_unique": {
+ "name": "workspace_invite_token_token_unique",
"nullsNotDistinct": false,
"columns": ["token"]
}
@@ -2470,22 +2461,29 @@
"name": "required",
"type": "boolean",
"primaryKey": false,
- "notNull": true
+ "notNull": true,
+ "default": false
},
- "default_value": {
- "name": "default_value",
+ "schema": {
+ "name": "schema",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
- "schema": {
- "name": "schema",
+ "value": {
+ "name": "value",
"type": "jsonb",
"primaryKey": false,
- "notNull": false
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "runbook_variable_runbook_id_key_index": {
+ "name": "runbook_variable_runbook_id_key_index",
+ "columns": ["runbook_id", "key"],
+ "isUnique": true
}
},
- "indexes": {},
"foreignKeys": {
"runbook_variable_runbook_id_runbook_id_fk": {
"name": "runbook_variable_runbook_id_runbook_id_fk",
@@ -2499,6 +2497,167 @@
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
+ },
+ "public.entity_role": {
+ "name": "entity_role",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "role_id": {
+ "name": "role_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "entity_type": {
+ "name": "entity_type",
+ "type": "entity_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "entity_id": {
+ "name": "entity_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scope_id": {
+ "name": "scope_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scope_type": {
+ "name": "scope_type",
+ "type": "scope_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "entity_role_role_id_entity_type_entity_id_scope_id_scope_type_index": {
+ "name": "entity_role_role_id_entity_type_entity_id_scope_id_scope_type_index",
+ "columns": [
+ "role_id",
+ "entity_type",
+ "entity_id",
+ "scope_id",
+ "scope_type"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "entity_role_role_id_role_id_fk": {
+ "name": "entity_role_role_id_role_id_fk",
+ "tableFrom": "entity_role",
+ "tableTo": "role",
+ "columnsFrom": ["role_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "public.role": {
+ "name": "role",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "role_workspace_id_workspace_id_fk": {
+ "name": "role_workspace_id_workspace_id_fk",
+ "tableFrom": "role",
+ "tableTo": "workspace",
+ "columnsFrom": ["workspace_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "public.role_permission": {
+ "name": "role_permission",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "role_id": {
+ "name": "role_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "permission": {
+ "name": "permission",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "role_permission_role_id_permission_index": {
+ "name": "role_permission_role_id_permission_index",
+ "columns": ["role_id", "permission"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "role_permission_role_id_role_id_fk": {
+ "name": "role_permission_role_id_role_id_fk",
+ "tableFrom": "role_permission",
+ "tableTo": "role",
+ "columnsFrom": ["role_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
}
},
"enums": {
@@ -2579,6 +2738,28 @@
"invalid_integration",
"external_run_not_found"
]
+ },
+ "public.entity_type": {
+ "name": "entity_type",
+ "schema": "public",
+ "values": ["user", "team"]
+ },
+ "public.scope_type": {
+ "name": "scope_type",
+ "schema": "public",
+ "values": [
+ "release",
+ "target",
+ "targetProvider",
+ "targetLabelGroup",
+ "workspace",
+ "environment",
+ "environmentPolicy",
+ "variableSet",
+ "system",
+ "deployment",
+ "jobAgent"
+ ]
}
},
"schemas": {},
diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json
index cbb568f0..8bb0a125 100644
--- a/packages/db/drizzle/meta/_journal.json
+++ b/packages/db/drizzle/meta/_journal.json
@@ -85,6 +85,13 @@
"when": 1725761787255,
"tag": "0011_opposite_doctor_faustus",
"breakpoints": true
+ },
+ {
+ "idx": 12,
+ "version": "6",
+ "when": 1725824665716,
+ "tag": "0012_shocking_ultimatum",
+ "breakpoints": true
}
]
}
diff --git a/packages/db/src/schema/deployment.ts b/packages/db/src/schema/deployment.ts
index 40c9dd19..a5108129 100644
--- a/packages/db/src/schema/deployment.ts
+++ b/packages/db/src/schema/deployment.ts
@@ -19,14 +19,16 @@ export const deployment = pgTable(
systemId: uuid("system_id")
.notNull()
.references(() => system.id),
- jobAgentId: uuid("job_agent_id").references(() => jobAgent.id),
+ jobAgentId: uuid("job_agent_id").references(() => jobAgent.id, {
+ onDelete: "set null",
+ }),
jobAgentConfig: jsonb("job_agent_config")
.default("{}")
.$type>()
.notNull(),
githubConfigFileId: uuid("github_config_file_id").references(
() => githubConfigFile.id,
- { onDelete: "cascade" },
+ { onDelete: "set null" },
),
},
(t) => ({ uniq: uniqueIndex().on(t.systemId, t.slug) }),
diff --git a/packages/db/src/schema/github.ts b/packages/db/src/schema/github.ts
index f3ab02ff..7f63b637 100644
--- a/packages/db/src/schema/github.ts
+++ b/packages/db/src/schema/github.ts
@@ -1,6 +1,5 @@
-import type { InferSelectModel } from "drizzle-orm";
+import type { InferInsertModel, InferSelectModel } from "drizzle-orm";
import {
- boolean,
integer,
pgTable,
text,
@@ -24,25 +23,36 @@ export const githubUser = pgTable("github_user", {
export type GithubUser = InferSelectModel;
-export const githubOrganization = pgTable("github_organization", {
- id: uuid("id").primaryKey().defaultRandom(),
- installationId: integer("installation_id").notNull(),
- organizationName: text("organization_name").notNull(),
- addedByUserId: uuid("added_by_user_id")
- .notNull()
- .references(() => user.id, { onDelete: "cascade" }),
- workspaceId: uuid("workspace_id")
- .notNull()
- .references(() => workspace.id, { onDelete: "cascade" }),
- avatarUrl: text("avatar_url"),
- createdAt: timestamp("created_at", { withTimezone: true })
- .notNull()
- .defaultNow(),
- connected: boolean("connected").notNull().default(true),
- branch: text("branch").notNull().default("main"),
-});
+export const githubOrganization = pgTable(
+ "github_organization",
+ {
+ id: uuid("id").primaryKey().defaultRandom(),
+ installationId: integer("installation_id").notNull(),
+ organizationName: text("organization_name").notNull(),
+ addedByUserId: uuid("added_by_user_id")
+ .notNull()
+ .references(() => user.id, { onDelete: "cascade" }),
+ workspaceId: uuid("workspace_id")
+ .notNull()
+ .references(() => workspace.id, { onDelete: "cascade" }),
+ avatarUrl: text("avatar_url"),
+ createdAt: timestamp("created_at", { withTimezone: true })
+ .notNull()
+ .defaultNow(),
+ branch: text("branch").notNull().default("main"),
+ },
+ (t) => ({
+ unique: uniqueIndex("unique_installation_workspace").on(
+ t.installationId,
+ t.workspaceId,
+ ),
+ }),
+);
export type GithubOrganization = InferSelectModel;
+export type GithubOrganizationInsert = InferInsertModel<
+ typeof githubOrganization
+>;
export const githubOrganizationInsert = createInsertSchema(githubOrganization);
diff --git a/packages/ui/src/alert.tsx b/packages/ui/src/alert.tsx
index 0c3853fa..95fad8d8 100644
--- a/packages/ui/src/alert.tsx
+++ b/packages/ui/src/alert.tsx
@@ -13,6 +13,8 @@ const alertVariants = cva(
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
warning: "border-orange-300 text-orange-300 [&>svg]:text-orange-300",
+ secondary:
+ "flex flex-col gap-2 rounded-md bg-neutral-800/50 text-muted-foreground [&>svg]:top-2 [&>svg]:text-muted-foreground",
},
},
defaultVariants: {
diff --git a/packages/validators/src/auth/index.ts b/packages/validators/src/auth/index.ts
index bf52a4ab..056216cb 100644
--- a/packages/validators/src/auth/index.ts
+++ b/packages/validators/src/auth/index.ts
@@ -3,6 +3,8 @@ import { z } from "zod";
export enum Permission {
WorkspaceInvite = "workspace.invite",
WorkspaceListMembers = "workspace.listMembers",
+ WorkspaceUpdate = "workspace.update",
+ WorkspaceListIntegrations = "workspace.listIntegrations",
JobAgentList = "jobAgent.list",
JobAgentCreate = "jobAgent.create",