From afde7a4c75ce8e5d29866b27b24fec66cd596131 Mon Sep 17 00:00:00 2001 From: Yoan de LUCA Date: Thu, 25 Aug 2022 15:22:01 +0200 Subject: [PATCH] :sparkles: creation scalingo application from slack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Mickael Alibert Co-authored-by: Yoan De Luca Co-authored-by: François De Metz Co-authored-by: Pierre Top --- common/controllers/index.js | 8 + common/models/ScalingoAppName.js | 18 ++ common/services/scalingo-client.js | 22 +++ .../slack/surfaces/messages/post-message.js | 4 +- .../surfaces/user-infos/get-user-infos.js | 21 +++ config.js | 4 +- run/controllers/slack.js | 9 + run/manifest.js | 7 + run/services/slack/shortcuts.js | 19 +- .../application-creation-confirmation.js | 34 ++++ .../scalingo-apps/application-creation.js | 44 +++++ run/services/slack/view-submissions.js | 36 ++++ test/acceptance/run/manifest_test.js | 6 + test/acceptance/run/slack_test.js | 175 ++++++++++++++++++ .../common/models/ScalingoAppName_test.js | 70 +++++++ 15 files changed, 473 insertions(+), 4 deletions(-) create mode 100644 common/models/ScalingoAppName.js create mode 100644 common/services/slack/surfaces/user-infos/get-user-infos.js create mode 100644 run/services/slack/surfaces/modals/scalingo-apps/application-creation-confirmation.js create mode 100644 run/services/slack/surfaces/modals/scalingo-apps/application-creation.js create mode 100644 test/unit/common/models/ScalingoAppName_test.js diff --git a/common/controllers/index.js b/common/controllers/index.js index 415adf2e..58f76131 100644 --- a/common/controllers/index.js +++ b/common/controllers/index.js @@ -5,12 +5,18 @@ const { const { sampleView: releaseTagSelection, } = require('../../run/services/slack/surfaces/modals/deploy-release/release-tag-selection'); +const { + sampleView: createAppOnScalingoSelection, +} = require('../../run/services/slack/surfaces/modals/scalingo-apps/application-creation'); const { sampleView: releaseDeploymentConfirmation, } = require('../../run/services/slack/surfaces/modals/deploy-release/release-deployment-confirmation'); const { sampleView: releasePublicationConfirmation, } = require('../../build/services/slack/surfaces/modals/publish-release/release-publication-confirmation'); +const { + sampleView: submitApplicationNameSelection, +} = require('../../run/services/slack/surfaces/modals/scalingo-apps/application-creation-confirmation'); module.exports = { getApiInfo() { @@ -25,8 +31,10 @@ module.exports = { const views = [ { name: 'release-type-selection', view: releaseTypeSelection() }, { name: 'release-tag-selection', view: releaseTagSelection() }, + { name: 'create-app-on-scalingo', view: createAppOnScalingoSelection() }, { name: 'release-deployment-confirmation', view: releaseDeploymentConfirmation() }, { name: 'release-publication-confirmation', view: releasePublicationConfirmation() }, + { name: 'application-creation-confirmation', view: submitApplicationNameSelection() }, ]; return views .map(({ name, view }) => { diff --git a/common/models/ScalingoAppName.js b/common/models/ScalingoAppName.js new file mode 100644 index 00000000..43a7b67f --- /dev/null +++ b/common/models/ScalingoAppName.js @@ -0,0 +1,18 @@ +//TODO use EnvVars +const config = require('../../config'); +const alphanumericAndDashOnly = /^([a-zA-Z0-9]+-)+[a-zA-Z0-9]+$/; +const prefix = 'pix-'; +class ScalingoAppName { + static isApplicationNameValid(applicationName) { + const suffix = config.scalingo.validAppSuffix; + const appNameMatchesRegex = applicationName.search(alphanumericAndDashOnly) >= 0; + const appNameHasCorrectLength = applicationName.length >= 6 && applicationName.length <= 46; + const appNameStartsWithPix = applicationName.startsWith(prefix); + const appNameEndsWithCorrectSuffix = suffix.includes(applicationName.split('-').slice(-1)[0]); + return appNameMatchesRegex && appNameHasCorrectLength && appNameStartsWithPix && appNameEndsWithCorrectSuffix; + } +} + +module.exports = { + ScalingoAppName, +}; diff --git a/common/services/scalingo-client.js b/common/services/scalingo-client.js index fa46b5e7..f5205ea0 100644 --- a/common/services/scalingo-client.js +++ b/common/services/scalingo-client.js @@ -83,6 +83,28 @@ class ScalingoClient { throw err; } } + async inviteCollaborator(applicationId, collaboratorEmail) { + try { + const { invitation_link } = await this.client.Collaborators.invite(applicationId, collaboratorEmail); + return invitation_link; + } catch (e) { + console.error(JSON.stringify(e)); + throw new Error(`Impossible to invite ${collaboratorEmail} on ${applicationId}`); + } + } + + async createApplication(name) { + const app = { + name: name, + }; + try { + const { id } = await this.client.Apps.create(app); + return id; + } catch (e) { + console.error(JSON.stringify(e)); + throw new Error(`Impossible to create ${app.name}, ${e.name}`); + } + } } async function _isUrlReachable(url) { diff --git a/common/services/slack/surfaces/messages/post-message.js b/common/services/slack/surfaces/messages/post-message.js index a37dbb30..ddf2eb4a 100644 --- a/common/services/slack/surfaces/messages/post-message.js +++ b/common/services/slack/surfaces/messages/post-message.js @@ -2,7 +2,7 @@ const axios = require('axios'); const config = require('../../../../../config'); module.exports = { - async postMessage(message, attachments) { + async postMessage(message, attachments, channel = '#tech-releases') { const options = { method: 'POST', url: 'https://slack.com/api/chat.postMessage', @@ -11,7 +11,7 @@ module.exports = { authorization: `Bearer ${config.slack.botToken}`, }, data: { - channel: '#tech-releases', + channel: channel, text: message, attachments: attachments, }, diff --git a/common/services/slack/surfaces/user-infos/get-user-infos.js b/common/services/slack/surfaces/user-infos/get-user-infos.js new file mode 100644 index 00000000..b4a68090 --- /dev/null +++ b/common/services/slack/surfaces/user-infos/get-user-infos.js @@ -0,0 +1,21 @@ +const axios = require('axios'); +const config = require('../../../../../config'); + +module.exports = { + async getUserEmail(userId) { + const options = { + method: 'GET', + url: `https://slack.com/api/users.info?user=${userId}`, + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${config.slack.botToken}`, + }, + }; + const response = await axios(options); + if (!response.data.ok) { + console.error(response.data); + throw new Error('Slack error received'); + } + return response.data.user.profile.email; + }, +}; diff --git a/config.js b/config.js index 158720be..00e77d84 100644 --- a/config.js +++ b/config.js @@ -46,7 +46,9 @@ module.exports = (function() { production: { token: process.env.SCALINGO_TOKEN_PRODUCTION, apiUrl: process.env.SCALINGO_API_URL_PRODUCTION, - } + }, + validAppSuffix: _getJSON(process.env.SCALINGO_VALID_APP_SUFFIX) || + ["production","review","integration","recette","sandbox","dev","router","test"] }, openApi: { diff --git a/run/controllers/slack.js b/run/controllers/slack.js index fade740e..62f53b4a 100644 --- a/run/controllers/slack.js +++ b/run/controllers/slack.js @@ -116,14 +116,23 @@ module.exports = { if (payload.callback_id === 'deploy-release') { shortcuts.openViewDeployReleaseTagSelection(payload); } + if (payload.callback_id === 'scalingo-app-creation') { + shortcuts.openViewCreateAppOnScalingoSelection(payload); + } return null; case 'view_submission': if (payload.view.callback_id === shortcuts.openViewDeployReleaseTagSelectionCallbackId) { return viewSubmissions.submitReleaseTagSelection(payload); } + if (payload.view.callback_id === shortcuts.openViewCreateAppOnScalingoSelectionCallbackId) { + return viewSubmissions.submitApplicationNameSelection(payload); + } if (payload.view.callback_id === viewSubmissions.submitReleaseTagSelectionCallbackId) { return viewSubmissions.submitReleaseDeploymentConfirmation(payload); } + if (payload.view.callback_id === viewSubmissions.submitApplicationNameSelectionCallbackId) { + return viewSubmissions.submitCreateAppOnScalingoConfirmation(payload); + } return null; case 'view_closed': case 'block_actions': diff --git a/run/manifest.js b/run/manifest.js index 753d0fcc..d5bf4c4e 100644 --- a/run/manifest.js +++ b/run/manifest.js @@ -110,6 +110,13 @@ manifest.registerShortcut({ description: "Lance le déploiement d'une version sur l'environnement de production", }); +manifest.registerShortcut({ + name: 'Créer une application sur Scalingo', + type: 'global', + callback_id: 'scalingo-app-creation', + description: "Lance la création d'une application sur Scalingo", +}); + manifest.addInteractivity({ path: '/run/slack/interactive-endpoint', handler: slackbotController.interactiveEndpoint, diff --git a/run/services/slack/shortcuts.js b/run/services/slack/shortcuts.js index 970c8436..81f74310 100644 --- a/run/services/slack/shortcuts.js +++ b/run/services/slack/shortcuts.js @@ -1,14 +1,18 @@ const axios = require('axios'); const deployReleaseTagSelectionModal = require('./surfaces/modals/deploy-release/release-tag-selection'); +const createAppOnScalingoModal = require('./surfaces/modals/scalingo-apps/application-creation'); const config = require('../../../config'); +const openViewUrl = 'https://slack.com/api/views.open'; module.exports = { openViewDeployReleaseTagSelectionCallbackId: deployReleaseTagSelectionModal.callbackId, + openViewCreateAppOnScalingoSelectionCallbackId: createAppOnScalingoModal.callbackId, + openViewDeployReleaseTagSelection(payload) { const options = { method: 'POST', - url: 'https://slack.com/api/views.open', + url: openViewUrl, headers: { 'content-type': 'application/json', authorization: `Bearer ${config.slack.botToken}`, @@ -17,4 +21,17 @@ module.exports = { }; return axios(options); }, + + openViewCreateAppOnScalingoSelection(payload) { + const options = { + method: 'POST', + url: openViewUrl, + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${config.slack.botToken}`, + }, + data: createAppOnScalingoModal(payload.trigger_id), + }; + return axios(options); + }, }; diff --git a/run/services/slack/surfaces/modals/scalingo-apps/application-creation-confirmation.js b/run/services/slack/surfaces/modals/scalingo-apps/application-creation-confirmation.js new file mode 100644 index 00000000..897139eb --- /dev/null +++ b/run/services/slack/surfaces/modals/scalingo-apps/application-creation-confirmation.js @@ -0,0 +1,34 @@ +const { Modal, Blocks } = require('slack-block-builder'); + +const callbackId = 'application-creation-confirmation'; + +function modal(applicationName, applicationEnvironment, applicationEnvironmentName, userEmail) { + return Modal({ + title: 'Confirmation', + callbackId, + privateMetaData: JSON.stringify({ + applicationName: applicationName, + applicationEnvironment: applicationEnvironment, + userEmail: userEmail, + }), + submit: '🚀 Go !', + close: 'Annuler', + }).blocks([ + Blocks.Section({ + text: `Vous vous apprêtez à créer l'application *${applicationName}* dans la région : *${applicationEnvironmentName}* et à inviter cet adesse email en tant que collaborateur : *${userEmail}*`, + }), + ]); +} + +module.exports = (applicationName, applicationEnvironment, applicationEnvironmentName, userEmail) => { + return { + response_action: 'push', + view: modal(applicationName, applicationEnvironment, applicationEnvironmentName, userEmail).buildToObject(), + }; +}; + +module.exports.sampleView = () => { + return modal('pix-application-name-recette', 'recette', 'Paris - SecNumCloud - Outscale', 'john.doe@pix.fr'); +}; + +module.exports.callbackId = callbackId; diff --git a/run/services/slack/surfaces/modals/scalingo-apps/application-creation.js b/run/services/slack/surfaces/modals/scalingo-apps/application-creation.js new file mode 100644 index 00000000..7002c78b --- /dev/null +++ b/run/services/slack/surfaces/modals/scalingo-apps/application-creation.js @@ -0,0 +1,44 @@ +const { Modal, Blocks, Elements, Bits } = require('slack-block-builder'); +const regions = [ + { id: 'production', name: 'Paris - SecNumCloud - Outscale' }, + { id: 'recette', name: 'Paris - Outscale' }, +]; +const callbackId = 'application-name-selection'; + +function modal() { + return Modal({ + title: 'Créer une application', + callbackId, + submit: 'Créer', + close: 'Annuler', + }).blocks([ + Blocks.Input({ + blockId: 'create-app-name', + label: "Nom de l'application", + }).element( + Elements.TextInput({ + actionId: 'scalingo-app-name', + placeholder: 'application-name', + initialValue: 'pix-super-application-recette', + }) + ), + Blocks.Input({ blockId: 'application-env', label: 'Quelle région ?' }).element( + Elements.StaticSelect({ placeholder: 'Choisis la région' }) + .actionId('item') + .options(regions.map((item) => Bits.Option({ text: item.name, value: item.id }))) + ), + ]); +} + +module.exports = (triggerId) => { + return { + trigger_id: triggerId, + view: modal(regions).buildToObject(), + }; +}; + +module.exports.sampleView = () => { + return modal(regions); +}; + +module.exports.callbackId = callbackId; diff --git a/run/services/slack/view-submissions.js b/run/services/slack/view-submissions.js index 931e95bf..4cf9d4a3 100644 --- a/run/services/slack/view-submissions.js +++ b/run/services/slack/view-submissions.js @@ -1,7 +1,11 @@ const openModalReleaseDeploymentConfirmation = require('./surfaces/modals/deploy-release/release-deployment-confirmation'); +const openModalApplicationCreationConfirmation = require('./surfaces/modals/scalingo-apps/application-creation-confirmation'); const { environments, deploy } = require('../../../common/services/releases'); const githubService = require('../../../common/services/github'); const slackPostMessageService = require('../../../common/services/slack/surfaces/messages/post-message'); +const slackGetUserInfos = require('../../../common/services/slack/surfaces/user-infos/get-user-infos'); +const ScalingoClient = require('../../../common/services/scalingo-client'); +const { ScalingoAppName } = require('../../../common/models/ScalingoAppName'); module.exports = { async submitReleaseTagSelection(payload) { @@ -10,8 +14,27 @@ module.exports = { return openModalReleaseDeploymentConfirmation(releaseTag, hasConfigFileChanged); }, + async submitApplicationNameSelection(payload) { + const applicationName = payload.view.state.values['create-app-name']['scalingo-app-name'].value; + const applicationEnvironment = payload.view.state.values['application-env']['item']['selected_option'].value; + const applicationEnvironmentName = + payload.view.state.values['application-env']['item']['selected_option']['text']['text']; + const userEmail = await slackGetUserInfos.getUserEmail(payload.user.id); + if (!ScalingoAppName.isApplicationNameValid(applicationName)) { + return `${applicationName} is incorrect`; + } + return openModalApplicationCreationConfirmation( + applicationName, + applicationEnvironment, + applicationEnvironmentName, + userEmail + ); + }, + submitReleaseTagSelectionCallbackId: openModalReleaseDeploymentConfirmation.callbackId, + submitApplicationNameSelectionCallbackId: openModalApplicationCreationConfirmation.callbackId, + submitReleaseDeploymentConfirmation(payload) { const releaseTag = payload.view.private_metadata; if (!githubService.isBuildStatusOK({ tagName: releaseTag.trim().toLowerCase() })) { @@ -23,4 +46,17 @@ module.exports = { response_action: 'clear', }; }, + + async submitCreateAppOnScalingoConfirmation(payload) { + const { applicationName, applicationEnvironment, userEmail } = JSON.parse(payload.view.private_metadata); + const client = await ScalingoClient.getInstance(applicationEnvironment); + const appId = await client.createApplication(applicationName); + const invitationLink = await client.inviteCollaborator(appId, userEmail); + const message = `app ${applicationName} created <${invitationLink}|invitation link>`; + const channel = `@${payload.user.id}`; + slackPostMessageService.postMessage(message, null, channel); + return { + response_action: 'clear', + }; + }, }; diff --git a/test/acceptance/run/manifest_test.js b/test/acceptance/run/manifest_test.js index 70553ccc..c5f74f2b 100644 --- a/test/acceptance/run/manifest_test.js +++ b/test/acceptance/run/manifest_test.js @@ -26,6 +26,12 @@ describe('Acceptance | Run | Manifest', function () { callback_id: 'deploy-release', description: "Lance le déploiement d'une version sur l'environnement de production", }, + { + name: 'Créer une application sur Scalingo', + type: 'global', + callback_id: 'scalingo-app-creation', + description: "Lance la création d'une application sur Scalingo", + }, ], slash_commands: [ { diff --git a/test/acceptance/run/slack_test.js b/test/acceptance/run/slack_test.js index 9bdec55f..41d04749 100644 --- a/test/acceptance/run/slack_test.js +++ b/test/acceptance/run/slack_test.js @@ -233,5 +233,180 @@ describe('Acceptance | Run | Slack', function () { }); }); }); + + describe('when using the shortcut scalingo-app-creation', function () { + it('calls slack with the create app modal', async function () { + const slackCall = nock('https://slack.com') + .post('/api/views.open', { + trigger_id: 'payload id', + view: { + title: { + type: 'plain_text', + text: 'Créer une application', + }, + submit: { + type: 'plain_text', + text: 'Créer', + }, + callback_id: 'application-name-selection', + close: { + type: 'plain_text', + text: 'Annuler', + }, + blocks: [ + { + block_id: 'create-app-name', + label: { + type: 'plain_text', + text: "Nom de l'application", + }, + element: { + action_id: 'scalingo-app-name', + placeholder: { + type: 'plain_text', + text: 'application-name', + }, + initial_value: 'pix-super-application-recette', + type: 'plain_text_input', + }, + type: 'input', + }, + { + block_id: 'application-env', + label: { + type: 'plain_text', + text: 'Quelle région ?', + }, + element: { + placeholder: { + type: 'plain_text', + text: 'Choisis la région', + }, + action_id: 'item', + options: [ + { + text: { + type: 'plain_text', + text: 'Paris - SecNumCloud - Outscale', + }, + value: 'production', + }, + { + text: { + type: 'plain_text', + text: 'Paris - Outscale', + }, + value: 'recette', + }, + ], + type: 'static_select', + }, + type: 'input', + }, + ], + type: 'modal', + }, + }) + .reply(200); + const body = { + type: 'shortcut', + callback_id: 'scalingo-app-creation', + trigger_id: 'payload id', + }; + // when + const res = await server.inject({ + method: 'POST', + url: '/run/slack/interactive-endpoint', + headers: createSlackWebhookSignatureHeaders(JSON.stringify(body)), + payload: body, + }); + // then + expect(res.statusCode).to.equal(204); + expect(slackCall.isDone()).to.be.true; + }); + + describe('with the callback application-name-selection', function () { + it('returns the confirmation modal', async function () { + const slackBody = { + ok: true, + user: { + profile: { + email: 'john.doe@pix.fr', + }, + }, + }; + const slackCall = nock('https://slack.com').get('/api/users.info?user=xxxxxx').reply(200, slackBody); + const body = { + type: 'view_submission', + user: { + id: 'xxxxxx', + }, + view: { + type: 'modal', + private_metadata: '', + callback_id: 'application-name-selection', + state: { + values: { + 'create-app-name': { + 'scalingo-app-name': { + value: 'pix-application-de-folie-recette', + }, + }, + 'application-env': { + item: { + selected_option: { + text: { + text: 'recette', + }, + value: 'recette', + }, + }, + }, + }, + }, + }, + }; + const res = await server.inject({ + method: 'POST', + url: '/run/slack/interactive-endpoint', + headers: createSlackWebhookSignatureHeaders(JSON.stringify(body)), + payload: body, + }); + + expect(slackCall.isDone()).to.be.true; + expect(res.statusCode).to.equal(200); + expect(JSON.parse(res.payload)).to.deep.equal({ + response_action: 'push', + view: { + title: { + type: 'plain_text', + text: 'Confirmation', + }, + submit: { + type: 'plain_text', + text: '🚀 Go !', + }, + callback_id: 'application-creation-confirmation', + private_metadata: + '{"applicationName":"pix-application-de-folie-recette","applicationEnvironment":"recette","userEmail":"john.doe@pix.fr"}', + close: { + type: 'plain_text', + text: 'Annuler', + }, + blocks: [ + { + text: { + type: 'mrkdwn', + text: "Vous vous apprêtez à créer l'application *pix-application-de-folie-recette* dans la région : *recette* et à inviter cet adesse email en tant que collaborateur : *john.doe@pix.fr*", + }, + type: 'section', + }, + ], + type: 'modal', + }, + }); + }); + }); + }); }); }); diff --git a/test/unit/common/models/ScalingoAppName_test.js b/test/unit/common/models/ScalingoAppName_test.js new file mode 100644 index 00000000..0ded60de --- /dev/null +++ b/test/unit/common/models/ScalingoAppName_test.js @@ -0,0 +1,70 @@ +const { expect } = require('../../../test-helper'); + +const { ScalingoAppName } = require('../../../../common/models/ScalingoAppName'); + +describe('Unit | Common | Models | ScalingoAppName', function () { + describe('#isApplicationNameValid', function () { + it('should return false if appName contains non alphanum or dash char', function () { + // given + const invalidAppName = 'pix#-application-nameat-production'; + + // when + const resultInvalidAppName = ScalingoAppName.isApplicationNameValid(invalidAppName); + console.log(resultInvalidAppName); + // then + expect(resultInvalidAppName, 'Invalid app name').to.be.false; + }); + + it('should return false if appName does not start with pix', function () { + // given + const doesNotStartWithPixAppName = 'app-name-format'; + + // when + const resultDoesNotStartWithPixAppName = ScalingoAppName.isApplicationNameValid(doesNotStartWithPixAppName); + + // then + expect(resultDoesNotStartWithPixAppName, 'Does not start with pix app name').to.be.false; + }); + + it('should return false if appName is too short', function () { + // given + const tooShortAppName = 'pix-a'; + + // when + const resultTooShortAppName = ScalingoAppName.isApplicationNameValid(tooShortAppName); + + // then + expect(resultTooShortAppName, 'Too short app name').to.be.false; + }); + it('should return false if appName is too long', function () { + // given + const tooLongAppName = 'pix-application-with-a-long-name-that-does-not-fit-production'; + + // when + const resultTooLongAppName = ScalingoAppName.isApplicationNameValid(tooLongAppName); + + // then + expect(resultTooLongAppName, 'Too long app name').to.be.false; + }); + it('should return false if appName end with incorrect suffix', function () { + // given + const incorrectSuffixAppName = 'pix-coucou-app-name-mauvaissuffix'; + + // when + const resultIncorrectSuffixAppName = ScalingoAppName.isApplicationNameValid(incorrectSuffixAppName); + + // then + expect(resultIncorrectSuffixAppName, 'Incorrect suffix app name').to.be.false; + }); + it('should return true if parameter is a valid appName', function () { + // given + const validAppName = 'pix-super-application-recette'; + + // when + const result = ScalingoAppName.isApplicationNameValid(validAppName); + + // then + expect(result).to.be.true; + }); + }); +});