From e55338fc135f6c627cb1b68d03ffcbbe2db83687 Mon Sep 17 00:00:00 2001 From: Josselin Buils Date: Thu, 10 Aug 2023 15:05:02 +0200 Subject: [PATCH] feat(release): add release command --- README.md | 1 + __tests__/__fixtures__/deploymentFixture.ts | 79 ++ .../__fixtures__/dockerBuildJobFixture.ts | 35 + .../hooks/deploymentHookFixture.ts | 45 + __tests__/__fixtures__/jobFixture.ts | 39 + __tests__/__fixtures__/releaseFixture.ts | 92 ++ __tests__/release/createRelease.test.ts | 797 ++++++++++++++++++ __tests__/release/endRelease.test.ts | 133 +++ config/homer/projectReleaseConfigs.ts | 15 + .../requestHandlers/commandRequestHandler.ts | 4 + src/core/requestHandlers/gitlabHookHandler.ts | 4 + .../blockActionsRequestHandler.ts | 4 + .../viewSubmissionRequestHandler.ts | 5 +- .../stateRequestHandler/State.tsx | 9 +- .../stateRequestHandler.tsx | 9 +- src/core/services/data.ts | 146 +++- src/core/services/gitlab.ts | 69 ++ src/core/typings/Data.ts | 28 + src/core/typings/GitlabDeployment.ts | 69 ++ src/core/typings/GitlabDeploymentHook.ts | 40 + src/core/typings/GitlabRelease.ts | 60 ++ .../cancel/cancelReleaseRequestHandler.ts | 47 ++ .../commands/cancel/selectReleaseToCancel.ts | 90 ++ .../create/createReleaseRequestHandler.ts | 22 + .../hookHandlers/deploymentHookHandler.ts | 128 +++ .../semanticReleaseTagManager.test.ts | 45 + .../create/managers/defaultReleaseManager.ts | 98 +++ .../managers/federationReleaseTagManager.ts | 29 + .../create/managers/libraryReleaseManager.ts | 29 + .../managers/semanticReleaseTagManager.ts | 22 + .../managers/stableDateReleaseTagManager.ts | 21 + .../create/utils/addLoaderToReleaseModal.ts | 28 + .../commands/create/utils/createRelease.ts | 80 ++ .../create/utils/getBranchLastPipeline.ts | 19 + .../create/utils/slackifyChangelog.ts | 19 + .../commands/create/utils/startRelease.ts | 76 ++ .../create/utils/updateReleaseChangelog.ts | 26 + .../create/utils/updateReleaseProject.ts | 24 + .../create/utils/waitForNonReadyReleases.ts | 12 + .../utils/waitForReadinessAndStartRelease.ts | 138 +++ .../create/utils/waitForReleasePipeline.ts | 35 + .../viewBuilders/buildReleaseModalView.ts | 245 ++++++ .../viewBuilders/buildReleaseStateMessage.ts | 93 ++ .../commands/end/endReleaseRequestHandler.ts | 47 ++ .../commands/end/selectReleaseToEnd.ts | 61 ++ src/release/releaseBlockActionsHandler.ts | 51 ++ src/release/releaseHookHandler.ts | 18 + src/release/releaseRequestHandler.ts | 37 + src/release/releaseViewSubmissionHandler.ts | 38 + src/release/typings/ProjectReleaseConfig.ts | 11 + src/release/typings/ReleaseManager.ts | 43 + src/release/typings/ReleaseState.ts | 1 + src/release/typings/ReleaseStateUpdate.ts | 17 + src/release/typings/ReleaseTagManager.ts | 4 + src/release/utils/configHelper.ts | 34 + .../buildReleaseSelectionEphemeral.ts | 73 ++ src/start.ts | 2 + 57 files changed, 3440 insertions(+), 6 deletions(-) create mode 100644 __tests__/__fixtures__/deploymentFixture.ts create mode 100644 __tests__/__fixtures__/dockerBuildJobFixture.ts create mode 100644 __tests__/__fixtures__/hooks/deploymentHookFixture.ts create mode 100644 __tests__/__fixtures__/jobFixture.ts create mode 100644 __tests__/__fixtures__/releaseFixture.ts create mode 100644 __tests__/release/createRelease.test.ts create mode 100644 __tests__/release/endRelease.test.ts create mode 100644 config/homer/projectReleaseConfigs.ts create mode 100644 src/core/typings/GitlabDeployment.ts create mode 100644 src/core/typings/GitlabDeploymentHook.ts create mode 100644 src/core/typings/GitlabRelease.ts create mode 100644 src/release/commands/cancel/cancelReleaseRequestHandler.ts create mode 100644 src/release/commands/cancel/selectReleaseToCancel.ts create mode 100644 src/release/commands/create/createReleaseRequestHandler.ts create mode 100644 src/release/commands/create/hookHandlers/deploymentHookHandler.ts create mode 100644 src/release/commands/create/managers/__tests__/semanticReleaseTagManager.test.ts create mode 100644 src/release/commands/create/managers/defaultReleaseManager.ts create mode 100644 src/release/commands/create/managers/federationReleaseTagManager.ts create mode 100644 src/release/commands/create/managers/libraryReleaseManager.ts create mode 100644 src/release/commands/create/managers/semanticReleaseTagManager.ts create mode 100644 src/release/commands/create/managers/stableDateReleaseTagManager.ts create mode 100644 src/release/commands/create/utils/addLoaderToReleaseModal.ts create mode 100644 src/release/commands/create/utils/createRelease.ts create mode 100644 src/release/commands/create/utils/getBranchLastPipeline.ts create mode 100644 src/release/commands/create/utils/slackifyChangelog.ts create mode 100644 src/release/commands/create/utils/startRelease.ts create mode 100644 src/release/commands/create/utils/updateReleaseChangelog.ts create mode 100644 src/release/commands/create/utils/updateReleaseProject.ts create mode 100644 src/release/commands/create/utils/waitForNonReadyReleases.ts create mode 100644 src/release/commands/create/utils/waitForReadinessAndStartRelease.ts create mode 100644 src/release/commands/create/utils/waitForReleasePipeline.ts create mode 100644 src/release/commands/create/viewBuilders/buildReleaseModalView.ts create mode 100644 src/release/commands/create/viewBuilders/buildReleaseStateMessage.ts create mode 100644 src/release/commands/end/endReleaseRequestHandler.ts create mode 100644 src/release/commands/end/selectReleaseToEnd.ts create mode 100644 src/release/releaseBlockActionsHandler.ts create mode 100644 src/release/releaseHookHandler.ts create mode 100644 src/release/releaseRequestHandler.ts create mode 100644 src/release/releaseViewSubmissionHandler.ts create mode 100644 src/release/typings/ProjectReleaseConfig.ts create mode 100644 src/release/typings/ReleaseManager.ts create mode 100644 src/release/typings/ReleaseState.ts create mode 100644 src/release/typings/ReleaseStateUpdate.ts create mode 100644 src/release/typings/ReleaseTagManager.ts create mode 100644 src/release/utils/configHelper.ts create mode 100644 src/release/viewBuilders/buildReleaseSelectionEphemeral.ts diff --git a/README.md b/README.md index a91b4a8..b9720e3 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ Here are the available commands: | `/homer project add ` | Add a Gitlab project to a channel. | | `/homer project list` | List the Gitlab projects added to a channel. | | `/homer project remove` | Remove a Gitlab project from a channel. | +| `/homer release` | Create a release for configured Gitlab project in a channel. | | `/homer review ` | Share a merge request on a channel.
Searches in title and description by default.
Accepts merge request URLs and merge request IDs prefixed with "!". | | `/homer review list` | List ongoing reviews shared in a channel. | diff --git a/__tests__/__fixtures__/deploymentFixture.ts b/__tests__/__fixtures__/deploymentFixture.ts new file mode 100644 index 0000000..13f98ec --- /dev/null +++ b/__tests__/__fixtures__/deploymentFixture.ts @@ -0,0 +1,79 @@ +import type { GitlabDeployment } from '@/core/typings/GitlabDeployment'; +import { pipelineFixture } from './pipelineFixture'; +import { tagFixture } from './tagFixture'; + +export const deploymentFixture: GitlabDeployment = { + id: 42, + iid: 2, + ref: tagFixture.name, + sha: tagFixture.commit.id, + created_at: '2016-08-11T11:32:35.444Z', + updated_at: '2016-08-11T11:34:01.123Z', + status: 'success', + user: { + name: 'Administrator', + username: 'root', + id: 1, + state: 'active', + avatar_url: + 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + web_url: 'http://localhost:3000/root', + }, + environment: { + id: 9, + name: 'production', + external_url: 'https://about.gitlab.com', + }, + deployable: { + id: 664, + status: 'success', + stage: 'deploy', + name: 'deploy', + ref: 'main', + tag: false, + coverage: null, + created_at: '2016-08-11T11:32:24.456Z', + started_at: null, + finished_at: '2016-08-11T11:32:35.145Z', + project: { + ci_job_token_scope_enabled: false, + }, + user: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: + 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + web_url: 'http://gitlab.dev/root', + created_at: '2015-12-21T13:14:24.077Z', + bio: null, + location: null, + skype: '', + linkedin: '', + twitter: '', + website_url: '', + organization: '', + }, + commit: { + id: 'a91957a858320c0e17f3a0eca7cfacbff50ea29a', + short_id: 'a91957a8', + title: "Merge branch 'rename-readme' into 'main'\r", + author_name: 'Administrator', + author_email: 'admin@example.com', + created_at: '2016-08-11T13:28:26.000+02:00', + message: + "Merge branch 'rename-readme' into 'main'\r\n\r\nRename README\r\n\r\n\r\n\r\nSee merge request !2", + }, + pipeline: { + ...pipelineFixture, + created_at: '2016-08-11T07:43:52.143Z', + ref: tagFixture.name, + sha: tagFixture.commit.id, + status: 'success', + updated_at: '2016-08-11T07:43:52.143Z', + web_url: 'https://example.com/foo/bar/pipelines/61', + }, + runner: null, + }, +}; diff --git a/__tests__/__fixtures__/dockerBuildJobFixture.ts b/__tests__/__fixtures__/dockerBuildJobFixture.ts new file mode 100644 index 0000000..9018fcb --- /dev/null +++ b/__tests__/__fixtures__/dockerBuildJobFixture.ts @@ -0,0 +1,35 @@ +import type { GitlabJob } from '@/core/typings/GitlabJob'; +import { pipelineFixture } from './pipelineFixture'; +import { userDetailsFixture } from './userDetailsFixture'; + +export const dockerBuildJobFixture: GitlabJob = { + commit: { + author_email: 'admin@example.com', + author_name: 'Administrator', + created_at: '2015-12-24T16:51:14.000+01:00', + id: '0ff3ae198f8601a285adcf5c0fff204ee6fba5fd', + message: 'Test the CI integration.', + short_id: '0ff3ae19', + title: 'Test the CI integration.', + }, + coverage: null, + allow_failure: false, + created_at: '2015-12-24T15:51:21.802Z', + started_at: '2015-12-24T17:54:27.722Z', + finished_at: '2015-12-24T17:54:27.895Z', + duration: 0.173, + queued_duration: 0.01, + artifacts: [], + artifacts_expire_at: '2016-01-23T17:54:27.895Z', + tag_list: ['docker runner', 'ubuntu18'], + id: 7, + name: 'build_image', + pipeline: pipelineFixture, + ref: 'main', + runner: null, + stage: 'test', + status: 'success', + tag: false, + web_url: 'https://example.com/foo/bar/-/jobs/7', + user: userDetailsFixture, +}; diff --git a/__tests__/__fixtures__/hooks/deploymentHookFixture.ts b/__tests__/__fixtures__/hooks/deploymentHookFixture.ts new file mode 100644 index 0000000..f2934f9 --- /dev/null +++ b/__tests__/__fixtures__/hooks/deploymentHookFixture.ts @@ -0,0 +1,45 @@ +import type { GitlabDeploymentHook } from '@/core/typings/GitlabDeploymentHook'; +import { deploymentFixture } from '../deploymentFixture'; +import { projectFixture } from '../projectFixture'; + +export const deploymentHookFixture: GitlabDeploymentHook = { + object_kind: 'deployment', + status: 'success', + status_changed_at: '2021-04-28 21:50:00 +0200', + deployment_id: deploymentFixture.id, + deployable_id: 796, + deployable_url: + 'http://10.126.0.2:3000/root/diaspora-project-site/-/jobs/796', + environment: 'staging', + project: { + id: projectFixture.id, + name: 'diaspora-project-site', + description: '', + web_url: 'http://example.com/diaspora/diaspora-project-site', + avatar_url: null, + git_ssh_url: 'ssh://vlad@10.126.0.2:2222/root/diaspora-project-site.git', + git_http_url: 'http://example.com/diaspora/diaspora-project-site.git', + namespace: 'Administrator', + visibility_level: 0, + path_with_namespace: 'root/diaspora-project-site', + default_branch: 'master', + ci_config_path: '', + homepage: 'http://example.com/diaspora/diaspora-project-site', + url: 'ssh://vlad@example.com/diaspora/diaspora-project-site.git', + ssh_url: 'ssh://vlad@example.com/diaspora/diaspora-project-site.git', + http_url: 'http://example.com/diaspora/diaspora-project-site.git', + }, + short_sha: '279484c0', + user: { + id: 1, + name: 'Administrator', + username: 'root', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + email: 'admin@example.com', + }, + user_url: 'http://10.126.0.2:3000/root', + commit_url: + 'http://example.com/diaspora/diaspora-project-site/-/commit/279484c09fbe69ededfced8c1bb6e6d24616b468', + commit_title: 'Add new file', +}; diff --git a/__tests__/__fixtures__/jobFixture.ts b/__tests__/__fixtures__/jobFixture.ts new file mode 100644 index 0000000..a892d78 --- /dev/null +++ b/__tests__/__fixtures__/jobFixture.ts @@ -0,0 +1,39 @@ +import type { GitlabJob } from '@/core/typings/GitlabJob'; +import type { GitlabPipeline } from '@/core/typings/GitlabPipeline'; +import { bridgeFixture } from './bridgeFixture'; +import { userDetailsFixture } from './userDetailsFixture'; + +export const jobFixture: GitlabJob = { + commit: { + author_email: 'admin@example.com', + author_name: 'Administrator', + created_at: '2015-12-24T16:51:14.000+01:00', + id: '0ff3ae198f8601a285adcf5c0fff204ee6fba5fd', + message: 'Test the CI integration.', + short_id: '0ff3ae19', + title: 'Test the CI integration.', + }, + coverage: null, + allow_failure: false, + created_at: '2015-12-24T15:51:21.802Z', + started_at: '2015-12-24T17:54:27.722Z', + finished_at: '2015-12-24T17:54:27.895Z', + duration: 0.173, + queued_duration: 0.01, + artifacts: [], + artifacts_expire_at: '2016-01-23T17:54:27.895Z', + tag_list: ['docker runner', 'ubuntu18'], + id: 7, + name: 'chat', + pipeline: { + ...bridgeFixture.downstream_pipeline, + project_id: 12, + } as GitlabPipeline, + ref: 'main', + runner: null, + stage: 'test', + status: 'success', + tag: false, + web_url: 'https://example.com/foo/bar/-/jobs/7', + user: userDetailsFixture, +}; diff --git a/__tests__/__fixtures__/releaseFixture.ts b/__tests__/__fixtures__/releaseFixture.ts new file mode 100644 index 0000000..df9a696 --- /dev/null +++ b/__tests__/__fixtures__/releaseFixture.ts @@ -0,0 +1,92 @@ +import { tagFixture } from './tagFixture'; + +export const releaseFixture = { + tag_name: tagFixture.name, + description: 'Super nice release', + name: 'New release', + description_html: '\u003cp dir="auto"\u003eSuper nice release\u003c/p\u003e', + created_at: '2019-01-03T02:22:45.118Z', + released_at: '2019-01-03T02:22:45.118Z', + author: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + web_url: 'https://gitlab.example.com/root', + }, + commit: tagFixture.commit, + milestones: [ + { + id: 51, + iid: 1, + project_id: 24, + title: 'v1.0-rc', + description: 'Voluptate fugiat possimus quis quod aliquam expedita.', + state: 'closed', + created_at: '2019-07-12T19:45:44.256Z', + updated_at: '2019-07-12T19:45:44.256Z', + due_date: '2019-08-16T11:00:00.256Z', + start_date: '2019-07-30T12:00:00.256Z', + web_url: 'https://gitlab.example.com/root/awesome-app/-/milestones/1', + issue_stats: { + total: 99, + closed: 76, + }, + }, + { + id: 52, + iid: 2, + project_id: 24, + title: 'v1.0', + description: 'Voluptate fugiat possimus quis quod aliquam expedita.', + state: 'closed', + created_at: '2019-07-16T14:00:12.256Z', + updated_at: '2019-07-16T14:00:12.256Z', + due_date: '2019-08-16T11:00:00.256Z', + start_date: '2019-07-30T12:00:00.256Z', + web_url: 'https://gitlab.example.com/root/awesome-app/-/milestones/2', + issue_stats: { + total: 24, + closed: 21, + }, + }, + ], + commit_path: + '/root/awesome-app/commit/588440f66559714280628a4f9799f0c4eb880a4a', + tag_path: '/root/awesome-app/-/tags/v0.11.1', + evidence_sha: '760d6cdfb0879c3ffedec13af470e0f71cf52c6cde4d', + assets: { + count: 5, + sources: [ + { + format: 'zip', + url: 'https://gitlab.example.com/root/awesome-app/-/archive/v0.3/awesome-app-v0.3.zip', + }, + { + format: 'tar.gz', + url: 'https://gitlab.example.com/root/awesome-app/-/archive/v0.3/awesome-app-v0.3.tar.gz', + }, + { + format: 'tar.bz2', + url: 'https://gitlab.example.com/root/awesome-app/-/archive/v0.3/awesome-app-v0.3.tar.bz2', + }, + { + format: 'tar', + url: 'https://gitlab.example.com/root/awesome-app/-/archive/v0.3/awesome-app-v0.3.tar', + }, + ], + links: [ + { + id: 3, + name: 'hoge', + url: 'https://gitlab.example.com/root/awesome-app/-/tags/v0.11.1/binaries/linux-amd64', + external: true, + link_type: 'other', + }, + ], + evidence_file_path: + 'https://gitlab.example.com/root/awesome-app/-/releases/v0.3/evidence.json', + }, +}; diff --git a/__tests__/release/createRelease.test.ts b/__tests__/release/createRelease.test.ts new file mode 100644 index 0000000..2b05a85 --- /dev/null +++ b/__tests__/release/createRelease.test.ts @@ -0,0 +1,797 @@ +import type { + InputBlock, + PlainTextInput, + StaticSelect, + ViewsOpenArguments, +} from '@slack/web-api'; +import { HTTP_STATUS_NO_CONTENT, HTTP_STATUS_OK } from '@/constants'; +import { slackBotWebClient } from '@/core/services/slack'; +import { getProjectReleaseConfig } from '@/release/utils/configHelper'; +import { dockerBuildJobFixture } from '../__fixtures__/dockerBuildJobFixture'; +import { jobFixture } from '../__fixtures__/jobFixture'; +import { mergeRequestFixture } from '../__fixtures__/mergeRequestFixture'; +import { pipelineFixture } from '../__fixtures__/pipelineFixture'; +import { projectFixture } from '../__fixtures__/projectFixture'; +import { releaseFixture } from '../__fixtures__/releaseFixture'; +import { tagFixture } from '../__fixtures__/tagFixture'; +import { fetch } from '../utils/fetch'; +import { getSlackHeaders } from '../utils/getSlackHeaders'; +import { mockGitlabCall } from '../utils/mockGitlabCall'; +import { waitFor } from '../utils/waitFor'; + +describe('release > createRelease', () => { + const releaseConfig = getProjectReleaseConfig(projectFixture.id); + + it('should create a release whereas main pipeline is ready', async () => { + /** Step 1: display release modal */ + + // Given + const { projectId } = releaseConfig; + const releaseTagName = 'stable-19700101-0100'; + let body: Record = { + channel_id: releaseConfig.releaseChannelId, + text: 'release', + trigger_id: 'triggerId', + }; + + jest.useFakeTimers(); + jest.setSystemTime(0); + mockGitlabCall(`/projects/${projectId}`, projectFixture); + mockGitlabCall( + `/projects/${projectId}/repository/tags?per_page=100`, + [...Array(10)].map((_, i) => ({ + tagFixture, + name: `${tagFixture.name.slice(0, -1)}${i}`, + })) + ); + mockGitlabCall( + `/projects/${projectId}/repository/tags/${tagFixture.name}`, + tagFixture + ); + mockGitlabCall( + `/projects/${projectId}/repository/commits?since=2017-07-26T09:08:54.000Z&per_page=100`, + [ + { + id: 'ed899a2f4b50b4370feeea94676502b42383c746', + title: "Merge branch 'branch-name' into 'master'", + message: `\ + Merge branch 'branch-name' into 'master' + + chore(test): replace sanitize with escape once + + See merge request ${projectFixture.path_with_namespace}!${mergeRequestFixture.iid}`, + }, + { + id: '6104942438c14ec7bd21c6cd5bd995272b3faff6', + title: 'Sanitize for network graph', + message: 'Sanitize for network graph', + }, + ] + ); + mockGitlabCall( + `/projects/${projectId}/merge_requests/${mergeRequestFixture.iid}`, + mergeRequestFixture + ); + mockGitlabCall( + `/projects/${projectId}/merge_requests/${mergeRequestFixture.iid}/commits?per_page=100`, + [ + { + message: 'feat(great): implement great feature\n\nJIRA: SPAR-156', + title: 'feat(great): implement great feature', + }, + { + message: + 'feat(great): implement another great feature\n\nJIRA: SPAR-158', + title: 'feat(great): implement another great feature', + }, + ] + ); + + // When + let response = await fetch('/api/v1/homer/command', { + body, + headers: getSlackHeaders(body), + }); + + // Then + expect(response.status).toEqual(HTTP_STATUS_NO_CONTENT); + expect(slackBotWebClient.views.open).toHaveBeenNthCalledWith(1, { + trigger_id: 'triggerId', + view: { + type: 'modal', + callback_id: 'release-create-modal', + title: { + type: 'plain_text', + text: 'Release', + }, + submit: { + type: 'plain_text', + text: 'Start', + }, + notify_on_close: false, + blocks: [ + { + type: 'input', + block_id: 'release-project-block', + dispatch_action: true, + element: { + type: 'static_select', + action_id: 'release-select-project-action', + initial_option: { + text: { + text: projectFixture.path_with_namespace, + type: 'plain_text', + }, + value: `${projectFixture.id}`, + }, + options: [ + { + text: { + type: 'plain_text', + text: projectFixture.path_with_namespace, + }, + value: `${projectFixture.id}`, + }, + ], + placeholder: { + type: 'plain_text', + text: 'Select the project', + }, + }, + label: { + type: 'plain_text', + text: 'Project', + }, + }, + { + block_id: 'release-tag-block', + element: { + action_id: 'release-tag-action', + initial_value: 'stable-19700101-0100', + type: 'plain_text_input', + }, + label: { + text: 'Release tag', + type: 'plain_text', + }, + type: 'input', + }, + { + block_id: 'release-previous-tag-block', + dispatch_action: true, + element: { + action_id: 'release-select-previous-tag-action', + initial_option: { + text: { + text: 'stable-20200101-1000', + type: 'plain_text', + }, + value: 'stable-20200101-1000', + }, + options: [ + { + text: { + text: 'stable-20200101-1000', + type: 'plain_text', + }, + value: 'stable-20200101-1000', + }, + { + text: { + text: 'stable-20200101-1001', + type: 'plain_text', + }, + value: 'stable-20200101-1001', + }, + { + text: { + text: 'stable-20200101-1002', + type: 'plain_text', + }, + value: 'stable-20200101-1002', + }, + { + text: { + text: 'stable-20200101-1003', + type: 'plain_text', + }, + value: 'stable-20200101-1003', + }, + { + text: { + text: 'stable-20200101-1004', + type: 'plain_text', + }, + value: 'stable-20200101-1004', + }, + ], + placeholder: { + text: 'Select the previous release tag', + type: 'plain_text', + }, + type: 'static_select', + }, + label: { + text: 'Previous release tag', + type: 'plain_text', + }, + type: 'input', + }, + { + block_id: 'release-previous-tag-info-block', + elements: [ + { + text: 'This should be changed only whether the previous release has been aborted.', + type: 'plain_text', + }, + ], + type: 'context', + }, + { + text: { + text: '*Changelog*', + type: 'mrkdwn', + }, + type: 'section', + }, + { + block_id: 'release-changelog-block', + text: { + text: '• - \n• - \n', + type: 'mrkdwn', + }, + type: 'section', + }, + ], + }, + }); + + /** Step 2: select project */ + + // Given + let { view } = (slackBotWebClient.views.open as jest.Mock).mock + .calls[0][0] as ViewsOpenArguments; + + const selectProjectBlock = [...view.blocks].find( + (block) => block.block_id === 'release-project-block' + ) as InputBlock; + const selectProjectElement = selectProjectBlock.element as StaticSelect; + + body = { + payload: JSON.stringify({ + actions: [{ action_id: selectProjectElement.action_id }], + type: 'block_actions', + view: { + ...view, + id: 'viewId', + state: { + values: { + [selectProjectBlock.block_id as string]: { + [selectProjectElement.action_id as string]: { + selected_option: selectProjectElement.options?.[0], + }, + }, + }, + }, + }, + }), + }; + + // When + response = await fetch('/api/v1/homer/interactive', { + body, + headers: getSlackHeaders(body), + }); + + expect(response.status).toEqual(HTTP_STATUS_OK); + expect(slackBotWebClient.views.update).toHaveBeenNthCalledWith(1, { + view: { + ...view, + blocks: [ + ...view.blocks.slice(0, 1), + { + type: 'section', + text: { + type: 'plain_text', + text: ':loader:', + }, + }, + ], + }, + view_id: 'viewId', + }); + expect(slackBotWebClient.views.update).toHaveBeenNthCalledWith(2, { + view: { + blocks: [ + { + type: 'input', + block_id: 'release-project-block', + dispatch_action: true, + element: { + type: 'static_select', + action_id: 'release-select-project-action', + initial_option: { + text: { + text: projectFixture.path_with_namespace, + type: 'plain_text', + }, + value: `${projectFixture.id}`, + }, + options: [ + { + text: { + type: 'plain_text', + text: projectFixture.path_with_namespace, + }, + value: `${projectFixture.id}`, + }, + ], + placeholder: { + type: 'plain_text', + text: 'Select the project', + }, + }, + label: { + type: 'plain_text', + text: 'Project', + }, + }, + { + block_id: 'release-tag-block', + element: { + action_id: 'release-tag-action', + initial_value: releaseTagName, + type: 'plain_text_input', + }, + label: { text: 'Release tag', type: 'plain_text' }, + type: 'input', + }, + { + block_id: 'release-previous-tag-block', + dispatch_action: true, + element: { + action_id: 'release-select-previous-tag-action', + initial_option: { + text: { text: 'stable-20200101-1000', type: 'plain_text' }, + value: 'stable-20200101-1000', + }, + options: [ + { + text: { text: 'stable-20200101-1000', type: 'plain_text' }, + value: 'stable-20200101-1000', + }, + { + text: { text: 'stable-20200101-1001', type: 'plain_text' }, + value: 'stable-20200101-1001', + }, + { + text: { text: 'stable-20200101-1002', type: 'plain_text' }, + value: 'stable-20200101-1002', + }, + { + text: { text: 'stable-20200101-1003', type: 'plain_text' }, + value: 'stable-20200101-1003', + }, + { + text: { text: 'stable-20200101-1004', type: 'plain_text' }, + value: 'stable-20200101-1004', + }, + ], + placeholder: { + text: 'Select the previous release tag', + type: 'plain_text', + }, + type: 'static_select', + }, + label: { text: 'Previous release tag', type: 'plain_text' }, + type: 'input', + }, + { + block_id: 'release-previous-tag-info-block', + elements: [ + { + text: 'This should be changed only whether the previous release has been aborted.', + type: 'plain_text', + }, + ], + type: 'context', + }, + { text: { text: '*Changelog*', type: 'mrkdwn' }, type: 'section' }, + { + block_id: 'release-changelog-block', + text: { + text: '• - \n• - \n', + type: 'mrkdwn', + }, + type: 'section', + }, + ], + callback_id: 'release-create-modal', + notify_on_close: false, + submit: { text: 'Start', type: 'plain_text' }, + title: { text: 'Release', type: 'plain_text' }, + type: 'modal', + }, + view_id: 'viewId', + }); + + /** Step 3: select previous release tag */ + + // Given + ({ view } = (slackBotWebClient.views.update as jest.Mock).mock + .calls[1][0] as ViewsOpenArguments); + + const previousTagBlock = [...view.blocks].find( + (block) => block.block_id === 'release-previous-tag-block' + ) as InputBlock; + const previousTagElement = previousTagBlock.element as StaticSelect; + + body = { + payload: JSON.stringify({ + actions: [{ action_id: previousTagElement.action_id }], + type: 'block_actions', + view: { + ...view, + id: 'viewId', + state: { + values: { + [selectProjectBlock.block_id as string]: { + [selectProjectElement.action_id as string]: { + selected_option: selectProjectElement.options?.[0], + }, + }, + [previousTagBlock.block_id as string]: { + [previousTagElement.action_id as string]: { + selected_option: previousTagElement.options?.[0], + }, + }, + }, + }, + }, + }), + }; + + // When + response = await fetch('/api/v1/homer/interactive', { + body, + headers: getSlackHeaders(body), + }); + + // Then + expect(response.status).toEqual(HTTP_STATUS_OK); + expect(slackBotWebClient.views.update).toHaveBeenNthCalledWith(3, { + view: { + ...view, + blocks: [ + ...view.blocks.slice(0, -2), + { + type: 'section', + text: { + type: 'plain_text', + text: ':loader:', + }, + }, + ], + }, + view_id: 'viewId', + }); + expect(slackBotWebClient.views.update).toHaveBeenNthCalledWith(4, { + view, + view_id: 'viewId', + }); + + /** Step 4: create release */ + + // Given + const releaseTagBlock = [...view.blocks].find( + (block) => block.block_id === 'release-tag-block' + ) as InputBlock; + const releaseTagElement = releaseTagBlock.element as PlainTextInput; + + body = { + payload: JSON.stringify({ + type: 'view_submission', + user: { id: 'userId' }, + view: { + ...view, + state: { + values: { + [selectProjectBlock.block_id as string]: { + [selectProjectElement.action_id as string]: { + selected_option: selectProjectElement.options?.[0], + }, + }, + [releaseTagBlock.block_id as string]: { + [releaseTagElement.action_id as string]: { + value: releaseTagElement.initial_value, + }, + }, + [previousTagBlock.block_id as string]: { + [previousTagElement.action_id as string]: { + selected_option: previousTagElement.options?.[0], + }, + }, + }, + }, + }, + }), + }; + + (slackBotWebClient.users.info as jest.Mock).mockImplementation(() => + Promise.resolve({ + user: { + id: 'slackUserId', + profile: { image_72: 'image_72' }, + real_name: 'real_name', + }, + }) + ); + + const releaseCallMock = mockGitlabCall( + `/projects/${projectId}/releases`, + releaseFixture + ); + mockGitlabCall(`/projects/${projectId}/pipelines?ref=master`, [ + pipelineFixture, + ]); + mockGitlabCall( + `/projects/${projectId}/pipelines/${pipelineFixture.id}/jobs?per_page=100`, + [dockerBuildJobFixture, jobFixture] + ); + mockGitlabCall( + `/projects/${projectId}/pipelines?ref=${releaseTagName}`, + [] + ); + + // When + response = await fetch('/api/v1/homer/interactive', { + body, + headers: getSlackHeaders(body), + }); + + // Then + const { hasModelEntry } = (await import('sequelize')) as any; + expect(response.status).toEqual(HTTP_STATUS_NO_CONTENT); + expect( + await hasModelEntry('Release', { + slackAuthor: + '{"id":"slackUserId","profile":{"image_72":"image_72"},"real_name":"real_name"}', + tagName: releaseTagName, + }) + ).toEqual(true); + expect(releaseCallMock.called).toEqual(true); + expect(releaseCallMock.calledWith?.[1]).toEqual({ + body: `{"description":"- [feat(great): implement great feature](http://gitlab.example.com/my-group/my-project/merge_requests/1) - [SPAR-156](https://my-ticket-management.com/view/SPAR-156)\\n- [feat(great): implement another great feature](http://gitlab.example.com/my-group/my-project/merge_requests/1) - [SPAR-158](https://my-ticket-management.com/view/SPAR-158)","tag_name":"${releaseTagName}","ref":"${pipelineFixture.sha}"}`, + headers: { 'Content-Type': 'application/json' }, + method: 'post', + }); + expect(slackBotWebClient.chat.postEphemeral).toHaveBeenCalledTimes(1); + expect(slackBotWebClient.chat.postEphemeral).toHaveBeenNthCalledWith(1, { + channel: releaseConfig.releaseChannelId, + text: `Release \`${releaseTagName}\` started for \`${projectFixture.path}\` :homer-happy:`, + user: 'slackUserId', + }); + + /** Step 5: post message with release pipeline and changelog */ + + // Given + mockGitlabCall(`/projects/${projectId}/pipelines?ref=${releaseTagName}`, [ + pipelineFixture, + ]); + + // When + jest.advanceTimersByTime(2000); + jest.useRealTimers(); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); // execute pending tasks in the event loop + + // Then + expect(slackBotWebClient.chat.postEphemeral).toHaveBeenCalledTimes(2); + expect(slackBotWebClient.chat.postEphemeral).toHaveBeenNthCalledWith(2, { + channel: releaseConfig.releaseChannelId, + text: `↳ <${pipelineFixture.web_url}|pipeline> :homer-donut:`, + user: 'slackUserId', + }); + expect(slackBotWebClient.chat.postMessage).toHaveBeenCalledTimes(1); + expect(slackBotWebClient.chat.postMessage).toHaveBeenCalledWith({ + channel: releaseConfig.releaseChannelId, + blocks: [ + { + text: { + text: `\ +:homer: New release <${projectFixture.web_url}/-/releases/stable-19700101-0100|${releaseTagName}> for project <${projectFixture.web_url}|${projectFixture.path_with_namespace}>: +  • - +  • - `, + type: 'mrkdwn', + }, + type: 'section', + }, + ], + icon_url: 'image_72', + text: `New release ${releaseTagName} for project ${projectFixture.path_with_namespace}.`, + username: 'real_name', + }); + }); + + it('should create a release whereas main pipeline is not yet ready', async () => { + /** Step 1: submit the release modal (full workflow tested above) */ + + // Given + const { projectId } = releaseConfig; + const releaseTagName = 'stable-20200101-1100'; + const userId = 'slackUserId'; + const body = { + payload: JSON.stringify({ + type: 'view_submission', + user: { id: userId }, + view: { + callback_id: 'release-create-modal', + state: { + values: { + 'release-project-block': { + 'release-select-project-action': { + selected_option: { + value: releaseConfig.projectId.toString(), + }, + }, + }, + 'release-previous-tag-block': { + 'release-select-previous-tag-action': { + selected_option: { + value: 'stable-20200101-1000', + }, + }, + }, + 'release-tag-block': { + 'release-tag-action': { + value: releaseTagName, + }, + }, + }, + }, + }, + }), + }; + + jest.useFakeTimers(); + + (slackBotWebClient.users.info as jest.Mock).mockImplementation(() => + Promise.resolve({ + user: { + id: 'slackUserId', + profile: { image_72: 'image_72' }, + real_name: 'real_name', + }, + }) + ); + + mockGitlabCall(`/projects/${projectId}/releases`, releaseFixture); + mockGitlabCall(`/projects/${projectId}/pipelines?ref=master`, [ + pipelineFixture, + ]); + mockGitlabCall( + `/projects/${projectId}/pipelines/${pipelineFixture.id}/jobs?per_page=100`, + [{ ...dockerBuildJobFixture, status: 'running' }] + ); + mockGitlabCall( + `/projects/${projectId}/repository/tags/${tagFixture.name}`, + tagFixture + ); + mockGitlabCall( + `/projects/${projectId}/repository/commits?since=2017-07-26T09:08:54.000Z&per_page=100`, + [ + { + id: 'ed899a2f4b50b4370feeea94676502b42383c746', + title: "Merge branch 'branch-name' into 'master'", + message: `\ + Merge branch 'branch-name' into 'master' + + chore(test): replace sanitize with escape once + + See merge request ${projectFixture.path_with_namespace}!${mergeRequestFixture.iid}`, + }, + { + id: '6104942438c14ec7bd21c6cd5bd995272b3faff6', + title: 'Sanitize for network graph', + message: 'Sanitize for network graph', + }, + ] + ); + mockGitlabCall( + `/projects/${projectId}/merge_requests/${mergeRequestFixture.iid}`, + mergeRequestFixture + ); + mockGitlabCall( + `/projects/${projectId}/merge_requests/${mergeRequestFixture.iid}/commits?per_page=100`, + [ + { + message: 'feat(great): implement great feature\n\nJIRA: SPAR-156', + title: 'feat(great): implement great feature', + }, + { + message: + 'feat(great): implement another great feature\n\nJIRA: SPAR-158', + title: 'feat(great): implement another great feature', + }, + ] + ); + mockGitlabCall(`/projects/${projectId}`, projectFixture); + + // When + const response = await fetch('/api/v1/homer/interactive', { + body, + headers: getSlackHeaders(body), + }); + + // Then + expect(response.status).toEqual(HTTP_STATUS_NO_CONTENT); + expect(slackBotWebClient.chat.postEphemeral).toHaveBeenNthCalledWith(1, { + channel: releaseConfig.releaseChannelId, + text: `The preconditions to launch a release are not yet met, I will \ +wait for them and start the release automatically (<${pipelineFixture.web_url}|pipeline>) :homer-donut:`, + user: userId, + }); + + /** Step 2: pipelines becomes ready and release starts */ + + // Given + const releaseCallMock = mockGitlabCall( + `/projects/${projectId}/releases`, + releaseFixture + ); + mockGitlabCall( + `/projects/${projectId}/pipelines/${pipelineFixture.id}/jobs?per_page=100`, + [dockerBuildJobFixture, jobFixture] + ); + mockGitlabCall(`/projects/${projectId}/pipelines?ref=${releaseTagName}`, [ + pipelineFixture, + ]); + + // When + jest.advanceTimersByTime(30000); + jest.useRealTimers(); + + // Then + await waitFor(() => { + expect(releaseCallMock.called).toEqual(true); + expect(releaseCallMock.calledWith?.[1]).toEqual({ + body: `{"description":"- [feat(great): implement great feature](http://gitlab.example.com/my-group/my-project/merge_requests/1) - [SPAR-156](https://my-ticket-management.com/view/SPAR-156)\\n- [feat(great): implement another great feature](http://gitlab.example.com/my-group/my-project/merge_requests/1) - [SPAR-158](https://my-ticket-management.com/view/SPAR-158)","tag_name":"${releaseTagName}","ref":"${pipelineFixture.sha}"}`, + headers: { 'Content-Type': 'application/json' }, + method: 'post', + }); + expect(slackBotWebClient.chat.postEphemeral).toHaveBeenCalledTimes(3); + expect(slackBotWebClient.chat.postEphemeral).toHaveBeenNthCalledWith(2, { + channel: releaseConfig.releaseChannelId, + text: `Release \`${releaseTagName}\` started for \`${projectFixture.path}\` :homer-happy:`, + user: 'slackUserId', + }); + expect(slackBotWebClient.chat.postEphemeral).toHaveBeenNthCalledWith(3, { + channel: releaseConfig.releaseChannelId, + text: `↳ <${pipelineFixture.web_url}|pipeline> :homer-donut:`, + user: 'slackUserId', + }); + }); + }); + + it('should answer with an error message if command was not launched on the right channel', async () => { + // Given + const body = { + channel_id: 'channelId', + text: 'release', + trigger_id: 'triggerId', + }; + + // When + const response = await fetch('/api/v1/homer/command', { + body, + headers: getSlackHeaders(body), + }); + + // Then + expect(response.status).toEqual(HTTP_STATUS_OK); + expect(await response.text()).toMatch( + 'The release command cannot be used in this channel because it has not been set up (or not correctly) in the config file, please follow the :homer-donut:' + ); + }); +}); diff --git a/__tests__/release/endRelease.test.ts b/__tests__/release/endRelease.test.ts new file mode 100644 index 0000000..c588fdc --- /dev/null +++ b/__tests__/release/endRelease.test.ts @@ -0,0 +1,133 @@ +import type { SectionBlock, StaticSelect } from '@slack/web-api'; +import { HTTP_STATUS_NO_CONTENT, HTTP_STATUS_OK } from '@/constants'; +import { createRelease } from '@/core/services/data'; +import { slackBotWebClient } from '@/core/services/slack'; +import type { SlackUser } from '@/core/typings/SlackUser'; +import { getProjectReleaseConfig } from '@/release/utils/configHelper'; +import { pipelineFixture } from '@root/__tests__/__fixtures__/pipelineFixture'; +import { projectFixture } from '@root/__tests__/__fixtures__/projectFixture'; +import { releaseFixture } from '@root/__tests__/__fixtures__/releaseFixture'; +import { fetch } from '@root/__tests__/utils/fetch'; +import { getSlackHeaders } from '@root/__tests__/utils/getSlackHeaders'; +import { mockGitlabCall } from '@root/__tests__/utils/mockGitlabCall'; + +describe('release > endRelease', () => { + describe('default workflow', () => { + it('should end a release in monitoring state', async () => { + // Given + const projectId = projectFixture.id; + const releaseConfig = getProjectReleaseConfig(projectId); + const channelId = releaseConfig.releaseChannelId; + const userId = 'userId'; + let body: any = { + channel_id: channelId, + text: 'release end', + user_id: userId, + }; + + await createRelease({ + description: '', + failedDeployments: [], + projectId, + slackAuthor: { + id: 'slackUserId', + profile: { image_72: 'image_72' }, + real_name: 'real_name', + } as SlackUser, + startedDeployments: ['int', 'staging', 'production'], + state: 'monitoring', + successfulDeployments: ['int', 'staging', 'production'], + tagName: releaseFixture.tag_name, + }); + + mockGitlabCall(`/projects/${projectId}`, projectFixture); + + // When + let response = await fetch('/api/v1/homer/command', { + body, + headers: getSlackHeaders(body), + }); + + // Then + expect(response.status).toEqual(HTTP_STATUS_NO_CONTENT); + expect(slackBotWebClient.chat.postEphemeral).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + blocks: expect.any(Array), + channel: channelId, + user: userId, + }) + ); + + const block = (slackBotWebClient.chat.postEphemeral as jest.Mock).mock + .calls[0][0].blocks[0] as SectionBlock | undefined; + + expect(block?.text?.text).toContain('Choose a release to end:'); + expect( + (block?.accessory as StaticSelect | undefined)?.option_groups + ).toHaveLength(1); + + // Given + const { action_id, option_groups } = block?.accessory as StaticSelect; + const responseUrl = 'https://slack/responseUrl'; + body = { + payload: JSON.stringify({ + actions: [ + { + action_id, + selected_option: { value: option_groups?.[0].options?.[0].value }, + }, + ], + container: { channel_id: channelId }, + response_url: responseUrl, + type: 'block_actions', + user: { id: userId }, + }), + }; + const { mockUrl } = (await import('node-fetch')) as any; + mockUrl(responseUrl, { json: Promise.resolve('') }); + + mockGitlabCall( + `/projects/${projectId}/pipelines?ref=${releaseFixture.tag_name}`, + [pipelineFixture] + ); + + // When + response = await fetch('/api/v1/homer/interactive', { + body, + headers: getSlackHeaders(body), + }); + + // Then + expect(response.status).toEqual(HTTP_STATUS_OK); + expect(slackBotWebClient.chat.postMessage).toHaveBeenCalledTimes(1); + releaseConfig.notificationChannelIds.forEach( + (notificationChannelId, index) => { + expect(slackBotWebClient.chat.postMessage).toHaveBeenNthCalledWith( + index + 1, + { + blocks: [ + { + text: { + text: `:ccheck: ${projectFixture.path} PRD - <${pipelineFixture.web_url}|pipeline> - <${projectFixture.web_url}/-/releases/${releaseFixture.tag_name}|release notes>`, + type: 'mrkdwn', + }, + type: 'section', + }, + ], + channel: notificationChannelId, + icon_url: 'image_72', + link_names: true, + text: ':ccheck: diaspora-project-site PRD', + username: 'real_name', + } + ); + } + ); + const { hasModelEntry } = (await import('sequelize')) as any; + expect( + await hasModelEntry('Release', { tagName: releaseFixture.tag_name }) + ).toEqual(false); + }); + }); +}); diff --git a/config/homer/projectReleaseConfigs.ts b/config/homer/projectReleaseConfigs.ts new file mode 100644 index 0000000..11413f7 --- /dev/null +++ b/config/homer/projectReleaseConfigs.ts @@ -0,0 +1,15 @@ +import { MOES_TAVERN_CHANNEL_ID } from '@/constants'; +import { defaultReleaseManager } from '@/release/commands/create/managers/defaultReleaseManager'; +import { stableDateReleaseTagManager } from '@/release/commands/create/managers/stableDateReleaseTagManager'; +import type { ProjectReleaseConfig } from '@/release/typings/ProjectReleaseConfig'; + +export const projectReleaseConfigs: ProjectReleaseConfig[] = [ + // tools/homer + { + notificationChannelIds: [MOES_TAVERN_CHANNEL_ID], + projectId: 1148, + releaseChannelId: MOES_TAVERN_CHANNEL_ID, + releaseManager: defaultReleaseManager, + releaseTagManager: stableDateReleaseTagManager, + }, +]; diff --git a/src/core/requestHandlers/commandRequestHandler.ts b/src/core/requestHandlers/commandRequestHandler.ts index 80339bd..a21255b 100644 --- a/src/core/requestHandlers/commandRequestHandler.ts +++ b/src/core/requestHandlers/commandRequestHandler.ts @@ -1,6 +1,7 @@ import type { Response } from 'express'; import { changelogRequestHandler } from '@/changelog/changelogRequestHandler'; import { projectRequestHandler } from '@/project/projectRequestHandler'; +import { releaseRequestHandler } from '@/release/releaseRequestHandler'; import { reviewRequestHandler } from '@/review/reviewRequestHandler'; import type { SlackExpressRequest } from '../typings/SlackSlashCommand'; import { buildHelpMessage } from '../viewBuilders/buildHelpMessage'; @@ -19,6 +20,9 @@ export async function commandRequestHandler( case 'project': return projectRequestHandler(req, res); + case 'release': + return releaseRequestHandler(req, res); + case 'review': return reviewRequestHandler(req, res); diff --git a/src/core/requestHandlers/gitlabHookHandler.ts b/src/core/requestHandlers/gitlabHookHandler.ts index 6e57c63..a058ca9 100644 --- a/src/core/requestHandlers/gitlabHookHandler.ts +++ b/src/core/requestHandlers/gitlabHookHandler.ts @@ -1,5 +1,6 @@ import type { Request, Response } from 'express'; import { HTTP_STATUS_NO_CONTENT } from '@/constants'; +import { releaseHookHandler } from '@/release/releaseHookHandler'; import { reviewHookHandler } from '@/review/reviewHookHandler'; export async function gitlabHookHandler( @@ -9,6 +10,9 @@ export async function gitlabHookHandler( const { object_kind } = req.body; switch (object_kind) { + case 'deployment': + return releaseHookHandler(req, res); + case 'merge_request': case 'note': case 'push': diff --git a/src/core/requestHandlers/interactiveRequestHandlers/blockActionsRequestHandler.ts b/src/core/requestHandlers/interactiveRequestHandlers/blockActionsRequestHandler.ts index 069196d..46da07d 100644 --- a/src/core/requestHandlers/interactiveRequestHandlers/blockActionsRequestHandler.ts +++ b/src/core/requestHandlers/interactiveRequestHandlers/blockActionsRequestHandler.ts @@ -4,6 +4,7 @@ import { HTTP_STATUS_OK } from '@/constants'; import { logger } from '@/core/services/logger'; import type { BlockActionsPayloadWithChannel } from '@/core/typings/BlockActionPayload'; import { projectBlockActionsHandler } from '@/project/projectBlockActionsHandler'; +import { releaseBlockActionsHandler } from '@/release/releaseBlockActionsHandler'; import { reviewBlockActionsHandler } from '@/review/reviewBlockActionsHandler'; export async function blockActionsRequestHandler( @@ -28,6 +29,9 @@ export async function blockActionsRequestHandler( case action_id.startsWith('project'): return projectBlockActionsHandler(payload); + case action_id.startsWith('release'): + return releaseBlockActionsHandler(payload); + case action_id.startsWith('review'): return reviewBlockActionsHandler(payload); diff --git a/src/core/requestHandlers/interactiveRequestHandlers/viewSubmissionRequestHandler.ts b/src/core/requestHandlers/interactiveRequestHandlers/viewSubmissionRequestHandler.ts index d069bef..aa94656 100644 --- a/src/core/requestHandlers/interactiveRequestHandlers/viewSubmissionRequestHandler.ts +++ b/src/core/requestHandlers/interactiveRequestHandlers/viewSubmissionRequestHandler.ts @@ -2,6 +2,7 @@ import type { Request, Response } from 'express'; import { HTTP_STATUS_NO_CONTENT } from '@/constants'; import { logger } from '@/core/services/logger'; import type { ModalViewSubmissionPayload } from '@/core/typings/ModalViewSubmissionPayload'; +import { releaseViewSubmissionHandler } from '@/release/releaseViewSubmissionHandler'; export async function viewSubmissionRequestHandler( req: Request, @@ -14,7 +15,9 @@ export async function viewSubmissionRequestHandler( res.sendStatus(HTTP_STATUS_NO_CONTENT); return; } - + if (callback_id.startsWith('release')) { + return releaseViewSubmissionHandler(req, res, payload); + } res.sendStatus(HTTP_STATUS_NO_CONTENT); logger.error(new Error(`Unknown view callback id: ${callback_id}`)); } diff --git a/src/core/requestHandlers/stateRequestHandler/State.tsx b/src/core/requestHandlers/stateRequestHandler/State.tsx index a6fe99c..deeeb06 100644 --- a/src/core/requestHandlers/stateRequestHandler/State.tsx +++ b/src/core/requestHandlers/stateRequestHandler/State.tsx @@ -1,17 +1,18 @@ import React from 'react'; -import type { DataProject, DataReview } from '@/core/typings/Data'; +import type { DataProject, DataRelease, DataReview } from '@/core/typings/Data'; import { Highlight, themeStyles } from './Highlight'; interface Props { data: { projects: DataProject[]; + releases: DataRelease[]; reviews: DataReview[]; }; search: string; } export function State({ data, search }: Props) { - const { projects, reviews } = data; + const { projects, releases, reviews } = data; return ( @@ -59,6 +60,10 @@ input {

Projects

+
+

Releases

+ +

Reviews

diff --git a/src/core/requestHandlers/stateRequestHandler/stateRequestHandler.tsx b/src/core/requestHandlers/stateRequestHandler/stateRequestHandler.tsx index 60a9dde..460427e 100644 --- a/src/core/requestHandlers/stateRequestHandler/stateRequestHandler.tsx +++ b/src/core/requestHandlers/stateRequestHandler/stateRequestHandler.tsx @@ -1,14 +1,19 @@ import type { Request, Response } from 'express'; import React from 'react'; import ReactDOMServer from 'react-dom/server'; -import { getProjects, getReviews } from '@/core/services/data'; +import { getProjects, getReleases, getReviews } from '@/core/services/data'; import { State } from './State'; export async function stateRequestHandler(req: Request, res: Response) { const search = typeof req.query.search === 'string' ? req.query.search : ''; - const [projects, reviews] = await Promise.all([getProjects(), getReviews()]); + const [projects, releases, reviews] = await Promise.all([ + getProjects(), + getReleases(), + getReviews(), + ]); const data = { projects: filter(projects, search), + releases: filter(releases, search), reviews: filter(reviews, search), }; diff --git a/src/core/services/data.ts b/src/core/services/data.ts index 6cf805f..e774e1a 100644 --- a/src/core/services/data.ts +++ b/src/core/services/data.ts @@ -1,8 +1,16 @@ -import { DataTypes, Op, Sequelize, type Model } from 'sequelize'; +import { + DataTypes, + Op, + Sequelize, + type Model, + type WhereOptions, +} from 'sequelize'; import { logger } from '@/core/services/logger'; import type { DatabaseEntry, DataProject, + DataRelease, + DataReleaseInternal, DataReview, } from '@/core/typings/Data'; import { getEnvVariable } from '@/core/utils/getEnvVariable'; @@ -26,6 +34,17 @@ const Project = sequelize.define>('Project', { projectId: { type: DataTypes.INTEGER, allowNull: false }, }); +const Release = sequelize.define>('Release', { + description: { type: DataTypes.TEXT, allowNull: true }, // TODO change this when possible + failedDeployments: { type: DataTypes.TEXT, allowNull: false }, + projectId: { type: DataTypes.INTEGER, allowNull: false }, + slackAuthor: { type: DataTypes.TEXT, allowNull: false }, + startedDeployments: { type: DataTypes.TEXT, allowNull: false }, + state: { type: DataTypes.STRING, allowNull: false }, + successfulDeployments: { type: DataTypes.TEXT, allowNull: false }, + tagName: { type: DataTypes.STRING, allowNull: false }, +}); + const Review = sequelize.define>('Review', { channelId: { type: DataTypes.STRING, allowNull: false }, mergeRequestIid: { type: DataTypes.INTEGER, allowNull: false }, @@ -44,6 +63,12 @@ export async function cleanOldEntries(): Promise { logger.info(`Cleaned ${cleanedReviewsCount} reviews.`); } +export async function cleanReleases( + filter: WhereOptions +): Promise { + await Release.destroy({ where: filter }); +} + export async function connectToDatabase(): Promise { await sequelize.sync({ alter: true }); await cleanOldEntries(); @@ -77,6 +102,44 @@ export async function addReviewToChannel({ } } +export async function createRelease( + release: DataRelease +): Promise { + const [releaseModel] = await Release.findOrCreate({ + where: { + ...release, + failedDeployments: JSON.stringify(release.failedDeployments), + slackAuthor: JSON.stringify(release.slackAuthor), + startedDeployments: JSON.stringify(release.startedDeployments), + successfulDeployments: JSON.stringify(release.successfulDeployments), + }, + }); + return formatRelease(releaseModel); +} + +export async function getProjectRelease( + projectId: number, + tagName: string +): Promise { + const releaseModel = await Release.findOne({ + where: { projectId, tagName }, + }); + if (!releaseModel) { + return undefined; + } + return formatRelease(releaseModel); +} + +export async function getProjectReleases( + projectId: number +): Promise { + return ( + await Release.findAll({ + where: { projectId }, + }) + ).map(formatRelease); +} + export async function getProjects(): Promise { return (await Project.findAll({ order: [['createdAt', 'DESC']] })).map( toJSON @@ -92,6 +155,14 @@ export async function getProjectsByChannelId( return projects.map(toJSON); } +export async function getReleases( + filter?: WhereOptions +): Promise { + return ( + await Release.findAll({ order: [['createdAt', 'DESC']], where: filter }) + ).map(formatRelease); +} + export async function getReviews(): Promise { return (await Review.findAll({ order: [['createdAt', 'DESC']] })).map(toJSON); } @@ -115,6 +186,13 @@ export async function getReviewsByMergeRequestIid( return reviews.map(toJSON); } +export async function hasRelease( + projectId: number, + tagName: string +): Promise { + return (await getProjectRelease(projectId, tagName)) !== undefined; +} + export async function removeProjectFromChannel( projectId: number, channelId: string @@ -124,6 +202,15 @@ export async function removeProjectFromChannel( }); } +export async function removeRelease( + projectId: number, + tagName: string +): Promise { + await Release.destroy({ + where: { projectId, tagName }, + }); +} + export async function removeReview(ts: string): Promise { await Review.destroy({ where: { ts }, @@ -138,6 +225,63 @@ export async function removeReviewsByMergeRequestIid( }); } +export async function updateRelease( + projectId: number, + tagName: string, + updatedReleaseDataGetter: (release: DataRelease) => Partial +): Promise { + // The transaction prevents race conditions when multiple calls to updateRelease are made at the + // same time. + return sequelize.transaction(async (transaction) => { + const releaseModel = await Release.findOne({ + where: { projectId, tagName }, + lock: transaction.LOCK.UPDATE, + transaction, + }); + + if (releaseModel === null) { + throw new Error(`Unable to find release ${tagName}`); + } + const release = formatRelease(releaseModel); + const updatedReleaseData = updatedReleaseDataGetter(release); + + return formatRelease( + await releaseModel.update( + { + ...release, + ...updatedReleaseData, + failedDeployments: JSON.stringify( + updatedReleaseData.failedDeployments ?? release.failedDeployments + ), + startedDeployments: JSON.stringify( + updatedReleaseData.startedDeployments ?? release.startedDeployments + ), + slackAuthor: JSON.stringify( + updatedReleaseData.slackAuthor ?? release.slackAuthor + ), + successfulDeployments: JSON.stringify( + updatedReleaseData.successfulDeployments ?? + release.successfulDeployments + ), + }, + { transaction } + ) + ); + }); +} + +function formatRelease(releaseModel: Model): DataRelease { + const internalRelease = toJSON(releaseModel); + + return { + ...internalRelease, + failedDeployments: JSON.parse(internalRelease.failedDeployments), + slackAuthor: JSON.parse(internalRelease.slackAuthor), + startedDeployments: JSON.parse(internalRelease.startedDeployments), + successfulDeployments: JSON.parse(internalRelease.successfulDeployments), + }; +} + function toJSON( model: Model ): DatabaseEntry { diff --git a/src/core/services/gitlab.ts b/src/core/services/gitlab.ts index 10714f9..aee286a 100644 --- a/src/core/services/gitlab.ts +++ b/src/core/services/gitlab.ts @@ -6,6 +6,7 @@ import type { GitlabApprovalsResponse } from '@/core/typings/GitlabApprovalsResp import type { GitlabBridge } from '@/core/typings/GitlabBridge'; import type { GitlabCiVariable } from '@/core/typings/GitlabCiVariable'; import type { GitlabCommit } from '@/core/typings/GitlabCommit'; +import type { GitlabDeployment } from '@/core/typings/GitlabDeployment'; import type { GitlabJob } from '@/core/typings/GitlabJob'; import type { GitlabMergeRequest, @@ -16,6 +17,7 @@ import type { GitlabProject, GitlabProjectDetails, } from '@/core/typings/GitlabProject'; +import type { GitlabRelease } from '@/core/typings/GitlabRelease'; import type { GitlabTag } from '@/core/typings/GitlabTag'; import type { GitlabUser, GitlabUserDetails } from '@/core/typings/GitlabUser'; import { getEnvVariable } from '@/core/utils/getEnvVariable'; @@ -79,6 +81,24 @@ export async function fetchBranchPipelines( return callAPI(`/projects/${projectId}/pipelines?ref=${branchName}`); } +export async function fetchDeploymentById( + projectId: number, + id: number +): Promise { + const deployment = await callAPI( + `/projects/${projectId}/deployments/${id}` + ); + + if (deployment.id === undefined) { + throw new Error( + `Unable to find deployment with id ${id} on project ${projectId}: ${JSON.stringify( + deployment + )}` + ); + } + return deployment; +} + export async function fetchMergeRequestApprovers( projectId: number, mergeRequestIid: number @@ -226,6 +246,24 @@ export async function fetchProjectTags( return callAPI(`/projects/${projectId}/repository/tags?per_page=100`); } +export async function fetchReleaseByTagName( + projectId: number, + tagName: string +): Promise { + const release = await callAPI( + `/projects/${projectId}/releases/${tagName}` + ); + + if (release.tag_name === undefined) { + throw new Error( + `Unable to find release with tag name ${tagName}: ${JSON.stringify( + release + )}` + ); + } + return release; +} + export async function fetchReviewers( projectId: number, mergeRequestIid: number @@ -326,6 +364,37 @@ export async function searchProjects(search: string): Promise { return callAPI(`/projects?search=${encodeURIComponent(search)}`); } +export async function updateReleaseName( + projectId: number, + tagName: string, + name: string +): Promise { + const body = { + id: projectId, + tag_name: tagName, + name, + }; + const response = await callAPI( + `/projects/${projectId}/releases/${tagName}`, + { + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' }, + method: 'put', + } + ); + + if (response?.tag_name === undefined) { + throw new Error( + `Unable to update release ${tagName} for project ${projectId}: ${JSON.stringify( + { + body, + response, + } + )}` + ); + } +} + async function callAPI(path: string, options?: RequestInit): Promise { const separator = path.includes('?') ? '&' : '?'; const response = await fetch( diff --git a/src/core/typings/Data.ts b/src/core/typings/Data.ts index db1317d..f50ebae 100644 --- a/src/core/typings/Data.ts +++ b/src/core/typings/Data.ts @@ -1,8 +1,36 @@ +import type { SlackUser } from '@/core/typings/SlackUser'; +import type { ReleaseState } from '@/release/typings/ReleaseState'; + export interface DataProject { channelId: string; projectId: number; } +export interface DataRelease { + description: string; + failedDeployments: string[]; + projectId: number; + slackAuthor: SlackUser; + startedDeployments: string[]; + state: ReleaseState; + successfulDeployments: string[]; + tagName: string; +} + +export interface DataReleaseInternal + extends Omit< + DataRelease, + | 'failedDeployments' + | 'slackAuthor' + | 'startedDeployments' + | 'successfulDeployments' + > { + failedDeployments: string; // stored as json + slackAuthor: string; // stored as json + startedDeployments: string; // stored as json + successfulDeployments: string; // stored as json +} + export interface DataReview { channelId: string; mergeRequestIid: number; diff --git a/src/core/typings/GitlabDeployment.ts b/src/core/typings/GitlabDeployment.ts new file mode 100644 index 0000000..7dc0dfa --- /dev/null +++ b/src/core/typings/GitlabDeployment.ts @@ -0,0 +1,69 @@ +import type { GitlabPipeline } from './GitlabPipeline'; +import type { GitlabUser } from './GitlabUser'; + +export type GitlabDeploymentStatus = + | 'canceled' + | 'failed' + | 'running' + | 'success'; + +export interface GitlabDeployment { + /** @example 2016-08-11T11:28:34.085Z */ + created_at: string; + deployable: { + commit: { + author_email: string; + author_name: string; + created_at: string; + id: string; + message: string; + short_id: string; + title: string; + }; + coverage: null; + created_at: string; + finished_at: string; + id: number; + name: string; + pipeline: Omit; + project: { + ci_job_token_scope_enabled: false; + }; + ref: string; + runner: null; + stage: string; + started_at: null; + status: string; + tag: false; + user: { + avatar_url: string; + bio: string | null; + /** @example 2016-08-11T11:28:34.085Z */ + created_at: string; + id: number; + linkedin: string; + location: null; + name: string; + organization: string; + skype: string; + state: string; + twitter: string; + username: string; + web_url: string; + website_url: string; + }; + }; + environment: { + id: number; + name: string; + external_url: string; + }; + id: number; + iid: number; + ref: string; + sha: string; + status: GitlabDeploymentStatus; + /** @example 2016-08-11T11:28:34.085Z */ + updated_at: string; + user: GitlabUser; +} diff --git a/src/core/typings/GitlabDeploymentHook.ts b/src/core/typings/GitlabDeploymentHook.ts new file mode 100644 index 0000000..139f8d9 --- /dev/null +++ b/src/core/typings/GitlabDeploymentHook.ts @@ -0,0 +1,40 @@ +import type { GitlabDeploymentStatus } from './GitlabDeployment'; + +export interface GitlabDeploymentHook { + commit_title: string; + commit_url: string; + deployable_id: number; + deployable_url: string; + deployment_id: number; + environment: string; + object_kind: 'deployment'; + project: { + avatar_url: null; + ci_config_path: string; + default_branch: string; + description: string; + git_http_url: string; + git_ssh_url: string; + homepage: string; + http_url: string; + id: number; + name: string; + namespace: string; + path_with_namespace: string; + ssh_url: string; + url: string; + visibility_level: number; + web_url: string; + }; + short_sha: string; + status: GitlabDeploymentStatus; + status_changed_at: string; // ex: 2021-04-28 21:50:00 +0200 + user: { + id: number; + name: string; + username: string; + avatar_url: string; + email: string; + }; + user_url: string; +} diff --git a/src/core/typings/GitlabRelease.ts b/src/core/typings/GitlabRelease.ts new file mode 100644 index 0000000..0e3416f --- /dev/null +++ b/src/core/typings/GitlabRelease.ts @@ -0,0 +1,60 @@ +export interface GitlabRelease { + tag_name: string; + description: string; + name: string; + created_at: string; + released_at: string; + author: { + id: number; + name: string; + username: string; + state: string; + avatar_url: string; + web_url: string; + }; + commit: { + id: string; + short_id: string; + title: string; + created_at: string; + parent_ids: string[]; + message: string; + author_name: string; + author_email: string; + authored_date: string; + committer_name: string; + committer_email: string; + committed_date: string; + }; + milestones: { + id: number; + iid: number; + project_id: number; + title: string; + description: string; + state: string; + created_at: string; + updated_at: string; + due_date: string; + start_date: string; + web_url: string; + issue_stats: { + total: number; + closed: number; + }; + }[]; + commit_path: string; + tag_path: string; + assets: { + count: number; + sources: { format: string; url: string }[]; + links: { + id: number; + name: string; + url: string; + external: boolean; + link_type: string; + }[]; + }; + evidences: { sha: string; filepath: string; collected_at: string }[]; +} diff --git a/src/release/commands/cancel/cancelReleaseRequestHandler.ts b/src/release/commands/cancel/cancelReleaseRequestHandler.ts new file mode 100644 index 0000000..854cb6f --- /dev/null +++ b/src/release/commands/cancel/cancelReleaseRequestHandler.ts @@ -0,0 +1,47 @@ +import type { Response } from 'express'; +import { Op } from 'sequelize'; +import { HTTP_STATUS_NO_CONTENT } from '@/constants'; +import { getReleases } from '@/core/services/data'; +import { slackBotWebClient } from '@/core/services/slack'; +import type { + SlackExpressRequest, + SlackSlashCommandResponse, +} from '@/core/typings/SlackSlashCommand'; +import type { ReleaseState } from '../../typings/ReleaseState'; +import { getChannelProjectReleaseConfigs } from '../../utils/configHelper'; +import { buildReleaseSelectionEphemeral } from '../../viewBuilders/buildReleaseSelectionEphemeral'; + +export async function cancelReleaseRequestHandler( + req: SlackExpressRequest, + res: Response +) { + res.sendStatus(HTTP_STATUS_NO_CONTENT); + + const { channel_id: channelId, user_id: userId } = + req.body as SlackSlashCommandResponse; + + const projectsIds = getChannelProjectReleaseConfigs(channelId).map( + ({ projectId }) => projectId + ); + const releases = await getReleases({ + projectId: { [Op.or]: projectsIds }, + state: { [Op.or]: ['notYetReady', 'created'] as ReleaseState[] }, + }); + + if (releases.length === 0) { + await slackBotWebClient.chat.postEphemeral({ + channel: channelId, + user: userId, + text: 'There is no release in progress in this channel :homer-donut:', + }); + } else { + await slackBotWebClient.chat.postEphemeral( + await buildReleaseSelectionEphemeral({ + action: 'cancel', + channelId, + releases, + userId, + }) + ); + } +} diff --git a/src/release/commands/cancel/selectReleaseToCancel.ts b/src/release/commands/cancel/selectReleaseToCancel.ts new file mode 100644 index 0000000..f3cff92 --- /dev/null +++ b/src/release/commands/cancel/selectReleaseToCancel.ts @@ -0,0 +1,90 @@ +import { getProjectRelease, removeRelease } from '@/core/services/data'; +import { + cancelPipeline, + fetchPipelinesByRef, + fetchProjectById, + fetchReleaseByTagName, + updateReleaseName, +} from '@/core/services/gitlab'; +import { logger } from '@/core/services/logger'; +import { + deleteEphemeralMessage, + fetchSlackUserFromGitlabUsername, + slackBotWebClient, +} from '@/core/services/slack'; +import type { BlockActionsPayload } from '@/core/typings/BlockActionPayload'; +import type { StaticSelectAction } from '@/core/typings/StaticSelectAction'; +import { extractActionParameters } from '@/core/utils/slackActions'; + +export async function selectReleaseToCancel( + payload: BlockActionsPayload, + action: StaticSelectAction +) { + const { container, response_url: responseUrl, user } = payload; + const { channel_id: channelId } = container; + const [projectIdAsString, tagName] = extractActionParameters( + action.selected_option.value + ); + const projectId = parseInt(projectIdAsString, 10); + + await deleteEphemeralMessage(responseUrl); + + const release = await getProjectRelease(projectId, tagName); + + if (release === undefined) { + throw new Error('Release to cancel not found'); + } + + switch (release.state) { + case 'notYetReady': + await removeRelease(projectId, tagName); + await slackBotWebClient.chat.postEphemeral({ + channel: channelId, + user: user.id, + text: 'Release canceled :homer-donut:', + }); + break; + + case 'created': { + const { name } = await fetchReleaseByTagName(projectId, tagName); + const [project, slackUser] = await Promise.all([ + fetchProjectById(projectId), + fetchSlackUserFromGitlabUsername(user.username), + updateReleaseName(projectId, tagName, `[NOT DEPLOYED] ${name}`), + removeRelease(projectId, tagName), + fetchPipelinesByRef(projectId, tagName).then(([pipeline]) => + cancelPipeline(projectId, pipeline.id) + ), + ]); + const releaseNotesUrl = `${project.web_url}/-/releases/${tagName}`; + + await slackBotWebClient.chat.postMessage({ + channel: channelId, + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `:homer: Release <${releaseNotesUrl}|${tagName}> canceled and marked as not deployed :homer-donut:`, + }, + }, + ], + icon_url: slackUser?.profile.image_72, + text: `Release <${releaseNotesUrl}|${tagName}> canceled and marked as not deployed.`, + username: slackUser?.real_name, + }); + break; + } + + case 'monitoring': + await slackBotWebClient.chat.postEphemeral({ + channel: channelId, + user: user.id, + text: 'It is too late to cancel that release :homer-stressed:', + }); + break; + + default: + logger.error(new Error(`Unknown release state: ${release.state}`)); + } +} diff --git a/src/release/commands/create/createReleaseRequestHandler.ts b/src/release/commands/create/createReleaseRequestHandler.ts new file mode 100644 index 0000000..89a3b4a --- /dev/null +++ b/src/release/commands/create/createReleaseRequestHandler.ts @@ -0,0 +1,22 @@ +import type { Response } from 'express'; +import { HTTP_STATUS_NO_CONTENT } from '@/constants'; +import { slackBotWebClient } from '@/core/services/slack'; +import type { + SlackExpressRequest, + SlackSlashCommandResponse, +} from '@/core/typings/SlackSlashCommand'; +import { buildReleaseModalView } from './viewBuilders/buildReleaseModalView'; + +export async function createReleaseRequestHandler( + req: SlackExpressRequest, + res: Response +) { + res.sendStatus(HTTP_STATUS_NO_CONTENT); + + const { channel_id, trigger_id } = req.body as SlackSlashCommandResponse; + + await slackBotWebClient.views.open({ + trigger_id, + view: await buildReleaseModalView({ channelId: channel_id }), + }); +} diff --git a/src/release/commands/create/hookHandlers/deploymentHookHandler.ts b/src/release/commands/create/hookHandlers/deploymentHookHandler.ts new file mode 100644 index 0000000..1ba7bcf --- /dev/null +++ b/src/release/commands/create/hookHandlers/deploymentHookHandler.ts @@ -0,0 +1,128 @@ +import type { Request, Response } from 'express'; +import { HTTP_STATUS_NO_CONTENT, HTTP_STATUS_OK } from '@/constants'; +import { hasRelease, removeRelease, updateRelease } from '@/core/services/data'; +import { fetchDeploymentById } from '@/core/services/gitlab'; +import { slackBotWebClient } from '@/core/services/slack'; +import type { DataRelease } from '@/core/typings/Data'; +import type { GitlabDeploymentStatus } from '@/core/typings/GitlabDeployment'; +import type { GitlabDeploymentHook } from '@/core/typings/GitlabDeploymentHook'; +import { + getProjectReleaseConfig, + hasProjectReleaseConfig, +} from '../../../utils/configHelper'; +import { buildReleaseStateMessage } from '../viewBuilders/buildReleaseStateMessage'; + +const STATUSES_TO_HANDLE: GitlabDeploymentStatus[] = [ + 'failed', + 'running', + 'success', +]; + +export async function deploymentHookHandler( + req: Request, + res: Response +): Promise { + const deploymentHook = req.body as GitlabDeploymentHook; + const { + deployment_id: deploymentId, + environment, + project, + status, + } = deploymentHook; + const projectId = project.id; + + if ( + !STATUSES_TO_HANDLE.includes(status) || + !hasProjectReleaseConfig(projectId) + ) { + res.sendStatus(HTTP_STATUS_NO_CONTENT); + return; + } + + const deployment = await fetchDeploymentById(projectId, deploymentId); + const releaseTagName = deployment.ref; + + if (!(await hasRelease(projectId, releaseTagName))) { + res.sendStatus(HTTP_STATUS_NO_CONTENT); + return; + } + res.sendStatus(HTTP_STATUS_OK); + + let updateGetter: (release: DataRelease) => Partial; + + switch (status) { + case 'failed': + updateGetter = ({ failedDeployments }) => ({ + failedDeployments: [...new Set([...failedDeployments, environment])], + }); + break; + + case 'running': + updateGetter = ({ startedDeployments }) => ({ + startedDeployments: [...new Set([...startedDeployments, environment])], + }); + break; + + case 'success': + updateGetter = ({ failedDeployments, successfulDeployments }) => ({ + failedDeployments: failedDeployments.filter((e) => e !== environment), + successfulDeployments: [ + ...new Set([...successfulDeployments, environment]), + ], + }); + break; + + default: + throw new Error(`Unhandled job status: ${status}`); + } + + const release = await updateRelease(projectId, releaseTagName, updateGetter); + const { notificationChannelIds, releaseManager } = + getProjectReleaseConfig(projectId); + + const releaseStateUpdates = await releaseManager.getReleaseStateUpdate( + release, + deploymentHook + ); + + if (releaseStateUpdates.length > 0) { + await Promise.all( + notificationChannelIds.map(async (channelId) => + slackBotWebClient.chat.postMessage( + buildReleaseStateMessage({ + channelId, + pipelineUrl: deployment.deployable.pipeline.web_url, + projectPathWithNamespace: project.path_with_namespace, + projectWebUrl: project.web_url, + releaseCreator: release.slackAuthor, + releaseStateUpdates, + releaseTagName: deployment.ref, + }) + ) + ) + ); + + const isReleaseCompleted = releaseStateUpdates.some( + (update) => + update.deploymentState === 'completed' && + ['production', 'support'].includes(update.environment) + ); + + if (isReleaseCompleted) { + await removeRelease(projectId, deployment.ref); + return; + } + + const isReleaseBeingMonitored = releaseStateUpdates.some( + (update) => + update.deploymentState === 'monitoring' && + ['production', 'support'].includes(update.environment) + ); + + if (isReleaseBeingMonitored) { + await updateRelease(projectId, releaseTagName, () => ({ + state: 'monitoring', + })); + } + } +} diff --git a/src/release/commands/create/managers/__tests__/semanticReleaseTagManager.test.ts b/src/release/commands/create/managers/__tests__/semanticReleaseTagManager.test.ts new file mode 100644 index 0000000..21504fc --- /dev/null +++ b/src/release/commands/create/managers/__tests__/semanticReleaseTagManager.test.ts @@ -0,0 +1,45 @@ +import { semanticReleaseTagManager } from '../semanticReleaseTagManager'; + +describe('semanticReleaseTagManager', () => { + describe('createReleaseTag', () => { + it('should create a tag', () => { + expect(semanticReleaseTagManager.createReleaseTag('1.2.3')).toEqual( + '1.2.4' + ); + }); + + it('should keep v prefix', () => { + expect(semanticReleaseTagManager.createReleaseTag('v1.2.3')).toEqual( + 'v1.2.4' + ); + }); + + it('should filter non v prefixes', () => { + expect(semanticReleaseTagManager.createReleaseTag('prefix1.2.3')).toEqual( + '1.2.4' + ); + }); + + it('should filter suffixes', () => { + expect( + semanticReleaseTagManager.createReleaseTag('1.2.3-suffix-test-123') + ).toEqual('1.2.4'); + }); + }); + + describe('isReleaseTag', () => { + it('should detect regular tags', () => { + expect(semanticReleaseTagManager.isReleaseTag('1.2.3')).toEqual(true); + }); + + it('should detect tags starting with non numeric value', () => { + expect(semanticReleaseTagManager.isReleaseTag('v1.2.3')).toEqual(true); + }); + + it('should detect tags ending with non numeric value', () => { + expect(semanticReleaseTagManager.isReleaseTag('1.2.3-beta')).toEqual( + true + ); + }); + }); +}); diff --git a/src/release/commands/create/managers/defaultReleaseManager.ts b/src/release/commands/create/managers/defaultReleaseManager.ts new file mode 100644 index 0000000..9f12e5c --- /dev/null +++ b/src/release/commands/create/managers/defaultReleaseManager.ts @@ -0,0 +1,98 @@ +import { fetchPipelineJobs } from '@/core/services/gitlab'; +import type { DataRelease } from '@/core/typings/Data'; +import type { GitlabDeploymentHook } from '@/core/typings/GitlabDeploymentHook'; +import type { ReleaseManager } from '../../../typings/ReleaseManager'; +import type { ReleaseStateUpdate } from '../../../typings/ReleaseStateUpdate'; + +const dockerBuildJobNames = [ + 'Build Image', + 'Build with KO', + 'build-application', + 'build_ecs_image', + 'build_image', +]; + +async function getReleaseStateUpdate( + { failedDeployments, state, successfulDeployments }: DataRelease, + deploymentHook?: GitlabDeploymentHook +): Promise { + if (deploymentHook === undefined) { + const isProductionEnvironment = successfulDeployments.some((env) => + env.startsWith('production') + ); + return isProductionEnvironment && state === 'monitoring' + ? [{ deploymentState: 'completed', environment: 'production' }] + : []; + } + + const { environment, status } = deploymentHook; + + if (environment.startsWith('staging')) { + switch (status) { + case 'failed': + return [{ deploymentState: 'failed', environment: 'staging' }]; + + case 'running': + return [{ deploymentState: 'deploying', environment: 'staging' }]; + + case 'success': + return [{ deploymentState: 'monitoring', environment: 'staging' }]; + + default: + throw new Error(`Unhandled staging deployment status: ${status}`); + } + } else if (environment.startsWith('production')) { + switch (status) { + case 'failed': + return [{ deploymentState: 'failed', environment: 'production' }]; + + case 'running': + return failedDeployments.length === 0 + ? [ + { deploymentState: 'completed', environment: 'staging' }, + { deploymentState: 'deploying', environment: 'production' }, + ] + : [{ deploymentState: 'deploying', environment: 'production' }]; + + case 'success': + return [{ deploymentState: 'monitoring', environment: 'production' }]; + + default: + throw new Error(`Unhandled production deployment status: ${status}`); + } + } else if (environment.startsWith('support')) { + switch (status) { + case 'failed': + return [{ deploymentState: 'failed', environment: 'support' }]; + + case 'running': + return [{ deploymentState: 'deploying', environment: 'support' }]; + + case 'success': + return [{ deploymentState: 'completed', environment: 'support' }]; + + default: + throw new Error(`Unhandled support deployment status: ${status}`); + } + } + return []; +} + +export async function isReadyToRelease( + { projectId }: DataRelease, + mainBranchPipelineId: number +) { + const pipelinesJobs = await fetchPipelineJobs( + projectId, + mainBranchPipelineId + ); + const dockerBuildJob = pipelinesJobs.find((job) => + dockerBuildJobNames.includes(job.name) + ); + return dockerBuildJob?.status === 'success'; +} + +export const defaultReleaseManager: ReleaseManager = { + getReleaseStateUpdate, + isReadyToRelease, +}; diff --git a/src/release/commands/create/managers/federationReleaseTagManager.ts b/src/release/commands/create/managers/federationReleaseTagManager.ts new file mode 100644 index 0000000..cab43e9 --- /dev/null +++ b/src/release/commands/create/managers/federationReleaseTagManager.ts @@ -0,0 +1,29 @@ +import dayjs from 'dayjs'; +import timezone from 'dayjs/plugin/timezone'; +import utc from 'dayjs/plugin/utc'; + +dayjs.extend(utc); +dayjs.extend(timezone); + +function createReleaseTag(appName: string): string { + return `mf-${appName}-${dayjs().tz('Europe/Paris').format('YYYYMMDD-HHmm')}`; +} + +function extractAppName(tag: string): string { + const appName = tag.match(/^mf-([a-z]+)-\d{8}-\d{4}$/)?.[1]; + + if (appName === undefined) { + throw new Error(`Unable to extract app name from '${tag}'`); + } + return appName; +} + +function isReleaseTag(tag: string, appName?: string): boolean { + return new RegExp(`^mf-${appName ?? '[a-z]+'}-\\d{8}-\\d{4}$`).test(tag); +} + +export const federationReleaseTagManager = { + createReleaseTag, + extractAppName, + isReleaseTag, +}; diff --git a/src/release/commands/create/managers/libraryReleaseManager.ts b/src/release/commands/create/managers/libraryReleaseManager.ts new file mode 100644 index 0000000..28d9d2c --- /dev/null +++ b/src/release/commands/create/managers/libraryReleaseManager.ts @@ -0,0 +1,29 @@ +import { fetchPipelineJobs } from '@/core/services/gitlab'; +import type { DataRelease } from '@/core/typings/Data'; +import type { ReleaseManager } from '../../../typings/ReleaseManager'; +import type { ReleaseStateUpdate } from '../../../typings/ReleaseStateUpdate'; + +const buildJobNames = ['goreleaser-build-snapshot']; + +async function getReleaseStateUpdate(): Promise { + return []; +} + +export async function isReadyToRelease( + { projectId }: DataRelease, + mainBranchPipelineId: number +) { + const pipelinesJobs = await fetchPipelineJobs( + projectId, + mainBranchPipelineId + ); + const buildJob = pipelinesJobs.find((job) => + buildJobNames.includes(job.name) + ); + return buildJob?.status === 'success'; +} + +export const libraryReleaseManager: ReleaseManager = { + getReleaseStateUpdate, + isReadyToRelease, +}; diff --git a/src/release/commands/create/managers/semanticReleaseTagManager.ts b/src/release/commands/create/managers/semanticReleaseTagManager.ts new file mode 100644 index 0000000..75d908a --- /dev/null +++ b/src/release/commands/create/managers/semanticReleaseTagManager.ts @@ -0,0 +1,22 @@ +import type { ReleaseTagManager } from '../../../typings/ReleaseTagManager'; + +/** x.x.x (semantic versioning) */ +function createReleaseTag(previousReleaseTag?: string): string { + if (previousReleaseTag === undefined) { + return '1.0.0'; + } + + const [prefix, major, minor, patch] = + previousReleaseTag.match(/(v?)(\d+)\.(\d+)\.(\d+)/)?.slice(1) ?? []; + + return `${prefix}${major}.${minor}.${Number(patch) + 1}`; +} + +function isReleaseTag(tag: string): boolean { + return /\d+\.\d+\.\d+/.test(tag); +} + +export const semanticReleaseTagManager: ReleaseTagManager = { + createReleaseTag, + isReleaseTag, +}; diff --git a/src/release/commands/create/managers/stableDateReleaseTagManager.ts b/src/release/commands/create/managers/stableDateReleaseTagManager.ts new file mode 100644 index 0000000..0aa6bf9 --- /dev/null +++ b/src/release/commands/create/managers/stableDateReleaseTagManager.ts @@ -0,0 +1,21 @@ +import dayjs from 'dayjs'; +import timezone from 'dayjs/plugin/timezone'; +import utc from 'dayjs/plugin/utc'; +import type { ReleaseTagManager } from '../../../typings/ReleaseTagManager'; + +dayjs.extend(utc); +dayjs.extend(timezone); + +/** stable-YYYYMMDD-HHmm */ +export function createReleaseTag(): string { + return `stable-${dayjs().tz('Europe/Paris').format('YYYYMMDD-HHmm')}`; +} + +function isReleaseTag(tag: string): boolean { + return /^stable-\d{8}-\d{4}$/.test(tag); +} + +export const stableDateReleaseTagManager: ReleaseTagManager = { + createReleaseTag, + isReleaseTag, +}; diff --git a/src/release/commands/create/utils/addLoaderToReleaseModal.ts b/src/release/commands/create/utils/addLoaderToReleaseModal.ts new file mode 100644 index 0000000..374d848 --- /dev/null +++ b/src/release/commands/create/utils/addLoaderToReleaseModal.ts @@ -0,0 +1,28 @@ +import { slackBotWebClient } from '@/core/services/slack'; +import type { BlockActionView } from '@/core/typings/BlockActionPayload'; + +export async function addLoaderToReleaseModal(view: BlockActionView) { + const { blocks, callback_id, id, notify_on_close, submit, title, type } = + view; + const currentView = { + blocks, + callback_id, + notify_on_close, + submit, + title, + type, + }; + + view.blocks.push({ + type: 'section', + text: { + type: 'plain_text', + text: ':loader:', + }, + }); + + await slackBotWebClient.views.update({ + view_id: id, + view: currentView, + }); +} diff --git a/src/release/commands/create/utils/createRelease.ts b/src/release/commands/create/utils/createRelease.ts new file mode 100644 index 0000000..bd237fc --- /dev/null +++ b/src/release/commands/create/utils/createRelease.ts @@ -0,0 +1,80 @@ +import { Op } from 'sequelize'; +import { generateChangelog } from '@/changelog/utils/generateChangelog'; +import { + cleanReleases, + createRelease as createReleaseEntry, + getProjectReleases, +} from '@/core/services/data'; +import { fetchSlackUserFromId } from '@/core/services/slack'; +import type { DataRelease } from '@/core/typings/Data'; +import type { ModalViewSubmissionPayload } from '@/core/typings/ModalViewSubmissionPayload'; +import { getProjectReleaseConfig } from '../../../utils/configHelper'; +import { waitForReadinessAndStartRelease } from './waitForReadinessAndStartRelease'; + +export async function createRelease( + payload: ModalViewSubmissionPayload +): Promise { + const { user, view } = payload; + const { values } = view.state; + + const projectId = parseInt( + values['release-project-block']['release-select-project-action'] + .selected_option.value, + 10 + ); + + const releaseTagName: string = + values['release-tag-block']['release-tag-action'].value; + + const previousReleaseTagName: string | undefined = + values['release-previous-tag-block']?.['release-select-previous-tag-action'] + ?.selected_option.value; + + const { releaseManager } = getProjectReleaseConfig(projectId); + + const [description, slackAuthor] = await Promise.all([ + generateChangelog( + projectId, + previousReleaseTagName, + (commit) => releaseManager.filterChangelog?.(commit, view.state) ?? true + ), + fetchSlackUserFromId(user.id), + ]); + + if (slackAuthor === undefined) { + throw new Error( + `Unable to retrieve Slack user of release creator using id ${user.id}` + ); + } + + const releaseData: DataRelease = { + description, + failedDeployments: [], + projectId, + slackAuthor, + startedDeployments: [], + state: 'notYetReady', + successfulDeployments: [], + tagName: releaseTagName, + }; + + // Clean previous release to prevent conflicts + if (releaseManager.filterReleasesToClean) { + const projectReleases = await getProjectReleases(projectId); + const tagNames = releaseManager + .filterReleasesToClean(releaseData, projectReleases) + .map(({ tagName }) => tagName); + + if (tagNames.length > 0) { + await cleanReleases({ + projectId, + tagName: { [Op.or]: tagNames }, + }); + } + } else { + await cleanReleases({ projectId }); + } + + const release = await createReleaseEntry(releaseData); + await waitForReadinessAndStartRelease(release); +} diff --git a/src/release/commands/create/utils/getBranchLastPipeline.ts b/src/release/commands/create/utils/getBranchLastPipeline.ts new file mode 100644 index 0000000..9befed7 --- /dev/null +++ b/src/release/commands/create/utils/getBranchLastPipeline.ts @@ -0,0 +1,19 @@ +import { fetchBranchPipelines } from '@/core/services/gitlab'; +import type { GitlabPipeline } from '@/core/typings/GitlabPipeline'; + +export async function getBranchLastPipeline( + projectId: number, + branchName: string +): Promise { + const branchPipelines = await fetchBranchPipelines(projectId, branchName); + + const lastPipeline = branchPipelines.find( + (pipeline) => + pipeline.status !== 'canceled' && pipeline.source !== 'schedule' + ); + + if (lastPipeline === undefined) { + throw new Error(`Unable to find a non cancelled ${branchName} pipeline`); + } + return lastPipeline; +} diff --git a/src/release/commands/create/utils/slackifyChangelog.ts b/src/release/commands/create/utils/slackifyChangelog.ts new file mode 100644 index 0000000..f6d04b2 --- /dev/null +++ b/src/release/commands/create/utils/slackifyChangelog.ts @@ -0,0 +1,19 @@ +import slackifyMarkdown from 'slackify-markdown'; + +const SLACK_CHARACTER_LIMIT = 3000; + +export function slackifyChangelog(changelog: string): string { + let slackifiedChangelog = slackifyMarkdown(changelog); + + // Slack allows only 3000 characters in text field + if (slackifiedChangelog.length > SLACK_CHARACTER_LIMIT) { + slackifiedChangelog = slackifiedChangelog + .slice(0, SLACK_CHARACTER_LIMIT) + .split('\n') + .slice(0, -2) + .join('\n'); + + slackifiedChangelog = `${slackifiedChangelog}\n\n*⚠️ Changelog truncated due to Slack limitations.*`; + } + return slackifiedChangelog; +} diff --git a/src/release/commands/create/utils/startRelease.ts b/src/release/commands/create/utils/startRelease.ts new file mode 100644 index 0000000..843e005 --- /dev/null +++ b/src/release/commands/create/utils/startRelease.ts @@ -0,0 +1,76 @@ +import slackifyMarkdown from 'slackify-markdown'; +import { createRelease as createGitlabRelease } from '@/core/services/gitlab'; +import { slackBotWebClient } from '@/core/services/slack'; +import type { GitlabProject } from '@/core/typings/GitlabProject'; +import type { SlackUser } from '@/core/typings/SlackUser'; +import { getProjectReleaseConfig } from '../../../utils/configHelper'; +import { waitForReleasePipeline } from './waitForReleasePipeline'; + +interface StartReleaseData { + commitId: string; + description: string; + project: GitlabProject; + releaseCreator: SlackUser; + releaseTagName: string; + hasReleasePipeline: boolean | undefined; +} + +export async function startRelease({ + commitId, + description, + project, + releaseCreator, + releaseTagName, + hasReleasePipeline = true, +}: StartReleaseData): Promise { + const { releaseChannelId } = getProjectReleaseConfig(project.id); + + await createGitlabRelease(project.id, commitId, releaseTagName, description); + + await slackBotWebClient.chat.postEphemeral({ + channel: releaseChannelId, + user: releaseCreator.id, + text: `Release \`${releaseTagName}\` started for \`${project.path}\` :homer-happy:`, + }); + + if (hasReleasePipeline) { + const pipeline = await waitForReleasePipeline(project.id, releaseTagName); + const pipelineUrl = pipeline?.web_url; + + if (pipelineUrl) { + await slackBotWebClient.chat.postEphemeral({ + channel: releaseChannelId, + user: releaseCreator.id, + text: `↳ <${pipelineUrl}|pipeline> :homer-donut:`, + }); + } + } + + await slackBotWebClient.chat.postMessage({ + channel: releaseChannelId, + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `:homer: New release <${ + project.web_url + }/-/releases/${releaseTagName}|${releaseTagName}> for project <${ + project.web_url + }|${project.path_with_namespace}>${ + description + ? `:\n${slackifyMarkdown(description) + .split('\n') + .filter(Boolean) + .map((line) => `  ${line}`) + .join('\n')}` + : '.' + }`, + }, + }, + ], + icon_url: releaseCreator.profile.image_72, + text: `New release ${releaseTagName} for project ${project.path_with_namespace}.`, + username: releaseCreator.real_name, + }); +} diff --git a/src/release/commands/create/utils/updateReleaseChangelog.ts b/src/release/commands/create/utils/updateReleaseChangelog.ts new file mode 100644 index 0000000..e4b31cf --- /dev/null +++ b/src/release/commands/create/utils/updateReleaseChangelog.ts @@ -0,0 +1,26 @@ +import { slackBotWebClient } from '@/core/services/slack'; +import type { BlockActionsPayload } from '@/core/typings/BlockActionPayload'; +import { cleanViewState } from '@/core/utils/cleanViewState'; +import { buildReleaseModalView } from '../viewBuilders/buildReleaseModalView'; +import { addLoaderToReleaseModal } from './addLoaderToReleaseModal'; + +export async function updateReleaseChangelog({ view }: BlockActionsPayload) { + const { blocks, id } = view; + const previousReleaseInfoBlockIndex = blocks.findIndex( + (block) => block.block_id === 'release-previous-tag-info-block' + ); + + if (previousReleaseInfoBlockIndex !== -1) { + blocks.splice(previousReleaseInfoBlockIndex + 1); + cleanViewState(view); + } + + const viewPromise = buildReleaseModalView({ view }); + + await addLoaderToReleaseModal(view); + + await slackBotWebClient.views.update({ + view_id: id, + view: await viewPromise, + }); +} diff --git a/src/release/commands/create/utils/updateReleaseProject.ts b/src/release/commands/create/utils/updateReleaseProject.ts new file mode 100644 index 0000000..3921f7e --- /dev/null +++ b/src/release/commands/create/utils/updateReleaseProject.ts @@ -0,0 +1,24 @@ +import { slackBotWebClient } from '@/core/services/slack'; +import type { BlockActionsPayload } from '@/core/typings/BlockActionPayload'; +import { cleanViewState } from '@/core/utils/cleanViewState'; +import { buildReleaseModalView } from '../viewBuilders/buildReleaseModalView'; +import { addLoaderToReleaseModal } from './addLoaderToReleaseModal'; + +export async function updateReleaseProject({ view }: BlockActionsPayload) { + const { blocks, id } = view; + const projectBlockIndex = blocks.findIndex( + (block) => block.block_id === 'release-project-block' + ); + + blocks.splice(projectBlockIndex + 1); + cleanViewState(view); + + const viewPromise = buildReleaseModalView({ view }); + + await addLoaderToReleaseModal(view); + + await slackBotWebClient.views.update({ + view_id: id, + view: await viewPromise, + }); +} diff --git a/src/release/commands/create/utils/waitForNonReadyReleases.ts b/src/release/commands/create/utils/waitForNonReadyReleases.ts new file mode 100644 index 0000000..824c1ee --- /dev/null +++ b/src/release/commands/create/utils/waitForNonReadyReleases.ts @@ -0,0 +1,12 @@ +import { getReleases } from '@/core/services/data'; +import { waitForReadinessAndStartRelease } from '@/release/commands/create/utils/waitForReadinessAndStartRelease'; + +export async function waitForNonReadyReleases(): Promise { + const releases = await getReleases({ state: 'notYetReady' }); + + await Promise.all( + releases.map(async (release) => + waitForReadinessAndStartRelease(release, false) + ) + ); +} diff --git a/src/release/commands/create/utils/waitForReadinessAndStartRelease.ts b/src/release/commands/create/utils/waitForReadinessAndStartRelease.ts new file mode 100644 index 0000000..251f47c --- /dev/null +++ b/src/release/commands/create/utils/waitForReadinessAndStartRelease.ts @@ -0,0 +1,138 @@ +import { + getProjectRelease, + hasRelease, + removeRelease, + updateRelease, +} from '@/core/services/data'; +import { fetchProjectById } from '@/core/services/gitlab'; +import { logger } from '@/core/services/logger'; +import { slackBotWebClient } from '@/core/services/slack'; +import type { DatabaseEntry, DataRelease } from '@/core/typings/Data'; +import { getBranchLastPipeline } from '@/release/commands/create/utils/getBranchLastPipeline'; +import { startRelease } from '@/release/commands/create/utils/startRelease'; +import type { ReleaseManager } from '@/release/typings/ReleaseManager'; +import { getProjectReleaseConfig } from '@/release/utils/configHelper'; + +const READINESS_TIMEOUT_DELAY_MS = 45 * 60 * 1000; // 45 minutes +const READINESS_CHECK_DELAY_MS = 30 * 1000; // 30 seconds + +export async function waitForReadinessAndStartRelease( + release: DataRelease, + notifyUserIfNotReady = true +): Promise { + const { createdAt, description, projectId, slackAuthor, tagName } = + release as DatabaseEntry; + + if (Date.now() - new Date(createdAt).getTime() > READINESS_TIMEOUT_DELAY_MS) { + logger.error( + `The release ${tagName} of project ${projectId} is obsolete because it has already reached timeout so it will be removed.` + ); + await removeRelease(projectId, tagName); + return; + } + + const { releaseChannelId, releaseManager, hasReleasePipeline } = + getProjectReleaseConfig(projectId); + + const project = await fetchProjectById(projectId); + + const mainBranchPipeline = await getBranchLastPipeline( + projectId, + project.default_branch + ); + + let hasReachedTimeout = false; + let isReady = await releaseManager.isReadyToRelease( + release, + mainBranchPipeline.id + ); + + if (!isReady) { + [{ hasReachedTimeout, isReady }] = await Promise.all([ + waitForReadiness(releaseManager, release, mainBranchPipeline.id), + notifyUserIfNotReady + ? slackBotWebClient.chat.postEphemeral({ + channel: releaseChannelId, + user: slackAuthor.id, + text: `The preconditions to launch a release are not yet met, I will \ +wait for them and start the release automatically (<${mainBranchPipeline.web_url}|pipeline>) :homer-donut:`, + }) + : Promise.resolve(), + ]); + + const releaseState = (await getProjectRelease(projectId, tagName))?.state; + + if (releaseState !== 'notYetReady') { + logger.error( + `Potential concurrency issue detected for release ${tagName} of project ${projectId}.` + ); + return; + } + } + + if (isReady) { + await Promise.all([ + startRelease({ + commitId: mainBranchPipeline.sha, + description, + project, + releaseCreator: slackAuthor, + releaseTagName: tagName, + hasReleasePipeline, + }), + updateRelease(project.id, tagName, () => ({ + state: 'created', + })), + ]); + } else if (hasReachedTimeout) { + await Promise.all([ + removeRelease(projectId, tagName), + slackBotWebClient.chat.postEphemeral({ + channel: releaseChannelId, + user: slackAuthor.id, + text: `Timeout has been reached while waiting for the <${mainBranchPipeline.web_url}|pipeline> to be ready :homer-stressed: Please launch a new release :homer-donut:`, + }), + ]); + } +} + +async function waitForReadiness( + releaseManager: ReleaseManager, + release: DataRelease, + mainBranchPipelineId: number +): Promise<{ hasReachedTimeout: boolean; isReady: boolean }> { + const { projectId, tagName } = release; + let hasReachedTimeout = false; + let isReady = false; + + const timeout = setTimeout(() => { + logger.error( + `Readiness timeout reached for release ${tagName} of project ${projectId}` + ); + hasReachedTimeout = true; + }, READINESS_TIMEOUT_DELAY_MS); + + while (!hasReachedTimeout) { + const doesReleaseExist = await hasRelease(projectId, tagName); + + if (!doesReleaseExist) { + clearTimeout(timeout); + break; + } + + isReady = await releaseManager.isReadyToRelease( + release, + mainBranchPipelineId + ); + + if (isReady) { + clearTimeout(timeout); + break; + } + + await new Promise((resolve) => { + setTimeout(resolve, READINESS_CHECK_DELAY_MS); + }); + } + return { hasReachedTimeout, isReady }; +} diff --git a/src/release/commands/create/utils/waitForReleasePipeline.ts b/src/release/commands/create/utils/waitForReleasePipeline.ts new file mode 100644 index 0000000..0f49dd9 --- /dev/null +++ b/src/release/commands/create/utils/waitForReleasePipeline.ts @@ -0,0 +1,35 @@ +import { fetchPipelinesByRef } from '@/core/services/gitlab'; +import { logger } from '@/core/services/logger'; +import type { GitlabPipeline } from '@/core/typings/GitlabPipeline'; + +const PIPELINE_RETRIEVE_TIMEOUT = 30000; +const DELAY_BETWEEN_PIPELINE_RETRIEVES = 2000; + +export async function waitForReleasePipeline( + projectId: number, + releaseTagName: string +): Promise { + let pipeline: GitlabPipeline | undefined; + let timeoutReached = false; + + const timeout = setTimeout(() => { + logger.error( + `Unable to retrieve pipeline for release ${releaseTagName} of project ${projectId}` + ); + timeoutReached = true; + }, PIPELINE_RETRIEVE_TIMEOUT); + + while (!timeoutReached) { + const pipelines = await fetchPipelinesByRef(projectId, releaseTagName); + + if (pipelines.length > 0) { + clearTimeout(timeout); + [pipeline] = pipelines; + break; + } + await new Promise((resolve) => { + setTimeout(resolve, DELAY_BETWEEN_PIPELINE_RETRIEVES); + }); + } + return pipeline; +} diff --git a/src/release/commands/create/viewBuilders/buildReleaseModalView.ts b/src/release/commands/create/viewBuilders/buildReleaseModalView.ts new file mode 100644 index 0000000..53b924a --- /dev/null +++ b/src/release/commands/create/viewBuilders/buildReleaseModalView.ts @@ -0,0 +1,245 @@ +import type { + Block, + InputBlock, + KnownBlock, + StaticSelect, + View, +} from '@slack/web-api'; +import { generateChangelog } from '@/changelog/utils/generateChangelog'; +import { fetchProjectById, fetchProjectTags } from '@/core/services/gitlab'; +import type { BlockActionView } from '@/core/typings/BlockActionPayload'; +import type { SlackOption } from '@/core/typings/SlackOption'; +import { + getChannelProjectReleaseConfigs, + getProjectReleaseConfig, +} from '../../../utils/configHelper'; +import { slackifyChangelog } from '../utils/slackifyChangelog'; + +interface ReleaseModalData { + channelId?: string; + view?: BlockActionView; +} + +export async function buildReleaseModalView({ + channelId, + view, +}: ReleaseModalData): Promise { + let previousReleaseTagName: string | undefined; + let projectId: number | undefined; + let projectOptions: SlackOption[] | undefined; + + if (view !== undefined) { + const { blocks, state } = view; + + previousReleaseTagName = + state.values['release-previous-tag-block']?.[ + 'release-select-previous-tag-action' + ]?.selected_option?.value; + + projectId = parseInt( + state.values['release-project-block']?.['release-select-project-action'] + ?.selected_option?.value, + 10 + ); + + projectOptions = ((blocks[0] as InputBlock).element as StaticSelect) + .options as SlackOption[]; + } + + if (projectOptions === undefined && channelId !== undefined) { + const projectReleaseConfigs = getChannelProjectReleaseConfigs(channelId); + const projects = await Promise.all( + projectReleaseConfigs.map(async (config) => + fetchProjectById(config.projectId) + ) + ); + + projectOptions = projects + .sort((a, b) => + a.path_with_namespace.localeCompare(b.path_with_namespace) + ) + .map((project) => ({ + text: { + type: 'plain_text', + text: project.path_with_namespace, + }, + value: project.id.toString(), + })) as SlackOption[]; + } + + if (!projectOptions || projectOptions.length === 0) { + throw new Error( + 'No releasable Gitlab project has been found on this channel :homer-stressed:' + ); + } + + if (projectId === undefined) { + projectId = parseInt(projectOptions[0].value, 10); + } + + const { releaseTagManager, releaseManager } = + getProjectReleaseConfig(projectId); + + if (releaseManager.buildReleaseModalView) { + return releaseManager.buildReleaseModalView({ + projectId, + projectOptions, + view, + }); + } + + if (releaseTagManager === undefined) { + throw new Error( + `The Gitlab project ${projectId} should either provide a release tag manager or a custom build modal method.` + ); + } + + const tags = (await fetchProjectTags(projectId)) + .filter(({ name }) => releaseTagManager.isReleaseTag(name)) + .slice(0, 5); + + const previousReleaseTag = + previousReleaseTagName !== undefined + ? tags.find(({ name }) => name === previousReleaseTagName) + : undefined; + + if ( + previousReleaseTagName !== undefined && + previousReleaseTag === undefined + ) { + throw new Error(`Previous release tag ${previousReleaseTagName} not found`); + } + + if (tags.length > 0 && previousReleaseTagName === undefined) { + previousReleaseTagName = tags[0].name; + } + + const changelog = previousReleaseTagName + ? await generateChangelog(projectId, previousReleaseTagName) + : ''; + + const previousReleaseOptions = tags.map(({ name }) => ({ + text: { + type: 'plain_text', + text: name, + }, + value: name, + })) as SlackOption[]; + + return { + type: 'modal', + callback_id: 'release-create-modal', + title: { + type: 'plain_text', + text: 'Release', + }, + submit: { + type: 'plain_text', + text: 'Start', + }, + notify_on_close: false, + blocks: [ + { + type: 'input', + block_id: 'release-project-block', + dispatch_action: true, + element: { + type: 'static_select', + action_id: 'release-select-project-action', + initial_option: projectOptions?.[0], + options: projectOptions, + placeholder: { + type: 'plain_text', + text: 'Select the project', + }, + }, + label: { + type: 'plain_text', + text: 'Project', + }, + }, + { + type: 'input', + block_id: 'release-tag-block', + element: { + type: 'plain_text_input', + action_id: 'release-tag-action', + initial_value: releaseTagManager.createReleaseTag( + previousReleaseTagName + ), + }, + label: { + type: 'plain_text', + text: 'Release tag', + }, + }, + previousReleaseOptions.length > 0 + ? [ + { + type: 'input', + block_id: 'release-previous-tag-block', + dispatch_action: true, + element: { + type: 'static_select', + action_id: 'release-select-previous-tag-action', + initial_option: previousReleaseOptions[0], + options: previousReleaseOptions, + placeholder: { + type: 'plain_text', + text: 'Select the previous release tag', + }, + }, + label: { + type: 'plain_text', + text: 'Previous release tag', + }, + }, + { + type: 'context', + block_id: 'release-previous-tag-info-block', + elements: [ + { + type: 'plain_text', + text: 'This should be changed only whether the previous release has been aborted.', + }, + ], + }, + ] + : [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: '*Previous release tag*', + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: 'No previous release tag has been found.', + }, + }, + ], + { + type: 'section', + text: { + type: 'mrkdwn', + text: '*Changelog*', + }, + }, + { + type: 'section', + block_id: 'release-changelog-block', + text: { + type: 'mrkdwn', + text: changelog + ? slackifyChangelog(changelog) + : 'No change has been found.', + }, + }, + ] + .flat() + .filter(Boolean) as (KnownBlock | Block)[], + }; +} diff --git a/src/release/commands/create/viewBuilders/buildReleaseStateMessage.ts b/src/release/commands/create/viewBuilders/buildReleaseStateMessage.ts new file mode 100644 index 0000000..9ce945d --- /dev/null +++ b/src/release/commands/create/viewBuilders/buildReleaseStateMessage.ts @@ -0,0 +1,93 @@ +import type { ChatPostMessageArguments } from '@slack/web-api'; +import type { SlackUser } from '@/core/typings/SlackUser'; +import type { ReleaseStateUpdate } from '../../../typings/ReleaseStateUpdate'; + +interface ReleaseMessageData { + channelId: string; + pipelineUrl: string; + projectPathWithNamespace: string; + projectWebUrl: string; + releaseCreator: SlackUser; + releaseStateUpdates: ReleaseStateUpdate[]; + releaseTagName: string; +} + +export function buildReleaseStateMessage({ + channelId, + pipelineUrl, + projectPathWithNamespace, + projectWebUrl, + releaseCreator, + releaseStateUpdates, + releaseTagName, +}: ReleaseMessageData): ChatPostMessageArguments { + const blocks = releaseStateUpdates.map( + ({ + deploymentState, + environment, + projectDisplayName = projectPathWithNamespace.split('/').pop(), + }) => { + let emoji = ''; + let formattedEnvironment = ''; + + switch (deploymentState) { + case 'deploying': + emoji = ':rocket:'; + break; + + case 'failed': + emoji = ':rocket-boom:'; + break; + + case 'monitoring': + emoji = ':mag:'; + break; + + case 'completed': + emoji = ':ccheck:'; + break; + + default: + throw new Error(`Unknown deployment state: ${deploymentState}`); + } + + switch (environment) { + case 'integration': + formattedEnvironment = 'INT'; + break; + + case 'production': + formattedEnvironment = 'PRD'; + break; + + case 'staging': + formattedEnvironment = 'STG'; + break; + + case 'support': + formattedEnvironment = 'SUP'; + break; + + default: + throw new Error(`Unknown environment: ${environment}`); + } + + return { + type: 'section', + text: { + type: 'mrkdwn', + text: `${emoji} ${projectDisplayName} ${formattedEnvironment} - <${pipelineUrl}|pipeline> - <${projectWebUrl}/-/releases/${releaseTagName}|release notes>`, + }, + }; + } + ); + + return { + channel: channelId, + blocks, + icon_url: releaseCreator.profile.image_72, + link_names: true, + text: blocks.map((block) => block.text.text.split(' - ')[0]).join('\n'), + username: releaseCreator.real_name, + }; +} diff --git a/src/release/commands/end/endReleaseRequestHandler.ts b/src/release/commands/end/endReleaseRequestHandler.ts new file mode 100644 index 0000000..409d880 --- /dev/null +++ b/src/release/commands/end/endReleaseRequestHandler.ts @@ -0,0 +1,47 @@ +import type { Response } from 'express'; +import { Op } from 'sequelize'; +import { HTTP_STATUS_NO_CONTENT } from '@/constants'; +import { getReleases } from '@/core/services/data'; +import { slackBotWebClient } from '@/core/services/slack'; +import type { + SlackExpressRequest, + SlackSlashCommandResponse, +} from '@/core/typings/SlackSlashCommand'; +import { getChannelProjectReleaseConfigs } from '../../utils/configHelper'; +import { buildReleaseSelectionEphemeral } from '../../viewBuilders/buildReleaseSelectionEphemeral'; + +export async function endReleaseRequestHandler( + req: SlackExpressRequest, + res: Response +) { + res.sendStatus(HTTP_STATUS_NO_CONTENT); + + const { channel_id: channelId, user_id: userId } = + req.body as SlackSlashCommandResponse; + + const projectIds = getChannelProjectReleaseConfigs(channelId).map( + ({ projectId }) => projectId + ); + + const releases = await getReleases({ + projectId: { [Op.or]: projectIds }, + state: 'monitoring', + }); + + if (releases.length === 0) { + await slackBotWebClient.chat.postEphemeral({ + channel: channelId, + user: userId, + text: 'There is no release to end in this channel :homer-donut:', + }); + } else { + await slackBotWebClient.chat.postEphemeral( + await buildReleaseSelectionEphemeral({ + action: 'end', + channelId, + releases, + userId, + }) + ); + } +} diff --git a/src/release/commands/end/selectReleaseToEnd.ts b/src/release/commands/end/selectReleaseToEnd.ts new file mode 100644 index 0000000..d26be4a --- /dev/null +++ b/src/release/commands/end/selectReleaseToEnd.ts @@ -0,0 +1,61 @@ +import { getProjectRelease, removeRelease } from '@/core/services/data'; +import { fetchPipelinesByRef, fetchProjectById } from '@/core/services/gitlab'; +import { + deleteEphemeralMessage, + slackBotWebClient, +} from '@/core/services/slack'; +import type { BlockActionsPayload } from '@/core/typings/BlockActionPayload'; +import type { StaticSelectAction } from '@/core/typings/StaticSelectAction'; +import { extractActionParameters } from '@/core/utils/slackActions'; +import { buildReleaseStateMessage } from '@/release/commands/create/viewBuilders/buildReleaseStateMessage'; +import { getProjectReleaseConfig } from '@/release/utils/configHelper'; + +export async function selectReleaseToEnd( + payload: BlockActionsPayload, + action: StaticSelectAction +) { + const { response_url } = payload; + const [projectIdAsString, tagName] = extractActionParameters( + action.selected_option.value + ); + const projectId = parseInt(projectIdAsString, 10); + + await deleteEphemeralMessage(response_url); + + const release = await getProjectRelease(projectId, tagName); + + if (release === undefined) { + throw new Error('Release to end not found'); + } + + const { notificationChannelIds, releaseManager } = + getProjectReleaseConfig(projectId); + + const releaseStateUpdates = await releaseManager.getReleaseStateUpdate( + release + ); + + if (releaseStateUpdates.length > 0) { + const [project, [pipeline]] = await Promise.all([ + fetchProjectById(projectId), + fetchPipelinesByRef(projectId, release.tagName), + ]); + + await Promise.all( + notificationChannelIds.map(async (channelId) => + slackBotWebClient.chat.postMessage( + buildReleaseStateMessage({ + channelId, + pipelineUrl: pipeline.web_url, + projectPathWithNamespace: project.path_with_namespace, + projectWebUrl: project.web_url, + releaseCreator: release.slackAuthor, + releaseStateUpdates, + releaseTagName: release.tagName, + }) + ) + ) + ); + } + await removeRelease(projectId, release.tagName); +} diff --git a/src/release/releaseBlockActionsHandler.ts b/src/release/releaseBlockActionsHandler.ts new file mode 100644 index 0000000..2eb3cd2 --- /dev/null +++ b/src/release/releaseBlockActionsHandler.ts @@ -0,0 +1,51 @@ +import { logger } from '@/core/services/logger'; +import type { BlockActionsPayload } from '@/core/typings/BlockActionPayload'; +import type { StaticSelectAction } from '@/core/typings/StaticSelectAction'; +import { selectReleaseToCancel } from './commands/cancel/selectReleaseToCancel'; +import { updateReleaseChangelog } from './commands/create/utils/updateReleaseChangelog'; +import { updateReleaseProject } from './commands/create/utils/updateReleaseProject'; +import { selectReleaseToEnd } from './commands/end/selectReleaseToEnd'; +import { getProjectReleaseConfig } from './utils/configHelper'; + +export async function releaseBlockActionsHandler( + payload: BlockActionsPayload +): Promise { + await Promise.all( + payload.actions.map(async (action) => { + const { action_id } = action; + + switch (action_id) { + case 'release-select-previous-tag-action': + return updateReleaseChangelog(payload); + + case 'release-select-project-action': + return updateReleaseProject(payload); + + case 'release-select-release-cancel-action': + return selectReleaseToCancel(payload, action as StaticSelectAction); + + case 'release-select-release-end-action': + return selectReleaseToEnd(payload, action as StaticSelectAction); + + default: { + const { state } = payload.view; + const projectId = parseInt( + state.values['release-project-block']?.[ + 'release-select-project-action' + ]?.selected_option?.value, + 10 + ); + const { releaseManager } = getProjectReleaseConfig(projectId); + + if (releaseManager.blockActionsHandler !== undefined) { + return releaseManager.blockActionsHandler( + payload, + action as StaticSelectAction + ); + } + logger.error(new Error(`Unknown block action: ${action_id}`)); + } + } + }) + ); +} diff --git a/src/release/releaseHookHandler.ts b/src/release/releaseHookHandler.ts new file mode 100644 index 0000000..6bc3eaf --- /dev/null +++ b/src/release/releaseHookHandler.ts @@ -0,0 +1,18 @@ +import type { Request, Response } from 'express'; +import { HTTP_STATUS_NO_CONTENT } from '@/constants'; +import { deploymentHookHandler } from './commands/create/hookHandlers/deploymentHookHandler'; + +export async function releaseHookHandler( + req: Request, + res: Response +): Promise { + const { object_kind } = req.body; + + switch (object_kind) { + case 'deployment': + return deploymentHookHandler(req, res); + + default: + res.sendStatus(HTTP_STATUS_NO_CONTENT); + } +} diff --git a/src/release/releaseRequestHandler.ts b/src/release/releaseRequestHandler.ts new file mode 100644 index 0000000..f980ce4 --- /dev/null +++ b/src/release/releaseRequestHandler.ts @@ -0,0 +1,37 @@ +import type { Response } from 'express'; +import { HOMER_GITLAB_URL } from '@/constants'; +import type { + SlackExpressRequest, + SlackSlashCommandResponse, +} from '@/core/typings/SlackSlashCommand'; +import { endReleaseRequestHandler } from '@/release/commands/end/endReleaseRequestHandler'; +import { cancelReleaseRequestHandler } from './commands/cancel/cancelReleaseRequestHandler'; +import { createReleaseRequestHandler } from './commands/create/createReleaseRequestHandler'; +import { hasChannelReleaseConfigs } from './utils/configHelper'; + +export async function releaseRequestHandler( + req: SlackExpressRequest, + res: Response +) { + const { channel_id, text } = req.body as SlackSlashCommandResponse; + + if (!hasChannelReleaseConfigs(channel_id)) { + res.send( + `The release command cannot be used in this channel because it has not been set up (or not correctly) in the config file, please follow the <${HOMER_GITLAB_URL}#configure-homer-to-release-a-gitlab-project|corresponding documentation> :homer-donut:` + ); + return; + } + + const command = text?.split(' ')?.[1]; + + switch (command) { + case 'cancel': + return cancelReleaseRequestHandler(req, res); + + case 'end': + return endReleaseRequestHandler(req, res); + + default: + return createReleaseRequestHandler(req, res); + } +} diff --git a/src/release/releaseViewSubmissionHandler.ts b/src/release/releaseViewSubmissionHandler.ts new file mode 100644 index 0000000..e37389b --- /dev/null +++ b/src/release/releaseViewSubmissionHandler.ts @@ -0,0 +1,38 @@ +import type { Request, Response } from 'express'; +import { HTTP_STATUS_NO_CONTENT } from '@/constants'; +import { logger } from '@/core/services/logger'; +import type { ModalViewSubmissionPayload } from '@/core/typings/ModalViewSubmissionPayload'; +import { createRelease } from './commands/create/utils/createRelease'; +import { buildReleaseModalView } from './commands/create/viewBuilders/buildReleaseModalView'; + +export async function releaseViewSubmissionHandler( + req: Request, + res: Response, + payload: ModalViewSubmissionPayload +): Promise { + const { callback_id } = payload.view; + + switch (callback_id) { + case 'release-create-modal': { + const { view } = payload; + const { state } = view; + + if (state.values['release-tag-block'] === undefined) { + res.json({ + response_action: 'update', + view: await buildReleaseModalView({ view }), + }); + return; + } + res.sendStatus(HTTP_STATUS_NO_CONTENT); + await createRelease(payload); + break; + } + + default: + res.sendStatus(HTTP_STATUS_NO_CONTENT); + logger.error( + new Error(`Unknown release view callback id: ${callback_id}`) + ); + } +} diff --git a/src/release/typings/ProjectReleaseConfig.ts b/src/release/typings/ProjectReleaseConfig.ts new file mode 100644 index 0000000..9d7b5b2 --- /dev/null +++ b/src/release/typings/ProjectReleaseConfig.ts @@ -0,0 +1,11 @@ +import type { ReleaseManager } from './ReleaseManager'; +import type { ReleaseTagManager } from './ReleaseTagManager'; + +export interface ProjectReleaseConfig { + notificationChannelIds: string[]; + projectId: number; + releaseChannelId: string; + releaseManager: ReleaseManager; + releaseTagManager?: ReleaseTagManager; + hasReleasePipeline?: boolean; +} diff --git a/src/release/typings/ReleaseManager.ts b/src/release/typings/ReleaseManager.ts new file mode 100644 index 0000000..a66727d --- /dev/null +++ b/src/release/typings/ReleaseManager.ts @@ -0,0 +1,43 @@ +import type { View } from '@slack/web-api'; +import type { + BlockActionsPayload, + BlockActionView, +} from '@/core/typings/BlockActionPayload'; +import type { DataRelease } from '@/core/typings/Data'; +import type { GitlabCommit } from '@/core/typings/GitlabCommit'; +import type { GitlabDeploymentHook } from '@/core/typings/GitlabDeploymentHook'; +import type { SlackOption } from '@/core/typings/SlackOption'; +import type { StaticSelectAction } from '@/core/typings/StaticSelectAction'; +import type { ReleaseStateUpdate } from './ReleaseStateUpdate'; + +export interface ReleaseModalData { + projectId: number; + projectOptions: SlackOption[]; + view?: BlockActionView; +} + +export interface ReleaseManager { + blockActionsHandler?( + payload: BlockActionsPayload, + action: StaticSelectAction + ): Promise; + buildReleaseModalView?(releaseModalData: ReleaseModalData): Promise; + filterChangelog?(commit: GitlabCommit, viewState: any): boolean; + filterReleasesToClean?( + newRelease: DataRelease, + oldReleases: DataRelease[] + ): DataRelease[]; + getReleaseStateUpdate( + release: DataRelease, + deploymentHook?: GitlabDeploymentHook + ): Promise; + /** + * Should be used to check whether release preconditions are ok. + * + * @example Build of docker images on master pipeline. + */ + isReadyToRelease( + release: DataRelease, + mainBranchPipelineId: number + ): Promise; +} diff --git a/src/release/typings/ReleaseState.ts b/src/release/typings/ReleaseState.ts new file mode 100644 index 0000000..a2e8a3b --- /dev/null +++ b/src/release/typings/ReleaseState.ts @@ -0,0 +1 @@ +export type ReleaseState = 'notYetReady' | 'created' | 'monitoring'; diff --git a/src/release/typings/ReleaseStateUpdate.ts b/src/release/typings/ReleaseStateUpdate.ts new file mode 100644 index 0000000..1753aee --- /dev/null +++ b/src/release/typings/ReleaseStateUpdate.ts @@ -0,0 +1,17 @@ +export type DeploymentState = + | 'deploying' + | 'failed' + | 'monitoring' + | 'completed'; + +export type ReleaseEnvironment = + | 'integration' + | 'staging' + | 'production' + | 'support'; + +export interface ReleaseStateUpdate { + environment: ReleaseEnvironment; + deploymentState: DeploymentState; + projectDisplayName?: string; +} diff --git a/src/release/typings/ReleaseTagManager.ts b/src/release/typings/ReleaseTagManager.ts new file mode 100644 index 0000000..073665b --- /dev/null +++ b/src/release/typings/ReleaseTagManager.ts @@ -0,0 +1,4 @@ +export interface ReleaseTagManager { + createReleaseTag(previousReleaseTag?: string): string; + isReleaseTag(tag: string): boolean; +} diff --git a/src/release/utils/configHelper.ts b/src/release/utils/configHelper.ts new file mode 100644 index 0000000..b326197 --- /dev/null +++ b/src/release/utils/configHelper.ts @@ -0,0 +1,34 @@ +import { projectReleaseConfigs } from '@root/config/homer/projectReleaseConfigs'; +import type { ProjectReleaseConfig } from '../typings/ProjectReleaseConfig'; + +export function getChannelProjectReleaseConfigs( + channelId: string +): ProjectReleaseConfig[] { + return projectReleaseConfigs.filter( + (config) => config.releaseChannelId === channelId + ); +} + +export function getProjectReleaseConfig( + projectId: number +): ProjectReleaseConfig { + const projectReleaseConfig = projectReleaseConfigs.find( + (config) => config.projectId === projectId + ); + + if (projectReleaseConfig === undefined) { + throw new Error(`Unable to find release config for project ${projectId}`); + } + return projectReleaseConfig; +} + +export function hasChannelReleaseConfigs(channelId: string): boolean { + return getChannelProjectReleaseConfigs(channelId).length > 0; +} + +export function hasProjectReleaseConfig(projectId: number): boolean { + const projectReleaseConfig = projectReleaseConfigs.find( + (config) => config.projectId === projectId + ); + return projectReleaseConfig !== undefined; +} diff --git a/src/release/viewBuilders/buildReleaseSelectionEphemeral.ts b/src/release/viewBuilders/buildReleaseSelectionEphemeral.ts new file mode 100644 index 0000000..9b106ce --- /dev/null +++ b/src/release/viewBuilders/buildReleaseSelectionEphemeral.ts @@ -0,0 +1,73 @@ +import type { ChatPostEphemeralArguments } from '@slack/web-api'; +import { fetchProjectById } from '@/core/services/gitlab'; +import type { DataRelease } from '@/core/typings/Data'; +import { injectActionsParameters } from '@/core/utils/slackActions'; + +interface ReleaseSelectionEphemeralData { + action: string; + channelId: string; + releases: DataRelease[]; + userId: string; +} + +export async function buildReleaseSelectionEphemeral({ + action, + channelId, + releases, + userId, +}: ReleaseSelectionEphemeralData): Promise { + const releaseGroups: { [project: string]: DataRelease[] } = {}; + + await Promise.all( + releases.map(async (release) => { + const { path_with_namespace: projectPath } = await fetchProjectById( + release.projectId + ); + if (releaseGroups[projectPath] === undefined) { + releaseGroups[projectPath] = []; + } + releaseGroups[projectPath].push(release); + }) + ); + + return { + channel: channelId, + user: userId, + text: `Choose a release to ${action}`, + blocks: [ + { + type: 'section', + block_id: `release-select-release-${action}-block`, + text: { + type: 'mrkdwn', + text: `Choose a release to ${action}:`, + }, + accessory: { + type: 'static_select', + action_id: `release-select-release-${action}-action`, + placeholder: { + type: 'plain_text', + text: 'Choose a release', + emoji: true, + }, + option_groups: Object.entries(releaseGroups) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([projectPath, projectReleases]) => ({ + label: { + type: 'plain_text', + text: projectPath, + }, + options: projectReleases.map(({ projectId, tagName }) => ({ + text: { + type: 'plain_text', + text: tagName, + emoji: true, + }, + value: injectActionsParameters('release', projectId, tagName), + })), + })), + }, + }, + ], + }; +} diff --git a/src/start.ts b/src/start.ts index dd51048..ed6f722 100644 --- a/src/start.ts +++ b/src/start.ts @@ -8,6 +8,7 @@ import { stateRequestHandler } from '@/core/requestHandlers/stateRequestHandler/ import { connectToDatabase } from '@/core/services/data'; import { logger } from '@/core/services/logger'; import { catchAsyncRouteErrors } from '@/core/utils/catchAsyncRouteErrors'; +import { waitForNonReadyReleases } from '@/release/commands/create/utils/waitForNonReadyReleases'; import { REQUEST_BODY_SIZE_LIMIT } from './constants'; import { router } from './router'; @@ -52,6 +53,7 @@ export async function start(): Promise<() => Promise> { return; } logger.info(`Homer started on port ${PORT}.`); + waitForNonReadyReleases(); // Promise ignored on purpose resolve( async () => new Promise((r) => {