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 ( + + ); +}; 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: +
    +
  1. + Connect a new organization: Install the + ctrlplane Github app on a new organization. +
  2. +
  3. + Select pre-connected: Select a + pre-connected organization where the ctrlplane app is + installed. +
  4. +
+ + Read more{" "} + + here + + . + +
+
+ )} + + + + + + + {validOrgsToAdd.length > 0 && ( +
+ +
+ )} +
+ + )} + + {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. - - -
-
- - - - - - - - - - {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 - - -
-
-
-
-
- - -
-
-
- - - {(loading || githubOrgsInstalled.isLoading) && ( -
- {_.range(3).map((i) => ( - - ))} -
- )} - {!loading && !githubOrgsInstalled.isLoading && ( - - {githubOrgsInstalled.data?.map( - ({ github_organization, github_user }) => ( -
-
- - - - - - -
-

- {github_organization.organizationName} -

- {github_user != null && ( -

- Enabled by {github_user.githubUsername} on{" "} - {github_organization.createdAt.toLocaleDateString()} -

- )} -
-
- - - - - - - { - e.preventDefault(); - window.open( - e.currentTarget.href, - "_blank", - "noopener,noreferrer", - ); - }} - > - Configure - - - { - githubOrgUpdate.mutateAsync({ - id: github_organization.id, - data: { - connected: false, - }, - }); - }} - > - Disconnect - - - -
- ), - )} -
- )} -
- ); -}; 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:

+
    +
  1. + 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. +
  2. +
  3. + 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. +
  4. +
+
+ ) : ( + + 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 ( + + + + + + + + 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 + + + + + + + + + + + {githubOrgs.map(({ id, login, avatar_url }) => ( + { + setValue(currentValue); + setImage(avatar_url); + setOpen(false); + }} + className="w-full cursor-pointer" + > +
+ + + {login.slice(0, 2)} + + {login} +
+
+ ))} +
+
+
+
+
+ + + +
+ +
+
+ + ); +}; 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 && ( - - )} - {githubUser.data != null && ( - - )} -
+
+ +
+

+ {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 && ( + + + + )} + {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 (
- + + + + {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",