diff --git a/backend/analytics_server/mhq/service/code/sync/etl_handler.py b/backend/analytics_server/mhq/service/code/sync/etl_handler.py index 5a7978dc..47dfa95c 100644 --- a/backend/analytics_server/mhq/service/code/sync/etl_handler.py +++ b/backend/analytics_server/mhq/service/code/sync/etl_handler.py @@ -3,6 +3,7 @@ import pytz +from mhq.store.models.code.enums import CodeProvider from mhq.service.settings.configuration_settings import ( get_settings_service, SettingsService, @@ -42,11 +43,11 @@ def __init__( self.bookmark_service = bookmark_service self.settings_service = settings_service - def sync_org_repos(self, org_id: str): + def sync_org_repos(self, org_id: str, provider: CodeProvider): if not self.etl_service.check_pat_validity(): LOG.error("Invalid PAT for code provider") return - org_repos: List[OrgRepo] = self._sync_org_repos(org_id) + org_repos: List[OrgRepo] = self._sync_org_repos(org_id, provider) for org_repo in org_repos: try: self._sync_repo_pull_requests_data(org_repo) @@ -56,9 +57,11 @@ def sync_org_repos(self, org_id: str): ) continue - def _sync_org_repos(self, org_id: str) -> List[OrgRepo]: + def _sync_org_repos(self, org_id: str, provider: CodeProvider) -> List[OrgRepo]: try: - org_repos = self.code_repo_service.get_active_org_repos(org_id) + org_repos = self.code_repo_service.get_active_org_repos_for_provider( + org_id, provider + ) org_repos = self.etl_service.get_org_repos(org_repos) self.code_repo_service.update_org_repos(org_repos) return org_repos @@ -141,7 +144,7 @@ def sync_code_repos(org_id: str): get_bookmark_service(), get_settings_service(), ) - code_etl_handler.sync_org_repos(org_id) + code_etl_handler.sync_org_repos(org_id, CodeProvider(provider)) LOG.info(f"Synced org repos for provider {provider}") except Exception as e: LOG.error(f"Error syncing org repos for provider {provider}: {str(e)}") diff --git a/backend/analytics_server/mhq/store/repos/code.py b/backend/analytics_server/mhq/store/repos/code.py index c6ad9707..115ad675 100644 --- a/backend/analytics_server/mhq/store/repos/code.py +++ b/backend/analytics_server/mhq/store/repos/code.py @@ -2,6 +2,7 @@ from operator import and_ from typing import Optional, List +from mhq.store.models.code.enums import CodeProvider from sqlalchemy import or_ from sqlalchemy.orm import defer from mhq.store.models.core import Team @@ -35,6 +36,20 @@ def get_active_org_repos(self, org_id: str) -> List[OrgRepo]: .all() ) + @rollback_on_exc + def get_active_org_repos_for_provider( + self, org_id: str, provider: CodeProvider + ) -> List[OrgRepo]: + return ( + self._db.session.query(OrgRepo) + .filter( + OrgRepo.org_id == org_id, + OrgRepo.is_active.is_(True), + OrgRepo.provider == provider.value, + ) + .all() + ) + @rollback_on_exc def update_org_repos(self, org_repos: List[OrgRepo]): [self._db.session.merge(org_repo) for org_repo in org_repos] diff --git a/web-server/pages/api/integrations/orgs.ts b/web-server/pages/api/integrations/orgs.ts deleted file mode 100644 index 0fa0344c..00000000 --- a/web-server/pages/api/integrations/orgs.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { forEachObjIndexed, mapObjIndexed, partition } from 'ramda'; -import * as yup from 'yup'; - -import { Endpoint, nullSchema } from '@/api-helpers/global'; -import { Columns, Row, Table } from '@/constants/db'; -import { - Integration, - CIProvider, - WorkflowType -} from '@/constants/integrations'; -import { selectedDBReposMock } from '@/mocks/github'; -import { DB_OrgRepo, ReqOrgRepo, ReqRepo } from '@/types/resources'; -import { db } from '@/utils/db'; -import groupBy from '@/utils/objectArray'; - -import { getSelectedReposForOrg } from './selected'; - -import { syncReposForOrg } from '../internal/[org_id]/sync_repos'; - -const patchSchema = yup.object().shape({ - org_id: yup.string().uuid().required(), - orgRepos: yup.lazy((obj) => - yup.object( - mapObjIndexed( - () => - yup.array().of( - yup.object().shape({ - idempotency_key: yup.string().required(), - slug: yup.string().required(), - name: yup.string().required() - }) - ), - obj - ) - ) - ), - repoWorkflows: yup.lazy((obj) => - yup.object( - mapObjIndexed( - () => - yup.array().of( - yup.object().shape({ - name: yup.string().required(), - value: yup.string().required() - }) - ), - obj - ) - ) - ), - provider: yup.string().oneOf(Object.values(Integration)) -}); - -const endpoint = new Endpoint(nullSchema); - -endpoint.handle.PATCH(patchSchema, async (req, res) => { - if (req.meta?.features?.use_mock_data) { - return res.send(selectedDBReposMock); - } - - const { org_id, orgRepos, provider, repoWorkflows } = req.payload; - - const orgReposList: ReqOrgRepo[] = []; - forEachObjIndexed( - (repos, org) => orgReposList.push({ org, repos }), - orgRepos - ); - const flatReposList: ReqRepo[] = orgReposList.flatMap(({ org, repos }) => - repos.map((repo) => ({ org, ...repo })) - ); - - const flatReposSet = new Set(flatReposList.map((r) => r.idempotency_key)); - const allActiveOrgRepos = await db(Table.OrgRepo) - .select('*') - .where(Columns[Table.OrgRepo].org_id, org_id) - .andWhere(Columns[Table.OrgRepo].is_active, true) - .andWhere(Columns[Table.OrgRepo].provider, provider); - - const orgReposToDisable = allActiveOrgRepos.filter( - (repo) => !flatReposSet.has(repo.idempotency_key) - ); - - const reposInUseByATeam = await db(Table.TeamRepos) - .select('*', 'OrgRepo.* as org_repo') - .leftJoin('OrgRepo', 'OrgRepo.id', 'TeamRepos.org_repo_id') - .leftJoin('Team', 'Team.id', 'TeamRepos.team_id') - .where('TeamRepos.is_active', true) - .andWhere('Team.is_deleted', false) - .whereIn( - Columns[Table.TeamRepos].org_repo_id, - orgReposToDisable.map((repo) => repo.id) - ); - - if (reposInUseByATeam.length) { - return res.status(409).send({ - disallowed_repos: Array.from( - new Set(reposInUseByATeam.map((repo) => repo.name)) - ) - }); - } - - /** - * Reasoning: - * 1. Update: Deactivate all org_repos - * 2. Update: Reactivate any existing org repos (can't upsert) - * 3. Insert: Add any new selected repos - */ - - let repos: Row<'OrgRepo'>[] = []; - // 1. Update: Deactivate all org_repos - try { - repos = await db(Table.OrgRepo) - .update({ is_active: false, updated_at: new Date() }) - .where({ org_id, provider }) - .returning('*'); - } catch (err) { - // Empty update throws, so do nothing - } - - // Among the repos passed to request payload, determine which ones - // were already present in the DB [selectedRepos] and those - // that aren't [remainingRepos] - const [selectedRepos, remainingRepos] = partition( - (flatRepo) => repos.some(reqRepoComparator(flatRepo)), - flatReposList - ); - - // 2. Update: Reactivate any existing org repos (can't upsert) - try { - const filteredRepos = repos.filter(dbRepoFilter(selectedRepos)); - - if (filteredRepos.length) - await db(Table.OrgRepo) - .update({ is_active: true, updated_at: new Date() }) - .and.whereIn( - 'id', - repos.filter(dbRepoFilter(selectedRepos)).map((repo) => repo.id) - ) - .returning('*'); - } catch (err) { - // Empty update throws, so do nothing - } - - // 3. Update: Add any new selected repos - if (remainingRepos.length) { - await db(Table.OrgRepo) - .insert( - remainingRepos.map((repo) => ({ - org_id, - name: repo.name, - slug: repo.slug, - idempotency_key: repo.idempotency_key, - provider, - org_name: repo.org - })) - ) - .returning('*'); - } - - const reposForWorkflows = Object.keys(repoWorkflows); - - if ( - reposForWorkflows.length && - (provider === Integration.GITHUB || provider === Integration.BITBUCKET) - ) { - // Step 1: Get all repos for the workflows - const dbReposForWorkflows = await db(Table.OrgRepo) - .select('*') - .whereIn('name', reposForWorkflows) - .where('org_id', org_id) - .andWhere('is_active', true) - .andWhere('provider', provider); - - const groupedRepos = groupBy(dbReposForWorkflows, 'name'); - - // Step 2: Disable all workflows for the above db repos - await db('RepoWorkflow') - .update('is_active', false) - .whereIn( - 'org_repo_id', - dbReposForWorkflows.map((r) => r.id) - ) - .andWhere('type', WorkflowType.DEPLOYMENT); - - await db('RepoWorkflow') - .insert( - Object.entries(repoWorkflows) - .filter(([repoName]) => groupedRepos[repoName]?.id) - .flatMap(([repoName, workflows]) => - workflows.map((workflow) => ({ - is_active: true, - name: workflow.name, - provider: - provider === Integration.GITHUB - ? CIProvider.GITHUB_ACTIONS - : provider === Integration.BITBUCKET - ? CIProvider.CIRCLE_CI - : null, - provider_workflow_id: String(workflow.value), - type: WorkflowType.DEPLOYMENT, - org_repo_id: groupedRepos[repoName]?.id - })) - ) - ) - .onConflict(['org_repo_id', 'provider_workflow_id']) - .merge(); - } - syncReposForOrg(); - - res.send(await getSelectedReposForOrg(org_id, provider as Integration)); -}); - -export default endpoint.serve(); - -const reqRepoComparator = (reqRepo: ReqRepo) => (tableRepo: DB_OrgRepo) => { - return ( - reqRepo.org === tableRepo.org_name && - reqRepo.idempotency_key === tableRepo.idempotency_key - ); -}; -const dbRepoComparator = (tableRepo: DB_OrgRepo) => (reqRepo: ReqRepo) => - reqRepo.org === tableRepo.org_name && - reqRepo.idempotency_key === tableRepo.idempotency_key; -const dbRepoFilter = (reqRepos: ReqRepo[]) => (tableRepo: DB_OrgRepo) => - reqRepos.some(dbRepoComparator(tableRepo)); diff --git a/web-server/pages/api/integrations/selected.ts b/web-server/pages/api/integrations/selected.ts index 6d75892a..5cae1566 100644 --- a/web-server/pages/api/integrations/selected.ts +++ b/web-server/pages/api/integrations/selected.ts @@ -13,7 +13,7 @@ import { db, dbRaw } from '@/utils/db'; const getSchema = yup.object().shape({ org_id: yup.string().uuid().required(), - provider: yup.string().oneOf(Object.values(Integration)) + providers: yup.array(yup.string().oneOf(Object.values(Integration))) }); const endpoint = new Endpoint(nullSchema); @@ -23,14 +23,14 @@ endpoint.handle.GET(getSchema, async (req, res) => { return res.send(selectedDBReposMock); } - const { org_id, provider } = req.payload; + const { org_id, providers } = req.payload; - res.send(await getSelectedReposForOrg(org_id, provider as Integration)); + res.send(await getSelectedReposForOrg(org_id, providers as Integration[])); }); export const getSelectedReposForOrg = async ( org_id: ID, - provider: Integration + providers: Integration[] ): Promise => { const dbRepos: RepoWithSingleWorkflow[] = await db(Table.OrgRepo) .leftJoin({ rw: Table.RepoWorkflow }, function () { @@ -47,7 +47,8 @@ export const getSelectedReposForOrg = async ( .select(dbRaw.raw('to_json(rw) as repo_workflow')) .select('tr.deployment_type', 'tr.team_id') .from('OrgRepo') - .where({ org_id, 'OrgRepo.provider': provider }) + .where('org_id', org_id) + .and.whereIn('OrgRepo.provider', providers) .andWhereNot('OrgRepo.is_active', false); const repoToWorkflowMap = dbRepos.reduce( diff --git a/web-server/pages/api/internal/[org_id]/git_provider_org.ts b/web-server/pages/api/internal/[org_id]/git_provider_org.ts index 928f85f0..fae050e8 100644 --- a/web-server/pages/api/internal/[org_id]/git_provider_org.ts +++ b/web-server/pages/api/internal/[org_id]/git_provider_org.ts @@ -1,6 +1,6 @@ import * as yup from 'yup'; -import { searchGithubRepos } from '@/api/internal/[org_id]/utils'; +import { gitlabSearch, searchGithubRepos } from '@/api/internal/[org_id]/utils'; import { Endpoint } from '@/api-helpers/global'; import { Integration } from '@/constants/integrations'; import { dec } from '@/utils/auth-supplementary'; @@ -16,19 +16,30 @@ const pathSchema = yup.object().shape({ }); const getSchema = yup.object().shape({ - provider: yup.string().oneOf(Object.values(Integration)), + providers: yup.array(yup.string().oneOf(Object.values(Integration))), search_text: yup.string().nullable().optional() }); const endpoint = new Endpoint(pathSchema); endpoint.handle.GET(getSchema, async (req, res) => { - const { org_id, search_text } = req.payload; + const { org_id, search_text, providers } = req.payload; - const token = await getGithubToken(org_id); - const repos = await searchGithubRepos(token, search_text); + const providerMap = fetchMap.filter((item) => + providers.includes(item.provider) + ); - return res.status(200).send(repos); + const tokens = await Promise.all( + providerMap.map((item) => item.getToken(org_id)) + ); + + const repos = await Promise.all( + providerMap.map((item) => item.search(tokens.shift(), search_text)) + ); + + const sortedRepos = repos.flat().sort((a, b) => a.name.localeCompare(b.name)); + + return res.status(200).send(sortedRepos); }); export default endpoint.serve(); @@ -44,3 +55,28 @@ const getGithubToken = async (org_id: ID) => { .then(getFirstRow) .then((r) => dec(r.access_token_enc_chunks)); }; + +const getGitlabToken = async (org_id: ID) => { + return await db('Integration') + .select() + .where({ + org_id, + name: Integration.GITLAB + }) + .returning('*') + .then(getFirstRow) + .then((r) => dec(r.access_token_enc_chunks)); +}; + +const fetchMap = [ + { + provider: Integration.GITHUB, + search: searchGithubRepos, + getToken: getGithubToken + }, + { + provider: Integration.GITLAB, + search: gitlabSearch, + getToken: getGitlabToken + } +]; diff --git a/web-server/pages/api/internal/[org_id]/utils.ts b/web-server/pages/api/internal/[org_id]/utils.ts index 97a9a043..d7948ca5 100644 --- a/web-server/pages/api/internal/[org_id]/utils.ts +++ b/web-server/pages/api/internal/[org_id]/utils.ts @@ -1,3 +1,7 @@ +import axios from 'axios'; +import fetch from 'node-fetch'; + +import { Integration } from '@/constants/integrations'; import { BaseRepo } from '@/types/resources'; const GITHUB_API_URL = 'https://api.github.com/graphql'; @@ -16,6 +20,8 @@ type GithubRepo = { owner: { login: string; }; + html_url?: string; + id?: number; }; type RepoReponse = { @@ -30,6 +36,40 @@ type RepoReponse = { }; export const searchGithubRepos = async ( + pat: string, + searchQuery: string +): Promise => { + let urlString = convertUrlToQuery(searchQuery); + if (urlString !== searchQuery) { + try { + return await searchRepoWithURL(urlString); + } catch (e) { + return await searchGithubReposWithNames(pat, urlString); + } + } + return await searchGithubReposWithNames(pat, urlString); +}; + +const searchRepoWithURL = async (searchString: string) => { + const apiUrl = `https://api.github.com/repos/${searchString}`; + const response = await axios.get(apiUrl); + const repo = response.data; + return [ + { + id: repo.id, + name: repo.name, + desc: repo.description, + slug: repo.name, + parent: repo.owner.login, + web_url: repo.html_url, + branch: repo.defaultBranchRef?.name, + language: repo.primaryLanguage?.name, + provider: Integration.GITHUB + } + ] as BaseRepo[]; +}; + +export const searchGithubReposWithNames = async ( pat: string, searchString: string ): Promise => { @@ -89,7 +129,155 @@ export const searchGithubRepos = async ( parent: repo.owner.login, web_url: repo.url, language: repo.primaryLanguage?.name, - branch: repo.defaultBranchRef?.name + branch: repo.defaultBranchRef?.name, + provider: Integration.GITHUB }) as BaseRepo ); }; + +// Gitlab functions + +// Define types for the response + +interface RepoNode { + id: string; + name: string; + webUrl: string; + description: string | null; + path: string; + fullPath: string; + nameWithNamespace: string; + languages: { + nodes: { + name: string; + }[]; + }; + repository: { + rootRef: string; + }; +} + +interface RepoResponse { + data: { + byFullPath: { + nodes: RepoNode[]; + }; + bySearch: { + nodes: RepoNode[]; + }; + }; + errors?: { message: string }[]; +} + +const GITLAB_API_URL = 'https://gitlab.com/api/graphql'; + +export const searchGitlabRepos = async ( + pat: string, + searchString: string +): Promise => { + const query = ` + query($fullPaths: [String!], $searchString: String!) { + byFullPath: projects(fullPaths: $fullPaths, first: 50) { + nodes { + id + fullPath + name + webUrl + group { + path + } + description + path + languages { + name + } + repository { + rootRef + } + } + } + bySearch: projects(search: $searchString, first: 50) { + nodes { + id + fullPath + name + webUrl + group { + path + } + description + path + languages { + name + } + repository { + rootRef + } + } + } + } +`; + + const response = await fetch(GITLAB_API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${pat}` + }, + body: JSON.stringify({ + query, + variables: { fullPaths: searchString, searchString: searchString } + }) + }); + + const responseBody = (await response.json()) as RepoResponse; + + if (responseBody.errors) { + throw new Error( + `GitLab API error: ${responseBody.errors + .map((e) => e.message) + .join(', ')}` + ); + } + + const repositories = [ + ...responseBody.data.byFullPath.nodes, + ...responseBody.data.bySearch.nodes + ]; + + return repositories.map( + (repo) => + ({ + id: Number(repo.id.replace('gid://gitlab/Project/', '')), + name: repo.name, + desc: repo.description, + slug: repo.path, + web_url: repo.webUrl, + branch: repo.repository?.rootRef || null, + parent: repo.fullPath.split('/').slice(0, -1).join('/'), + provider: Integration.GITLAB + }) as BaseRepo + ); +}; + +export const gitlabSearch = async (pat: string, searchString: string) => { + let search = convertUrlToQuery(searchString); + return searchGitlabRepos(pat, search); +}; + +const convertUrlToQuery = (url: string) => { + let query = url; + try { + const urlObject = new URL(url); + query = urlObject.pathname; + query = query.startsWith('/') ? query.slice(1) : query; + } catch (_) { + query = query.replace('https://', ''); + query = query.replace('http://', ''); + query = query.replace('github.com/', ''); + query = query.replace('gitlab.com/', ''); + query = query.startsWith('www.') ? query.slice(4) : query; + query = query.endsWith('/') ? query.slice(0, -1) : query; + } + return query; // of type parent/repo or group/subgroup/repo +}; diff --git a/web-server/pages/api/resources/orgs/[org_id]/integration.ts b/web-server/pages/api/resources/orgs/[org_id]/integration.ts index 8360d4bf..ecebf5da 100644 --- a/web-server/pages/api/resources/orgs/[org_id]/integration.ts +++ b/web-server/pages/api/resources/orgs/[org_id]/integration.ts @@ -15,7 +15,8 @@ const deleteSchema = yup.object().shape({ const postSchema = yup.object().shape({ the_good_stuff: yup.string().required(), - provider: yup.string().oneOf(Object.values(Integration)).required() + provider: yup.string().oneOf(Object.values(Integration)).required(), + meta_data: yup.object().optional() }); const endpoint = new Endpoint(pathnameSchema); @@ -55,14 +56,15 @@ endpoint.handle.POST(postSchema, async (req, res) => { return res.send({ status: 'OK' }); } - const { org_id, provider, the_good_stuff } = req.payload; + const { org_id, provider, the_good_stuff, meta_data } = req.payload; await db('Integration') .insert({ access_token_enc_chunks: enc(the_good_stuff), updated_at: new Date(), name: provider, - org_id + org_id, + provider_meta: meta_data }) .onConflict(INTEGRATION_CONFLICT_COLUMNS) .merge(); diff --git a/web-server/pages/api/resources/orgs/[org_id]/teams/v2.ts b/web-server/pages/api/resources/orgs/[org_id]/teams/v2.ts index cd5c4ab0..d32983e5 100644 --- a/web-server/pages/api/resources/orgs/[org_id]/teams/v2.ts +++ b/web-server/pages/api/resources/orgs/[org_id]/teams/v2.ts @@ -29,6 +29,7 @@ import groupBy from '@/utils/objectArray'; const repoSchema = yup.object().shape({ idempotency_key: yup.string().required(), deployment_type: yup.string().required(), + provider: yup.string().oneOf(Object.values(Integration)).required(), slug: yup.string().required(), name: yup.string().required(), repo_workflows: yup.array().of( @@ -40,12 +41,13 @@ const repoSchema = yup.object().shape({ }); const getSchema = yup.object().shape({ - provider: yup.string().oneOf(Object.values(Integration)).required() + providers: yup.array( + yup.string().oneOf(Object.values(Integration)).optional() + ) }); const postSchema = yup.object().shape({ name: yup.string().required(), - provider: yup.string().oneOf(Object.values(Integration)).required(), org_repos: yup.lazy((obj) => yup.object(mapObjIndexed(() => yup.array().of(repoSchema), obj)) ) @@ -54,7 +56,6 @@ const postSchema = yup.object().shape({ const patchSchema = yup.object().shape({ id: yup.string().uuid().required(), name: yup.string().nullable().optional(), - provider: yup.string().oneOf(Object.values(Integration)).required(), org_repos: yup.lazy((obj) => yup.object(mapObjIndexed(() => yup.array().of(repoSchema), obj)) ) @@ -75,7 +76,7 @@ endpoint.handle.GET(getSchema, async (req, res) => { return res.send(getTeamV2Mock); } - const { org_id, provider } = req.payload; + const { org_id, providers } = req.payload; const getQuery = db('Team') .select('*') .where('org_id', org_id) @@ -85,8 +86,11 @@ endpoint.handle.GET(getSchema, async (req, res) => { const teams = await getQuery; const reposWithWorkflows = await getSelectedReposForOrg( org_id, - provider as Integration - ); + providers?.length + ? (providers as Integration[]) + : [Integration.GITHUB, Integration.GITLAB] + ).then((res) => res.flat()); + res.send({ teams: teams, teamReposMap: ramdaGroupBy(prop('team_id'), reposWithWorkflows), @@ -99,14 +103,14 @@ endpoint.handle.POST(postSchema, async (req, res) => { return res.send(getTeamV2Mock); } - const { org_repos, org_id, provider, name } = req.payload; + const { org_repos, org_id, name } = req.payload; const orgReposList: ReqRepoWithProvider[] = []; forEachObjIndexed((repos, org) => { repos.forEach((repo) => { orgReposList.push({ ...repo, org, - provider + provider: repo.provider } as any as ReqRepoWithProvider); }); }, org_repos); @@ -127,10 +131,12 @@ endpoint.handle.POST(postSchema, async (req, res) => { } } ); - await updateReposWorkflows(org_id, provider as Integration, orgReposList); + + const providers = Array.from(new Set(orgReposList.map((r) => r.provider))); + await updateReposWorkflows(org_id, orgReposList); const reposWithWorkflows = await getSelectedReposForOrg( org_id, - provider as Integration + providers as Integration[] ); updateOnBoardingState(org_id, updatedOnboardingState); syncReposForOrg(); @@ -146,14 +152,14 @@ endpoint.handle.PATCH(patchSchema, async (req, res) => { return res.send(getTeamV2Mock); } - const { org_id, id, name, org_repos, provider } = req.payload; + const { org_id, id, name, org_repos } = req.payload; const orgReposList: ReqRepoWithProvider[] = []; forEachObjIndexed((repos, org) => { repos.forEach((repo) => { orgReposList.push({ ...repo, org, - provider + provider: repo.provider } as any as ReqRepoWithProvider); }); }, org_repos); @@ -167,10 +173,13 @@ endpoint.handle.PATCH(patchSchema, async (req, res) => { } }).then((repos) => repos.map((r) => ({ ...r, team_id: id }))) ]); - await updateReposWorkflows(org_id, provider as Integration, orgReposList); + await updateReposWorkflows(org_id, orgReposList); + + const providers = Array.from(new Set(orgReposList.map((r) => r.provider))); + const reposWithWorkflows = await getSelectedReposForOrg( org_id, - provider as Integration + providers as Integration[] ); syncReposForOrg(); res.send({ @@ -226,7 +235,6 @@ const createTeam = async ( const updateReposWorkflows = async ( org_id: ID, - provider: Integration, orgReposList: ReqRepoWithProvider[] ) => { const repoWorkflows = orgReposList.reduce( @@ -235,25 +243,26 @@ const updateReposWorkflows = async ( [curr.name]: curr.repo_workflows?.map((w) => ({ value: String(w.value), - name: w.name + name: w.name, + provider: curr.provider })) || [] }), - {} as Record + {} as Record< + string, + { name: string; value: string; provider: Integration }[] + > ); const reposForWorkflows = Object.keys(repoWorkflows); - if ( - reposForWorkflows.length && - (provider === Integration.GITHUB || provider === Integration.BITBUCKET) - ) { + if (reposForWorkflows.length) { // Step 1: Get all repos for the workflows const dbReposForWorkflows = await db(Table.OrgRepo) .select('*') .whereIn('name', reposForWorkflows) .where('org_id', org_id) .andWhere('is_active', true) - .andWhere('provider', provider); + .and.whereIn('provider', [Integration.GITHUB, Integration.GITLAB]); const groupedRepos = groupBy(dbReposForWorkflows, 'name'); @@ -273,9 +282,9 @@ const updateReposWorkflows = async ( is_active: true, name: workflow.name, provider: - provider === Integration.GITHUB + workflow.provider === Integration.GITHUB ? CIProvider.GITHUB_ACTIONS - : provider === Integration.BITBUCKET + : workflow.provider === Integration.BITBUCKET ? CIProvider.CIRCLE_CI : null, provider_workflow_id: String(workflow.value), diff --git a/web-server/pages/dora-metrics/index.tsx b/web-server/pages/dora-metrics/index.tsx index 6df4cca3..8dcb801b 100644 --- a/web-server/pages/dora-metrics/index.tsx +++ b/web-server/pages/dora-metrics/index.tsx @@ -15,9 +15,7 @@ function Page() { const isLoading = useSelector( (s) => s.doraMetrics.requests?.metrics_summary === FetchState.REQUEST ); - const { - integrations: { github: isGithubIntegrated } - } = useAuth(); + const { integrationList } = useAuth(); return ( - {isGithubIntegrated ? : } + {integrationList.length > 0 ? : } ); } diff --git a/web-server/pages/integrations.tsx b/web-server/pages/integrations.tsx index e1b3b9de..a92ea8c9 100644 --- a/web-server/pages/integrations.tsx +++ b/web-server/pages/integrations.tsx @@ -1,16 +1,18 @@ import { Add } from '@mui/icons-material'; import { LoadingButton } from '@mui/lab'; import { Button, Divider, Card } from '@mui/material'; +import { differenceInMilliseconds } from 'date-fns'; +import { millisecondsInMinute } from 'date-fns/constants'; import { useEffect, useMemo } from 'react'; import { Authenticated } from 'src/components/Authenticated'; import { handleApi } from '@/api-helpers/axios-api-instance'; import { FlexBox } from '@/components/FlexBox'; import { Line } from '@/components/Text'; -import { Integration } from '@/constants/integrations'; import { ROUTES } from '@/constants/routes'; import { FetchState } from '@/constants/ui-states'; -import { GithubIntegrationCard } from '@/content/Dashboards/IntegrationCards'; +import { GithubIntegrationCard } from '@/content/Dashboards/GithubIntegrationCard'; +import { GitlabIntegrationCard } from '@/content/Dashboards/GitlabIntegrationCard'; import { PageWrapper } from '@/content/PullRequests/PageWrapper'; import { useAuth } from '@/hooks/useAuth'; import { useBoolState, useEasyState } from '@/hooks/useEasyState'; @@ -18,10 +20,10 @@ import ExtendedSidebarLayout from '@/layouts/ExtendedSidebarLayout'; import { appSlice } from '@/slices/app'; import { fetchTeams } from '@/slices/team'; import { useDispatch, useSelector } from '@/store'; -import { PageLayout, IntegrationGroup } from '@/types/resources'; +import { PageLayout } from '@/types/resources'; import { depFn } from '@/utils/fn'; -const TIME_LIMIT_FOR_FORCE_SYNC = 600000; +const TIME_LIMIT_FOR_FORCE_SYNC = 10 * millisecondsInMinute; function Integrations() { const isLoading = useSelector( @@ -56,8 +58,8 @@ Integrations.getLayout = (page: PageLayout) => ( export default Integrations; const Content = () => { - const { orgId, integrations, integrationSet, activeCodeProvider } = useAuth(); - const hasCodeProviderLinked = integrationSet.has(IntegrationGroup.CODE); + const { orgId, integrations, integrationList } = useAuth(); + const hasCodeProviderLinked = integrationList.length > 0; const teamCount = useSelector((s) => s.team.teams?.length); const dispatch = useDispatch(); const loadedTeams = useBoolState(false); @@ -65,29 +67,42 @@ const Content = () => { const localLastForceSyncedAt = useEasyState(null); const showCreationCTA = hasCodeProviderLinked && !teamCount && loadedTeams.value; + + const lastSyncMap = useMemo(() => { + return integrationList + .map((item) => { + const linkedAt = + integrations[item as 'github' | 'gitlab' | 'bitbucket'].linked_at; + if (!linkedAt) return null; + const codeProviderLinkedAt = new Date(linkedAt); + const currentDate = new Date(); + const diff = differenceInMilliseconds( + currentDate, + codeProviderLinkedAt + ); + return diff; + }) + .filter(Boolean); + }, [integrationList, integrations]); + const showForceSyncBtn = useMemo(() => { if (!hasCodeProviderLinked) return false; - const githubLinkedAt = new Date(integrations[Integration.GITHUB].linked_at); - const currentDate = new Date(); - if (githubLinkedAt) { - const diffMilliseconds = currentDate.getTime() - githubLinkedAt.getTime(); - return diffMilliseconds >= TIME_LIMIT_FOR_FORCE_SYNC; - } - }, [hasCodeProviderLinked, integrations]); + if (!lastSyncMap.length) return true; + return lastSyncMap.every((diff) => diff >= TIME_LIMIT_FOR_FORCE_SYNC); + }, [hasCodeProviderLinked, lastSyncMap]); const enableForceSyncBtn = useMemo(() => { - if (!integrations[activeCodeProvider]?.last_synced_at) return true; - const lastForceSyncedAt = Math.max( - new Date(integrations[activeCodeProvider]?.last_synced_at).getTime(), - localLastForceSyncedAt.value?.getTime() + const diff = differenceInMilliseconds( + new Date(), + localLastForceSyncedAt.value ); - const currentDate = new Date(); - const diffMilliseconds = currentDate.getTime() - lastForceSyncedAt; - return diffMilliseconds >= TIME_LIMIT_FOR_FORCE_SYNC; - }, [activeCodeProvider, integrations, localLastForceSyncedAt.value]); + if (diff >= TIME_LIMIT_FOR_FORCE_SYNC) return true; + if (!lastSyncMap.length) return true; + return lastSyncMap.some((diff) => diff >= TIME_LIMIT_FOR_FORCE_SYNC); + }, [lastSyncMap, localLastForceSyncedAt.value]); useEffect(() => { - if (!orgId || !activeCodeProvider) return; + if (!orgId || !integrationList.length) return; if (!hasCodeProviderLinked) { dispatch(appSlice.actions.setSingleTeam([])); return; @@ -95,15 +110,14 @@ const Content = () => { if (hasCodeProviderLinked && !teamCount) { dispatch( fetchTeams({ - org_id: orgId, - provider: activeCodeProvider + org_id: orgId }) ).finally(loadedTeams.true); } }, [ - activeCodeProvider, dispatch, hasCodeProviderLinked, + integrationList, loadedTeams.true, orgId, teamCount @@ -126,15 +140,8 @@ const Content = () => { return ( - - Integrate your Github to fetch DORA for your team + + Integrate your Code Providers to fetch DORA for your team {showDoraCTA && ( + ); +}; + +const LinkedIcon = () => { + const isVisible = useBoolState(false); + useEffect(() => { + setTimeout(isVisible.true, 200); + }, [isVisible.true]); + return ( + + + + + + + + + + + ); +}; diff --git a/web-server/src/content/Dashboards/githubIntegration.tsx b/web-server/src/content/Dashboards/githubIntegration.tsx index 71b77a7d..4a6cad42 100644 --- a/web-server/src/content/Dashboards/githubIntegration.tsx +++ b/web-server/src/content/Dashboards/githubIntegration.tsx @@ -1,16 +1,26 @@ import faker from '@faker-js/faker'; import { GitHub } from '@mui/icons-material'; -const bgOpacity = 0.6; +import GitlabIcon from '@/mocks/icons/gitlab.svg'; -export const integrationsDisplay = { +export const githubIntegrationsDisplay = { id: faker.datatype.uuid(), type: 'github', name: 'Github', description: 'Code insights & blockers', color: '#fff', - bg: `linear-gradient(135deg, hsla(160, 10%, 61%, ${bgOpacity}) 0%, hsla(247, 0%, 21%, ${bgOpacity}) 100%)`, + bg: `linear-gradient(135deg, hsla(160, 10%, 61%, 0.6) 0%, hsla(247, 0%, 21%, 0.6) 100%)`, icon: }; -export type IntegrationItem = typeof integrationsDisplay; +export const gitLabIntegrationDisplay = { + id: '39936e43-178a-4272-bef3-948d770bc98f', + type: 'gitlab', + name: 'Gitlab', + description: 'Code insights & blockers', + color: '#554488', + bg: 'linear-gradient(-45deg, hsla(17, 95%, 50%, 0.6) 0%, hsla(42, 94%, 67%, 0.6) 100%)', + icon: +} as IntegrationItem; + +export type IntegrationItem = typeof githubIntegrationsDisplay; diff --git a/web-server/src/content/Dashboards/useIntegrationHandlers.tsx b/web-server/src/content/Dashboards/useIntegrationHandlers.tsx index 4b15644f..0ded2063 100644 --- a/web-server/src/content/Dashboards/useIntegrationHandlers.tsx +++ b/web-server/src/content/Dashboards/useIntegrationHandlers.tsx @@ -1,25 +1,12 @@ -import { LoadingButton } from '@mui/lab'; -import { Divider, Link, TextField, alpha } from '@mui/material'; -import Image from 'next/image'; -import { useSnackbar } from 'notistack'; -import { FC, useCallback, useMemo } from 'react'; +import { useMemo } from 'react'; -import { FlexBox } from '@/components/FlexBox'; -import { Line } from '@/components/Text'; import { Integration } from '@/constants/integrations'; +import { ConfigureGitlabModalBody } from '@/content/Dashboards/ConfigureGitlabModalBody'; import { useModal } from '@/contexts/ModalContext'; import { useAuth } from '@/hooks/useAuth'; -import { useBoolState, useEasyState } from '@/hooks/useEasyState'; -import { fetchCurrentOrg } from '@/slices/auth'; -import { fetchTeams } from '@/slices/team'; -import { useDispatch } from '@/store'; -import { - unlinkProvider, - checkGitHubValidity, - linkProvider, - getMissingPATScopes -} from '@/utils/auth'; -import { depFn } from '@/utils/fn'; +import { unlinkProvider } from '@/utils/auth'; + +import { ConfigureGithubModalBody } from './ConfigureGithubModalBody'; export const useIntegrationHandlers = () => { const { orgId } = useAuth(); @@ -34,268 +21,20 @@ export const useIntegrationHandlers = () => { title: 'Configure Github', body: , showCloseIcon: true + }), + gitlab: () => + addModal({ + title: 'Configure Gitlab', + body: , + showCloseIcon: true }) }, unlink: { - github: () => unlinkProvider(orgId, Integration.GITHUB) + github: () => unlinkProvider(orgId, Integration.GITHUB), + gitlab: () => unlinkProvider(orgId, Integration.GITLAB) } }; return handlers; }, [addModal, closeAllModals, orgId]); }; - -const ConfigureGithubModalBody: FC<{ - onClose: () => void; -}> = ({ onClose }) => { - const token = useEasyState(''); - const { orgId } = useAuth(); - const { enqueueSnackbar } = useSnackbar(); - const dispatch = useDispatch(); - const isLoading = useBoolState(); - - const showError = useEasyState(''); - - const setError = useCallback( - (error: string) => { - console.error(error); - depFn(showError.set, error); - }, - [showError.set] - ); - - const handleChange = (e: string) => { - token.set(e); - showError.set(''); - }; - - const handleSubmission = useCallback(async () => { - if (!token.value) { - setError('Please enter a valid token'); - return; - } - depFn(isLoading.true); - checkGitHubValidity(token.value) - .then(async (isValid) => { - if (!isValid) throw new Error('Invalid token'); - }) - .then(async () => { - try { - const res = await getMissingPATScopes(token.value); - if (res.length) { - throw new Error(`Token is missing scopes: ${res.join(', ')}`); - } - } catch (e) { - // @ts-ignore - throw new Error(e?.message, e); - } - }) - .then(async () => { - try { - return await linkProvider(token.value, orgId, Integration.GITHUB); - } catch (e: any) { - throw new Error( - `Failed to link Github${e?.message ? `: ${e?.message}` : ''}`, - e - ); - } - }) - .then(() => { - dispatch(fetchCurrentOrg()); - dispatch( - fetchTeams({ - org_id: orgId, - provider: Integration.GITHUB - }) - ); - enqueueSnackbar('Github linked successfully', { - variant: 'success', - autoHideDuration: 2000 - }); - onClose(); - }) - .catch((e) => { - setError(e.message); - console.error(`Error while linking token: ${e.message}`, e); - }) - .finally(isLoading.false); - }, [ - dispatch, - enqueueSnackbar, - isLoading.false, - isLoading.true, - onClose, - orgId, - setError, - token.value - ]); - - return ( - - - Enter you Github token below. - - { - if (e.key === 'Enter') { - e.preventDefault(); - e.stopPropagation(); - e.nativeEvent.stopImmediatePropagation(); - handleSubmission(); - return; - } - }} - error={!!showError.value} - sx={{ width: '100%' }} - value={token.value} - onChange={(e) => { - handleChange(e.currentTarget.value); - }} - label="Github Personal Access Token" - type="password" - /> - - {showError.value} - - - - - - Generate new classic token - - - {' ->'} - - - - - - - Learn more about Github - - Personal Access Token (PAT) - - here - - - - - - Confirm - - - - - - - - ); -}; - -const TokenPermissions = () => { - const imageLoaded = useBoolState(false); - - const expandedStyles = useMemo(() => { - const baseStyles = { - border: `2px solid ${alpha('rgb(256,0,0)', 0.4)}`, - transition: 'all 0.8s ease', - borderRadius: '12px', - opacity: 1, - width: '240px', - position: 'absolute', - maxWidth: 'calc(100% - 48px)', - left: '24px' - }; - - return [ - { - height: '170px', - top: '58px' - }, - { - height: '42px', - top: '230px' - }, - { - height: '120px', - - top: '378px' - }, - { - height: '120px', - - top: '806px' - } - ].map((item) => ({ ...item, ...baseStyles })); - }, []); - - return ( - -
- PAT_permissions - - {imageLoaded.value && - expandedStyles.map((style, index) => ( - - ))} - - {!imageLoaded.value && ( - - Loading... - - )} -
- - Scroll to see all required permissions - -
- ); -}; diff --git a/web-server/src/content/DoraMetrics/DoraMetricsBody.tsx b/web-server/src/content/DoraMetrics/DoraMetricsBody.tsx index c41635fd..cd8642e3 100644 --- a/web-server/src/content/DoraMetrics/DoraMetricsBody.tsx +++ b/web-server/src/content/DoraMetrics/DoraMetricsBody.tsx @@ -1,7 +1,7 @@ import { AutoGraphRounded } from '@mui/icons-material'; import { Grid, Divider, Button } from '@mui/material'; import Link from 'next/link'; -import { FC, useEffect } from 'react'; +import { FC, useEffect, useMemo } from 'react'; import { AiButton } from '@/components/AiButton'; import { DoraMetricsConfigurationSettings } from '@/components/DoraMetricsConfigurationSettings'; @@ -37,10 +37,11 @@ import { WeeklyDeliveryVolumeCard } from './DoraCards/WeeklyDeliveryVolumeCard'; export const DoraMetricsBody = () => { const dispatch = useDispatch(); - const { - orgId, - integrations: { github: isGithubIntegrated } - } = useAuth(); + const { orgId, integrationList } = useAuth(); + const isCodeProviderIntegrated = useMemo( + () => integrationList.length > 0, + [integrationList.length] + ); const { singleTeamId, dates } = useSingleTeamConfig(); const branchPayloadForPrFilters = useBranchesForPrFilters(); const isLoading = useSelector( @@ -65,9 +66,11 @@ export const DoraMetricsBody = () => { const { addPage } = useOverlayPage(); + console.log('DEbugging', isCodeProviderIntegrated); + useEffect(() => { - if (!singleTeamId) return; - if (!isGithubIntegrated) return; + // if (!singleTeamId) return; + // if (!isCodeProviderIntegrated) return; dispatch( fetchTeamDoraMetrics({ orgId, @@ -83,7 +86,7 @@ export const DoraMetricsBody = () => { dispatch, orgId, singleTeamId, - isGithubIntegrated, + isCodeProviderIntegrated, branchPayloadForPrFilters ]); diff --git a/web-server/src/contexts/ThirdPartyAuthContext.tsx b/web-server/src/contexts/ThirdPartyAuthContext.tsx index 15d8d7c5..eed4a149 100644 --- a/web-server/src/contexts/ThirdPartyAuthContext.tsx +++ b/web-server/src/contexts/ThirdPartyAuthContext.tsx @@ -25,6 +25,7 @@ export interface AuthContextValue extends AuthState { role: UserRole; integrations: Org['integrations']; onboardingState: OnboardingStep[]; + integrationList: Integration[]; integrationSet: Set; activeCodeProvider: CodeProviderIntegrations | null; } @@ -35,6 +36,7 @@ export const AuthContext = createContext({ role: UserRole.MOM, integrations: {}, onboardingState: [], + integrationList: [], integrationSet: new Set(), activeCodeProvider: null }); @@ -108,9 +110,22 @@ export const AuthProvider: FC = (props) => { const integrationSet = useMemo( () => new Set( - [].concat(integrations.github && IntegrationGroup.CODE).filter(Boolean) + [] + .concat( + (integrations.github || integrations.gitlab) && + IntegrationGroup.CODE + ) + .filter(Boolean) ), - [integrations.github] + [integrations.github, integrations.gitlab] + ); + + const integrationList = useMemo( + () => + Object.entries(integrations) + .filter(([_, value]) => value.integrated) + .map(([key, _]) => key) as Integration[], + [integrations] ); const activeCodeProvider = useMemo( @@ -131,6 +146,7 @@ export const AuthProvider: FC = (props) => { role, integrations, integrationSet, + integrationList, activeCodeProvider, onboardingState: (state.org?.onboarding_state as OnboardingStep[]) || [] }} diff --git a/web-server/src/layouts/ExtendedSidebarLayout/Sidebar/SidebarMenu/useFilteredSidebarItems.tsx b/web-server/src/layouts/ExtendedSidebarLayout/Sidebar/SidebarMenu/useFilteredSidebarItems.tsx index 86a24d44..44c6aafe 100644 --- a/web-server/src/layouts/ExtendedSidebarLayout/Sidebar/SidebarMenu/useFilteredSidebarItems.tsx +++ b/web-server/src/layouts/ExtendedSidebarLayout/Sidebar/SidebarMenu/useFilteredSidebarItems.tsx @@ -16,9 +16,7 @@ const checkTag = (tag: string | number[] | number, check: string | number) => { }; export const useFilteredSidebarItems = () => { - const { - integrations: { github: isGithubIntegrated } - } = useAuth(); + const { integrationList } = useAuth(); const flagFilteredMenuItems = useMemo(() => { return menuItems(); @@ -27,7 +25,10 @@ export const useFilteredSidebarItems = () => { const sidebarItems = useMemo(() => { const filterCheck = (item: MenuItem): boolean => { if (checkTag(item.tag, ItemTags.HideItem)) return false; - if (!isGithubIntegrated && item.name !== SideBarItems.MANAGE_INTEGRATIONS) + if ( + !integrationList.length && + item.name !== SideBarItems.MANAGE_INTEGRATIONS + ) return false; return true; }; @@ -45,7 +46,7 @@ export const useFilteredSidebarItems = () => { items: itemsFilter(section.items) })) .filter((section) => section.items?.length); - }, [flagFilteredMenuItems, isGithubIntegrated]); + }, [flagFilteredMenuItems, integrationList]); return sidebarItems; }; diff --git a/web-server/src/slices/repos.ts b/web-server/src/slices/repos.ts index 4ef78d28..d2835168 100644 --- a/web-server/src/slices/repos.ts +++ b/web-server/src/slices/repos.ts @@ -1,15 +1,5 @@ import { createSlice } from '@reduxjs/toolkit'; import { createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; -import { - uniq, - groupBy, - mapObjIndexed, - prop, - isNil, - reject, - find, - propEq -} from 'ramda'; import { handleApi } from '@/api-helpers/axios-api-instance'; import { AsyncSelectOption } from '@/components/AsyncSelect'; @@ -81,25 +71,6 @@ export const reposSlice = createSlice({ ): void { state.teamRepoIds = action.payload; }, - toggleRepo(state: State, action: PayloadAction): void { - const repo = action.payload; - const stateRepos = state.selectionMap[repo.parent] || []; - const repoExists = find(propEq('idempotency_key', repo.id), stateRepos); - - state.selectionMap[repo.parent] = ( - repoExists - ? stateRepos.filter( - (stateRepo) => stateRepo.idempotency_key !== repo.id - ) - : uniq( - stateRepos.concat({ - idempotency_key: repo.id.toString(), - name: repo.name, - slug: repo.slug - }) - ) - ).sort(); - }, setRepoWorkflow( state: State, action: PayloadAction<{ @@ -132,19 +103,6 @@ export const reposSlice = createSlice({ 'repos', (state, action) => (state.repos = action.payload) ); - - addFetchCasesToReducer( - builder, - fetchSelectedRepos, - 'selectionMap', - refreshState - ); - addFetchCasesToReducer( - builder, - updatedRepoAndWorkflowSelection, - 'workflowMap', - refreshState - ); addFetchCasesToReducer( builder, fetchUnassignedRepos, @@ -154,44 +112,6 @@ export const reposSlice = createSlice({ } }); -const refreshState = ( - state: State, - action: { - payload: RepoWithMultipleWorkflows[]; - type: string; - } -) => { - if (!action.payload.length) return state; - - if (!state.selectedOrg) state.selectedOrg = action.payload[0].org_name; - state.selectionMap = mapObjIndexed( - (group: RepoWithMultipleWorkflows[]) => - group - .map((repo) => ({ - idempotency_key: repo.idempotency_key, - name: repo.name, - slug: repo.slug - })) - .sort(), - groupBy(prop('org_name'), action.payload || []) - ); - state.workflowMap = action.payload.reduce( - (map, repo) => ({ - ...map, - [repo.name]: repo.repo_workflows?.length - ? repo.repo_workflows.map((wf) => ({ - value: wf?.provider_workflow_id, - name: wf?.name - })) - : null - }), - {} as State['workflowMap'] - ); - - state.persistedConfig.selectionMap = state.selectionMap; - state.persistedConfig.workflowMap = state.workflowMap; -}; - export const fetchUnassignedRepos = createAsyncThunk( 'repos/fetchUnassignedRepos', async (params: { orgId: ID; provider: Integration }) => { @@ -203,10 +123,10 @@ export const fetchUnassignedRepos = createAsyncThunk( export const fetchProviderOrgs = createAsyncThunk( 'repos/fetchProviderOrgs', - async (params: { orgId: ID; provider: Integration }) => { + async (params: { orgId: ID; providers: Integration[] }) => { return await handleApi( `/internal/${params.orgId}/git_provider_org`, - { params: { provider: params.provider } } + { params: { providers: params.providers } } ); } ); @@ -214,7 +134,7 @@ export const fetchProviderOrgs = createAsyncThunk( export const fetchReposForOrgFromProvider = createAsyncThunk( 'repos/fetchReposForOrgFromProvider', async ( - params: { orgId: ID; provider: Integration; orgName: string }, + params: { orgId: ID; providers: Integration[]; orgName: string }, { getState } ) => { const { @@ -225,7 +145,7 @@ export const fetchReposForOrgFromProvider = createAsyncThunk( `/internal/${params.orgId}/git_provider_org`, { params: { - provider: params.provider, + providers: params.providers, org_name: params.orgName, team_id: selectedTeam?.value } @@ -245,27 +165,3 @@ export const fetchSelectedRepos = createAsyncThunk( ); } ); - -export const updatedRepoAndWorkflowSelection = createAsyncThunk( - 'repos/updatedRepoAndWorkflowSelection', - async (params: { - orgId: ID; - provider: Integration; - workflowMap: RepoWorkflowMap; - selections: RepoSelectionMap; - onError: (e: Error) => any; - }) => { - return await handleApi(`/integrations/orgs`, { - method: 'PATCH', - data: { - org_id: params.orgId, - orgRepos: params.selections, - provider: params.provider, - repoWorkflows: reject(isNil, params.workflowMap) - } - }).catch((e) => { - params.onError(e); - throw e; - }); - } -); diff --git a/web-server/src/slices/team.ts b/web-server/src/slices/team.ts index 6fd6790d..36126449 100644 --- a/web-server/src/slices/team.ts +++ b/web-server/src/slices/team.ts @@ -135,7 +135,7 @@ export const teamSlice = createSlice({ export const fetchTeams = createAsyncThunk( 'teams/fetchTeams', - async (params: { org_id: ID; provider: Integration }) => { + async (params: { org_id: ID; providers?: Integration[] }) => { return await handleApi<{ teams: Team[]; teamReposMap: Record; @@ -167,15 +167,13 @@ export const createTeam = createAsyncThunk( org_id: ID; team_name: string; org_repos: Record; - provider: Integration; }) => { - const { org_id, team_name, org_repos, provider } = params; + const { org_id, team_name, org_repos } = params; return await handleApi(`/resources/orgs/${org_id}/teams/v2`, { method: 'POST', data: { name: team_name, - org_repos: org_repos, - provider: provider + org_repos: org_repos } }); } @@ -188,15 +186,13 @@ export const updateTeam = createAsyncThunk( team_name: string; org_id: ID; org_repos: Record; - provider: Integration; }) => { - const { team_id, team_name, org_id, org_repos, provider } = params; + const { team_id, team_name, org_id, org_repos } = params; return await handleApi(`/resources/orgs/${org_id}/teams/v2`, { method: 'PATCH', data: { name: team_name, org_repos: org_repos, - provider: provider, id: team_id } }); diff --git a/web-server/src/types/resources.ts b/web-server/src/types/resources.ts index 9987f7ea..294a370f 100644 --- a/web-server/src/types/resources.ts +++ b/web-server/src/types/resources.ts @@ -250,6 +250,7 @@ export type BaseRepo = { branch: string; deployment_type: DeploymentSources; repo_workflows: AdaptedRepoWorkflow[]; + provider?: Integration; }; export enum NotificationType { @@ -461,7 +462,12 @@ export type RepoWithMultipleWorkflows = Omit< export type RepoUniqueDetails = Pick< RepoWithMultipleWorkflows, - 'name' | 'slug' | 'default_branch' | 'idempotency_key' | 'deployment_type' + | 'name' + | 'slug' + | 'default_branch' + | 'idempotency_key' + | 'deployment_type' + | 'provider' > & { repo_workflows: AdaptedRepoWorkflow[] }; export type RepoContributors = { diff --git a/web-server/src/utils/auth.ts b/web-server/src/utils/auth.ts index 31f0504d..a3f97507 100644 --- a/web-server/src/utils/auth.ts +++ b/web-server/src/utils/auth.ts @@ -1,4 +1,5 @@ import axios from 'axios'; +import { isNil, reject } from 'ramda'; import { Integration } from '@/constants/integrations'; @@ -11,14 +12,21 @@ export const unlinkProvider = async (orgId: string, provider: Integration) => { export const linkProvider = async ( stuff: string, orgId: string, - provider: Integration + provider: Integration, + meta?: Record ) => { - return await axios.post(`/api/resources/orgs/${orgId}/integration`, { - provider, - the_good_stuff: stuff - }); + return await axios.post( + `/api/resources/orgs/${orgId}/integration`, + reject(isNil, { + provider, + the_good_stuff: stuff, + meta_data: meta + }) + ); }; +// GitHub functions + export async function checkGitHubValidity( good_stuff: string ): Promise { @@ -52,3 +60,28 @@ export const getMissingPATScopes = async (pat: string) => { throw new Error('Failed to get missing PAT scopes', error); } }; + +// Gitlab functions + +export const checkGitLabValidity = async (accessToken: string) => { + const url = 'https://gitlab.com/api/v4/personal_access_tokens/self'; + try { + const response = await axios.get(url, { + headers: { + 'PRIVATE-TOKEN': accessToken + } + }); + return response.data; + } catch (error) { + throw new Error('Invalid access token', error); + } +}; + +const GITLAB_SCOPES = ['api', 'read_api', 'read_user']; + +export const getMissingGitLabScopes = (scopes: string[]): string[] => { + const missingScopes = GITLAB_SCOPES.filter( + (scope) => !scopes.includes(scope) + ); + return missingScopes; +};