From c8f0796952f2ecff1c29f6b1ecda0f23455f278d Mon Sep 17 00:00:00 2001 From: Salman Date: Thu, 15 Feb 2024 09:41:57 +0530 Subject: [PATCH] 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