diff --git a/backend/src/integrations/apps.ts b/backend/src/integrations/apps.ts index aa408e7e6..1df5bdaa7 100644 --- a/backend/src/integrations/apps.ts +++ b/backend/src/integrations/apps.ts @@ -13,12 +13,14 @@ import { INTEGRATION_RENDER, INTEGRATION_FLYIO, INTEGRATION_CIRCLECI, + INTEGRATION_TRAVISCI, INTEGRATION_HEROKU_API_URL, INTEGRATION_VERCEL_API_URL, INTEGRATION_NETLIFY_API_URL, INTEGRATION_RENDER_API_URL, INTEGRATION_FLYIO_API_URL, INTEGRATION_CIRCLECI_API_URL, + INTEGRATION_TRAVISCI_API_URL, } from "../variables"; /** @@ -42,7 +44,7 @@ const getApps = async ({ owner?: string; } - let apps: App[]; + let apps: App[] = []; try { switch (integrationAuth.integration) { case INTEGRATION_AZURE_KEY_VAULT: @@ -90,6 +92,11 @@ const getApps = async ({ accessToken, }); break; + case INTEGRATION_TRAVISCI: + apps = await getAppsTravisCI({ + accessToken, + }) + break; } } catch (err) { Sentry.setUser(null); @@ -364,4 +371,34 @@ const getAppsCircleCI = async ({ accessToken }: { accessToken: string }) => { return apps; }; +const getAppsTravisCI = async ({ accessToken }: { accessToken: string }) => { + let apps: any; + try { + const res = ( + await axios.get( + `${INTEGRATION_TRAVISCI_API_URL}/repos`, + { + headers: { + "Authorization": `token ${accessToken}`, + "Accept-Encoding": "application/json", + }, + } + ) + ).data; + + apps = res?.map((a: any) => { + return { + name: a?.slug?.split("/")[1], + appId: a?.id, + } + }); + }catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error("Failed to get TravisCI projects"); + } + + return apps; +} + export { getApps }; diff --git a/backend/src/integrations/sync.ts b/backend/src/integrations/sync.ts index 2d84620d3..338c7894b 100644 --- a/backend/src/integrations/sync.ts +++ b/backend/src/integrations/sync.ts @@ -22,12 +22,14 @@ import { INTEGRATION_RENDER, INTEGRATION_FLYIO, INTEGRATION_CIRCLECI, + INTEGRATION_TRAVISCI, INTEGRATION_HEROKU_API_URL, INTEGRATION_VERCEL_API_URL, INTEGRATION_NETLIFY_API_URL, INTEGRATION_RENDER_API_URL, INTEGRATION_FLYIO_API_URL, INTEGRATION_CIRCLECI_API_URL, + INTEGRATION_TRAVISCI_API_URL, } from "../variables"; import request from '../config/request'; @@ -128,6 +130,14 @@ const syncSecrets = async ({ secrets, accessToken, }); + break; + case INTEGRATION_TRAVISCI: + await syncSecretsTravisCI({ + integration, + secrets, + accessToken, + }); + break; } } catch (err) { Sentry.setUser(null); @@ -1315,4 +1325,96 @@ const syncSecretsCircleCI = async ({ } }; +/** + * Sync/push [secrets] to TravisCI project + * @param {Object} obj + * @param {IIntegration} obj.integration - integration details + * @param {Object} obj.secrets - secrets to push to integration (object where keys are secret keys and values are secret values) + * @param {String} obj.accessToken - access token for TravisCI integration + */ +const syncSecretsTravisCI = async ({ + integration, + secrets, + accessToken, +}: { + integration: IIntegration; + secrets: any; + accessToken: string; +}) => { + try { + // get secrets from travis-ci + const getSecretsRes = ( + await axios.get( + `${INTEGRATION_TRAVISCI_API_URL}/settings/env_vars?repository_id=${integration.appId}`, + { + headers: { + "Authorization": `token ${accessToken}`, + "Accept-Encoding": "application/json", + }, + } + ) + ).data?.env_vars; + + // add secrets + for (const key of Object.keys(secrets)) { + const existingSecret = getSecretsRes.find((s: any) => s.name == key); + if(!existingSecret){ + await axios.post( + `${INTEGRATION_TRAVISCI_API_URL}/settings/env_vars?repository_id=${integration.appId}`, + { + env_var: { + name: key, + value: secrets[key], + } + }, + { + headers: { + "Authorization": `token ${accessToken}`, + "Content-Type": "application/json", + "Accept-Encoding": "application/json", + }, + } + ) + }else { // update secret + await axios.patch( + `${INTEGRATION_TRAVISCI_API_URL}/settings/env_vars/${existingSecret.id}?repository_id=${existingSecret.repository_id}`, + { + env_var: { + name: key, + value: secrets[key], + } + }, + { + headers: { + "Authorization": `token ${accessToken}`, + "Content-Type": "application/json", + "Accept-Encoding": "application/json", + }, + } + ) + } + } + + // delete secret + for (const sec of getSecretsRes) { + if (!(sec.name in secrets)){ + await axios.delete( + `${INTEGRATION_TRAVISCI_API_URL}/settings/env_vars/${sec.id}?repository_id=${sec.repository_id}`, + { + headers: { + "Authorization": `token ${accessToken}`, + "Content-Type": "application/json", + "Accept-Encoding": "application/json", + }, + } + ); + } + } + }catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error("Failed to sync secrets to CircleCI"); + } +} + export { syncSecrets }; diff --git a/backend/src/models/integration.ts b/backend/src/models/integration.ts index 1b52fabd9..35ef6106b 100644 --- a/backend/src/models/integration.ts +++ b/backend/src/models/integration.ts @@ -10,6 +10,7 @@ import { INTEGRATION_RENDER, INTEGRATION_FLYIO, INTEGRATION_CIRCLECI, + INTEGRATION_TRAVISCI, } from "../variables"; export interface IIntegration { @@ -33,7 +34,8 @@ export interface IIntegration { | 'github' | 'render' | 'flyio' - | 'circleci'; + | 'circleci' + | 'travisci'; integrationAuth: Types.ObjectId; } @@ -97,6 +99,7 @@ const integrationSchema = new Schema( INTEGRATION_RENDER, INTEGRATION_FLYIO, INTEGRATION_CIRCLECI, + INTEGRATION_TRAVISCI, ], required: true, }, diff --git a/backend/src/models/integrationAuth.ts b/backend/src/models/integrationAuth.ts index 95f8c75af..c68fdd336 100644 --- a/backend/src/models/integrationAuth.ts +++ b/backend/src/models/integrationAuth.ts @@ -10,12 +10,13 @@ import { INTEGRATION_RENDER, INTEGRATION_FLYIO, INTEGRATION_CIRCLECI, + INTEGRATION_TRAVISCI, } from "../variables"; export interface IIntegrationAuth { _id: Types.ObjectId; workspace: Types.ObjectId; - integration: 'heroku' | 'vercel' | 'netlify' | 'github' | 'render' | 'flyio' | 'azure-key-vault' | 'circleci' | 'aws-parameter-store' | 'aws-secret-manager'; + integration: 'heroku' | 'vercel' | 'netlify' | 'github' | 'render' | 'flyio' | 'azure-key-vault' | 'circleci' | 'travisci' | 'aws-parameter-store' | 'aws-secret-manager'; teamId: string; accountId: string; refreshCiphertext?: string; @@ -50,6 +51,7 @@ const integrationAuthSchema = new Schema( INTEGRATION_RENDER, INTEGRATION_FLYIO, INTEGRATION_CIRCLECI, + INTEGRATION_TRAVISCI, ], required: true, }, diff --git a/backend/src/variables/index.ts b/backend/src/variables/index.ts index 92038eb67..2be394971 100644 --- a/backend/src/variables/index.ts +++ b/backend/src/variables/index.ts @@ -16,6 +16,7 @@ import { INTEGRATION_RENDER, INTEGRATION_FLYIO, INTEGRATION_CIRCLECI, + INTEGRATION_TRAVISCI, INTEGRATION_SET, INTEGRATION_OAUTH2, INTEGRATION_AZURE_TOKEN_URL, @@ -29,6 +30,7 @@ import { INTEGRATION_RENDER_API_URL, INTEGRATION_FLYIO_API_URL, INTEGRATION_CIRCLECI_API_URL, + INTEGRATION_TRAVISCI_API_URL, INTEGRATION_OPTIONS, } from "./integration"; import { OWNER, ADMIN, MEMBER, INVITED, ACCEPTED } from "./organization"; @@ -81,6 +83,7 @@ export { INTEGRATION_RENDER, INTEGRATION_FLYIO, INTEGRATION_CIRCLECI, + INTEGRATION_TRAVISCI, INTEGRATION_SET, INTEGRATION_OAUTH2, INTEGRATION_AZURE_TOKEN_URL, @@ -94,6 +97,7 @@ export { INTEGRATION_RENDER_API_URL, INTEGRATION_FLYIO_API_URL, INTEGRATION_CIRCLECI_API_URL, + INTEGRATION_TRAVISCI_API_URL, EVENT_PUSH_SECRETS, EVENT_PULL_SECRETS, ACTION_LOGIN, diff --git a/backend/src/variables/integration.ts b/backend/src/variables/integration.ts index 3ef8d6f1e..a6e2029e2 100644 --- a/backend/src/variables/integration.ts +++ b/backend/src/variables/integration.ts @@ -20,6 +20,7 @@ const INTEGRATION_GITHUB = "github"; const INTEGRATION_RENDER = "render"; const INTEGRATION_FLYIO = "flyio"; const INTEGRATION_CIRCLECI = "circleci"; +const INTEGRATION_TRAVISCI = "travisci"; const INTEGRATION_SET = new Set([ INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_HEROKU, @@ -29,6 +30,7 @@ const INTEGRATION_SET = new Set([ INTEGRATION_RENDER, INTEGRATION_FLYIO, INTEGRATION_CIRCLECI, + INTEGRATION_TRAVISCI, ]); // integration types @@ -50,6 +52,7 @@ const INTEGRATION_NETLIFY_API_URL = "https://api.netlify.com"; const INTEGRATION_RENDER_API_URL = "https://api.render.com"; const INTEGRATION_FLYIO_API_URL = "https://api.fly.io/graphql"; const INTEGRATION_CIRCLECI_API_URL = "https://circleci.com/api"; +const INTEGRATION_TRAVISCI_API_URL = "https://api.travis-ci.com"; const INTEGRATION_OPTIONS = [ { @@ -157,8 +160,8 @@ const INTEGRATION_OPTIONS = [ name: 'Travis CI', slug: 'travisci', image: 'Travis CI.png', - isAvailable: false, - type: '', + isAvailable: true, + type: 'pat', clientId: '', docsLink: '' } @@ -175,6 +178,7 @@ export { INTEGRATION_RENDER, INTEGRATION_FLYIO, INTEGRATION_CIRCLECI, + INTEGRATION_TRAVISCI, INTEGRATION_SET, INTEGRATION_OAUTH2, INTEGRATION_AZURE_TOKEN_URL, @@ -188,5 +192,6 @@ export { INTEGRATION_RENDER_API_URL, INTEGRATION_FLYIO_API_URL, INTEGRATION_CIRCLECI_API_URL, + INTEGRATION_TRAVISCI_API_URL, INTEGRATION_OPTIONS, }; diff --git a/frontend/public/data/frequentConstants.ts b/frontend/public/data/frequentConstants.ts index f2e3acf70..0a6733b25 100644 --- a/frontend/public/data/frequentConstants.ts +++ b/frontend/public/data/frequentConstants.ts @@ -12,7 +12,8 @@ const integrationSlugNameMapping: Mapping = { 'github': 'GitHub', 'render': 'Render', 'flyio': 'Fly.io', - "circleci": 'CircleCI' + 'circleci': 'CircleCI', + 'travisci': 'TravisCI' } const envMapping: Mapping = { diff --git a/frontend/src/pages/integrations/[id].tsx b/frontend/src/pages/integrations/[id].tsx index a5057c05e..60cb102ac 100644 --- a/frontend/src/pages/integrations/[id].tsx +++ b/frontend/src/pages/integrations/[id].tsx @@ -201,6 +201,9 @@ export default function Integrations() { case 'circleci': link = `${window.location.origin}/integrations/circleci/authorize` break; + case 'travisci': + link = `${window.location.origin}/integrations/travisci/authorize` + break; default: break; } @@ -247,6 +250,9 @@ export default function Integrations() { case 'circleci': link = `${window.location.origin}/integrations/circleci/create?integrationAuthId=${integrationAuth._id}`; break; + case 'travisci': + link = `${window.location.origin}/integrations/travisci/create?integrationAuthId=${integrationAuth._id}`; + break; default: break; } diff --git a/frontend/src/pages/integrations/travisci/authorize.tsx b/frontend/src/pages/integrations/travisci/authorize.tsx new file mode 100644 index 000000000..e55b82f1a --- /dev/null +++ b/frontend/src/pages/integrations/travisci/authorize.tsx @@ -0,0 +1,77 @@ +import { useState } from 'react'; +import { useRouter } from 'next/router'; + +import { getTranslatedServerSideProps } from '../../../components/utilities/withTranslateProps'; +import { + Button, + Card, + CardTitle, + FormControl, + Input, +} from '../../../components/v2'; +import saveIntegrationAccessToken from "../../api/integrations/saveIntegrationAccessToken"; + +export default function TravisCICreateIntegrationPage() { + 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 Key cannot be blank'); + return; + } + + setIsLoading(true); + + const integrationAuth = await saveIntegrationAccessToken({ + workspaceId: localStorage.getItem('projectData.id'), + integration: 'travisci', + accessToken: apiKey, + accessId: null, + }); + + setIsLoading(false); + + router.push( + `/integrations/travisci/create?integrationAuthId=${integrationAuth._id}` + ); + } catch (err) { + console.error(err); + } + } + + return ( +
+ + TravisCI Integration + + setApiKey(e.target.value)} + /> + + + +
+ ) +} + +TravisCICreateIntegrationPage.requireAuth = true; + +export const getServerSideProps = getTranslatedServerSideProps(['integrations']); \ No newline at end of file diff --git a/frontend/src/pages/integrations/travisci/create.tsx b/frontend/src/pages/integrations/travisci/create.tsx new file mode 100644 index 000000000..4a06c838a --- /dev/null +++ b/frontend/src/pages/integrations/travisci/create.tsx @@ -0,0 +1,124 @@ +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +import queryString from 'query-string'; + +import { getTranslatedServerSideProps } from '../../../components/utilities/withTranslateProps'; +import { + Button, + Card, + CardTitle, + FormControl, + 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 TravisCICreateIntegrationPage() { + 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 as string ?? ''); + + const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState(''); + const [targetApp, setTargetApp] = useState(''); + + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (workspace) { + setSelectedSourceEnvironment(workspace.environments[0].slug); + } + }, [workspace]); + + useEffect(() => { + // TODO: handle case where apps can be empty + if (integrationAuthApps) { + setTargetApp(integrationAuthApps[0]?.name); + } + }, [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, + owner: null, + path: null, + region: null, + }); + + setIsLoading(false); + + router.push( + `/integrations/${localStorage.getItem('projectData.id')}` + ); + } catch (err) { + console.error(err); + } + } + + return (integrationAuth && workspace && selectedSourceEnvironment && integrationAuthApps && targetApp) ? ( +
+ + TravisCI Integration + + + + + + + + +
+ ) :
+} + +TravisCICreateIntegrationPage.requireAuth = true; + +export const getServerSideProps = getTranslatedServerSideProps(['integrations']); \ No newline at end of file