From 2dd0b37d85f1208c3b400c5cfe36ef9b268ee2bb Mon Sep 17 00:00:00 2001 From: Nicolas Lepage <19571875+nlepage@users.noreply.github.com> Date: Tue, 22 Oct 2024 15:13:24 +0200 Subject: [PATCH] feat: listen to issue_comment webhook to deploy front apps --- build/controllers/github.js | 50 ++++++++++ common/services/github.js | 6 ++ common/services/scalingo-client.js | 7 ++ test/acceptance/build/github_test.js | 93 ++++++++++++++++++- test/unit/build/services/github_test.js | 23 +++++ .../common/services/scalingo-client_test.js | 32 +++++++ 6 files changed, 210 insertions(+), 1 deletion(-) diff --git a/build/controllers/github.js b/build/controllers/github.js index b0c0b971..b23555fe 100644 --- a/build/controllers/github.js +++ b/build/controllers/github.js @@ -23,6 +23,10 @@ const repositoryToScalingoAppsReview = { pix4pix: ['pix-4pix-front-review', 'pix-4pix-api-review'], }; +const repositoryToScalingoOnDemandAppsReview = { + pix: ['pix-front-review'], +}; + const __dirname = url.fileURLToPath(new URL('.', import.meta.url)); function getMessageTemplate(repositoryName) { @@ -139,6 +143,49 @@ async function _handleCloseRA(request, scalingoClient = ScalingoClient) { return `Closed RA for PR ${prId} : ${result.join(', ')}.`; } +async function _handleIssueComment(request, scalingoClient = ScalingoClient, githubService = commonGithubService) { + const payload = request.payload; + const repo = payload.repository.name; + const owner = payload.repository.owner.login; + const reviewApps = repositoryToScalingoOnDemandAppsReview[repo]; + const pull_number = payload.issue.number; + + if (!reviewApps) { + return `${repo} is not managed by Pix Bot nor on-demand review app.`; + } + let client; + + try { + client = await scalingoClient.getInstance('reviewApps'); + } catch (error) { + throw new Error(`Scalingo auth APIError: ${error.message}`); + } + + const reviewAppName = `${reviewApps[0]}-pr${pull_number}`; + + const reviewAppExists = await client.reviewAppExists(reviewAppName); + if (!reviewAppExists) { + return `Review app ${reviewAppName} does not exist.`; + } + + const selectedApps = Array.from(payload.comment.body.matchAll(/^- \[[xX]\].+$/gm), ([, app]) => app); + + if (selectedApps.length === 0) { + return `No front apps selected for review app ${reviewAppName}.`; + } + + await client.bulkUpdateEnvVar(reviewAppName, { + CI_FRONT_TASKS: selectedApps.map((app) => `ci:${app}`).join(' '), + BUILD_FRONT_TASKS: selectedApps.map((app) => `build:${app}`).join(' '), + }); + + const branchName = await githubService.getPullRequestBranchName({ repo, owner, pull_number }); + + await client.deployUsingSCM(reviewAppName, branchName); + + return 'ok'; +} + async function deployPullRequest( scalingoClient, reviewApps, @@ -238,6 +285,7 @@ async function processWebhook( pushOnDefaultBranchWebhook = _pushOnDefaultBranchWebhook, handleRA = _handleRA, handleCloseRA = _handleCloseRA, + handleIssueComment = _handleIssueComment, } = {}, ) { const eventName = request.headers['x-github-event']; @@ -251,6 +299,8 @@ async function processWebhook( return handleCloseRA(request); } return `Ignoring ${request.payload.action} action`; + } else if (eventName === 'issue_comment') { + return handleIssueComment(request); } else { return `Ignoring ${eventName} event`; } diff --git a/common/services/github.js b/common/services/github.js index 4203712c..975c615a 100644 --- a/common/services/github.js +++ b/common/services/github.js @@ -458,6 +458,12 @@ const github = { }, commentPullRequest, + + async getPullRequestBranchName({ owner, repo, pull_number }) { + const { pulls } = _createOctokit(); + const pull = await pulls.get({ owner, repo, pull_number }); + return pull.data.head.ref; + }, }; export default github; diff --git a/common/services/scalingo-client.js b/common/services/scalingo-client.js index b4c03cbe..e9fd92f5 100644 --- a/common/services/scalingo-client.js +++ b/common/services/scalingo-client.js @@ -180,6 +180,13 @@ class ScalingoClient { logger.error(err); } } + + async bulkUpdateEnvVar(appName, variables) { + await this.client.Environment.bulkUpdate( + appName, + Object.entries(variables).map(([name, value]) => ({ name, value })), + ); + } } async function _isUrlReachable(url) { diff --git a/test/acceptance/build/github_test.js b/test/acceptance/build/github_test.js index a641dd20..02995a5e 100644 --- a/test/acceptance/build/github_test.js +++ b/test/acceptance/build/github_test.js @@ -39,11 +39,19 @@ describe('Acceptance | Build | Github', function () { } function deleteReviewAppNock({ reviewAppName, returnCode = StatusCodes.NO_CONTENT }) { - nock('https://scalingo.reviewApps') + return nock('https://scalingo.reviewApps') .delete(`/v1/apps/${reviewAppName}?current_name=${reviewAppName}`) .reply(returnCode); } + function bulkUpdateEnvVarNock({ reviewAppName, variables = {}, returnCode = StatusCodes.OK }) { + return nock('https://scalingo.reviewApps') + .put(`/v1/apps/${reviewAppName}/variables`, { + variables: Object.entries(variables).map(([name, value]) => ({ name, value })), + }) + .reply(returnCode); + } + let body; ['opened', 'reopened'].forEach((action) => { @@ -726,6 +734,89 @@ describe('Acceptance | Build | Github', function () { }); }); + describe('on issue_comment event', function () { + describe('when user has checked some apps', function () { + it('should deploy the corresponding review apps', async function () { + const scalingoAuth = nock('https://auth.scalingo.com').post('/v1/tokens/exchange').reply(StatusCodes.OK); + + const scalingoRAExists = getAppNock({ reviewAppName: 'pix-front-review-pr2' }); + + const scalingoBulkUpdateEnvVar = bulkUpdateEnvVarNock({ + reviewAppName: 'pix-front-review-pr2', + variables: { + CI_FRONT_TASKS: 'ci:mon-pix ci:certif', + BUILD_FRONT_TASKS: 'build:mon-pix build:certif', + }, + }); + + const githubPull = nock('https://api.github.com') + .get('/repos/1024pix/pix/pulls/2') + .reply(200, { + number: 2, + head: { + ref: 'pix-12345-graphql-api', + }, + }); + + const scalingoManualDeploy = getManualDeployNock({ + reviewAppName: 'pix-front-review-pr2', + branch: 'pix-12345-graphql-api', + }); + + body = { + action: 'edited', + comment: { + body: `Une fois les applications déployées, elles seront accessibles via les liens suivants : +- [API](https://api-pr2.review.pix.fr/api/) +- [Audit Logger](https://pix-audit-logger-review-pr2.osc-fr1.scalingo.io/api/) +- [x] [App (.fr)](https://app-pr2.review.pix.fr) / [App (.org)](https://app-pr2.review.pix.org) +- [ ] [Orga (.fr)](https://orga-pr2.review.pix.fr) / [Orga (.org)](https://orga-pr2.review.pix.org) +- [x] [Certif (.fr)](https://certif-pr2.review.pix.fr) / [Certif (.org)](https://certif-pr2.review.pix.org) +- [ ] [Junior](https://junior-pr2.review.pix.fr) +- [ ] [Admin](https://admin-pr2.review.pix.fr) + +Les variables d'environnement seront accessibles via les liens suivants : + * [scalingo front](https://dashboard.scalingo.com/apps/osc-fr1/pix-front-review-pr2/environment) + * [scalingo api](https://dashboard.scalingo.com/apps/osc-fr1/pix-api-review-pr2/environment) + * [scalingo audit-logger](https://dashboard.scalingo.com/apps/osc-fr1/pix-audit-logger-review-pr2/environment) +`, + user: { + login: 'pix-bot-github', + }, + }, + issue: { + number: 2, + }, + repository: { + name: 'pix', + full_name: '1024pix/pix', + owner: { + login: '1024pix', + }, + fork: false, + organization: '1024pix', + }, + }; + + const res = await server.inject({ + method: 'POST', + url: '/github/webhook', + headers: { + ...createGithubWebhookSignatureHeader(JSON.stringify(body)), + 'x-github-event': 'issue_comment', + }, + payload: body, + }); + expect(res.statusCode).to.equal(StatusCodes.OK); + expect(scalingoRAExists.isDone()).to.be.true; + expect(scalingoAuth.isDone()).to.be.true; + expect(scalingoBulkUpdateEnvVar.isDone()).to.be.true; + expect(githubPull.isDone()).to.be.true; + expect(scalingoManualDeploy.isDone()).to.be.true; + }); + }); + }); + it('responds with 200 and do nothing for other event', async function () { body = {}; const res = await server.inject({ diff --git a/test/unit/build/services/github_test.js b/test/unit/build/services/github_test.js index 7509d888..bf371d3d 100644 --- a/test/unit/build/services/github_test.js +++ b/test/unit/build/services/github_test.js @@ -603,4 +603,27 @@ describe('Unit | Build | github-test', function () { }); }); }); + + describe('#getPullRequestBranchName', function () { + it('should retrieve branch name for pull request id', async function () { + // given + nock('https://api.github.com') + .get('/repos/toto/lasticot/pulls/666') + .reply(200, { + number: 666, + head: { + ref: 'pix-12345-feature-bug', + }, + }); + const owner = 'toto'; + const repo = 'lasticot'; + const pull_number = 666; + + // when + const branchName = await githubService.getPullRequestBranchName({ owner, repo, pull_number }); + + // then + expect(branchName).to.equal('pix-12345-feature-bug'); + }); + }); }); diff --git a/test/unit/common/services/scalingo-client_test.js b/test/unit/common/services/scalingo-client_test.js index 9b02b224..7aad864e 100644 --- a/test/unit/common/services/scalingo-client_test.js +++ b/test/unit/common/services/scalingo-client_test.js @@ -848,4 +848,36 @@ describe('Scalingo client', function () { expect(clientAppsDestroy).to.have.been.calledWithExactly(appName, appName); }); }); + + describe('#Scalingo.bulkUpdateEnvVar', function () { + let scalingoClient; + let bulkUpdateStub; + + beforeEach(async function () { + bulkUpdateStub = sinon.stub(); + const clientStub = { + clientFromToken: async function () { + return { + Environment: { bulkUpdate: bulkUpdateStub }, + }; + }, + }; + scalingoClient = await ScalingoClient.getInstance('production', clientStub); + }); + + it('should update several environment variables', async function () { + const appId = 'pix-front-review-pr2'; + const variables = { + FOO: 'foo', + BAR: 'bar', + }; + + await scalingoClient.bulkUpdateEnvVar(appId, variables); + + expect(bulkUpdateStub).to.have.been.calledWithExactly(appId, [ + { name: 'FOO', value: 'foo' }, + { name: 'BAR', value: 'bar' }, + ]); + }); + }); });