From c8cfb433165b8ff994710664ee9d24a6c5ec8bf9 Mon Sep 17 00:00:00 2001 From: Salman Date: Tue, 13 Feb 2024 05:42:29 +0530 Subject: [PATCH 01/16] Update github integration refactored to react-hook-form --- .../src/pages/integrations/github/create.tsx | 423 ++++++++++-------- 1 file changed, 247 insertions(+), 176 deletions(-) diff --git a/frontend/src/pages/integrations/github/create.tsx b/frontend/src/pages/integrations/github/create.tsx index 23296cefd..f09602384 100644 --- a/frontend/src/pages/integrations/github/create.tsx +++ b/frontend/src/pages/integrations/github/create.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; import Head from "next/head"; import Image from "next/image"; import Link from "next/link"; @@ -12,9 +13,13 @@ import { faCircleInfo } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { yupResolver } from "@hookform/resolvers/yup"; +import axios from "axios"; import { motion } from "framer-motion"; import queryString from "query-string"; +import * as yup from "yup"; +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { useCreateIntegration } from "@app/hooks/api"; import { @@ -45,9 +50,22 @@ enum TabSections { Options = "options" } +const schema = yup.object({ + selectedSourceEnvironment: yup.string().trim().required("Project Environment is required"), + secretPath: yup.string().trim().required("Secrets Path is required"), + targetAppIds: yup + .array(yup.string().required()) + .min(1, "Select atleast one repo") // .min() not working showing error for empty array + .required("Select atleast one repo"), + secretSuffix: yup.string().trim().optional() +}); + +type FormData = yup.InferType; + export default function GitHubCreateIntegrationPage() { const router = useRouter(); const { mutateAsync } = useCreateIntegration(); + const { createNotification } = useNotificationContext(); const { integrationAuthId } = queryString.parse(router.asPath.split("?")[1]); @@ -58,37 +76,44 @@ export default function GitHubCreateIntegrationPage() { integrationAuthId: (integrationAuthId as string) ?? "" }); - const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState(""); - const [secretPath, setSecretPath] = useState("/"); - const [targetAppIds, setTargetAppIds] = useState([]); - const [secretSuffix, setSecretSuffix] = useState(""); + const { control, handleSubmit, watch, setValue } = useForm({ + resolver: yupResolver(schema), + defaultValues: { + selectedSourceEnvironment: "", + secretPath: "/", + targetAppIds: [], + secretSuffix: "" + } + }); + + const targetAppIds = watch("targetAppIds"); const [isLoading, setIsLoading] = useState(false); useEffect(() => { if (workspace) { - setSelectedSourceEnvironment(workspace.environments[0].slug); + setValue("selectedSourceEnvironment", workspace.environments[0].slug); } }, [workspace]); useEffect(() => { if (integrationAuthApps) { if (integrationAuthApps.length > 0) { - setTargetAppIds([String(integrationAuthApps[0].appId)]); + setValue("targetAppIds", [String(integrationAuthApps[0].appId)]); } else { - setTargetAppIds(["none"]); + setValue("targetAppIds", ["none"]); } } }, [integrationAuthApps]); - const handleButtonClick = async () => { + const onFormSubmit = async (data: FormData) => { try { setIsLoading(true); if (!integrationAuth?.id) return; const targetApps = integrationAuthApps?.filter((integrationAuthApp) => - targetAppIds.includes(String(integrationAuthApp.appId)) + data.targetAppIds.includes(String(integrationAuthApp.appId)) ); if (!targetApps) return; @@ -99,11 +124,11 @@ export default function GitHubCreateIntegrationPage() { integrationAuthId: integrationAuth?.id, isActive: true, app: targetApp.name, - sourceEnvironment: selectedSourceEnvironment, owner: targetApp.owner, - secretPath, + secretPath: data.secretPath, + sourceEnvironment: data.selectedSourceEnvironment, metadata: { - secretSuffix + secretSuffix: data.secretSuffix } }); }) @@ -113,183 +138,229 @@ export default function GitHubCreateIntegrationPage() { router.push(`/integrations/${localStorage.getItem("projectData.id")}`); } catch (err) { console.error(err); + if (axios.isAxiosError(err)) { + const { message } = err?.response?.data as { message: string }; + createNotification({ + text: message, + type: "error" + }); + } + setIsLoading(false); } }; - return integrationAuth && - workspace && - selectedSourceEnvironment && - integrationAuthApps && - targetAppIds ? ( + return integrationAuth && workspace && integrationAuthApps ? (
Set Up GitHub Integration - -
-
- GitHub logo +
+ +
+
+ GitHub logo +
+ GitHub Integration + + +
+ + Docs + +
+
+
- GitHub Integration - - -
- - Docs - -
-
- -
- - - -
- Connection - Options -
-
- - - - - - - setSecretPath(evt.target.value)} - placeholder="Provide a path, default is /" - /> - - - - - {integrationAuthApps.length > 0 ? ( -
- {targetAppIds.length === 1 - ? integrationAuthApps?.find( - (integrationAuthApp) => - targetAppIds[0] === String(integrationAuthApp.appId) - )?.name - : `${targetAppIds.length} repositories selected`} - -
- ) : ( -
- No repositories found -
- )} -
- - {integrationAuthApps.length > 0 ? ( - integrationAuthApps.map((integrationAuthApp) => { - const isSelected = targetAppIds.includes(String(integrationAuthApp.appId)); - - return ( - { - if (targetAppIds.includes(String(integrationAuthApp.appId))) { - setTargetAppIds( - targetAppIds.filter( - (appId) => appId !== String(integrationAuthApp.appId) - ) - ); - } else { - setTargetAppIds([ - ...targetAppIds, - String(integrationAuthApp.appId) - ]); - } - }} - key={integrationAuthApp.appId} - icon={ - isSelected ? ( - - ) : ( -
- ) - } - iconPos="left" - className="w-[28.4rem] text-sm" + setSecretSuffix(evt.target.value)} - placeholder="Provide a suffix for secret names, default is no suffix" + {sourceEnvironment.name} + + ))} + + + )} /> - - - - - + + ( + + + + )} + /> + + ( + + + + {integrationAuthApps.length > 0 ? ( +
+ {targetAppIds.length === 1 + ? integrationAuthApps?.find( + (integrationAuthApp) => + targetAppIds[0] === String(integrationAuthApp.appId) + )?.name + : `${targetAppIds.length} repositories selected`} + +
+ ) : ( +
+ No repositories found +
+ )} +
+ + {integrationAuthApps.length > 0 ? ( + integrationAuthApps.map((integrationAuthApp) => { + const isSelected = targetAppIds.includes( + String(integrationAuthApp.appId) + ); + + return ( + { + if (targetAppIds.includes(String(integrationAuthApp.appId))) { + onChange( + targetAppIds.filter( + (appId) => appId !== String(integrationAuthApp.appId) + ) + ); + } else { + onChange([...targetAppIds, String(integrationAuthApp.appId)]); + } + }} + key={integrationAuthApp.appId} + icon={ + isSelected ? ( + + ) : ( +
+ ) + } + iconPos="left" + className="w-[28.4rem] text-sm" + > + {integrationAuthApp.name} + + ); + }) + ) : ( +
+ )} + + + + )} + /> + + + + + ( + + + + )} + /> + + + +
+ +
+
From df459d456aac06b194693d484110e4a563e0fe28 Mon Sep 17 00:00:00 2001 From: Salman Date: Wed, 14 Feb 2024 04:21:50 +0530 Subject: [PATCH 02/16] Update github form ui add scope and init org api --- .../routes/v1/integration-auth-router.ts | 32 ++ .../integration-auth-service.ts | 30 ++ .../integration-auth-types.ts | 4 + docs/spec.yaml | 12 + .../src/hooks/api/integrationAuth/index.tsx | 1 + .../src/hooks/api/integrationAuth/queries.tsx | 20 ++ .../src/pages/integrations/github/create.tsx | 314 +++++++++++++----- 7 files changed, 325 insertions(+), 88 deletions(-) diff --git a/backend/src/server/routes/v1/integration-auth-router.ts b/backend/src/server/routes/v1/integration-auth-router.ts index 4d7aa1b1e..871b2ec65 100644 --- a/backend/src/server/routes/v1/integration-auth-router.ts +++ b/backend/src/server/routes/v1/integration-auth-router.ts @@ -343,6 +343,38 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) } }); + server.route({ + url: "/:integrationAuthId/github/orgs", + method: "GET", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + integrationAuthId: z.string().trim() + }), + response: { + 200: z.object({ + orgs: z.object({ name: z.string(), orgId: z.string() }).array() + }) + } + }, + handler: async (req) => { + try { + const orgs = await server.services.integrationAuth.getGithubOrgs({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + id: req.params.integrationAuthId + }); + if (!orgs) throw new Error("No organization found."); + + return { orgs: orgs || [] }; + } catch (e) { + console.error(e); + return { orgs: [] }; + } + } + }); + server.route({ url: "/:integrationAuthId/qovery/orgs", method: "GET", diff --git a/backend/src/services/integration-auth/integration-auth-service.ts b/backend/src/services/integration-auth/integration-auth-service.ts index 0b3f9b4c3..c158567c3 100644 --- a/backend/src/services/integration-auth/integration-auth-service.ts +++ b/backend/src/services/integration-auth/integration-auth-service.ts @@ -1,4 +1,5 @@ import { ForbiddenError } from "@casl/ability"; +import { Octokit } from "@octokit/rest"; import { SecretEncryptionAlgo, SecretKeyEncoding, TIntegrationAuths, TIntegrationAuthsInsert } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; @@ -23,6 +24,7 @@ import { TIntegrationAuthAppsDTO, TIntegrationAuthBitbucketWorkspaceDTO, TIntegrationAuthChecklyGroupsDTO, + TIntegrationAuthGithubOrgsDTO, TIntegrationAuthNorthflankSecretGroupDTO, TIntegrationAuthQoveryEnvironmentsDTO, TIntegrationAuthQoveryOrgsDTO, @@ -381,6 +383,33 @@ export const integrationAuthServiceFactory = ({ return []; }; + const getGithubOrgs = async ({ actorId, actor, actorOrgId, id }: TIntegrationAuthGithubOrgsDTO) => { + const integrationAuth = await integrationAuthDAL.findById(id); + if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); + const botKey = await projectBotService.getBotKey(integrationAuth.projectId); + const { accessToken } = await getIntegrationAccessToken(integrationAuth, botKey); + + const octokit = new Octokit({ + auth: accessToken + }); + + const { data } = await octokit.request("GET /organizations", { + headers: { + "X-GitHub-Api-Version": "2022-11-28" + } + }); + if (!data) return []; + return data.map(({ login: name, id: orgId }) => ({ name, orgId })); + }; + const getQoveryOrgs = async ({ actorId, actor, actorOrgId, id }: TIntegrationAuthQoveryOrgsDTO) => { const integrationAuth = await integrationAuthDAL.findById(id); if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); @@ -911,6 +940,7 @@ export const integrationAuthServiceFactory = ({ getIntegrationApps, getVercelBranches, getApps, + getGithubOrgs, getChecklyGroups, getQoveryApps, getQoveryEnvs, diff --git a/backend/src/services/integration-auth/integration-auth-types.ts b/backend/src/services/integration-auth/integration-auth-types.ts index 34c5d995a..8cf20d747 100644 --- a/backend/src/services/integration-auth/integration-auth-types.ts +++ b/backend/src/services/integration-auth/integration-auth-types.ts @@ -44,6 +44,10 @@ export type TIntegrationAuthChecklyGroupsDTO = { accountId: string; } & Omit; +export type TIntegrationAuthGithubOrgsDTO = { + id: string; +} & Omit; + export type TIntegrationAuthQoveryOrgsDTO = { id: string; } & Omit; diff --git a/docs/spec.yaml b/docs/spec.yaml index c3d050395..d7442497a 100644 --- a/docs/spec.yaml +++ b/docs/spec.yaml @@ -1869,6 +1869,18 @@ paths: responses: '200': description: OK + /api/v1/integration-auth/{integrationAuthId}/github/orgs: + get: + description: '' + parameters: + - name: integrationAuthId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK /api/v1/integration-auth/{integrationAuthId}/qovery/orgs: get: description: '' diff --git a/frontend/src/hooks/api/integrationAuth/index.tsx b/frontend/src/hooks/api/integrationAuth/index.tsx index e70ffdccb..358ed1a63 100644 --- a/frontend/src/hooks/api/integrationAuth/index.tsx +++ b/frontend/src/hooks/api/integrationAuth/index.tsx @@ -6,6 +6,7 @@ export { useGetIntegrationAuthBitBucketWorkspaces, useGetIntegrationAuthById, useGetIntegrationAuthChecklyGroups, + useGetIntegrationAuthGithubOrgs, useGetIntegrationAuthNorthflankSecretGroups, useGetIntegrationAuthRailwayEnvironments, useGetIntegrationAuthRailwayServices, diff --git a/frontend/src/hooks/api/integrationAuth/queries.tsx b/frontend/src/hooks/api/integrationAuth/queries.tsx index 83dfb5d1a..eb675c628 100644 --- a/frontend/src/hooks/api/integrationAuth/queries.tsx +++ b/frontend/src/hooks/api/integrationAuth/queries.tsx @@ -38,6 +38,8 @@ const integrationAuthKeys = { integrationAuthId: string; accountId: string; }) => [{ integrationAuthId, accountId }, "integrationAuthChecklyGroups"] as const, + getIntegrationAuthGithubOrgs: (integrationAuthId: string) => + [{ integrationAuthId }, "integrationAuthGithubOrgs"] as const, getIntegrationAuthQoveryOrgs: (integrationAuthId: string) => [{ integrationAuthId }, "integrationAuthQoveryOrgs"] as const, getIntegrationAuthQoveryProjects: ({ @@ -174,6 +176,16 @@ const fetchIntegrationAuthVercelBranches = async ({ return branches; }; +const fetchIntegrationAuthGithubOrgs = async (integrationAuthId: string) => { + const { + data: { orgs } + } = await apiRequest.get<{ orgs: Org[] }>( + `/api/v1/integration-auth/${integrationAuthId}/github/orgs` + ); + + return orgs; +}; + const fetchIntegrationAuthQoveryOrgs = async (integrationAuthId: string) => { const { data: { orgs } @@ -465,6 +477,14 @@ export const useGetIntegrationAuthChecklyGroups = ({ }); }; +export const useGetIntegrationAuthGithubOrgs = (integrationAuthId: string) => { + return useQuery({ + queryKey: integrationAuthKeys.getIntegrationAuthGithubOrgs(integrationAuthId), + queryFn: () => fetchIntegrationAuthGithubOrgs(integrationAuthId), + enabled: true + }); +}; + export const useGetIntegrationAuthQoveryOrgs = (integrationAuthId: string) => { return useQuery({ queryKey: integrationAuthKeys.getIntegrationAuthQoveryOrgs(integrationAuthId), diff --git a/frontend/src/pages/integrations/github/create.tsx b/frontend/src/pages/integrations/github/create.tsx index f09602384..6aea82cf3 100644 --- a/frontend/src/pages/integrations/github/create.tsx +++ b/frontend/src/pages/integrations/github/create.tsx @@ -20,8 +20,6 @@ import queryString from "query-string"; import * as yup from "yup"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; -import { useCreateIntegration } from "@app/hooks/api"; - import { Button, Card, @@ -38,26 +36,50 @@ import { TabList, TabPanel, Tabs -} from "../../../components/v2"; +} from "@app/components/v2"; import { + useCreateIntegration, useGetIntegrationAuthApps, - useGetIntegrationAuthById -} from "../../../hooks/api/integrationAuth"; -import { useGetWorkspaceById } from "../../../hooks/api/workspace"; + useGetIntegrationAuthById, + useGetIntegrationAuthGithubOrgs, + useGetWorkspaceById +} from "@app/hooks/api"; enum TabSections { Connection = "connection", Options = "options" } +const targetEnv = [ + "github-repo", + "github-org", + "github-env" +] as const; +type TargetEnv = typeof targetEnv[number]; + + const schema = yup.object({ selectedSourceEnvironment: yup.string().trim().required("Project Environment is required"), secretPath: yup.string().trim().required("Secrets Path is required"), - targetAppIds: yup + secretSuffix: yup.string().trim().optional(), + + scope: yup.mixed().oneOf(targetEnv.slice()).required(), + repoIds: yup .array(yup.string().required()) .min(1, "Select atleast one repo") // .min() not working showing error for empty array - .required("Select atleast one repo"), - secretSuffix: yup.string().trim().optional() + .optional(), + repoId: yup + .string() + .optional(), + + envId: yup + .string() + .optional(), + + orgId: yup + .string() + .optional(), + }); type FormData = yup.InferType; @@ -75,19 +97,25 @@ export default function GitHubCreateIntegrationPage() { useGetIntegrationAuthApps({ integrationAuthId: (integrationAuthId as string) ?? "" }); + + const { data: integrationAuthOrgs } = + useGetIntegrationAuthGithubOrgs(integrationAuthId as string); const { control, handleSubmit, watch, setValue } = useForm({ resolver: yupResolver(schema), defaultValues: { selectedSourceEnvironment: "", secretPath: "/", - targetAppIds: [], - secretSuffix: "" + repoIds: [], + secretSuffix: "", + scope: "github-repo" } }); - const targetAppIds = watch("targetAppIds"); + const scope = watch("scope"); + const repoIds = watch("repoIds"); + const [isLoading, setIsLoading] = useState(false); useEffect(() => { @@ -99,9 +127,9 @@ export default function GitHubCreateIntegrationPage() { useEffect(() => { if (integrationAuthApps) { if (integrationAuthApps.length > 0) { - setValue("targetAppIds", [String(integrationAuthApps[0].appId)]); + setValue("repoIds", [String(integrationAuthApps[0].appId)]); } else { - setValue("targetAppIds", ["none"]); + setValue("repoIds", ["none"]); } } }, [integrationAuthApps]); @@ -113,7 +141,7 @@ export default function GitHubCreateIntegrationPage() { if (!integrationAuth?.id) return; const targetApps = integrationAuthApps?.filter((integrationAuthApp) => - data.targetAppIds.includes(String(integrationAuthApp.appId)) + data.repoIds?.includes(String(integrationAuthApp.appId)) ); if (!targetApps) return; @@ -217,7 +245,7 @@ export default function GitHubCreateIntegrationPage() { {workspace?.environments.map((sourceEnvironment) => ( {sourceEnvironment.name} @@ -226,7 +254,6 @@ export default function GitHubCreateIntegrationPage() { )} /> - ( + name="scope" + render={({ field: { onChange, ...field }, fieldState: { error } }) => ( - - - {integrationAuthApps.length > 0 ? ( -
- {targetAppIds.length === 1 - ? integrationAuthApps?.find( - (integrationAuthApp) => - targetAppIds[0] === String(integrationAuthApp.appId) - )?.name - : `${targetAppIds.length} repositories selected`} - -
- ) : ( -
- No repositories found -
- )} -
- - {integrationAuthApps.length > 0 ? ( - integrationAuthApps.map((integrationAuthApp) => { - const isSelected = targetAppIds.includes( - String(integrationAuthApp.appId) - ); - - return ( - { - if (targetAppIds.includes(String(integrationAuthApp.appId))) { - onChange( - targetAppIds.filter( - (appId) => appId !== String(integrationAuthApp.appId) - ) - ); - } else { - onChange([...targetAppIds, String(integrationAuthApp.appId)]); - } - }} - key={integrationAuthApp.appId} - icon={ - isSelected ? ( - - ) : ( -
- ) - } - iconPos="left" - className="w-[28.4rem] text-sm" - > - {integrationAuthApp.name} - - ); - }) - ) : ( -
- )} - - + )} /> + + {scope === "github-repo" && repoIds && ( + ( + + + + {integrationAuthApps.length > 0 ? ( +
+ {repoIds.length === 1 + ? integrationAuthApps?.find( + (integrationAuthApp) => + repoIds[0] === String(integrationAuthApp.appId) + )?.name + : `${repoIds.length} repositories selected`} + +
+ ) : ( +
+ No repositories found +
+ )} +
+ + {integrationAuthApps.length > 0 ? ( + integrationAuthApps.map((integrationAuthApp) => { + const isSelected = repoIds.includes( + String(integrationAuthApp.appId) + ); + + return ( + { + if (repoIds.includes(String(integrationAuthApp.appId))) { + onChange( + repoIds.filter( + (appId) => appId !== String(integrationAuthApp.appId) + ) + ); + } else { + onChange([...repoIds, String(integrationAuthApp.appId)]); + } + }} + key={`repos-id-${integrationAuthApp.appId}`} + icon={ + isSelected ? ( + + ) : ( +
+ ) + } + iconPos="left" + className="w-[28.4rem] text-sm" + > + {integrationAuthApp.name} + + ); + }) + ) : ( +
+ )} + + + + )} + /> + + )} + {scope === "github-org" && ( + ( + + + + )} + /> + )} + {scope === "github-env" && ( + ( + + + + )} + /> + )} + {scope === "github-env" && ( + ( + + + + )} + /> + )} @@ -355,7 +493,7 @@ export default function GitHubCreateIntegrationPage() { variant="outline_bg" className="mb-6" isLoading={isLoading} - isDisabled={integrationAuthApps.length === 0 || targetAppIds.length === 0} + isDisabled={integrationAuthApps.length === 0 || repoIds?.length === 0} > Create Integration From c8f0796952f2ecff1c29f6b1ecda0f23455f278d Mon Sep 17 00:00:00 2001 From: Salman Date: Thu, 15 Feb 2024 09:41:57 +0530 Subject: [PATCH 03/16] Update github integrations ui for organization and environment --- .../routes/v1/integration-auth-router.ts | 52 ++- .../integration-auth-service.ts | 48 ++- .../integration-auth-types.ts | 6 + .../src/hooks/api/integrationAuth/index.tsx | 1 + .../src/hooks/api/integrationAuth/queries.tsx | 34 ++ .../src/pages/integrations/github/create.tsx | 313 +++++++++++------- 6 files changed, 311 insertions(+), 143 deletions(-) diff --git a/backend/src/server/routes/v1/integration-auth-router.ts b/backend/src/server/routes/v1/integration-auth-router.ts index 871b2ec65..0decaf119 100644 --- a/backend/src/server/routes/v1/integration-auth-router.ts +++ b/backend/src/server/routes/v1/integration-auth-router.ts @@ -358,20 +358,48 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) } }, handler: async (req) => { - try { - const orgs = await server.services.integrationAuth.getGithubOrgs({ - actorId: req.permission.id, - actor: req.permission.type, - actorOrgId: req.permission.orgId, - id: req.params.integrationAuthId - }); - if (!orgs) throw new Error("No organization found."); + const orgs = await server.services.integrationAuth.getGithubOrgs({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + id: req.params.integrationAuthId + }); + if (!orgs) throw new Error("No organization found."); - return { orgs: orgs || [] }; - } catch (e) { - console.error(e); - return { orgs: [] }; + return { orgs }; + } + }); + + server.route({ + url: "/:integrationAuthId/github/envs", + method: "GET", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + integrationAuthId: z.string().trim() + }), + querystring: z.object({ + repoOwner: z.string().trim(), + repoName: z.string().trim() + }), + response: { + 200: z.object({ + envs: z.object({ name: z.string(), envId: z.string() }).array() + }) } + }, + handler: async (req) => { + const envs = await server.services.integrationAuth.getGithubEnvs({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + id: req.params.integrationAuthId, + repoName: req.query.repoName, + repoOwner: req.query.repoOwner + }); + if (!envs) throw new Error("No organization found."); + + return { envs }; } }); diff --git a/backend/src/services/integration-auth/integration-auth-service.ts b/backend/src/services/integration-auth/integration-auth-service.ts index c158567c3..4b8d0ee80 100644 --- a/backend/src/services/integration-auth/integration-auth-service.ts +++ b/backend/src/services/integration-auth/integration-auth-service.ts @@ -24,6 +24,7 @@ import { TIntegrationAuthAppsDTO, TIntegrationAuthBitbucketWorkspaceDTO, TIntegrationAuthChecklyGroupsDTO, + TIntegrationAuthGithubEnvsDTO, TIntegrationAuthGithubOrgsDTO, TIntegrationAuthNorthflankSecretGroupDTO, TIntegrationAuthQoveryEnvironmentsDTO, @@ -401,13 +402,51 @@ export const integrationAuthServiceFactory = ({ auth: accessToken }); - const { data } = await octokit.request("GET /organizations", { + const { data } = await octokit.request("GET /user/orgs", { headers: { "X-GitHub-Api-Version": "2022-11-28" } }); if (!data) return []; - return data.map(({ login: name, id: orgId }) => ({ name, orgId })); + return data.map(({ login: name, id: orgId }) => ({ name, orgId: String(orgId) })); + }; + + const getGithubEnvs = async ({ + actorId, + actor, + actorOrgId, + id, + repoOwner, + repoName + }: TIntegrationAuthGithubEnvsDTO) => { + const integrationAuth = await integrationAuthDAL.findById(id); + if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); + const botKey = await projectBotService.getBotKey(integrationAuth.projectId); + const { accessToken } = await getIntegrationAccessToken(integrationAuth, botKey); + + const octokit = new Octokit({ + auth: accessToken + }); + + const { + data: { environments } + } = await octokit.request("GET /repos/{owner}/{repo}/environments", { + headers: { + "X-GitHub-Api-Version": "2022-11-28" + }, + owner: repoOwner, + repo: repoName + }); + if (!environments) return []; + return environments.map(({ id: envId, name }) => ({ name, envId: String(envId) })); }; const getQoveryOrgs = async ({ actorId, actor, actorOrgId, id }: TIntegrationAuthQoveryOrgsDTO) => { @@ -762,9 +801,7 @@ export const integrationAuthServiceFactory = ({ while (hasNextPage) { // eslint-disable-next-line - const { data }: { data: { values: TBitbucketWorkspace[]; next: string } } = await request.get( - workspaceUrl, - { + const { data }: { data: { values: TBitbucketWorkspace[]; next: string } } = await request.get(workspaceUrl, { headers: { Authorization: `Bearer ${accessToken}`, "Accept-Encoding": "application/json" @@ -941,6 +978,7 @@ export const integrationAuthServiceFactory = ({ getVercelBranches, getApps, getGithubOrgs, + getGithubEnvs, getChecklyGroups, getQoveryApps, getQoveryEnvs, diff --git a/backend/src/services/integration-auth/integration-auth-types.ts b/backend/src/services/integration-auth/integration-auth-types.ts index 8cf20d747..0c411b573 100644 --- a/backend/src/services/integration-auth/integration-auth-types.ts +++ b/backend/src/services/integration-auth/integration-auth-types.ts @@ -48,6 +48,12 @@ export type TIntegrationAuthGithubOrgsDTO = { id: string; } & Omit; +export type TIntegrationAuthGithubEnvsDTO = { + id: string; + repoName: string; + repoOwner: string; +} & Omit; + export type TIntegrationAuthQoveryOrgsDTO = { id: string; } & Omit; diff --git a/frontend/src/hooks/api/integrationAuth/index.tsx b/frontend/src/hooks/api/integrationAuth/index.tsx index 358ed1a63..545985fa9 100644 --- a/frontend/src/hooks/api/integrationAuth/index.tsx +++ b/frontend/src/hooks/api/integrationAuth/index.tsx @@ -6,6 +6,7 @@ export { useGetIntegrationAuthBitBucketWorkspaces, useGetIntegrationAuthById, useGetIntegrationAuthChecklyGroups, + useGetIntegrationAuthGithubEnvs, useGetIntegrationAuthGithubOrgs, useGetIntegrationAuthNorthflankSecretGroups, useGetIntegrationAuthRailwayEnvironments, diff --git a/frontend/src/hooks/api/integrationAuth/queries.tsx b/frontend/src/hooks/api/integrationAuth/queries.tsx index eb675c628..dc697bb36 100644 --- a/frontend/src/hooks/api/integrationAuth/queries.tsx +++ b/frontend/src/hooks/api/integrationAuth/queries.tsx @@ -40,6 +40,8 @@ const integrationAuthKeys = { }) => [{ integrationAuthId, accountId }, "integrationAuthChecklyGroups"] as const, getIntegrationAuthGithubOrgs: (integrationAuthId: string) => [{ integrationAuthId }, "integrationAuthGithubOrgs"] as const, + getIntegrationAuthGithubEnvs: (integrationAuthId: string, repoName: string, repoOwner: string) => + [{ integrationAuthId, repoName, repoOwner }, "integrationAuthGithubOrgs"] as const, getIntegrationAuthQoveryOrgs: (integrationAuthId: string) => [{ integrationAuthId }, "integrationAuthQoveryOrgs"] as const, getIntegrationAuthQoveryProjects: ({ @@ -186,6 +188,22 @@ const fetchIntegrationAuthGithubOrgs = async (integrationAuthId: string) => { return orgs; }; +const fetchIntegrationAuthGithubEnvs = async ( + integrationAuthId: string, + repoName: string, + repoOwner: string +) => { + if (!repoName || !repoOwner) return []; + + const { + data: { envs } + } = await apiRequest.get<{ envs: Array<{ name: string; envId: string }> }>( + `/api/v1/integration-auth/${integrationAuthId}/github/envs?repoName=${repoName}&repoOwner=${repoOwner}` + ); + + return envs; +}; + const fetchIntegrationAuthQoveryOrgs = async (integrationAuthId: string) => { const { data: { orgs } @@ -485,6 +503,22 @@ export const useGetIntegrationAuthGithubOrgs = (integrationAuthId: string) => { }); }; +export const useGetIntegrationAuthGithubEnvs = ( + integrationAuthId: string, + repoName: string, + repoOwner: string +) => { + return useQuery({ + queryKey: integrationAuthKeys.getIntegrationAuthGithubEnvs( + integrationAuthId, + repoName, + repoOwner + ), + queryFn: () => fetchIntegrationAuthGithubEnvs(integrationAuthId, repoName, repoOwner), + enabled: true + }); +}; + export const useGetIntegrationAuthQoveryOrgs = (integrationAuthId: string) => { return useQuery({ queryKey: integrationAuthKeys.getIntegrationAuthQoveryOrgs(integrationAuthId), diff --git a/frontend/src/pages/integrations/github/create.tsx b/frontend/src/pages/integrations/github/create.tsx index 6aea82cf3..ff29583c2 100644 --- a/frontend/src/pages/integrations/github/create.tsx +++ b/frontend/src/pages/integrations/github/create.tsx @@ -41,6 +41,7 @@ import { useCreateIntegration, useGetIntegrationAuthApps, useGetIntegrationAuthById, + useGetIntegrationAuthGithubEnvs, useGetIntegrationAuthGithubOrgs, useGetWorkspaceById } from "@app/hooks/api"; @@ -50,13 +51,8 @@ enum TabSections { Options = "options" } -const targetEnv = [ - "github-repo", - "github-org", - "github-env" -] as const; -type TargetEnv = typeof targetEnv[number]; - +const targetEnv = ["github-repo", "github-org", "github-env"] as const; +type TargetEnv = (typeof targetEnv)[number]; const schema = yup.object({ selectedSourceEnvironment: yup.string().trim().required("Project Environment is required"), @@ -64,22 +60,31 @@ const schema = yup.object({ secretSuffix: yup.string().trim().optional(), scope: yup.mixed().oneOf(targetEnv.slice()).required(), - repoIds: yup - .array(yup.string().required()) - .min(1, "Select atleast one repo") // .min() not working showing error for empty array - .optional(), - repoId: yup - .string() - .optional(), - - envId: yup - .string() - .optional(), - - orgId: yup - .string() - .optional(), - + + repoIds: yup.mixed().when("scope", { + is: "github-repo", + then: yup.array(yup.string().required()).min(1, "Select atleast one repositories") + }), + + repoName: yup.mixed().when("scope", { + is: "github-env", + then: yup.string().required("Repository is required") + }), + + repoOwner: yup.mixed().when("scope", { + is: "github-env", + then: yup.string().required("Repository is required") + }), + + envId: yup.mixed().when("scope", { + is: "github-env", + then: yup.string().required("Environment is required") + }), + + orgId: yup.mixed().when("scope", { + is: "github-org", + then: yup.string().required("Organization is required") + }) }); type FormData = yup.InferType; @@ -89,33 +94,41 @@ export default function GitHubCreateIntegrationPage() { const { mutateAsync } = useCreateIntegration(); const { createNotification } = useNotificationContext(); - const { integrationAuthId } = queryString.parse(router.asPath.split("?")[1]); + const integrationAuthId = + (queryString.parse(router.asPath.split("?")[1]).integrationAuthId as string) ?? ""; const { data: workspace } = useGetWorkspaceById(localStorage.getItem("projectData.id") ?? ""); - const { data: integrationAuth } = useGetIntegrationAuthById((integrationAuthId as string) ?? ""); + const { data: integrationAuth } = useGetIntegrationAuthById(integrationAuthId); + const { data: integrationAuthApps, isLoading: isIntegrationAuthAppsLoading } = useGetIntegrationAuthApps({ - integrationAuthId: (integrationAuthId as string) ?? "" + integrationAuthId }); - - const { data: integrationAuthOrgs } = - useGetIntegrationAuthGithubOrgs(integrationAuthId as string); + + const { data: integrationAuthOrgs } = useGetIntegrationAuthGithubOrgs( + integrationAuthId as string + ); const { control, handleSubmit, watch, setValue } = useForm({ resolver: yupResolver(schema), defaultValues: { - selectedSourceEnvironment: "", secretPath: "/", - repoIds: [], - secretSuffix: "", - scope: "github-repo" + scope: "github-repo", + repoIds: [] } }); const scope = watch("scope"); - const repoIds = watch("repoIds"); - + const repoName = watch("repoName"); + const repoOwner = watch("repoOwner"); + + const { data: integrationAuthGithubEnvs } = useGetIntegrationAuthGithubEnvs( + integrationAuthId as string, + repoName, + repoOwner + ); + const [isLoading, setIsLoading] = useState(false); useEffect(() => { @@ -124,61 +137,93 @@ export default function GitHubCreateIntegrationPage() { } }, [workspace]); - useEffect(() => { - if (integrationAuthApps) { - if (integrationAuthApps.length > 0) { - setValue("repoIds", [String(integrationAuthApps[0].appId)]); - } else { - setValue("repoIds", ["none"]); - } - } - }, [integrationAuthApps]); - const onFormSubmit = async (data: FormData) => { try { setIsLoading(true); if (!integrationAuth?.id) return; - const targetApps = integrationAuthApps?.filter((integrationAuthApp) => - data.repoIds?.includes(String(integrationAuthApp.appId)) - ); + switch (data.scope) { + case "github-repo": { + const targetApps = integrationAuthApps?.filter((integrationAuthApp) => + data.repoIds?.includes(String(integrationAuthApp.appId)) + ); - if (!targetApps) return; + if (!targetApps) return; - await Promise.all( - targetApps.map(async (targetApp) => { + await Promise.all( + targetApps.map(async (targetApp) => { + await mutateAsync({ + integrationAuthId: integrationAuth?.id, + isActive: true, + scope: data.scope, + secretPath: data.secretPath, + sourceEnvironment: data.selectedSourceEnvironment, + app: targetApp.name, // repo name + owner: targetApp.owner, // repo owner + metadata: { + secretSuffix: data.secretSuffix + } + }); + }) + ); + + break; + } + case "github-org": await mutateAsync({ integrationAuthId: integrationAuth?.id, isActive: true, - app: targetApp.name, - owner: targetApp.owner, secretPath: data.secretPath, sourceEnvironment: data.selectedSourceEnvironment, + scope: data.scope, + targetServiceId: data.orgId, // github org id metadata: { secretSuffix: data.secretSuffix } }); - }) - ); + break; + + case "github-env": + await mutateAsync({ + integrationAuthId: integrationAuth?.id, + isActive: true, + secretPath: data.secretPath, + sourceEnvironment: data.selectedSourceEnvironment, + scope: data.scope, + app: repoName, // repo name // TODO: CHANGE THIS STATE INTO YUP + owner: repoOwner, // repo owner + targetEnvironmentId: data.envId, // github environment id + metadata: { + secretSuffix: data.secretSuffix + } + }); + break; + default: + throw new Error("Invalid scope"); + } setIsLoading(false); router.push(`/integrations/${localStorage.getItem("projectData.id")}`); } catch (err) { console.error(err); + + let errorMessage: string = "Something went wrong!"; if (axios.isAxiosError(err)) { const { message } = err?.response?.data as { message: string }; - createNotification({ - text: message, - type: "error" - }); + errorMessage = message; } + + createNotification({ + text: errorMessage, + type: "error" + }); setIsLoading(false); } }; return integrationAuth && workspace && integrationAuthApps ? ( -
+
Set Up GitHub Integration @@ -239,7 +284,7 @@ export default function GitHubCreateIntegrationPage() { > onChange(e)} + onValueChange={onChange} className="w-full border border-mineshaft-500" > Github Repositories @@ -289,17 +329,15 @@ export default function GitHubCreateIntegrationPage() { )} /> - - {scope === "github-repo" && repoIds && ( + {scope === "github-repo" && ( ( + render={({ field: { onChange }, fieldState: { error } }) => ( @@ -335,7 +373,8 @@ export default function GitHubCreateIntegrationPage() { if (repoIds.includes(String(integrationAuthApp.appId))) { onChange( repoIds.filter( - (appId) => appId !== String(integrationAuthApp.appId) + (appId: string) => + appId !== String(integrationAuthApp.appId) ) ); } else { @@ -368,68 +407,73 @@ export default function GitHubCreateIntegrationPage() { )} /> - )} {scope === "github-org" && ( ( - - - - )} - /> + + + )} + /> )} {scope === "github-env" && ( ( )} @@ -442,15 +486,33 @@ export default function GitHubCreateIntegrationPage() { render={({ field: { onChange, ...field }, fieldState: { error } }) => ( )} @@ -493,7 +555,6 @@ export default function GitHubCreateIntegrationPage() { variant="outline_bg" className="mb-6" isLoading={isLoading} - isDisabled={integrationAuthApps.length === 0 || repoIds?.length === 0} > Create Integration From dc696f8932a1298e7d358c4e6a925e073dc35192 Mon Sep 17 00:00:00 2001 From: Salman Date: Thu, 15 Feb 2024 15:59:18 +0530 Subject: [PATCH 04/16] Update integration section for github repo, org and env --- frontend/src/hooks/api/integrations/types.ts | 38 +++++-------- .../src/pages/integrations/github/create.tsx | 5 +- .../IntegrationsSection.tsx | 56 +++++++++---------- 3 files changed, 43 insertions(+), 56 deletions(-) diff --git a/frontend/src/hooks/api/integrations/types.ts b/frontend/src/hooks/api/integrations/types.ts index f63257f84..db205a3ec 100644 --- a/frontend/src/hooks/api/integrations/types.ts +++ b/frontend/src/hooks/api/integrations/types.ts @@ -11,31 +11,23 @@ export type TCloudIntegration = { export type TIntegration = { id: string; - projectId: string; - envId: string; - environment: { slug: string; name: string; id: string }; isActive: boolean; - url: any; - app: string; - appId: string; - targetEnvironment: string; - targetEnvironmentId: string; - targetService: string; - targetServiceId: string; - owner: string; - path: string; - region: string; + url?: string; + app?: string; + appId?: string; + targetEnvironment?: string; + targetEnvironmentId?: string; + targetService?: string; + targetServiceId?: string; + owner?: string; + path?: string; + region?: string; + scope?: string; integration: string; - integrationAuth: string; + metadata?: Record; + integrationAuthId: string; + envId: string; secretPath: string; createdAt: string; updatedAt: string; - __v: number; - metadata?: { - secretSuffix?: string; - scope: string; - org: string; - project: string; - environment: string; - }; -}; +}; \ No newline at end of file diff --git a/frontend/src/pages/integrations/github/create.tsx b/frontend/src/pages/integrations/github/create.tsx index ff29583c2..398245748 100644 --- a/frontend/src/pages/integrations/github/create.tsx +++ b/frontend/src/pages/integrations/github/create.tsx @@ -63,7 +63,7 @@ const schema = yup.object({ repoIds: yup.mixed().when("scope", { is: "github-repo", - then: yup.array(yup.string().required()).min(1, "Select atleast one repositories") + then: yup.array(yup.string().required()).min(1, "Select at least one repositories") }), repoName: yup.mixed().when("scope", { @@ -177,6 +177,7 @@ export default function GitHubCreateIntegrationPage() { secretPath: data.secretPath, sourceEnvironment: data.selectedSourceEnvironment, scope: data.scope, + owner: integrationAuthOrgs?.find(e=>e.orgId === data.orgId)?.name, // repo owner targetServiceId: data.orgId, // github org id metadata: { secretSuffix: data.secretSuffix @@ -588,7 +589,7 @@ export default function GitHubCreateIntegrationPage() { /> ) : (
- +

Something went wrong. Please contact{" "} ; + environments: Array<{ name: string; slug: string; id: string }>; integrations?: TIntegration[]; isLoading?: boolean; onIntegrationDelete: (integration: TIntegration, cb: () => void) => void; @@ -84,25 +81,11 @@ export const IntegrationsSection = ({ key={`integration-${integration?.id.toString()}`} >

-
- - - +
+ +
+ {environments.find((e) => e.id === integration.envId)?.name || "-"} +
@@ -142,11 +125,21 @@ export const IntegrationsSection = ({
)}
- -
- {integration.integration === "hashicorp-vault" - ? `${integration.app} - path: ${integration.path}` - : integration.app} + +
+ { + (integration.integration === "hashicorp-vault" && `${integration.app} - path: ${integration.path}`) || + (integration.scope === "github-org" && `${integration.owner}` ) || + (integration.scope?.startsWith("github-") && `${integration.owner}/${integration.app}` ) || + integration.app + }
{(integration.integration === "vercel" || @@ -154,11 +147,12 @@ export const IntegrationsSection = ({ integration.integration === "railway" || integration.integration === "gitlab" || integration.integration === "teamcity" || - integration.integration === "bitbucket") && ( + integration.integration === "bitbucket" || + (integration.integration === "github" && integration.scope === "github-env")) && (
-
- {integration.targetEnvironment} +
+ {integration.targetEnvironment || integration.targetEnvironmentId}
)} From b80a5989a856cf91c8ab0955375550f5e3a04c33 Mon Sep 17 00:00:00 2001 From: Salman Date: Fri, 16 Feb 2024 00:58:10 +0530 Subject: [PATCH 05/16] Fix reset env on repo change --- .../src/pages/integrations/github/create.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/integrations/github/create.tsx b/frontend/src/pages/integrations/github/create.tsx index 398245748..74cf42fec 100644 --- a/frontend/src/pages/integrations/github/create.tsx +++ b/frontend/src/pages/integrations/github/create.tsx @@ -137,6 +137,16 @@ export default function GitHubCreateIntegrationPage() { } }, [workspace]); + + useEffect(() => { + if (integrationAuthGithubEnvs && integrationAuthGithubEnvs?.length > 0) { + setValue("envId", integrationAuthGithubEnvs[0].envId); + } + else { + setValue("envId", undefined); + } + }, [integrationAuthGithubEnvs]) + const onFormSubmit = async (data: FormData) => { try { setIsLoading(true); @@ -422,7 +432,7 @@ export default function GitHubCreateIntegrationPage() { isError={Boolean(integrationAuthOrgs?.length && error?.message)} > { setValue("repoName", e); setValue( @@ -495,7 +505,7 @@ export default function GitHubCreateIntegrationPage() { isError={Boolean(integrationAuthGithubEnvs?.length || error?.message)} > )} diff --git a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx index bb517950a..10ef21e27 100644 --- a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx +++ b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx @@ -60,7 +60,7 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) => link = `https://app.netlify.com/authorize?client_id=${integrationOption.clientId}&response_type=code&state=${state}&redirect_uri=${window.location.origin}/integrations/netlify/oauth2/callback`; break; case "github": - link = `https://github.com/login/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=repo&redirect_uri=${window.location.origin}/integrations/github/oauth2/callback&state=${state}`; + link = `https://github.com/login/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=repo,admin:org&redirect_uri=${window.location.origin}/integrations/github/oauth2/callback&state=${state}`; break; case "gitlab": link = `${window.location.origin}/integrations/gitlab/authorize`; From 2b7784718da80d5238146edbd2a3850cd168532d Mon Sep 17 00:00:00 2001 From: Salman Date: Sat, 16 Mar 2024 08:01:28 +0530 Subject: [PATCH 13/16] fix: disabled repo env until repo selected --- frontend/src/pages/integrations/github/create.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/integrations/github/create.tsx b/frontend/src/pages/integrations/github/create.tsx index f81770441..c1e2073c1 100644 --- a/frontend/src/pages/integrations/github/create.tsx +++ b/frontend/src/pages/integrations/github/create.tsx @@ -17,6 +17,7 @@ import { yupResolver } from "@hookform/resolvers/yup"; import axios from "axios"; import { motion } from "framer-motion"; import queryString from "query-string"; +import { twMerge } from "tailwind-merge"; import * as yup from "yup"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; @@ -124,6 +125,7 @@ export default function GitHubCreateIntegrationPage() { }); const scope = watch("scope"); + const repoId = watch("repoId"); const repoIds = watch("repoIds"); const repoName = watch("repoName"); const repoOwner = watch("repoOwner"); @@ -133,7 +135,7 @@ export default function GitHubCreateIntegrationPage() { repoName, repoOwner ); - + const [isLoading, setIsLoading] = useState(false); useEffect(() => { @@ -237,7 +239,7 @@ export default function GitHubCreateIntegrationPage() { }; return integrationAuth && workspace && integrationAuthApps ? ( -
+
Set Up GitHub Integration @@ -508,7 +510,11 @@ export default function GitHubCreateIntegrationPage() {