diff --git a/backend/src/integrations/apps.ts b/backend/src/integrations/apps.ts index 0e5a9bf33..199d8c121 100644 --- a/backend/src/integrations/apps.ts +++ b/backend/src/integrations/apps.ts @@ -35,6 +35,8 @@ import { INTEGRATION_RENDER_API_URL, INTEGRATION_SUPABASE, INTEGRATION_SUPABASE_API_URL, + INTEGRATION_TEAMCITY, + INTEGRATION_TEAMCITY_API_URL, INTEGRATION_TERRAFORM_CLOUD, INTEGRATION_TERRAFORM_CLOUD_API_URL, INTEGRATION_TRAVISCI, @@ -151,6 +153,11 @@ const getApps = async ({ accessToken, }); break; + case INTEGRATION_TEAMCITY: + apps = await getAppsTeamCity({ + accessToken, + }); + break; case INTEGRATION_SUPABASE: apps = await getAppsSupabase({ accessToken, @@ -722,6 +729,37 @@ const getAppsGitlab = async ({ return apps; }; +/** + * Return list of projects for TeamCity integration + * @param {Object} obj + * @param {String} obj.accessToken - access token for TeamCity API + * @returns {Object[]} apps - names and ids of TeamCity projects + * @returns {String} apps.name - name of TeamCity projects + */ +const getAppsTeamCity = async ({ + accessToken, +}: { + accessToken: string; +}) => { + const res = ( + await standardRequest.get(`${INTEGRATION_TEAMCITY_API_URL}/app/rest/projects`, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + }, + }) + ).data.project.slice(1); + + const apps = res.map((a: any) => { + return { + name: a.name, + appId: a.id, + }; + }); + + return apps; +}; + /** * Return list of projects for Supabase integration * @param {Object} obj diff --git a/backend/src/integrations/sync.ts b/backend/src/integrations/sync.ts index 7aa74112f..a719f6397 100644 --- a/backend/src/integrations/sync.ts +++ b/backend/src/integrations/sync.ts @@ -44,6 +44,8 @@ import { INTEGRATION_RENDER_API_URL, INTEGRATION_SUPABASE, INTEGRATION_SUPABASE_API_URL, + INTEGRATION_TEAMCITY, + INTEGRATION_TEAMCITY_API_URL, INTEGRATION_TERRAFORM_CLOUD, INTEGRATION_TERRAFORM_CLOUD_API_URL, INTEGRATION_TRAVISCI, @@ -233,6 +235,13 @@ const syncSecrets = async ({ accessToken, }); break; + case INTEGRATION_TEAMCITY: + await syncSecretsTeamCity({ + integration, + secrets, + accessToken, + }); + break; case INTEGRATION_BITBUCKET: await syncSecretsBitBucket({ integration, @@ -1971,6 +1980,96 @@ const syncSecretsTerraformCloud = async ({ } }; +/** + * Sync/push [secrets] to TeamCity project + * @param {Object} obj + * @param {IIntegration} obj.integration - integration details + * @param {Object} obj.secrets - secrets to push to integration + * @param {String} obj.accessToken - access token for TeamCity integration + */ +const syncSecretsTeamCity = async ({ + integration, + secrets, + accessToken, +}: { + integration: IIntegration; + secrets: any; + accessToken: string; +}) => { + + // get projects from TeamCity + const res = ( + await standardRequest.get(`${INTEGRATION_TEAMCITY_API_URL}/app/rest/projects`, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + }, + }) + ).data.project.slice(1); + + // get secrets from Teamcity + const getParametersRes = ( + await standardRequest.get( + `${INTEGRATION_TEAMCITY_API_URL}/app/rest/projects/id:${integration.appId}/parameters`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + }, + }) + ).data.property; + + for await (const key of Object.keys(secrets)) { + if (key in res) { + await standardRequest.put( + `${INTEGRATION_TEAMCITY_API_URL}/app/rest/projects/id:${integration.appId}/parameters/${res.id}`, + { + name: `env.${key}`, + value: secrets[key] + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + } + ); + } else { + await standardRequest.post( + `${INTEGRATION_TEAMCITY_API_URL}/app/rest/projects/id:${integration.appId}/parameters`, + { + name: `env.${key}`, + value: secrets[key] + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + } + ); + } + } + + for await (const sec of getParametersRes) { + const originalString = sec.name; + const modifiedString = originalString.replace(/^env\./, ""); + + if (!(modifiedString in secrets)) { + await standardRequest.delete( + `${INTEGRATION_TEAMCITY_API_URL}/app/rest/projects/id:${integration.appId}/parameters/${sec.name}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + } + ); + } + } + +}; + /** * Sync/push [secrets] to HashiCorp Vault path * @param {Object} obj diff --git a/backend/src/models/integration.ts b/backend/src/models/integration.ts index e6673e77e..579a7dbf2 100644 --- a/backend/src/models/integration.ts +++ b/backend/src/models/integration.ts @@ -20,6 +20,7 @@ import { INTEGRATION_RAILWAY, INTEGRATION_RENDER, INTEGRATION_SUPABASE, + INTEGRATION_TEAMCITY, INTEGRATION_TERRAFORM_CLOUD, INTEGRATION_TRAVISCI, INTEGRATION_VERCEL, @@ -61,6 +62,7 @@ export interface IIntegration { | "supabase" | "checkly" | "terraform-cloud" + | "teamcity" | "hashicorp-vault" | "cloudflare-pages" | "bitbucket" @@ -157,6 +159,7 @@ const integrationSchema = new Schema( INTEGRATION_SUPABASE, INTEGRATION_CHECKLY, INTEGRATION_TERRAFORM_CLOUD, + INTEGRATION_TEAMCITY, INTEGRATION_HASHICORP_VAULT, INTEGRATION_CLOUDFLARE_PAGES, INTEGRATION_CODEFRESH, diff --git a/backend/src/models/integrationAuth.ts b/backend/src/models/integrationAuth.ts index 6791868cd..66a26898f 100644 --- a/backend/src/models/integrationAuth.ts +++ b/backend/src/models/integrationAuth.ts @@ -22,6 +22,7 @@ import { INTEGRATION_RAILWAY, INTEGRATION_RENDER, INTEGRATION_SUPABASE, + INTEGRATION_TEAMCITY, INTEGRATION_TERRAFORM_CLOUD, INTEGRATION_TRAVISCI, INTEGRATION_VERCEL, @@ -55,6 +56,7 @@ export interface IIntegrationAuth extends Document { | "bitbucket" | "cloud-66" | "terraform-cloud" + | "teamcity" | "northflank" | "windmill"; teamId: string; @@ -99,6 +101,7 @@ const integrationAuthSchema = new Schema( INTEGRATION_CIRCLECI, INTEGRATION_LARAVELFORGE, INTEGRATION_TRAVISCI, + INTEGRATION_TEAMCITY, INTEGRATION_SUPABASE, INTEGRATION_TERRAFORM_CLOUD, INTEGRATION_HASHICORP_VAULT, diff --git a/backend/src/variables/integration.ts b/backend/src/variables/integration.ts index a649763c0..81bb84468 100644 --- a/backend/src/variables/integration.ts +++ b/backend/src/variables/integration.ts @@ -23,6 +23,7 @@ export const INTEGRATION_FLYIO = "flyio"; export const INTEGRATION_LARAVELFORGE = "laravel-forge" export const INTEGRATION_CIRCLECI = "circleci"; export const INTEGRATION_TRAVISCI = "travisci"; +export const INTEGRATION_TEAMCITY = "teamcity"; export const INTEGRATION_SUPABASE = "supabase"; export const INTEGRATION_CHECKLY = "checkly"; export const INTEGRATION_TERRAFORM_CLOUD = "terraform-cloud"; @@ -46,6 +47,7 @@ export const INTEGRATION_SET = new Set([ INTEGRATION_CIRCLECI, INTEGRATION_LARAVELFORGE, INTEGRATION_TRAVISCI, + INTEGRATION_TEAMCITY, INTEGRATION_SUPABASE, INTEGRATION_CHECKLY, INTEGRATION_TERRAFORM_CLOUD, @@ -87,6 +89,7 @@ export const INTEGRATION_SUPABASE_API_URL = "https://api.supabase.com"; export const INTEGRATION_LARAVELFORGE_API_URL = "https://forge.laravel.com"; export const INTEGRATION_CHECKLY_API_URL = "https://api.checklyhq.com"; export const INTEGRATION_TERRAFORM_CLOUD_API_URL = "https://app.terraform.io"; +export const INTEGRATION_TEAMCITY_API_URL = "https://devaandrew.teamcity.com"; // URL needs to be changed export const INTEGRATION_CLOUDFLARE_PAGES_API_URL = "https://api.cloudflare.com"; export const INTEGRATION_BITBUCKET_API_URL = "https://api.bitbucket.org"; export const INTEGRATION_CODEFRESH_API_URL = "https://g.codefresh.io/api"; @@ -233,6 +236,15 @@ export const getIntegrationOptions = async () => { clientId: "", docsLink: "", }, + { + name: "TeamCity", + slug: "teamcity", + image: "TeamCity.png", + isAvailable: true, + type: "pat", + clientId: "", + docsLink: "", + }, { name: "Supabase", slug: "supabase", diff --git a/frontend/public/data/frequentConstants.ts b/frontend/public/data/frequentConstants.ts index 3352999f6..bcfaf1943 100644 --- a/frontend/public/data/frequentConstants.ts +++ b/frontend/public/data/frequentConstants.ts @@ -19,7 +19,8 @@ const integrationSlugNameMapping: Mapping = { travisci: "TravisCI", supabase: "Supabase", checkly: "Checkly", - 'terraform-cloud': 'Terraform Cloud', + "terraform-cloud": "Terraform Cloud", + "teamcity": "TeamCity", "hashicorp-vault": "Vault", "cloudflare-pages": "Cloudflare Pages", "codefresh": "Codefresh", @@ -27,7 +28,7 @@ const integrationSlugNameMapping: Mapping = { bitbucket: "BitBucket", "cloud-66": "Cloud 66", northflank: "Northflank", - 'windmill': 'Windmill' + "windmill": "Windmill" } const envMapping: Mapping = { diff --git a/frontend/public/images/integrations/TeamCity.png b/frontend/public/images/integrations/TeamCity.png new file mode 100644 index 000000000..c0042cda7 Binary files /dev/null and b/frontend/public/images/integrations/TeamCity.png differ diff --git a/frontend/src/pages/integrations/teamcity/authorize.tsx b/frontend/src/pages/integrations/teamcity/authorize.tsx new file mode 100644 index 000000000..02b25680f --- /dev/null +++ b/frontend/src/pages/integrations/teamcity/authorize.tsx @@ -0,0 +1,65 @@ +import { useState } from "react"; +import { useRouter } from "next/router"; + +import { Button, Card, CardTitle, FormControl, Input } from "../../../components/v2"; +import saveIntegrationAccessToken from "../../api/integrations/saveIntegrationAccessToken"; + +export default function TeamCityCreateIntegrationPage() { + const router = useRouter(); + const [apiKey, setApiKey] = useState(""); + const [apiKeyErrorText, setApiKeyErrorText] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + const handleButtonClick = async () => { + try { + setApiKeyErrorText(""); + + if (apiKey.length === 0) { + setApiKeyErrorText("API Token cannot be blank"); + return; + } + + setIsLoading(true); + + const integrationAuth = await saveIntegrationAccessToken({ + workspaceId: localStorage.getItem("projectData.id"), + integration: "teamcity", + accessId: null, + accessToken: apiKey, + url: null, + namespace: null + }); + + setIsLoading(false); + + router.push(`/integrations/teamcity/create?integrationAuthId=${integrationAuth._id}`); + } catch (err) { + console.error(err); + } + }; + + return ( +
+ + TeamCity Integration + + setApiKey(e.target.value)} /> + + + +
+ ); +} + +TeamCityCreateIntegrationPage.requireAuth = true; diff --git a/frontend/src/pages/integrations/teamcity/create.tsx b/frontend/src/pages/integrations/teamcity/create.tsx new file mode 100644 index 000000000..a2aa86db4 --- /dev/null +++ b/frontend/src/pages/integrations/teamcity/create.tsx @@ -0,0 +1,156 @@ +import { useEffect, useState } from "react"; +import { useRouter } from "next/router"; +import queryString from "query-string"; + +import { + Button, + Card, + CardTitle, + FormControl, + Input, + Select, + SelectItem +} from "../../../components/v2"; +import { + useGetIntegrationAuthApps, + useGetIntegrationAuthById +} from "../../../hooks/api/integrationAuth"; +import { useGetWorkspaceById } from "../../../hooks/api/workspace"; +import createIntegration from "../../api/integrations/createIntegration"; + +export default function TeamCityCreateIntegrationPage() { + const router = useRouter(); + + const { integrationAuthId } = queryString.parse(router.asPath.split("?")[1]); + + const { data: workspace } = useGetWorkspaceById(localStorage.getItem("projectData.id") ?? ""); + const { data: integrationAuth } = useGetIntegrationAuthById((integrationAuthId as string) ?? ""); + const { data: integrationAuthApps } = useGetIntegrationAuthApps({ + integrationAuthId: (integrationAuthId as string) ?? "" + }); + + const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState(""); + const [targetApp, setTargetApp] = useState(""); + const [secretPath, setSecretPath] = useState("/"); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (workspace) { + setSelectedSourceEnvironment(workspace.environments[0].slug); + } + }, [workspace]); + + useEffect(() => { + if (integrationAuthApps) { + if (integrationAuthApps.length > 0) { + setTargetApp(integrationAuthApps[0].name); + } else { + setTargetApp("none"); + } + } + }, [integrationAuthApps]); + + const handleButtonClick = async () => { + try { + if (!integrationAuth?._id) return; + + setIsLoading(true); + + await createIntegration({ + integrationAuthId: integrationAuth?._id, + isActive: true, + app: targetApp, + appId: + integrationAuthApps?.find((integrationAuthApp) => integrationAuthApp.name === targetApp) + ?.appId ?? null, + sourceEnvironment: selectedSourceEnvironment, + targetEnvironment: null, + targetEnvironmentId: null, + targetService: null, + targetServiceId: null, + owner: null, + path: null, + region: null, + secretPath + }); + + setIsLoading(false); + + router.push(`/integrations/${localStorage.getItem("projectData.id")}`); + } catch (err) { + console.error(err); + } + }; + + + return integrationAuth && + workspace && + selectedSourceEnvironment && + integrationAuthApps && + targetApp ? ( +
+ + TeamCity Integration + + + + + setSecretPath(evt.target.value)} + placeholder="Provide a path, default is /" + /> + + + + + + +
+ ) : ( +
+ ); +} + +TeamCityCreateIntegrationPage.requireAuth = true; diff --git a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx index 1f845b644..e267c13f5 100644 --- a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx +++ b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx @@ -113,6 +113,9 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) => case "windmill": link = `${window.location.origin}/integrations/windmill/authorize`; break; + case "teamcity": + link = `${window.location.origin}/integrations/teamcity/authorize`; + break; default: break; }