Merge pull request #2377 from Infisical/daniel/refactor-circleci-integration

fix(integrations/circle-ci): Refactored Circle CI integration
This commit is contained in:
Maidul Islam
2024-09-05 16:04:04 -04:00
committed by GitHub
5 changed files with 165 additions and 57 deletions
@@ -460,16 +460,21 @@ const getAppsFlyio = async ({ accessToken }: { accessToken: string }) => {
*/ */
const getAppsCircleCI = async ({ accessToken }: { accessToken: string }) => { const getAppsCircleCI = async ({ accessToken }: { accessToken: string }) => {
const res = ( const res = (
await request.get<{ reponame: string }[]>(`${IntegrationUrls.CIRCLECI_API_URL}/v1.1/projects`, { await request.get<{ reponame: string; username: string; vcs_url: string }[]>(
headers: { `${IntegrationUrls.CIRCLECI_API_URL}/v1.1/projects`,
"Circle-Token": accessToken, {
"Accept-Encoding": "application/json" headers: {
"Circle-Token": accessToken,
"Accept-Encoding": "application/json"
}
} }
}) )
).data; ).data;
const apps = res?.map((a) => ({ const apps = res.map((a) => ({
name: a?.reponame owner: a.username, // username maps to unique organization name in CircleCI
name: a.reponame, // reponame maps to project name within an organization in CircleCI
appId: a.vcs_url.split("/").pop() // vcs_url maps to the project id in CircleCI
})); }));
return apps; return apps;
@@ -1929,22 +1929,62 @@ const syncSecretsCircleCI = async ({
secrets: Record<string, { value: string; comment?: string }>; secrets: Record<string, { value: string; comment?: string }>;
accessToken: string; accessToken: string;
}) => { }) => {
const circleciOrganizationDetail = ( const getProjectSlug = async () => {
await request.get(`${IntegrationUrls.CIRCLECI_API_URL}/v2/me/collaborations`, { const requestConfig = {
headers: { headers: {
"Circle-Token": accessToken, "Circle-Token": accessToken,
"Accept-Encoding": "application/json" "Accept-Encoding": "application/json"
} }
}) };
).data[0];
const { slug } = circleciOrganizationDetail; try {
const projectDetails = (
await request.get<{ slug: string }>(
`${IntegrationUrls.CIRCLECI_API_URL}/v2/project/${integration.appId}`,
requestConfig
)
).data;
return projectDetails.slug;
} catch (err) {
if (err instanceof AxiosError) {
if (err.response?.data?.message !== "Not Found") {
throw new Error("Failed to get project slug from CircleCI during first attempt.");
}
}
}
// For backwards compatibility with old CircleCI integrations where we don't keep track of the organization name, so we can't filter by organization
try {
const circleCiOrganization = (
await request.get<{ slug: string; name: string }[]>(
`${IntegrationUrls.CIRCLECI_API_URL}/v2/me/collaborations`,
requestConfig
)
).data;
// Case 1: This is a new integration where the organization name is stored under `integration.owner`
if (integration.owner) {
const org = circleCiOrganization.find((o) => o.name === integration.owner);
if (org) {
return `${org.slug}/${integration.app}`;
}
}
// Case 2: This is an old integration where the organization name is not stored, so we have to assume the first organization is the correct one
return `${circleCiOrganization[0].slug}/${integration.app}`;
} catch (err) {
throw new Error("Failed to get project slug from CircleCI during second attempt.");
}
};
const projectSlug = await getProjectSlug();
// sync secrets to CircleCI // sync secrets to CircleCI
await Promise.all( await Promise.all(
Object.keys(secrets).map(async (key) => Object.keys(secrets).map(async (key) =>
request.post( request.post(
`${IntegrationUrls.CIRCLECI_API_URL}/v2/project/${slug}/${integration.app}/envvar`, `${IntegrationUrls.CIRCLECI_API_URL}/v2/project/${projectSlug}/envvar`,
{ {
name: key, name: key,
value: secrets[key].value value: secrets[key].value
@@ -1962,7 +2002,7 @@ const syncSecretsCircleCI = async ({
// get secrets from CircleCI // get secrets from CircleCI
const getSecretsRes = ( const getSecretsRes = (
await request.get<{ items: { name: string }[] }>( await request.get<{ items: { name: string }[] }>(
`${IntegrationUrls.CIRCLECI_API_URL}/v2/project/${slug}/${integration.app}/envvar`, `${IntegrationUrls.CIRCLECI_API_URL}/v2/project/${projectSlug}/envvar`,
{ {
headers: { headers: {
"Circle-Token": accessToken, "Circle-Token": accessToken,
@@ -1976,15 +2016,12 @@ const syncSecretsCircleCI = async ({
await Promise.all( await Promise.all(
getSecretsRes.map(async (sec) => { getSecretsRes.map(async (sec) => {
if (!(sec.name in secrets)) { if (!(sec.name in secrets)) {
return request.delete( return request.delete(`${IntegrationUrls.CIRCLECI_API_URL}/v2/project/${projectSlug}/envvar/${sec.name}`, {
`${IntegrationUrls.CIRCLECI_API_URL}/v2/project/${slug}/${integration.app}/envvar/${sec.name}`, headers: {
{ "Circle-Token": accessToken,
headers: { "Content-Type": "application/json"
"Circle-Token": accessToken,
"Content-Type": "application/json"
}
} }
); });
} }
}) })
); );
@@ -69,7 +69,7 @@ export default function AWSParameterStoreAuthorizeIntegrationPage() {
subTitle="After adding the details below, you will be prompted to set up an integration for a particular Infisical project and environment." subTitle="After adding the details below, you will be prompted to set up an integration for a particular Infisical project and environment."
> >
<div className="flex flex-row items-center"> <div className="flex flex-row items-center">
<div className="inline flex items-center"> <div className="flex items-center">
<Image <Image
src="/images/integrations/Amazon Web Services.png" src="/images/integrations/Amazon Web Services.png"
height={35} height={35}
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import Head from "next/head"; import Head from "next/head";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
@@ -12,6 +12,7 @@ import {
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import queryString from "query-string"; import queryString from "query-string";
import { createNotification } from "@app/components/notifications";
import { useCreateIntegration } from "@app/hooks/api"; import { useCreateIntegration } from "@app/hooks/api";
import { import {
@@ -45,9 +46,10 @@ export default function CircleCICreateIntegrationPage() {
}); });
const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState(""); const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState("");
const [targetOrganization, setTargetOrganization] = useState("");
const [secretPath, setSecretPath] = useState("/"); const [secretPath, setSecretPath] = useState("/");
const [targetApp, setTargetApp] = useState(""); const [targetProjectId, setTargetProjectId] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -57,29 +59,40 @@ export default function CircleCICreateIntegrationPage() {
} }
}, [workspace]); }, [workspace]);
useEffect(() => {
if (integrationAuthApps) {
if (integrationAuthApps.length > 0) {
setTargetApp(integrationAuthApps[0]?.name);
} else {
setTargetApp("none");
}
}
}, [integrationAuthApps]);
const handleButtonClick = async () => { const handleButtonClick = async () => {
try { try {
if (!integrationAuth?.id) return; if (!integrationAuth?.id) return;
if (!targetProjectId || targetOrganization === "none") {
createNotification({
type: "error",
text: "Please select a project"
});
setIsLoading(false);
return;
}
setIsLoading(true); setIsLoading(true);
const selectedApp = integrationAuthApps?.find(
(integrationAuthApp) => integrationAuthApp.appId === targetProjectId
);
if (!selectedApp) {
createNotification({
type: "error",
text: "Invalid project selected"
});
setIsLoading(false);
return;
}
await mutateAsync({ await mutateAsync({
integrationAuthId: integrationAuth?.id, integrationAuthId: integrationAuth?.id,
isActive: true, isActive: true,
app: targetApp, app: selectedApp.name, // project name
appId: integrationAuthApps?.find( owner: selectedApp.owner, // organization name
(integrationAuthApp) => integrationAuthApp.name === targetApp appId: selectedApp.appId, // project id (used for syncing)
)?.appId,
sourceEnvironment: selectedSourceEnvironment, sourceEnvironment: selectedSourceEnvironment,
secretPath secretPath
}); });
@@ -92,11 +105,28 @@ export default function CircleCICreateIntegrationPage() {
} }
}; };
return integrationAuth && const filteredProjects = useMemo(() => {
workspace && if (!integrationAuthApps) return [];
selectedSourceEnvironment &&
integrationAuthApps && return integrationAuthApps.filter((integrationAuthApp) => {
targetApp ? ( return integrationAuthApp.owner === targetOrganization;
});
}, [integrationAuthApps, targetOrganization]);
const filteredOrganizations = useMemo(() => {
const organizations = new Set<string>();
if (integrationAuthApps) {
integrationAuthApps.forEach((integrationAuthApp) => {
if (!integrationAuthApp.owner) return;
organizations.add(integrationAuthApp.owner);
});
}
return Array.from(organizations);
}, [integrationAuthApps]);
return integrationAuth && workspace && selectedSourceEnvironment && integrationAuthApps ? (
<div className="flex h-full w-full flex-col items-center justify-center"> <div className="flex h-full w-full flex-col items-center justify-center">
<Head> <Head>
<title>Set Up CircleCI Integration</title> <title>Set Up CircleCI Integration</title>
@@ -108,7 +138,7 @@ export default function CircleCICreateIntegrationPage() {
subTitle="Choose which environment or folder in Infisical you want to sync to CircleCI environment variables." subTitle="Choose which environment or folder in Infisical you want to sync to CircleCI environment variables."
> >
<div className="flex flex-row items-center"> <div className="flex flex-row items-center">
<div className="inline flex items-center pb-0.5"> <div className="flex items-center pb-0.5">
<Image <Image
src="/images/integrations/Circle CI.png" src="/images/integrations/Circle CI.png"
height={30} height={30}
@@ -131,6 +161,7 @@ export default function CircleCICreateIntegrationPage() {
</Link> </Link>
</div> </div>
</CardTitle> </CardTitle>
<FormControl label="Project Environment" className="px-6"> <FormControl label="Project Environment" className="px-6">
<Select <Select
value={selectedSourceEnvironment} value={selectedSourceEnvironment}
@@ -154,29 +185,55 @@ export default function CircleCICreateIntegrationPage() {
placeholder="Provide a path, default is /" placeholder="Provide a path, default is /"
/> />
</FormControl> </FormControl>
<FormControl label="CircleCI Project" className="px-6">
<FormControl label="CircleCI Organization" className="px-6">
<Select <Select
value={targetApp} value={targetOrganization}
onValueChange={(val) => setTargetApp(val)} onValueChange={(val) => {
setTargetOrganization(val);
setTargetProjectId("none");
}}
className="w-full border border-mineshaft-500" className="w-full border border-mineshaft-500"
isDisabled={integrationAuthApps.length === 0} isDisabled={filteredOrganizations.length === 0}
> >
{integrationAuthApps.length > 0 ? ( {filteredOrganizations.length > 0 ? (
integrationAuthApps.map((integrationAuthApp) => ( filteredOrganizations.map((org) => (
<SelectItem <SelectItem value={org} key={`target-org-${org}`}>
value={integrationAuthApp.name} {org}
key={`target-app-${integrationAuthApp.name}`}
>
{integrationAuthApp.name}
</SelectItem> </SelectItem>
)) ))
) : ( ) : (
<SelectItem value="none" key="target-app-none"> <SelectItem value="none" key="target-app-none">
No projects found No organizations found
</SelectItem> </SelectItem>
)} )}
</Select> </Select>
</FormControl> </FormControl>
{targetOrganization && (
<FormControl label="CircleCI Project ID" className="px-6">
<Select
value={targetProjectId}
onValueChange={(val) => {
setTargetProjectId(val);
}}
className="w-full border border-mineshaft-500"
isDisabled={filteredProjects.length === 0}
>
{filteredProjects.length > 0 ? (
filteredProjects.map((project) => (
<SelectItem value={project.appId!} key={`target-project-${project.owner}`}>
{project.name}
</SelectItem>
))
) : (
<SelectItem value="none" key="target-app-none">
No projects found
</SelectItem>
)}
</Select>
</FormControl>
)}
<Button <Button
onClick={handleButtonClick} onClick={handleButtonClick}
colorSchema="primary" colorSchema="primary"
@@ -99,6 +99,7 @@ export const ConfiguredIntegrationItem = ({
<FormLabel <FormLabel
label={ label={
(integration.integration === "qovery" && integration?.scope) || (integration.integration === "qovery" && integration?.scope) ||
(integration.integration === "circleci" && "Project") ||
(integration.integration === "aws-secret-manager" && "Secret") || (integration.integration === "aws-secret-manager" && "Secret") ||
(["aws-parameter-store", "rundeck"].includes(integration.integration) && "Path") || (["aws-parameter-store", "rundeck"].includes(integration.integration) && "Path") ||
(integration?.integration === "terraform-cloud" && "Project") || (integration?.integration === "terraform-cloud" && "Project") ||
@@ -142,6 +143,14 @@ export const ConfiguredIntegrationItem = ({
</div> </div>
</div> </div>
)} )}
{integration.integration === "circleci" && integration.owner && (
<div className="ml-2">
<FormLabel label="Organization" />
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration.owner}
</div>
</div>
)}
{integration.integration === "terraform-cloud" && integration.targetService && ( {integration.integration === "terraform-cloud" && integration.targetService && (
<div className="ml-2"> <div className="ml-2">
<FormLabel label="Category" /> <FormLabel label="Category" />