From 977f544a38fb55ed377d8aac573c1f37af273c48 Mon Sep 17 00:00:00 2001 From: Pascal Fong Kye Date: Mon, 14 Oct 2024 15:24:35 +0200 Subject: [PATCH 1/2] feat(plugin): add simple plugin manager for release --- PLUGIN_RELEASE.md | 37 +++++++++++ README.md | 4 ++ .../ReleasePluginManager.test.ts | 45 ++++++++++++++ __tests__/release/createRelease.test.ts | 10 ++- __tests__/release/endRelease.test.ts | 6 +- __tests__/release/utils/configBuilder.test.ts | 48 ++++++++++----- .../release}/defaultReleaseManager.ts | 17 ++++-- .../release}/libraryReleaseManager.ts | 8 ++- .../pluginManager/ReleasePluginManager.ts | 60 ++++++++++++++++++ .../cancel/cancelReleaseRequestHandler.ts | 8 +-- .../hookHandlers/deploymentHookHandler.ts | 20 +++--- .../commands/create/utils/createRelease.ts | 10 ++- .../commands/create/utils/startRelease.ts | 6 +- .../utils/waitForReadinessAndStartRelease.ts | 11 ++-- .../viewBuilders/buildReleaseModalView.ts | 24 ++++---- .../commands/end/endReleaseRequestHandler.ts | 8 +-- .../commands/end/selectReleaseToEnd.ts | 9 ++- src/release/releaseBlockActionsHandler.ts | 10 ++- src/release/releaseOptions.ts | 37 +++++++++++ src/release/releaseRequestHandler.ts | 4 +- src/release/typings/ReleaseManager.ts | 57 +++++++++++++++-- src/release/typings/ReleaseTagManager.ts | 3 +- src/release/utils/ConfigHelper.ts | 61 +++++++++++++++++++ src/release/utils/configBuilder.ts | 34 +++++------ src/release/utils/configHelper.ts | 59 ------------------ tsconfig.build.json | 2 +- tsconfig.json | 2 +- 27 files changed, 439 insertions(+), 161 deletions(-) create mode 100644 PLUGIN_RELEASE.md create mode 100644 __tests__/core/pluginManager/ReleasePluginManager.test.ts rename {src/release/commands/create/managers => plugins/release}/defaultReleaseManager.ts (87%) rename {src/release/commands/create/managers => plugins/release}/libraryReleaseManager.ts (71%) create mode 100644 src/core/pluginManager/ReleasePluginManager.ts create mode 100644 src/release/releaseOptions.ts create mode 100644 src/release/utils/ConfigHelper.ts delete mode 100644 src/release/utils/configHelper.ts diff --git a/PLUGIN_RELEASE.md b/PLUGIN_RELEASE.md new file mode 100644 index 0000000..1eafe30 --- /dev/null +++ b/PLUGIN_RELEASE.md @@ -0,0 +1,37 @@ +# Plugin System for Custom Release Manager + +## Introduction + +This document provides instructions on how to use the plugin system to add your own release manager to the Homer project. +You can create a project containing a `plugins/release` folder which will contain your release manager and build a new Docker image where `plugins/release` will be copied to `dist/plugins`. + +## Steps to Add a Custom Release Manager + +1. **Create a New Release Manager** + + - Navigate to the `plugins/release` directory in your Homer project. + - Create `myOwnReleaseManager.js` and implement the logic(for now the types are not available as a standalone package but you can have the interface [here](./src/release/typings/ReleaseManager.ts)). + - You have an implementation example with [defaultReleaseManager](./plugins/release/defaultReleaseManager.ts) + +2. **Register the Plugin** + + - If you have a project which needs the custom release manager, declare the project in the configuration [file](./config/homer/projects.json) + - Add an entry for your project (the name of the release manager must correspond to its file name). + ```json + { + "description": "project_example", + "notificationChannelIds": ["C0XXXXXXXXX"], + "projectId": 1234, + "releaseChannelId": "C0XXXXXXXXX", + "releaseManager": "myOwnReleaseManager", + "releaseTagManager": "stableDateReleaseTagManager" + } + ``` + +3. **Deploy** + - Build the plugins and config with Homer project before deploying the application. + +## Conclusion + +By following these steps, you can extend the Homer project with your own custom release manager using the plugin system. +Please only **add your own release managers or managers from trusted sources** to minimize security breaches in your Homer Slack application. diff --git a/README.md b/README.md index a68d8ee..77b525d 100644 --- a/README.md +++ b/README.md @@ -326,6 +326,10 @@ Here is a sample configuration with one project: } ``` +### 8. Add your own release manager + +A simple plugin system enables the addition of custom release managers. See this dedicated [page](./PLUGIN_RELEASE.md) for more details. + ## Contributing See [CONTRIBUTING.md](./CONTRIBUTING.md). diff --git a/__tests__/core/pluginManager/ReleasePluginManager.test.ts b/__tests__/core/pluginManager/ReleasePluginManager.test.ts new file mode 100644 index 0000000..94fc54f --- /dev/null +++ b/__tests__/core/pluginManager/ReleasePluginManager.test.ts @@ -0,0 +1,45 @@ +import ReleasePluginManager from '@/core/pluginManager/ReleasePluginManager'; +import { semanticReleaseTagManager } from '@/release/commands/create/managers/semanticReleaseTagManager'; +import defaultReleaseManager from '@root/plugins/release/defaultReleaseManager'; + +describe('pluginManager', () => { + beforeAll(async () => { + // Due to singleton pattern, the release manager may already be loaded during test launch + if (!ReleasePluginManager.getReleaseManager('defaultReleaseManager')) { + await ReleasePluginManager.loadReleaseManagerPlugin( + '@root/plugins/release/defaultReleaseManager' + ); + } + }); + it('should throw an error if the plugin is not found', async () => { + await expect(async () => + ReleasePluginManager.loadReleaseManagerPlugin('invalidPath') + ).rejects.toThrow( + 'Cannot load release manager plugin. Invalid path or plugin already loaded.' + ); + }); + it('should throw an error if the plugin is already added', async () => { + await expect(async () => + ReleasePluginManager.loadReleaseManagerPlugin( + '@root/plugins/release/defaultReleaseManager' + ) + ).rejects.toThrow( + 'Cannot load release manager plugin. Invalid path or plugin already loaded.' + ); + }); + it('should return release manager by name', async () => { + expect( + ReleasePluginManager.getReleaseManager('defaultReleaseManager') + ).toEqual(defaultReleaseManager); + }); + it('should return a provided release manager', async () => { + expect( + ReleasePluginManager.getReleaseManager('defaultReleaseManager') + ).toEqual(defaultReleaseManager); + }); + it('should return release tag manager by name', async () => { + expect( + ReleasePluginManager.getReleaseTagManager('semanticReleaseTagManager') + ).toEqual(semanticReleaseTagManager); + }); +}); diff --git a/__tests__/release/createRelease.test.ts b/__tests__/release/createRelease.test.ts index 157515a..6032e7a 100644 --- a/__tests__/release/createRelease.test.ts +++ b/__tests__/release/createRelease.test.ts @@ -6,7 +6,8 @@ import type { } 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 type { ProjectReleaseConfig } from '@/release/typings/ProjectReleaseConfig'; +import ConfigHelper from '@/release/utils/ConfigHelper'; import { dockerBuildJobFixture } from '../__fixtures__/dockerBuildJobFixture'; import { jobFixture } from '../__fixtures__/jobFixture'; import { mergeRequestFixture } from '../__fixtures__/mergeRequestFixture'; @@ -20,7 +21,12 @@ import { mockGitlabCall } from '../utils/mockGitlabCall'; import { waitFor } from '../utils/waitFor'; describe('release > createRelease', () => { - const releaseConfig = getProjectReleaseConfig(projectFixture.id); + let releaseConfig: ProjectReleaseConfig; + beforeAll(async () => { + releaseConfig = await ConfigHelper.getProjectReleaseConfig( + projectFixture.id + ); + }); it('should create a release whereas main pipeline is ready', async () => { /** Step 1: display release modal */ diff --git a/__tests__/release/endRelease.test.ts b/__tests__/release/endRelease.test.ts index c588fdc..6f946c4 100644 --- a/__tests__/release/endRelease.test.ts +++ b/__tests__/release/endRelease.test.ts @@ -3,7 +3,7 @@ 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 ConfigHelper 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'; @@ -16,7 +16,9 @@ describe('release > endRelease', () => { it('should end a release in monitoring state', async () => { // Given const projectId = projectFixture.id; - const releaseConfig = getProjectReleaseConfig(projectId); + const releaseConfig = await ConfigHelper.getProjectReleaseConfig( + projectId + ); const channelId = releaseConfig.releaseChannelId; const userId = 'userId'; let body: any = { diff --git a/__tests__/release/utils/configBuilder.test.ts b/__tests__/release/utils/configBuilder.test.ts index e30ada4..84e4aee 100644 --- a/__tests__/release/utils/configBuilder.test.ts +++ b/__tests__/release/utils/configBuilder.test.ts @@ -1,9 +1,10 @@ -import { defaultReleaseManager } from '@/release/commands/create/managers/defaultReleaseManager'; import { federationReleaseTagManager } from '@/release/commands/create/managers/federationReleaseTagManager'; +import { stableDateReleaseTagManager } from '@/release/commands/create/managers/stableDateReleaseTagManager'; import { buildProjectReleaseConfigs } from '@/release/utils/configBuilder'; +import defaultReleaseManager from '@root/plugins/release/defaultReleaseManager'; describe('configBuilder', () => { - it('should build configs', () => { + it('should build configs', async () => { const projects = [ { releaseManager: 'defaultReleaseManager', @@ -20,9 +21,8 @@ describe('configBuilder', () => { }, ]; expect( - buildProjectReleaseConfigs( + await buildProjectReleaseConfigs( { projects }, - { defaultReleaseManager }, { federationReleaseTagManager } ) ).toEqual([ @@ -41,25 +41,45 @@ describe('configBuilder', () => { }, ]); }); - it('should throw an error if projects is not an array', () => { - expect(() => - buildProjectReleaseConfigs( - {} as any, - { defaultReleaseManager }, - { federationReleaseTagManager } + it('should load a third party release manager', async () => { + const projects = [ + { + releaseManager: 'defaultReleaseManager', + releaseTagManager: 'stableDateReleaseTagManager', + notificationChannelIds: ['C678'], + projectId: 123, + releaseChannelId: 'C456', + }, + ]; + expect( + await buildProjectReleaseConfigs( + { projects }, + { federationReleaseTagManager, stableDateReleaseTagManager } ) - ).toThrow( + ).toEqual([ + { + notificationChannelIds: ['C678'], + projectId: 123, + releaseChannelId: 'C456', + releaseManager: defaultReleaseManager, + releaseTagManager: stableDateReleaseTagManager, + }, + ]); + }); + it('should throw an error if projects is not an array', async () => { + expect(() => + buildProjectReleaseConfigs({} as any, { federationReleaseTagManager }) + ).rejects.toThrow( 'The config file should contain an array of valid project configurations' ); }); - it('should throw an error if there is an invalid project configuration', () => { + it('should throw an error if there is an invalid project configuration', async () => { expect(() => buildProjectReleaseConfigs( [{ projectId: 123, releaseManager: 'defaultReleaseManager' }] as any, - { defaultReleaseManager }, { federationReleaseTagManager } ) - ).toThrow( + ).rejects.toThrow( 'The config file should contain an array of valid project configurations' ); }); diff --git a/src/release/commands/create/managers/defaultReleaseManager.ts b/plugins/release/defaultReleaseManager.ts similarity index 87% rename from src/release/commands/create/managers/defaultReleaseManager.ts rename to plugins/release/defaultReleaseManager.ts index 9f12e5c..63fd900 100644 --- a/src/release/commands/create/managers/defaultReleaseManager.ts +++ b/plugins/release/defaultReleaseManager.ts @@ -1,8 +1,10 @@ -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'; +import type { + ReleaseManager, + ReleaseOptions, +} from '../../src/release/typings/ReleaseManager'; +import type { ReleaseStateUpdate } from '../../src/release/typings/ReleaseStateUpdate'; const dockerBuildJobNames = [ 'Build Image', @@ -78,9 +80,10 @@ async function getReleaseStateUpdate( return []; } -export async function isReadyToRelease( +async function isReadyToRelease( { projectId }: DataRelease, - mainBranchPipelineId: number + mainBranchPipelineId: number, + { gitlab: { fetchPipelineJobs } }: ReleaseOptions ) { const pipelinesJobs = await fetchPipelineJobs( projectId, @@ -92,7 +95,9 @@ export async function isReadyToRelease( return dockerBuildJob?.status === 'success'; } -export const defaultReleaseManager: ReleaseManager = { +const defaultReleaseManager: ReleaseManager = { getReleaseStateUpdate, isReadyToRelease, }; + +export default defaultReleaseManager; diff --git a/src/release/commands/create/managers/libraryReleaseManager.ts b/plugins/release/libraryReleaseManager.ts similarity index 71% rename from src/release/commands/create/managers/libraryReleaseManager.ts rename to plugins/release/libraryReleaseManager.ts index 28d9d2c..770818d 100644 --- a/src/release/commands/create/managers/libraryReleaseManager.ts +++ b/plugins/release/libraryReleaseManager.ts @@ -1,7 +1,7 @@ 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'; +import type { ReleaseManager } from '../../src/release/typings/ReleaseManager'; +import type { ReleaseStateUpdate } from '../../src/release/typings/ReleaseStateUpdate'; const buildJobNames = ['goreleaser-build-snapshot']; @@ -23,7 +23,9 @@ export async function isReadyToRelease( return buildJob?.status === 'success'; } -export const libraryReleaseManager: ReleaseManager = { +const libraryReleaseManager: ReleaseManager = { getReleaseStateUpdate, isReadyToRelease, }; + +export default libraryReleaseManager; diff --git a/src/core/pluginManager/ReleasePluginManager.ts b/src/core/pluginManager/ReleasePluginManager.ts new file mode 100644 index 0000000..19b4681 --- /dev/null +++ b/src/core/pluginManager/ReleasePluginManager.ts @@ -0,0 +1,60 @@ +import { federationReleaseTagManager } from '@/release/commands/create/managers/federationReleaseTagManager'; +import { semanticReleaseTagManager } from '@/release/commands/create/managers/semanticReleaseTagManager'; +import { stableDateReleaseTagManager } from '@/release/commands/create/managers/stableDateReleaseTagManager'; +import type { ReleaseManager } from '@/release/typings/ReleaseManager'; +import type { ReleaseTagManager } from '@/release/typings/ReleaseTagManager'; + +export default class ReleasePluginManager { + // singleton to load release and release tag managers + private static instance: ReleasePluginManager = new ReleasePluginManager(); + private readonly releaseManagers: Map = new Map(); + private readonly releaseTagManagers: Map = + new Map(); + + private constructor() { + // provided release tag managers + this.releaseTagManagers.set( + 'semanticReleaseTagManager', + semanticReleaseTagManager + ); + this.releaseTagManagers.set( + 'federationReleaseTagManager', + federationReleaseTagManager + ); + this.releaseTagManagers.set( + 'stableDateReleaseTagManager', + stableDateReleaseTagManager + ); + } + + /** + * Dynamically load a plugin from the given path + * @param path where the plugin is located + * @returns default export of the plugin + */ + static async loadReleaseManagerPlugin(path: string) { + const errMessage = + 'Cannot load release manager plugin. Invalid path or plugin already loaded.'; + const releaseManagerName = path.split('/').pop()!; + if (this.instance.releaseManagers.has(releaseManagerName)) { + throw new Error(errMessage); + } + try { + // import default export from the given path + const { default: releaseManager } = await import(path); + // add the release manager to the map + this.instance.releaseManagers.set(releaseManagerName, releaseManager); + return releaseManager; + } catch (error) { + throw new Error(errMessage); + } + } + + static getReleaseManager(name: string) { + return this.instance.releaseManagers.get(name); + } + + static getReleaseTagManager(name: string) { + return this.instance.releaseTagManagers.get(name); + } +} diff --git a/src/release/commands/cancel/cancelReleaseRequestHandler.ts b/src/release/commands/cancel/cancelReleaseRequestHandler.ts index 854cb6f..b4277ac 100644 --- a/src/release/commands/cancel/cancelReleaseRequestHandler.ts +++ b/src/release/commands/cancel/cancelReleaseRequestHandler.ts @@ -8,7 +8,7 @@ import type { SlackSlashCommandResponse, } from '@/core/typings/SlackSlashCommand'; import type { ReleaseState } from '../../typings/ReleaseState'; -import { getChannelProjectReleaseConfigs } from '../../utils/configHelper'; +import ConfigHelper from '../../utils/ConfigHelper'; import { buildReleaseSelectionEphemeral } from '../../viewBuilders/buildReleaseSelectionEphemeral'; export async function cancelReleaseRequestHandler( @@ -20,9 +20,9 @@ export async function cancelReleaseRequestHandler( const { channel_id: channelId, user_id: userId } = req.body as SlackSlashCommandResponse; - const projectsIds = getChannelProjectReleaseConfigs(channelId).map( - ({ projectId }) => projectId - ); + const projectsIds = ( + await ConfigHelper.getChannelProjectReleaseConfigs(channelId) + ).map(({ projectId }) => projectId); const releases = await getReleases({ projectId: { [Op.or]: projectsIds }, state: { [Op.or]: ['notYetReady', 'created'] as ReleaseState[] }, diff --git a/src/release/commands/create/hookHandlers/deploymentHookHandler.ts b/src/release/commands/create/hookHandlers/deploymentHookHandler.ts index 1ba7bcf..73ce91f 100644 --- a/src/release/commands/create/hookHandlers/deploymentHookHandler.ts +++ b/src/release/commands/create/hookHandlers/deploymentHookHandler.ts @@ -6,10 +6,8 @@ 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 getReleaseOptions from '@/release/releaseOptions'; +import ConfigHelper from '../../../utils/ConfigHelper'; import { buildReleaseStateMessage } from '../viewBuilders/buildReleaseStateMessage'; const STATUSES_TO_HANDLE: GitlabDeploymentStatus[] = [ @@ -31,10 +29,11 @@ export async function deploymentHookHandler( } = deploymentHook; const projectId = project.id; - if ( - !STATUSES_TO_HANDLE.includes(status) || - !hasProjectReleaseConfig(projectId) - ) { + const hasProjectReleaseConfig = await ConfigHelper.hasProjectReleaseConfig( + projectId + ); + + if (!STATUSES_TO_HANDLE.includes(status) || !hasProjectReleaseConfig) { res.sendStatus(HTTP_STATUS_NO_CONTENT); return; } @@ -78,11 +77,12 @@ export async function deploymentHookHandler( const release = await updateRelease(projectId, releaseTagName, updateGetter); const { notificationChannelIds, releaseManager } = - getProjectReleaseConfig(projectId); + await ConfigHelper.getProjectReleaseConfig(projectId); const releaseStateUpdates = await releaseManager.getReleaseStateUpdate( release, - deploymentHook + deploymentHook, + getReleaseOptions() ); if (releaseStateUpdates.length > 0) { diff --git a/src/release/commands/create/utils/createRelease.ts b/src/release/commands/create/utils/createRelease.ts index bd237fc..84646fa 100644 --- a/src/release/commands/create/utils/createRelease.ts +++ b/src/release/commands/create/utils/createRelease.ts @@ -8,7 +8,8 @@ import { 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 getReleaseOptions from '@/release/releaseOptions'; +import ConfigHelper from '../../../utils/ConfigHelper'; import { waitForReadinessAndStartRelease } from './waitForReadinessAndStartRelease'; export async function createRelease( @@ -30,7 +31,9 @@ export async function createRelease( values['release-previous-tag-block']?.['release-select-previous-tag-action'] ?.selected_option.value; - const { releaseManager } = getProjectReleaseConfig(projectId); + const { releaseManager } = await ConfigHelper.getProjectReleaseConfig( + projectId + ); const [description, slackAuthor] = await Promise.all([ generateChangelog( @@ -39,6 +42,7 @@ export async function createRelease( (commit) => releaseManager.filterChangelog?.(commit, view.state) ?? true ), fetchSlackUserFromId(user.id), + getReleaseOptions(), ]); if (slackAuthor === undefined) { @@ -62,7 +66,7 @@ export async function createRelease( if (releaseManager.filterReleasesToClean) { const projectReleases = await getProjectReleases(projectId); const tagNames = releaseManager - .filterReleasesToClean(releaseData, projectReleases) + .filterReleasesToClean(releaseData, projectReleases, getReleaseOptions()) .map(({ tagName }) => tagName); if (tagNames.length > 0) { diff --git a/src/release/commands/create/utils/startRelease.ts b/src/release/commands/create/utils/startRelease.ts index 843e005..7cdb937 100644 --- a/src/release/commands/create/utils/startRelease.ts +++ b/src/release/commands/create/utils/startRelease.ts @@ -3,7 +3,7 @@ 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 ConfigHelper from '../../../utils/ConfigHelper'; import { waitForReleasePipeline } from './waitForReleasePipeline'; interface StartReleaseData { @@ -23,7 +23,9 @@ export async function startRelease({ releaseTagName, hasReleasePipeline = true, }: StartReleaseData): Promise { - const { releaseChannelId } = getProjectReleaseConfig(project.id); + const { releaseChannelId } = await ConfigHelper.getProjectReleaseConfig( + project.id + ); await createGitlabRelease(project.id, commitId, releaseTagName, description); diff --git a/src/release/commands/create/utils/waitForReadinessAndStartRelease.ts b/src/release/commands/create/utils/waitForReadinessAndStartRelease.ts index 251f47c..b4fec20 100644 --- a/src/release/commands/create/utils/waitForReadinessAndStartRelease.ts +++ b/src/release/commands/create/utils/waitForReadinessAndStartRelease.ts @@ -10,8 +10,9 @@ 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 getReleaseOptions from '@/release/releaseOptions'; import type { ReleaseManager } from '@/release/typings/ReleaseManager'; -import { getProjectReleaseConfig } from '@/release/utils/configHelper'; +import ConfigHelper from '@/release/utils/ConfigHelper'; const READINESS_TIMEOUT_DELAY_MS = 45 * 60 * 1000; // 45 minutes const READINESS_CHECK_DELAY_MS = 30 * 1000; // 30 seconds @@ -32,7 +33,7 @@ export async function waitForReadinessAndStartRelease( } const { releaseChannelId, releaseManager, hasReleasePipeline } = - getProjectReleaseConfig(projectId); + await ConfigHelper.getProjectReleaseConfig(projectId); const project = await fetchProjectById(projectId); @@ -44,7 +45,8 @@ export async function waitForReadinessAndStartRelease( let hasReachedTimeout = false; let isReady = await releaseManager.isReadyToRelease( release, - mainBranchPipeline.id + mainBranchPipeline.id, + getReleaseOptions() ); if (!isReady) { @@ -122,7 +124,8 @@ async function waitForReadiness( isReady = await releaseManager.isReadyToRelease( release, - mainBranchPipelineId + mainBranchPipelineId, + getReleaseOptions() ); if (isReady) { diff --git a/src/release/commands/create/viewBuilders/buildReleaseModalView.ts b/src/release/commands/create/viewBuilders/buildReleaseModalView.ts index 53b924a..43b4382 100644 --- a/src/release/commands/create/viewBuilders/buildReleaseModalView.ts +++ b/src/release/commands/create/viewBuilders/buildReleaseModalView.ts @@ -9,10 +9,8 @@ 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 getReleaseOptions from '@/release/releaseOptions'; +import ConfigHelper from '../../../utils/ConfigHelper'; import { slackifyChangelog } from '../utils/slackifyChangelog'; interface ReleaseModalData { @@ -47,7 +45,8 @@ export async function buildReleaseModalView({ } if (projectOptions === undefined && channelId !== undefined) { - const projectReleaseConfigs = getChannelProjectReleaseConfigs(channelId); + const projectReleaseConfigs = + await ConfigHelper.getChannelProjectReleaseConfigs(channelId); const projects = await Promise.all( projectReleaseConfigs.map(async (config) => fetchProjectById(config.projectId) @@ -78,14 +77,17 @@ export async function buildReleaseModalView({ } const { releaseTagManager, releaseManager } = - getProjectReleaseConfig(projectId); + await ConfigHelper.getProjectReleaseConfig(projectId); if (releaseManager.buildReleaseModalView) { - return releaseManager.buildReleaseModalView({ - projectId, - projectOptions, - view, - }); + return releaseManager.buildReleaseModalView( + { + projectId, + projectOptions, + view, + }, + getReleaseOptions() + ); } if (releaseTagManager === undefined) { diff --git a/src/release/commands/end/endReleaseRequestHandler.ts b/src/release/commands/end/endReleaseRequestHandler.ts index 409d880..ffa1a34 100644 --- a/src/release/commands/end/endReleaseRequestHandler.ts +++ b/src/release/commands/end/endReleaseRequestHandler.ts @@ -7,7 +7,7 @@ import type { SlackExpressRequest, SlackSlashCommandResponse, } from '@/core/typings/SlackSlashCommand'; -import { getChannelProjectReleaseConfigs } from '../../utils/configHelper'; +import ConfigHelper from '../../utils/ConfigHelper'; import { buildReleaseSelectionEphemeral } from '../../viewBuilders/buildReleaseSelectionEphemeral'; export async function endReleaseRequestHandler( @@ -19,9 +19,9 @@ export async function endReleaseRequestHandler( const { channel_id: channelId, user_id: userId } = req.body as SlackSlashCommandResponse; - const projectIds = getChannelProjectReleaseConfigs(channelId).map( - ({ projectId }) => projectId - ); + const projectIds = ( + await ConfigHelper.getChannelProjectReleaseConfigs(channelId) + ).map(({ projectId }) => projectId); const releases = await getReleases({ projectId: { [Op.or]: projectIds }, diff --git a/src/release/commands/end/selectReleaseToEnd.ts b/src/release/commands/end/selectReleaseToEnd.ts index d26be4a..c1a4371 100644 --- a/src/release/commands/end/selectReleaseToEnd.ts +++ b/src/release/commands/end/selectReleaseToEnd.ts @@ -8,7 +8,8 @@ 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'; +import getReleaseOptions from '@/release/releaseOptions'; +import ConfigHelper from '@/release/utils/ConfigHelper'; export async function selectReleaseToEnd( payload: BlockActionsPayload, @@ -29,10 +30,12 @@ export async function selectReleaseToEnd( } const { notificationChannelIds, releaseManager } = - getProjectReleaseConfig(projectId); + await ConfigHelper.getProjectReleaseConfig(projectId); const releaseStateUpdates = await releaseManager.getReleaseStateUpdate( - release + release, + undefined, + getReleaseOptions() ); if (releaseStateUpdates.length > 0) { diff --git a/src/release/releaseBlockActionsHandler.ts b/src/release/releaseBlockActionsHandler.ts index 2eb3cd2..bd4f190 100644 --- a/src/release/releaseBlockActionsHandler.ts +++ b/src/release/releaseBlockActionsHandler.ts @@ -5,7 +5,8 @@ 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'; +import getReleaseOptions from './releaseOptions'; +import ConfigHelper from './utils/ConfigHelper'; export async function releaseBlockActionsHandler( payload: BlockActionsPayload @@ -35,12 +36,15 @@ export async function releaseBlockActionsHandler( ]?.selected_option?.value, 10 ); - const { releaseManager } = getProjectReleaseConfig(projectId); + const { releaseManager } = await ConfigHelper.getProjectReleaseConfig( + projectId + ); if (releaseManager.blockActionsHandler !== undefined) { return releaseManager.blockActionsHandler( payload, - action as StaticSelectAction + action as StaticSelectAction, + getReleaseOptions() ); } logger.error(new Error(`Unknown block action: ${action_id}`)); diff --git a/src/release/releaseOptions.ts b/src/release/releaseOptions.ts new file mode 100644 index 0000000..bbfde78 --- /dev/null +++ b/src/release/releaseOptions.ts @@ -0,0 +1,37 @@ +import { generateChangelog } from '@/changelog/utils/generateChangelog'; +import { slackifyChangelog } from '@/changelog/utils/slackifyChangelog'; +import ReleasePluginManager from '@/core/pluginManager/ReleasePluginManager'; +import { + fetchPipelineBridges, + fetchPipelineJobs, + fetchProjectTags, +} from '@/core/services/gitlab'; +import { logger } from '@/core/services/logger'; +import { slackBotWebClient } from '@/core/services/slack'; +import { cleanViewState } from '@/core/utils/cleanViewState'; +import { addLoaderToReleaseModal } from './commands/create/utils/addLoaderToReleaseModal'; +import type { ReleaseOptions } from './typings/ReleaseManager'; + +export default function getReleaseOptions(): ReleaseOptions { + return { + changelog: { generateChangelog }, + logger, + slack: { + addLoaderToReleaseModal, + cleanViewState, + slackifyChangelog, + webClient: slackBotWebClient, + }, + release: { + getReleaseManager: (managerName: string) => + ReleasePluginManager.getReleaseManager(managerName), + getReleaseTagManager: (tagManagerName: string) => + ReleasePluginManager.getReleaseTagManager(tagManagerName), + }, + gitlab: { + fetchPipelineBridges, + fetchPipelineJobs, + fetchProjectTags, + }, + }; +} diff --git a/src/release/releaseRequestHandler.ts b/src/release/releaseRequestHandler.ts index 0662bb3..b9f0024 100644 --- a/src/release/releaseRequestHandler.ts +++ b/src/release/releaseRequestHandler.ts @@ -7,7 +7,7 @@ import type { import { endReleaseRequestHandler } from '@/release/commands/end/endReleaseRequestHandler'; import { cancelReleaseRequestHandler } from './commands/cancel/cancelReleaseRequestHandler'; import { createReleaseRequestHandler } from './commands/create/createReleaseRequestHandler'; -import { hasChannelReleaseConfigs } from './utils/configHelper'; +import ConfigHelper from './utils/ConfigHelper'; export async function releaseRequestHandler( req: SlackExpressRequest, @@ -15,7 +15,7 @@ export async function releaseRequestHandler( ) { const { channel_id, text } = req.body as SlackSlashCommandResponse; - if (!hasChannelReleaseConfigs(channel_id)) { + if (!(await ConfigHelper.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_GIT_URL}#configure-homer-to-release-a-gitlab-project|corresponding documentation> :homer-donut:` ); diff --git a/src/release/typings/ReleaseManager.ts b/src/release/typings/ReleaseManager.ts index a66727d..2224f03 100644 --- a/src/release/typings/ReleaseManager.ts +++ b/src/release/typings/ReleaseManager.ts @@ -1,4 +1,13 @@ import type { View } from '@slack/web-api'; +import type { generateChangelog } from '@/changelog/utils/generateChangelog'; +import type { slackifyChangelog } from '@/changelog/utils/slackifyChangelog'; +import type { + fetchPipelineBridges, + fetchPipelineJobs, + fetchProjectTags, +} from '@/core/services/gitlab'; +import type { logger } from '@/core/services/logger'; +import type { slackBotWebClient } from '@/core/services/slack'; import type { BlockActionsPayload, BlockActionView, @@ -8,7 +17,10 @@ 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 { cleanViewState } from '@/core/utils/cleanViewState'; +import type { addLoaderToReleaseModal } from '../commands/create/utils/addLoaderToReleaseModal'; import type { ReleaseStateUpdate } from './ReleaseStateUpdate'; +import type { ReleaseTagManager } from './ReleaseTagManager'; export interface ReleaseModalData { projectId: number; @@ -16,20 +28,52 @@ export interface ReleaseModalData { view?: BlockActionView; } +export interface ReleaseOptions { + changelog: { generateChangelog: typeof generateChangelog }; + gitlab: { + fetchPipelineBridges: typeof fetchPipelineBridges; + fetchPipelineJobs: typeof fetchPipelineJobs; + fetchProjectTags: typeof fetchProjectTags; + }; + release: { + getReleaseManager: (managerName: string) => ReleaseManager | undefined; + getReleaseTagManager: ( + tagManagerName: string + ) => ReleaseTagManager | undefined; + }; + slack: { + slackifyChangelog: typeof slackifyChangelog; + addLoaderToReleaseModal: typeof addLoaderToReleaseModal; + cleanViewState: typeof cleanViewState; + webClient: typeof slackBotWebClient; + }; + logger: typeof logger; +} + export interface ReleaseManager { blockActionsHandler?( payload: BlockActionsPayload, - action: StaticSelectAction + action: StaticSelectAction, + options?: ReleaseOptions ): Promise; - buildReleaseModalView?(releaseModalData: ReleaseModalData): Promise; - filterChangelog?(commit: GitlabCommit, viewState: any): boolean; + buildReleaseModalView?( + releaseModalData: ReleaseModalData, + options?: ReleaseOptions + ): Promise; + filterChangelog?( + commit: GitlabCommit, + viewState: any, + options?: ReleaseOptions + ): boolean; filterReleasesToClean?( newRelease: DataRelease, - oldReleases: DataRelease[] + oldReleases: DataRelease[], + options?: ReleaseOptions ): DataRelease[]; getReleaseStateUpdate( release: DataRelease, - deploymentHook?: GitlabDeploymentHook + deploymentHook?: GitlabDeploymentHook, + options?: ReleaseOptions ): Promise; /** * Should be used to check whether release preconditions are ok. @@ -38,6 +82,7 @@ export interface ReleaseManager { */ isReadyToRelease( release: DataRelease, - mainBranchPipelineId: number + mainBranchPipelineId: number, + options?: ReleaseOptions ): Promise; } diff --git a/src/release/typings/ReleaseTagManager.ts b/src/release/typings/ReleaseTagManager.ts index 073665b..9cb7631 100644 --- a/src/release/typings/ReleaseTagManager.ts +++ b/src/release/typings/ReleaseTagManager.ts @@ -1,4 +1,5 @@ export interface ReleaseTagManager { createReleaseTag(previousReleaseTag?: string): string; - isReleaseTag(tag: string): boolean; + isReleaseTag(tag: string, appName?: string): boolean; + extractAppName?(tag: string): string; } diff --git a/src/release/utils/ConfigHelper.ts b/src/release/utils/ConfigHelper.ts new file mode 100644 index 0000000..8c4bc52 --- /dev/null +++ b/src/release/utils/ConfigHelper.ts @@ -0,0 +1,61 @@ +import { federationReleaseTagManager } from '@/release/commands/create/managers/federationReleaseTagManager'; +import { semanticReleaseTagManager } from '@/release/commands/create/managers/semanticReleaseTagManager'; +import { stableDateReleaseTagManager } from '@/release/commands/create/managers/stableDateReleaseTagManager'; +import type { ProjectReleaseConfig } from '@/release/typings/ProjectReleaseConfig'; +import type { ReleaseTagManager } from '@/release/typings/ReleaseTagManager'; +import { buildProjectReleaseConfigs } from '@/release/utils/configBuilder'; +import projectsConfig from '@root/config/homer/projects.json'; + +const releaseTagManagers: Record = { + federationReleaseTagManager, + semanticReleaseTagManager, + stableDateReleaseTagManager, +}; + +export default class ConfigHelper { + private static projectReleaseConfigs: ProjectReleaseConfig[]; + + private static async init() { + if (this.projectReleaseConfigs === undefined) { + this.projectReleaseConfigs = await buildProjectReleaseConfigs( + projectsConfig, + releaseTagManagers + ); + } + } + + static async getChannelProjectReleaseConfigs( + channelId: string + ): Promise { + await this.init(); + return this.projectReleaseConfigs.filter( + (config) => config.releaseChannelId === channelId + ); + } + + static async hasChannelReleaseConfigs(channelId: string): Promise { + return (await this.getChannelProjectReleaseConfigs(channelId)).length > 0; + } + + static async getProjectReleaseConfig( + projectId: number + ): Promise { + await this.init(); + const projectReleaseConfig = this.projectReleaseConfigs.find( + (config) => config.projectId === projectId + ); + + if (projectReleaseConfig === undefined) { + throw new Error(`Unable to find release config for project ${projectId}`); + } + return projectReleaseConfig; + } + + static async hasProjectReleaseConfig(projectId: number): Promise { + await this.init(); + const projectReleaseConfig = this.projectReleaseConfigs.find( + (config) => config.projectId === projectId + ); + return projectReleaseConfig !== undefined; + } +} diff --git a/src/release/utils/configBuilder.ts b/src/release/utils/configBuilder.ts index 9a4e7a6..2a25bb2 100644 --- a/src/release/utils/configBuilder.ts +++ b/src/release/utils/configBuilder.ts @@ -1,11 +1,11 @@ import type { JSONSchemaType } from 'ajv'; import Ajv from 'ajv'; +import ReleasePluginManager from '@/core/pluginManager/ReleasePluginManager'; import type { ProjectConfigurationsJSON, ProjectReleaseConfig, ProjectConfigJSON, } from '../typings/ProjectReleaseConfig'; -import type { ReleaseManager } from '../typings/ReleaseManager'; import type { ReleaseTagManager } from '../typings/ReleaseTagManager'; // only one Ajv instance should be used across all the application @@ -46,9 +46,8 @@ const configsSchema: JSONSchemaType = { const validateProjectReleaseConfig = ajv.compile(configsSchema); -export function buildProjectReleaseConfigs( +export async function buildProjectReleaseConfigs( configs: ProjectConfigurationsJSON, - releaseManagers: Record, releaseTagManagers: Record ) { if (!validateProjectReleaseConfig(configs)) { @@ -58,24 +57,19 @@ export function buildProjectReleaseConfigs( } const projects: ProjectReleaseConfig[] = []; for (const project of configs.projects) { - const { - releaseManager, - releaseTagManager, - notificationChannelIds, - projectId, - releaseChannelId, - } = project; - if (releaseManager in releaseManagers) { - projects.push({ - notificationChannelIds, - projectId, - releaseChannelId, - releaseManager: releaseManagers[releaseManager], - releaseTagManager: releaseTagManager - ? releaseTagManagers[releaseTagManager] - : undefined, - }); + const { releaseManager, releaseTagManager, ...ids } = project; + if (ReleasePluginManager.getReleaseManager(releaseManager) === undefined) { + await ReleasePluginManager.loadReleaseManagerPlugin( + `@root/plugins/release/${releaseManager}` + ); } + projects.push({ + ...ids, + releaseManager: ReleasePluginManager.getReleaseManager(releaseManager)!, + releaseTagManager: releaseTagManager + ? releaseTagManagers[releaseTagManager] + : undefined, + }); } return projects; } diff --git a/src/release/utils/configHelper.ts b/src/release/utils/configHelper.ts deleted file mode 100644 index c0c8fac..0000000 --- a/src/release/utils/configHelper.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { defaultReleaseManager } from '@/release/commands/create/managers/defaultReleaseManager'; -import { federationReleaseTagManager } from '@/release/commands/create/managers/federationReleaseTagManager'; -import { libraryReleaseManager } from '@/release/commands/create/managers/libraryReleaseManager'; -import { semanticReleaseTagManager } from '@/release/commands/create/managers/semanticReleaseTagManager'; -import { stableDateReleaseTagManager } from '@/release/commands/create/managers/stableDateReleaseTagManager'; -import type { ProjectReleaseConfig } from '@/release/typings/ProjectReleaseConfig'; -import type { ReleaseManager } from '@/release/typings/ReleaseManager'; -import type { ReleaseTagManager } from '@/release/typings/ReleaseTagManager'; -import { buildProjectReleaseConfigs } from '@/release/utils/configBuilder'; -import projectsConfig from '@root/config/homer/projects.json'; - -const releaseManagers: Record = { - defaultReleaseManager, - libraryReleaseManager, -}; -const releaseTagManagers: Record = { - federationReleaseTagManager, - semanticReleaseTagManager, - stableDateReleaseTagManager, -}; - -export const projectReleaseConfigs: ProjectReleaseConfig[] = - buildProjectReleaseConfigs( - projectsConfig, - releaseManagers, - releaseTagManagers - ); - -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/tsconfig.build.json b/tsconfig.build.json index be67f56..b0f59ca 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,4 +1,4 @@ { "extends": "./tsconfig.json", - "include": ["config/homer", "src"] + "include": ["config/homer", "src", "plugins"] } diff --git a/tsconfig.json b/tsconfig.json index a6f918f..3505cdd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,6 +19,6 @@ "strict": true, "target": "es2020" }, - "include": ["__mocks__", "__tests__", "config", "src"], + "include": ["__mocks__", "__tests__", "config", "src", "plugins"], "exclude": ["**/node_modules", "**/.*/"] } From d50ed0d071e687091b7f8db28b57314b2beea2f8 Mon Sep 17 00:00:00 2001 From: Pascal Fong Kye Date: Thu, 17 Oct 2024 17:28:08 +0200 Subject: [PATCH 2/2] Fix wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Grégoire Paris --- PLUGIN_RELEASE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PLUGIN_RELEASE.md b/PLUGIN_RELEASE.md index 1eafe30..9a74c06 100644 --- a/PLUGIN_RELEASE.md +++ b/PLUGIN_RELEASE.md @@ -3,7 +3,7 @@ ## Introduction This document provides instructions on how to use the plugin system to add your own release manager to the Homer project. -You can create a project containing a `plugins/release` folder which will contain your release manager and build a new Docker image where `plugins/release` will be copied to `dist/plugins`. +You can create a project containing a `plugins/release` directory which will contain your release manager and build a new Docker image where `plugins/release` will be copied to `dist/plugins`. ## Steps to Add a Custom Release Manager