Skip to content

Commit

Permalink
Merge pull request #42 from pfongkye/feat/plugin-manager
Browse files Browse the repository at this point in the history
feat(plugin): add simple plugin manager for release
  • Loading branch information
greg0ire authored Oct 17, 2024
2 parents 8d98eff + d50ed0d commit 56b0f4b
Show file tree
Hide file tree
Showing 27 changed files with 439 additions and 161 deletions.
37 changes: 37 additions & 0 deletions PLUGIN_RELEASE.md
Original file line number Diff line number Diff line change
@@ -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` 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

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.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
45 changes: 45 additions & 0 deletions __tests__/core/pluginManager/ReleasePluginManager.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
10 changes: 8 additions & 2 deletions __tests__/release/createRelease.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 */
Expand Down
6 changes: 4 additions & 2 deletions __tests__/release/endRelease.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 = {
Expand Down
48 changes: 34 additions & 14 deletions __tests__/release/utils/configBuilder.test.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -20,9 +21,8 @@ describe('configBuilder', () => {
},
];
expect(
buildProjectReleaseConfigs(
await buildProjectReleaseConfigs(
{ projects },
{ defaultReleaseManager },
{ federationReleaseTagManager }
)
).toEqual([
Expand All @@ -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'
);
});
Expand Down
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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,
Expand All @@ -92,7 +95,9 @@ export async function isReadyToRelease(
return dockerBuildJob?.status === 'success';
}

export const defaultReleaseManager: ReleaseManager = {
const defaultReleaseManager: ReleaseManager = {
getReleaseStateUpdate,
isReadyToRelease,
};

export default defaultReleaseManager;
Original file line number Diff line number Diff line change
@@ -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'];

Expand All @@ -23,7 +23,9 @@ export async function isReadyToRelease(
return buildJob?.status === 'success';
}

export const libraryReleaseManager: ReleaseManager = {
const libraryReleaseManager: ReleaseManager = {
getReleaseStateUpdate,
isReadyToRelease,
};

export default libraryReleaseManager;
60 changes: 60 additions & 0 deletions src/core/pluginManager/ReleasePluginManager.ts
Original file line number Diff line number Diff line change
@@ -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<string, ReleaseManager> = new Map();
private readonly releaseTagManagers: Map<string, ReleaseTagManager> =
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);
}
}
8 changes: 4 additions & 4 deletions src/release/commands/cancel/cancelReleaseRequestHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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[] },
Expand Down
Loading

0 comments on commit 56b0f4b

Please sign in to comment.