diff --git a/backend/src/api/integration/helpers/getGithubInstallations.ts b/backend/src/api/integration/helpers/getGithubInstallations.ts new file mode 100644 index 000000000..08448c107 --- /dev/null +++ b/backend/src/api/integration/helpers/getGithubInstallations.ts @@ -0,0 +1,9 @@ +import Permissions from '../../../security/permissions' +import IntegrationService from '../../../services/integrationService' +import PermissionChecker from '../../../services/user/permissionChecker' + +export default async (req, res) => { + new PermissionChecker(req).validateHas(Permissions.values.tenantEdit) + const payload = await new IntegrationService(req).getGithubInstallations() + await req.responseHandler.success(req, res, payload) +} diff --git a/backend/src/api/integration/helpers/githubConnectInstallation.ts b/backend/src/api/integration/helpers/githubConnectInstallation.ts new file mode 100644 index 000000000..d7da9b0f4 --- /dev/null +++ b/backend/src/api/integration/helpers/githubConnectInstallation.ts @@ -0,0 +1,9 @@ +import Permissions from '../../../security/permissions' +import IntegrationService from '../../../services/integrationService' +import PermissionChecker from '../../../services/user/permissionChecker' + +export default async (req, res) => { + new PermissionChecker(req).validateHas(Permissions.values.tenantEdit) + const payload = await new IntegrationService(req).connectGithubInstallation(req.body.installId) + await req.responseHandler.success(req, res, payload) +} diff --git a/backend/src/api/integration/index.ts b/backend/src/api/integration/index.ts index e90faedeb..35541a2d2 100644 --- a/backend/src/api/integration/index.ts +++ b/backend/src/api/integration/index.ts @@ -189,6 +189,16 @@ export default (app) => { safeWrap(require('./helpers/jiraConnectOrUpdate').default), ) + app.get( + '/tenant/:tenantId/github-installations', + safeWrap(require('./helpers/getGithubInstallations').default), + ) + + app.post( + '/tenant/:tenantId/github-connect-installation', + safeWrap(require('./helpers/githubConnectInstallation').default), + ) + app.get('/gitlab/:tenantId/connect', safeWrap(require('./helpers/gitlabAuthenticate').default)) app.get( diff --git a/backend/src/database/migrations/U1725439490__installed-github-orgs.sql b/backend/src/database/migrations/U1725439490__installed-github-orgs.sql new file mode 100644 index 000000000..e69de29bb diff --git a/backend/src/database/migrations/V1725439490__installed-github-orgs.sql b/backend/src/database/migrations/V1725439490__installed-github-orgs.sql new file mode 100644 index 000000000..c8d963932 --- /dev/null +++ b/backend/src/database/migrations/V1725439490__installed-github-orgs.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS "githubInstallations" ( + "id" UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + "installationId" VARCHAR(255) NOT NULL UNIQUE, + "type" VARCHAR(255) NOT NULL, + "numRepos" INTEGER NOT NULL, + "login" VARCHAR(255) NOT NULL, + "avatarUrl" TEXT, + "createdAt" TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); diff --git a/backend/src/database/repositories/githubInstallationsRepository.ts b/backend/src/database/repositories/githubInstallationsRepository.ts new file mode 100644 index 000000000..c2f27cebd --- /dev/null +++ b/backend/src/database/repositories/githubInstallationsRepository.ts @@ -0,0 +1,20 @@ +import { QueryTypes } from 'sequelize' +import { IRepositoryOptions } from './IRepositoryOptions' +import SequelizeRepository from './sequelizeRepository' + +export default class GithubInstallationsRepository { + static async getInstallations(options: IRepositoryOptions) { + const transaction = SequelizeRepository.getTransaction(options) + const seq = SequelizeRepository.getSequelize(options) + + return seq.query( + ` + select * from "githubInstallations" + `, + { + transaction, + type: QueryTypes.SELECT, + }, + ) + } +} diff --git a/backend/src/services/integrationService.ts b/backend/src/services/integrationService.ts index 79533752f..028afd1fb 100644 --- a/backend/src/services/integrationService.ts +++ b/backend/src/services/integrationService.ts @@ -63,6 +63,7 @@ import SearchSyncService from './searchSyncService' import { IRepositoryOptions } from '@/database/repositories/IRepositoryOptions' import IntegrationProgressRepository from '@/database/repositories/integrationProgressRepository' import { IntegrationProgress } from '@/serverless/integrations/types/regularTypes' +import GithubInstallationsRepository from '@/database/repositories/githubInstallationsRepository' import { fetchGitlabUserProjects, fetchGitlabGroupProjects, @@ -433,6 +434,39 @@ export default class IntegrationService { return integration } + async connectGithubInstallation(installId: string) { + const installToken = await IntegrationService.getInstallToken(installId) + const repos = await getInstalledRepositories(installToken) + const githubOwner = IntegrationService.extractOwner(repos, this.options) + + let orgAvatar + try { + const response = await request('GET /users/{user}', { + user: githubOwner, + }) + orgAvatar = response.data.avatar_url + } catch (err) { + this.options.log.warn(err, 'Error while fetching GitHub user!') + } + + const integration = await this.createOrUpdateGithubIntegration( + { + platform: PlatformType.GITHUB, + token: installToken, + settings: { updateMemberAttributes: true, orgAvatar }, + integrationIdentifier: installId, + status: 'mapping', + }, + repos, + ) + + return integration + } + + async getGithubInstallations() { + return GithubInstallationsRepository.getInstallations(this.options) + } + /** * Creates or updates a GitHub integration, handling large repos data * @param integrationData The integration data to create or update diff --git a/frontend/src/integrations/github/components/github-connect-modal.vue b/frontend/src/integrations/github/components/github-connect-modal.vue new file mode 100644 index 000000000..f08fd42ad --- /dev/null +++ b/frontend/src/integrations/github/components/github-connect-modal.vue @@ -0,0 +1,221 @@ + + + + + diff --git a/frontend/src/integrations/github/components/github-connect.vue b/frontend/src/integrations/github/components/github-connect.vue index 1bd0bbef4..87b0a1cd4 100644 --- a/frontend/src/integrations/github/components/github-connect.vue +++ b/frontend/src/integrations/github/components/github-connect.vue @@ -1,11 +1,15 @@ diff --git a/frontend/src/modules/integration/components/integration-list.vue b/frontend/src/modules/integration/components/integration-list.vue index f16f3174b..dc4e510e1 100644 --- a/frontend/src/modules/integration/components/integration-list.vue +++ b/frontend/src/modules/integration/components/integration-list.vue @@ -137,6 +137,12 @@ onMounted(async () => { }); showGitlabDialog.value = false; } else { + const state = params.get('state'); + + if (state === 'noconnect') { + return; + } + showGithubDialog.value = true; await store.dispatch('integration/doGithubConnect', { code, diff --git a/frontend/src/modules/integration/integration-service.js b/frontend/src/modules/integration/integration-service.js index 1dc05c4bc..0a2f18312 100644 --- a/frontend/src/modules/integration/integration-service.js +++ b/frontend/src/modules/integration/integration-service.js @@ -519,6 +519,29 @@ export class IntegrationService { return response.data; } + static async githubConnectInstallation(installId) { + const tenantId = AuthService.getTenantId(); + + const response = await authAxios.post( + `/tenant/${tenantId}/github-connect-installation`, + { + installId, + }, + ); + + return response.data; + } + + static async getGithubInstallations() { + const tenantId = AuthService.getTenantId(); + + const response = await authAxios.get( + `/tenant/${tenantId}/github-installations`, + ); + + return response.data; + } + static async gitlabConnect(code, state) { const tenantId = AuthService.getTenantId(); const response = await authAxios.get(`/gitlab/${tenantId}/callback`, { diff --git a/services/apps/webhook_api/src/routes/github.ts b/services/apps/webhook_api/src/routes/github.ts index cc276efe0..22839d25b 100644 --- a/services/apps/webhook_api/src/routes/github.ts +++ b/services/apps/webhook_api/src/routes/github.ts @@ -26,8 +26,26 @@ export const installGithubRoutes = async (app: express.Express) => { throw new Error400BadRequest('Missing installation id!') } const identifier = data.installation.id.toString() - // load integration from database to verify that it exists const repo = new WebhooksRepository(req.dbStore, req.log) + + if (event === 'installation' && data.action === 'created') { + await repo.addGithubInstallation( + identifier, + data.installation.account.type, + data.installation.account.login, + data.installation.account.avatar_url, + data.repositories.length, + ) + res.sendStatus(204) + return + } + if (event === 'installation' && data.action === 'deleted') { + await repo.deleteGithubInstallation(identifier) + res.sendStatus(204) + return + } + + // load integration from database to verify that it exists const integration = await repo.findIntegrationByIdentifier(PlatformType.GITHUB, identifier) if (integration) { diff --git a/services/libs/data-access-layer/src/old/apps/webhook_api/webhooks.repo.ts b/services/libs/data-access-layer/src/old/apps/webhook_api/webhooks.repo.ts index e0d8c3c50..ff9f883a6 100644 --- a/services/libs/data-access-layer/src/old/apps/webhook_api/webhooks.repo.ts +++ b/services/libs/data-access-layer/src/old/apps/webhook_api/webhooks.repo.ts @@ -94,6 +94,45 @@ export class WebhooksRepository extends RepositoryBase { return result } + public async addGithubInstallation( + installationId: string, + type: string, + login: string, + avatarUrl: string | null, + numRepos: number, + ): Promise { + await this.db().none( + ` + INSERT INTO "githubInstallations" ("installationId", "type", "login", "avatarUrl", "numRepos") + VALUES ($(installationId), $(type), $(login), $(avatarUrl), $(numRepos)) + ON CONFLICT ("installationId") DO UPDATE + SET "type" = EXCLUDED."type", + "login" = EXCLUDED."login", + "avatarUrl" = EXCLUDED."avatarUrl", + "numRepos" = EXCLUDED."numRepos", + "updatedAt" = CURRENT_TIMESTAMP + `, + { + installationId, + type, + login, + avatarUrl, + numRepos, + }, + ) + } + + public async deleteGithubInstallation(installationId: string): Promise { + await this.db().none( + ` + DELETE FROM "githubInstallations" WHERE "installationId" = $(installationId) + `, + { + installationId, + }, + ) + } + public async findIntegrationById(id: string): Promise { const result = await this.db().oneOrNone( `