Merge pull request #19633 from davelil4/bitbucket-server-events

Bitbucket Server Catalog Plugin Event Support
This commit is contained in:
Fredrik Adelöw
2025-04-01 11:22:39 +01:00
committed by GitHub
28 changed files with 1390 additions and 79 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-catalog-backend-module-bitbucket-server': minor
'@backstage/plugin-events-backend-module-bitbucket-server': minor
---
Added the ability for the plugin to receive events coming from Bitbucket Server push webhooks. It then performs a delta mutation on the catalog.
+18 -5
View File
@@ -26,17 +26,30 @@ dependency to `@backstage/plugin-catalog-backend-module-bitbucket-server` to you
yarn --cwd packages/backend add @backstage/plugin-catalog-backend-module-bitbucket-server
```
And update your backend by adding the following line:
### Installation with New Backend System
```ts title="packages/backend/src/index.ts"
backend.add(import('@backstage/plugin-catalog-backend'));
/* highlight-add-start */
```ts
// optional if you want HTTP endpojnts to receive external events
// backend.add(import('@backstage/plugin-events-backend'));
// optional if you want to use AWS SQS instead of HTTP endpoints to receive external events
// backend.add(import('@backstage/plugin-events-backend-module-aws-sqs'));
backend.add(import('@backstage/plugin-events-backend-module-bitbucket-server'));
backend.add(
import('@backstage/plugin-catalog-backend-module-bitbucket-server'),
);
/* highlight-add-end */
```
You need to decide how you want to receive events from external sources like
- [via HTTP endpoint](https://github.com/backstage/backstage/tree/master/plugins/events-backend/README.md)
- [via an AWS SQS queue](https://github.com/backstage/backstage/tree/master/plugins/events-backend-module-aws-sqs/README.md)
Further documentation:
- <https://github.com/backstage/backstage/tree/master/plugins/events-backend/README.md>
- <https://github.com/backstage/backstage/tree/master/plugins/events-backend-module-aws-sqs/README.md>
- <https://github.com/backstage/backstage/tree/master/plugins/events-backend-module-bitbucket-server/README.md>
## Configuration
To use the entity provider, you'll need a [Bitbucket Server integration set up](locations.md).
@@ -21,16 +21,12 @@
"license": "Apache-2.0",
"exports": {
".": "./src/index.ts",
"./alpha": "./src/alpha.ts",
"./package.json": "./package.json"
},
"main": "src/index.ts",
"types": "src/index.ts",
"typesVersions": {
"*": {
"alpha": [
"src/alpha.ts"
],
"package.json": [
"package.json"
]
@@ -51,17 +47,21 @@
},
"dependencies": {
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/catalog-client": "workspace:^",
"@backstage/catalog-model": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/integration": "workspace:^",
"@backstage/plugin-catalog-common": "workspace:^",
"@backstage/plugin-catalog-node": "workspace:^",
"@backstage/plugin-events-node": "workspace:^",
"p-throttle": "^4.1.1",
"uuid": "^11.0.0"
},
"devDependencies": {
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"@backstage/plugin-events-backend-test-utils": "workspace:^",
"luxon": "^3.0.0",
"msw": "^1.0.0"
},
@@ -1,13 +0,0 @@
## API Report File for "@backstage/plugin-catalog-backend-module-bitbucket-server"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { BackendFeature } from '@backstage/backend-plugin-api';
// @alpha (undocumented)
const _feature: BackendFeature;
export default _feature;
// (No @packageDocumentation comment for this package)
```
@@ -3,13 +3,16 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { AuthService } from '@backstage/backend-plugin-api';
import { BackendFeature } from '@backstage/backend-plugin-api';
import { BitbucketServerIntegrationConfig } from '@backstage/integration';
import { CatalogService } from '@backstage/plugin-catalog-node';
import { Config } from '@backstage/config';
import { Entity } from '@backstage/catalog-model';
import { EntityProvider } from '@backstage/plugin-catalog-node';
import { EntityProviderConnection } from '@backstage/plugin-catalog-node';
import { LocationSpec } from '@backstage/plugin-catalog-node';
import { EventsService } from '@backstage/plugin-events-node';
import { LocationSpec } from '@backstage/plugin-catalog-common';
import { LoggerService } from '@backstage/backend-plugin-api';
import { SchedulerService } from '@backstage/backend-plugin-api';
import { SchedulerServiceTaskRunner } from '@backstage/backend-plugin-api';
@@ -22,6 +25,11 @@ export class BitbucketServerClient {
config: BitbucketServerIntegrationConfig;
}): BitbucketServerClient;
// (undocumented)
getDefaultBranch(options: {
projectKey: string;
repo: string;
}): Promise<BitbucketServerDefaultBranch>;
// (undocumented)
getFile(options: {
projectKey: string;
repo: string;
@@ -47,6 +55,16 @@ export class BitbucketServerClient {
};
}
// @public (undocumented)
export type BitbucketServerDefaultBranch = {
id: string;
displayId: string;
type: string;
latestCommit: string;
latestChangeset: string;
isDefault: boolean;
};
// @public
export class BitbucketServerEntityProvider implements EntityProvider {
connect(connection: EntityProviderConnection): Promise<void>;
@@ -55,9 +73,12 @@ export class BitbucketServerEntityProvider implements EntityProvider {
config: Config,
options: {
logger: LoggerService;
events?: EventsService;
parser?: BitbucketServerLocationParser;
schedule?: SchedulerServiceTaskRunner;
scheduler?: SchedulerService;
catalogApi?: CatalogService;
auth?: AuthService;
},
): BitbucketServerEntityProvider[];
getProviderName(): string;
@@ -108,6 +129,7 @@ export type BitbucketServerRepository = {
}[]
>;
archived: boolean;
defaultBranch: string;
};
// @public (undocumented)
@@ -21,12 +21,16 @@
*/
export { default } from './module';
export { BitbucketServerClient } from './lib';
export type {
BitbucketServerProject,
BitbucketServerRepository,
BitbucketServerPagedResponse,
BitbucketServerListOptions,
BitbucketServerDefaultBranch,
} from './lib';
export { BitbucketServerEntityProvider } from './providers';
export type { BitbucketServerLocationParser } from './providers';
@@ -23,7 +23,12 @@ import {
BitbucketServerPagedResponse,
paginated,
} from './BitbucketServerClient';
import { BitbucketServerProject, BitbucketServerRepository } from './types';
import {
BitbucketServerDefaultBranch,
BitbucketServerProject,
BitbucketServerRepository,
} from './types';
import { NotFoundError } from '@backstage/errors';
const server = setupServer();
@@ -122,6 +127,7 @@ describe('BitbucketServerClient', () => {
],
},
archived: false,
defaultBranch: 'master',
},
],
};
@@ -200,6 +206,7 @@ describe('BitbucketServerClient', () => {
],
},
archived: false,
defaultBranch: 'master',
};
return res(ctx.json(response));
@@ -217,4 +224,82 @@ describe('BitbucketServerClient', () => {
'https://bitbucket.mycompany.com/projects/test-project',
);
});
it('getRepository no repo', async () => {
server.use(
rest.get(
`${config.apiBaseUrl}/projects/test-project/repos/wrong-repo`,
(_, res, ctx) => {
return res(ctx.status(404));
},
),
);
const error = async () => {
await client.getRepository({
projectKey: 'test-project',
repo: 'wrong-repo',
});
};
await expect(error).rejects.toThrow(
"Repository 'wrong-repo' in project 'test-project' does not exist.",
);
await expect(error).rejects.toThrow(NotFoundError);
});
it('getDefaultBranch success', async () => {
server.use(
rest.get(
`${config.apiBaseUrl}/projects/test-project/repos/test-repo/default-branch`,
(req, res, ctx) => {
if (
req.headers.get('authorization') !==
'Basic dGVzdC11c2VyOnRlc3QtcHc='
) {
return res(ctx.status(400));
}
const response: BitbucketServerDefaultBranch = {
id: 'refs/heads/master',
displayId: 'master',
type: 'BRANCH',
latestCommit: '0f2f3ae484054696568bf4560ba4da280f7df82a',
latestChangeset: '0f2f3ae484054696568bf4560ba4da280f7df82a',
isDefault: true,
};
return res(ctx.json(response));
},
),
);
const repo = await client.getDefaultBranch({
projectKey: 'test-project',
repo: 'test-repo',
});
expect(repo.displayId).toEqual('master');
});
it('getDefaultBranch endpoint', async () => {
server.use(
rest.get(
`${config.apiBaseUrl}/projects/test-project/repos/wrong-repo/default-branch`,
(_, res, ctx) => {
return res(ctx.status(404));
},
),
);
const error = async () => {
await client.getDefaultBranch({
projectKey: 'test-project',
repo: 'wrong-repo',
});
};
await expect(error).rejects.toThrow(
"Your Bitbucket Server version no longer supports the default branch endpoint or 'wrong-repo' in 'test-project' does not exist.",
);
await expect(error).rejects.toThrow(NotFoundError);
});
});
@@ -18,8 +18,14 @@ import {
BitbucketServerIntegrationConfig,
getBitbucketServerRequestOptions,
} from '@backstage/integration';
import { BitbucketServerProject, BitbucketServerRepository } from './types';
import pThrottle from 'p-throttle';
import {
BitbucketServerDefaultBranch,
BitbucketServerRepository,
} from './index';
import { BitbucketServerProject } from './types';
import { NotFoundError } from '@backstage/errors';
import { ResponseError } from '@backstage/errors';
// 1 per second
const throttle = pThrottle({
@@ -92,7 +98,35 @@ export class BitbucketServerClient {
request,
getBitbucketServerRequestOptions(this.config),
);
return response.json();
if (response.ok) {
return response.json();
}
if (response.status === 404) {
throw new NotFoundError(
`Repository '${options.repo}' in project '${options.projectKey}' does not exist.`,
);
}
throw await ResponseError.fromResponse(response);
}
async getDefaultBranch(options: {
projectKey: string;
repo: string;
}): Promise<BitbucketServerDefaultBranch> {
const request = `${this.config.apiBaseUrl}/projects/${options.projectKey}/repos/${options.repo}/default-branch`;
const response = await fetch(
request,
getBitbucketServerRequestOptions(this.config),
);
if (response.ok) {
return response.json();
}
if (response.status === 404) {
throw new NotFoundError(
`Your Bitbucket Server version no longer supports the default branch endpoint or '${options.repo}' in '${options.projectKey}' does not exist.`,
);
}
throw await ResponseError.fromResponse(response);
}
resolvePath(options: { projectKey: string; repo: string; path: string }): {
@@ -20,6 +20,9 @@ export type {
BitbucketServerListOptions,
} from './BitbucketServerClient';
export type {
BitbucketServerDefaultBranch,
BitbucketServerRepository,
BitbucketServerEvents,
BitbucketServerProject,
} from './types';
// export * from './types';
@@ -15,7 +15,7 @@
*/
/** @public */
export type BitbucketServerRepository = {
type BitbucketServerRepository = {
project: {
key: string;
};
@@ -28,9 +28,55 @@ export type BitbucketServerRepository = {
}[]
>;
archived: boolean;
defaultBranch: string;
};
/** @public */
export type BitbucketServerProject = {
type BitbucketServerProject = {
key: string;
};
/** @public */
type BitbucketServerDefaultBranch = {
id: string;
displayId: string;
type: string;
latestCommit: string;
latestChangeset: string;
isDefault: boolean;
};
namespace BitbucketServerEvents {
interface Event {
eventKey: string;
}
export interface RefsChangedEvent extends Event {
date: string;
actor: Actor;
repository: Repository;
changes: Change[];
commits: undefined;
ToCommit: undefined;
}
type Actor = {
name?: string;
id: number;
};
type Change = {
ref: { id: string; displayId: string; type: string };
};
type Repository = {
slug: string;
id: number;
name: string;
project: BitbucketServerProject;
};
}
export type {
BitbucketServerDefaultBranch,
BitbucketServerProject,
BitbucketServerEvents,
BitbucketServerRepository,
};
@@ -16,20 +16,33 @@
import { SchedulerServiceTaskScheduleDefinition } from '@backstage/backend-plugin-api';
import { mockServices, startTestBackend } from '@backstage/backend-test-utils';
import { createServiceFactory } from '@backstage/backend-plugin-api';
import { EntityProviderConnection } from '@backstage/plugin-catalog-node';
import { catalogProcessingExtensionPoint } from '@backstage/plugin-catalog-node/alpha';
import { TestEventsService } from '@backstage/plugin-events-backend-test-utils';
import { eventsServiceRef } from '@backstage/plugin-events-node';
import { catalogModuleBitbucketServerEntityProvider } from './catalogModuleBitbucketServerEntityProvider';
import { BitbucketServerEntityProvider } from '../providers';
import { BitbucketServerEntityProvider } from '../providers/BitbucketServerEntityProvider';
describe('catalogModuleBitbucketServerEntityProvider', () => {
it('should register provider at the catalog extension point', async () => {
const events = new TestEventsService();
const eventsServiceFactory = createServiceFactory({
service: eventsServiceRef,
deps: {},
async factory({}) {
return events;
},
});
let addedProviders: Array<BitbucketServerEntityProvider> | undefined;
let usedSchedule: SchedulerServiceTaskScheduleDefinition | undefined;
const extensionPoint = {
const catalogExtensionPointImpl = {
addEntityProvider: (providers: any) => {
addedProviders = providers;
},
};
const connection = jest.fn() as unknown as EntityProviderConnection;
const runner = jest.fn();
const scheduler = mockServices.scheduler.mock({
createScheduledTaskRunner(schedule) {
@@ -38,33 +51,35 @@ describe('catalogModuleBitbucketServerEntityProvider', () => {
},
});
const config = {
catalog: {
providers: {
bitbucketServer: {
host: 'bitbucket.mycompany.com',
schedule: {
frequency: 'P1M',
timeout: 'PT3M',
await startTestBackend({
extensionPoints: [
[catalogProcessingExtensionPoint, catalogExtensionPointImpl],
],
features: [
eventsServiceFactory,
catalogModuleBitbucketServerEntityProvider,
mockServices.rootConfig.factory({
data: {
catalog: {
providers: {
bitbucketServer: {
host: 'bitbucket.mycompany.com',
schedule: {
frequency: 'P1M',
timeout: 'PT3M',
},
},
},
},
integrations: {
bitbucketServer: [
{
host: 'bitbucket.mycompany.com',
},
],
},
},
},
},
integrations: {
bitbucketServer: [
{
host: 'bitbucket.mycompany.com',
},
],
},
};
await startTestBackend({
extensionPoints: [[catalogProcessingExtensionPoint, extensionPoint]],
features: [
catalogModuleBitbucketServerEntityProvider,
mockServices.rootConfig.factory({ data: config }),
mockServices.logger.factory(),
}),
scheduler.factory,
],
});
@@ -72,9 +87,14 @@ describe('catalogModuleBitbucketServerEntityProvider', () => {
expect(usedSchedule?.frequency).toEqual({ months: 1 });
expect(usedSchedule?.timeout).toEqual({ minutes: 3 });
expect(addedProviders?.length).toEqual(1);
expect(addedProviders?.pop()?.getProviderName()).toEqual(
expect(runner).not.toHaveBeenCalled();
const provider = addedProviders!.pop()!;
expect(provider.getProviderName()).toEqual(
'bitbucketServer-provider:default',
);
expect(runner).not.toHaveBeenCalled();
await provider.connect(connection);
expect(events.subscribed).toHaveLength(1);
expect(events.subscribed[0].id).toEqual('bitbucketServer-provider:default');
expect(runner).toHaveBeenCalledTimes(1);
});
});
@@ -18,8 +18,10 @@ import {
coreServices,
createBackendModule,
} from '@backstage/backend-plugin-api';
import { catalogServiceRef } from '@backstage/plugin-catalog-node';
import { catalogProcessingExtensionPoint } from '@backstage/plugin-catalog-node/alpha';
import { BitbucketServerEntityProvider } from '../providers';
import { eventsServiceRef } from '@backstage/plugin-events-node';
import { BitbucketServerEntityProvider } from '../providers/BitbucketServerEntityProvider';
/**
* @public
@@ -31,14 +33,28 @@ export const catalogModuleBitbucketServerEntityProvider = createBackendModule({
env.registerInit({
deps: {
catalog: catalogProcessingExtensionPoint,
catalogApi: catalogServiceRef,
config: coreServices.rootConfig,
events: eventsServiceRef,
logger: coreServices.logger,
scheduler: coreServices.scheduler,
auth: coreServices.auth,
},
async init({ catalog, config, logger, scheduler }) {
async init({
catalog,
catalogApi,
config,
events,
logger,
scheduler,
auth,
}) {
const providers = BitbucketServerEntityProvider.fromConfig(config, {
catalogApi,
events,
logger,
scheduler,
auth,
});
catalog.addEntityProvider(providers);
@@ -18,17 +18,30 @@ import {
SchedulerService,
SchedulerServiceTaskRunner,
SchedulerServiceTaskInvocationDefinition,
BackstageCredentials,
} from '@backstage/backend-plugin-api';
import {
mockServices,
registerMswTestHooks,
} from '@backstage/backend-test-utils';
import { ConfigReader } from '@backstage/config';
import { EntityProviderConnection } from '@backstage/plugin-catalog-node';
import {
CatalogService,
DeferredEntity,
EntityProviderConnection,
locationSpecToLocationEntity,
} from '@backstage/plugin-catalog-node';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { BitbucketServerEntityProvider } from './BitbucketServerEntityProvider';
import {
BitbucketServerEntityProvider,
toDeferredEntities,
} from './BitbucketServerEntityProvider';
import { BitbucketServerPagedResponse } from '../lib';
import { Entity, LocationEntity } from '@backstage/catalog-model';
import { BitbucketServerEvents } from '../lib/index';
import { DefaultEventsService } from '@backstage/plugin-events-node';
import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils';
class PersistingTaskRunner implements SchedulerServiceTaskRunner {
private tasks: SchedulerServiceTaskInvocationDefinition[] = [];
@@ -57,9 +70,17 @@ function pagedResponse(values: any): BitbucketServerPagedResponse<any> {
const logger = mockServices.logger.mock();
const authService = mockServices.auth.mock();
const events = DefaultEventsService.create({ logger });
const server = setupServer();
function setupStubs(projects: Project[], baseUrl: string) {
function setupStubs(
projects: Project[],
baseUrl: string,
defaultBranch: string,
) {
// Stub projects
server.use(
rest.get(`${baseUrl}/rest/api/1.0/projects`, (_, res, ctx) => {
@@ -93,6 +114,7 @@ function setupStubs(projects: Project[], baseUrl: string) {
],
},
archived: repo.archived ?? false,
defaultBranch: defaultBranch,
});
}
return res(ctx.json(pagedResponse(response)));
@@ -102,6 +124,102 @@ function setupStubs(projects: Project[], baseUrl: string) {
}
}
const host = 'bitbucket.mycompany.com';
const targetPath = `/catalog-info.yaml`;
const test1RepoUrl = `https://${host}/projects/TEST/repos/test1/browse`;
function setupRepositoryReqHandler(defaultBranch: string) {
server.use(
rest.get(
`https://${host}/rest/api/1.0/projects/TEST/repos/test1`,
(_, res, ctx) => {
const response = {
slug: 'test1',
id: 1,
name: 'test1',
project: {
key: 'TEST',
id: 1,
name: 'TEST',
links: {
self: [
{
href: `https://${host}/projects/TEST`,
},
],
},
},
links: {
self: [
{
href: `${test1RepoUrl}`,
},
],
},
defaultBranch: defaultBranch,
};
return res(ctx.json(response));
},
),
);
}
const repoPushEvent: BitbucketServerEvents.RefsChangedEvent = {
eventKey: 'repo:refs_changed',
date: '2017-09-19T09:45:32+1000',
actor: {
name: 'admin',
id: 1,
},
repository: {
slug: 'test1',
id: 84,
name: 'test1',
project: {
key: 'TEST',
},
},
changes: [
{
ref: {
id: 'refs/heads/master',
displayId: 'master',
type: 'BRANCH',
},
},
],
commits: undefined,
ToCommit: undefined,
};
const repoPushEventParams = {
topic: 'bitbucketServer.repo:refs_changed',
eventPayload: repoPushEvent,
metadata: { 'x-event-key': 'repo:refs_changed' },
};
const createLocationEntity = (
repoUrl: string,
path: string,
defaultBranch: string,
): LocationEntity => {
const target = `${repoUrl}${path}`;
const entity = locationSpecToLocationEntity({
location: {
type: 'url',
target: target,
presence: 'optional',
},
});
entity.metadata.annotations = {
...entity.metadata.annotations,
[`${host}/repo-url`]: target,
['bitbucket.org/default-branch']: defaultBranch,
};
return entity;
};
describe('BitbucketServerEntityProvider', () => {
registerMswTestHooks(server);
afterEach(() => {
@@ -157,6 +275,7 @@ describe('BitbucketServerEntityProvider', () => {
});
const providers = BitbucketServerEntityProvider.fromConfig(config, {
logger,
events,
schedule,
});
@@ -191,6 +310,7 @@ describe('BitbucketServerEntityProvider', () => {
});
const providers = BitbucketServerEntityProvider.fromConfig(config, {
logger,
events,
schedule,
});
@@ -204,7 +324,6 @@ describe('BitbucketServerEntityProvider', () => {
});
it('apply full update on scheduled execution with filters', async () => {
const host = 'bitbucket.mycompany.com';
const config = new ConfigReader({
integrations: {
bitbucketServer: [
@@ -253,6 +372,7 @@ describe('BitbucketServerEntityProvider', () => {
{ key: 'other-project', repos: [{ name: 'other-repo' }] },
],
`https://${host}`,
'master',
);
await provider.connect(entityProviderConnection);
@@ -270,6 +390,7 @@ describe('BitbucketServerEntityProvider', () => {
annotations: {
'backstage.io/managed-by-location': `url:${url}`,
'backstage.io/managed-by-origin-location': `url:${url}`,
'bitbucket.org/default-branch': 'master',
},
name: 'generated-77f4323822420990f8c3e3c981d38c2dec4ae3a6',
},
@@ -291,7 +412,6 @@ describe('BitbucketServerEntityProvider', () => {
});
it('apply full update on scheduled execution without filters', async () => {
const host = 'bitbucket.mycompany.com';
const config = new ConfigReader({
integrations: {
bitbucketServer: [
@@ -329,6 +449,7 @@ describe('BitbucketServerEntityProvider', () => {
{ key: 'other-project', repos: [{ name: 'other-repo' }] },
],
`https://${host}`,
'master',
);
await provider.connect(entityProviderConnection);
@@ -345,6 +466,7 @@ describe('BitbucketServerEntityProvider', () => {
annotations: {
'backstage.io/managed-by-location': `url:https://${host}/projects/project-test/repos/repo-test/browse/catalog-info.yaml`,
'backstage.io/managed-by-origin-location': `url:https://${host}/projects/project-test/repos/repo-test/browse/catalog-info.yaml`,
'bitbucket.org/default-branch': 'master',
},
name: 'generated-77f4323822420990f8c3e3c981d38c2dec4ae3a6',
},
@@ -364,6 +486,7 @@ describe('BitbucketServerEntityProvider', () => {
annotations: {
'backstage.io/managed-by-location': `url:https://${host}/projects/other-project/repos/other-repo/browse/catalog-info.yaml`,
'backstage.io/managed-by-origin-location': `url:https://${host}/projects/other-project/repos/other-repo/browse/catalog-info.yaml`,
'bitbucket.org/default-branch': 'master',
},
name: 'generated-d8d4944c30c2906dfee172ddda9537f9893b2c0f',
},
@@ -441,7 +564,6 @@ describe('BitbucketServerEntityProvider', () => {
});
it('apply full update with schedule in config', async () => {
const host = 'bitbucket.mycompany.com';
const config = new ConfigReader({
integrations: {
bitbucketServer: [
@@ -488,6 +610,7 @@ describe('BitbucketServerEntityProvider', () => {
{ key: 'other-project', repos: [{ name: 'other-repo' }] },
],
`https://${host}`,
'master',
);
await provider.connect(entityProviderConnection);
@@ -504,6 +627,7 @@ describe('BitbucketServerEntityProvider', () => {
annotations: {
'backstage.io/managed-by-location': `url:https://${host}/projects/project-test/repos/repo-test/browse/catalog-info.yaml`,
'backstage.io/managed-by-origin-location': `url:https://${host}/projects/project-test/repos/repo-test/browse/catalog-info.yaml`,
'bitbucket.org/default-branch': 'master',
},
name: 'generated-77f4323822420990f8c3e3c981d38c2dec4ae3a6',
},
@@ -523,6 +647,7 @@ describe('BitbucketServerEntityProvider', () => {
annotations: {
'backstage.io/managed-by-location': `url:https://${host}/projects/other-project/repos/other-repo/browse/catalog-info.yaml`,
'backstage.io/managed-by-origin-location': `url:https://${host}/projects/other-project/repos/other-repo/browse/catalog-info.yaml`,
'bitbucket.org/default-branch': 'master',
},
name: 'generated-d8d4944c30c2906dfee172ddda9537f9893b2c0f',
},
@@ -542,4 +667,351 @@ describe('BitbucketServerEntityProvider', () => {
entities: expectedEntities,
});
});
it('Multiple location entities to deferred entities', async () => {
const schedule = new PersistingTaskRunner();
const config = new ConfigReader({
catalog: {
providers: {
bitbucketServer: {
host: host,
},
},
},
integrations: {
bitbucketServer: [
{
host: host,
},
],
},
});
const providers = BitbucketServerEntityProvider.fromConfig(config, {
logger,
events,
schedule,
});
expect(providers).toHaveLength(1);
expect(providers[0].getProviderName()).toEqual(
'bitbucketServer-provider:default',
);
const locationEntities = [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Location',
metadata: {
annotations: {
'backstage.io/managed-by-location': `url:https://${host}/projects/project-test/repos/repo-test/browse/catalog-info.yaml`,
'backstage.io/managed-by-origin-location': `url:https://${host}/projects/project-test/repos/repo-test/browse/catalog-info.yaml`,
[`${host}/repo-url`]: `https://${host}/projects/project-test/repos/repo-test/browse/catalog-info.yaml`,
'bitbucket.org/default-branch': 'master',
},
name: 'generated-77f4323822420990f8c3e3c981d38c2dec4ae3a6',
},
spec: {
presence: 'optional',
target: `https://${host}/projects/project-test/repos/repo-test/browse/catalog-info.yaml`,
type: 'url',
},
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Location',
metadata: {
annotations: {
'backstage.io/managed-by-location': `url:https://${host}/projects/other-project/repos/other-repo/browse/catalog-info.yaml`,
'backstage.io/managed-by-origin-location': `url:https://${host}/projects/other-project/repos/other-repo/browse/catalog-info.yaml`,
[`${host}/repo-url`]: `https://${host}/projects/other-project/repos/other-repo/browse/catalog-info.yaml`,
'bitbucket.org/default-branch': 'master',
},
name: 'generated-d8d4944c30c2906dfee172ddda9537f9893b2c0f',
},
spec: {
presence: 'optional',
target: `https://${host}/projects/other-project/repos/other-repo/browse/catalog-info.yaml`,
type: 'url',
},
},
];
const deferredEntities = toDeferredEntities(
locationEntities,
providers[0].getProviderName(),
);
expect(deferredEntities).toEqual([
{
locationKey: providers[0].getProviderName(),
entity: locationEntities[0],
},
{
locationKey: providers[0].getProviderName(),
entity: locationEntities[1],
},
]);
});
it('refresh onRepoPush', async () => {
const schedule = new PersistingTaskRunner();
const keptModule = createLocationEntity(
test1RepoUrl,
`/kept-module:${targetPath}`,
'master',
);
const entityProviderConnection: EntityProviderConnection = {
applyMutation: jest.fn(),
refresh: jest.fn(),
};
setupRepositoryReqHandler('master');
// authService.getOwnServiceCredentials();
const config = new ConfigReader({
integrations: {
bitbucketServer: [
{
host: host,
},
],
},
catalog: {
providers: {
bitbucketServer: {
mainProvider: {
host: host,
apiBaseUrl: `https://${host}/rest/api/1.0`,
catalogPath: `/kept-module:/catalog-info.yaml`,
defaultBranch: 'master',
},
},
},
},
});
const catalogApi = catalogServiceMock.mock({
getEntities: async (
request: { filter: Record<string, string> },
credentials: { credentials: BackstageCredentials },
): Promise<{ items: Entity[] }> => {
if (
credentials.credentials !==
(await authService.getOwnServiceCredentials()) ||
request.filter.kind !== 'Location' ||
request.filter[`metadata.annotations.${host}/repo-url`] !==
`${test1RepoUrl}/kept-module:${targetPath}`
) {
return { items: [] };
}
return {
items: [keptModule],
};
},
});
const provider = BitbucketServerEntityProvider.fromConfig(config, {
catalogApi: catalogApi,
logger,
schedule,
events,
auth: authService,
})[0];
await provider.connect(entityProviderConnection);
await events.publish(repoPushEventParams);
expect(entityProviderConnection.refresh).toHaveBeenCalledTimes(1);
expect(entityProviderConnection.refresh).toHaveBeenCalledWith({
keys: [`url:${test1RepoUrl}/kept-module:${targetPath}`],
});
expect(entityProviderConnection.applyMutation).toHaveBeenCalledTimes(0);
});
it('no refresh onRepoPush due to different default branch', async () => {
const schedule = new PersistingTaskRunner();
const keptModule = createLocationEntity(
test1RepoUrl,
`/kept-module:${targetPath}`,
'main',
);
const entityProviderConnection: EntityProviderConnection = {
applyMutation: jest.fn(),
refresh: jest.fn(),
};
setupRepositoryReqHandler('main');
const config = new ConfigReader({
integrations: {
bitbucketServer: [
{
host: host,
},
],
},
catalog: {
providers: {
bitbucketServer: {
mainProvider: {
host: host,
apiBaseUrl: `https://${host}/rest/api/1.0`,
catalogPath: `/kept-module:/catalog-info.yaml`,
},
},
},
},
});
const catalogApi = catalogServiceMock.mock({
getEntities: async (
request: { filter: Record<string, string> },
credentials: { credentials: BackstageCredentials },
): Promise<{ items: Entity[] }> => {
if (
credentials.credentials !==
(await authService.getOwnServiceCredentials()) ||
request.filter.kind !== 'Location' ||
request.filter[`metadata.annotations.${host}/repo-url`] !==
`${test1RepoUrl}/kept-module:${targetPath}`
) {
return { items: [] };
}
return {
items: [keptModule],
};
},
});
const provider = BitbucketServerEntityProvider.fromConfig(config, {
catalogApi: catalogApi,
logger,
schedule,
events,
auth: authService,
})[0];
await provider.connect(entityProviderConnection);
await events.publish(repoPushEventParams);
expect(entityProviderConnection.refresh).toHaveBeenCalledTimes(0);
expect(entityProviderConnection.applyMutation).toHaveBeenCalledTimes(0);
});
it('add onRepoPush', async () => {
const schedule = new PersistingTaskRunner();
setupRepositoryReqHandler('master');
authService.getPluginRequestToken.mockResolvedValue({
token: 'fake-token',
});
const addedModule = createLocationEntity(
test1RepoUrl,
`/added-module:${targetPath}`,
'master',
);
const entityProviderConnection: EntityProviderConnection = {
applyMutation: jest.fn(),
refresh: jest.fn(),
};
const config = new ConfigReader({
integrations: {
bitbucketServer: [
{
host: host,
},
],
},
catalog: {
providers: {
bitbucketServer: {
mainProvider: {
host: host,
apiBaseUrl: `https://${host}/rest/api/1.0`,
catalogPath: `/added-module:/catalog-info.yaml`,
},
},
},
},
});
const catalogApi = catalogServiceMock({ entities: [] });
const provider = BitbucketServerEntityProvider.fromConfig(config, {
catalogApi: catalogApi,
logger,
schedule,
events,
auth: authService,
})[0];
await provider.connect(entityProviderConnection);
await events.publish(repoPushEventParams);
const addedEntities = [
{
entity: addedModule,
locationKey: 'bitbucketServer-provider:mainProvider',
},
];
const removedEntities: DeferredEntity[] = [];
expect(entityProviderConnection.refresh).toHaveBeenCalledTimes(1);
expect(entityProviderConnection.refresh).toHaveBeenCalledWith({
keys: [],
});
expect(entityProviderConnection.applyMutation).toHaveBeenCalledTimes(1);
expect(entityProviderConnection.applyMutation).toHaveBeenCalledWith({
type: 'delta',
added: addedEntities,
removed: removedEntities,
});
});
it('fail add onRepoPush from wrong default branch', async () => {
const schedule = new PersistingTaskRunner();
setupRepositoryReqHandler('main');
const entityProviderConnection: EntityProviderConnection = {
applyMutation: jest.fn(),
refresh: jest.fn(),
};
const config = new ConfigReader({
integrations: {
bitbucketServer: [
{
host: host,
},
],
},
catalog: {
providers: {
bitbucketServer: {
mainProvider: {
host: host,
apiBaseUrl: `https://${host}/rest/api/1.0`,
catalogPath: `/added-module:/catalog-info.yaml`,
},
},
},
},
});
const catalogApi = catalogServiceMock({ entities: [] });
const provider = BitbucketServerEntityProvider.fromConfig(config, {
catalogApi: catalogApi as any as CatalogService,
logger,
schedule,
events,
auth: authService,
})[0];
await provider.connect(entityProviderConnection);
await events.publish(repoPushEventParams);
expect(entityProviderConnection.refresh).toHaveBeenCalledTimes(0);
expect(entityProviderConnection.applyMutation).toHaveBeenCalledTimes(0);
});
});
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { Entity, LocationEntity } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { InputError } from '@backstage/errors';
import {
@@ -24,6 +24,8 @@ import {
import {
EntityProvider,
EntityProviderConnection,
DeferredEntity,
CatalogService,
} from '@backstage/plugin-catalog-node';
import * as uuid from 'uuid';
import { BitbucketServerClient, paginated } from '../lib';
@@ -36,10 +38,16 @@ import {
defaultBitbucketServerLocationParser,
} from './BitbucketServerLocationParser';
import {
AuthService,
BackstageCredentials,
LoggerService,
SchedulerService,
SchedulerServiceTaskRunner,
} from '@backstage/backend-plugin-api';
import { BitbucketServerEvents } from '../lib';
import { EventsService } from '@backstage/plugin-events-node';
const TOPIC_REPO_REFS_CHANGED = 'bitbucketServer.repo:refs_changed';
/**
* Discovers catalog files located in Bitbucket Server.
@@ -56,14 +64,23 @@ export class BitbucketServerEntityProvider implements EntityProvider {
private readonly logger: LoggerService;
private readonly scheduleFn: () => Promise<void>;
private connection?: EntityProviderConnection;
private readonly catalogApi?: CatalogService;
private readonly events?: EventsService;
private readonly auth?: AuthService;
private eventConfigErrorThrown = false;
private readonly targetAnnotation: string;
private readonly defaultBranchAnnotation: string;
static fromConfig(
config: Config,
options: {
logger: LoggerService;
events?: EventsService;
parser?: BitbucketServerLocationParser;
schedule?: SchedulerServiceTaskRunner;
scheduler?: SchedulerService;
catalogApi?: CatalogService;
auth?: AuthService;
},
): BitbucketServerEntityProvider[] {
const integrations = ScmIntegrations.fromConfig(config);
@@ -98,6 +115,9 @@ export class BitbucketServerEntityProvider implements EntityProvider {
options.logger,
taskRunner,
options.parser,
options.catalogApi,
options.events,
options.auth,
);
});
}
@@ -108,6 +128,9 @@ export class BitbucketServerEntityProvider implements EntityProvider {
logger: LoggerService,
taskRunner: SchedulerServiceTaskRunner,
parser?: BitbucketServerLocationParser,
catalogApi?: CatalogService,
events?: EventsService,
auth?: AuthService,
) {
this.integration = integration;
this.config = config;
@@ -116,6 +139,11 @@ export class BitbucketServerEntityProvider implements EntityProvider {
target: this.getProviderName(),
});
this.scheduleFn = this.createScheduleFn(taskRunner);
this.catalogApi = catalogApi;
this.auth = auth;
this.targetAnnotation = `${this.config.host.split(':')[0]}/repo-url`;
this.defaultBranchAnnotation = 'bitbucket.org/default-branch';
this.events = events;
}
private createScheduleFn(
@@ -154,6 +182,22 @@ export class BitbucketServerEntityProvider implements EntityProvider {
async connect(connection: EntityProviderConnection): Promise<void> {
this.connection = connection;
await this.scheduleFn();
if (this.events) {
await this.events.subscribe({
id: this.getProviderName(),
topics: [TOPIC_REPO_REFS_CHANGED],
onEvent: async params => {
if (params.topic !== TOPIC_REPO_REFS_CHANGED) {
return;
}
await this.onRepoPush(
params.eventPayload as BitbucketServerEvents.RefsChangedEvent,
);
},
});
}
}
async refresh(logger: LoggerService) {
@@ -218,10 +262,245 @@ export class BitbucketServerEntityProvider implements EntityProvider {
presence: 'optional',
},
})) {
if (entity.metadata.annotations === undefined) {
entity.metadata.annotations = {};
}
if (repository.defaultBranch === undefined) {
const defaultBranchResponse = await client.getDefaultBranch({
repo: repository.slug,
projectKey: project.key,
});
entity.metadata.annotations[this.defaultBranchAnnotation] =
defaultBranchResponse.displayId;
} else {
entity.metadata.annotations[this.defaultBranchAnnotation] =
repository.defaultBranch;
}
result.push(entity);
}
}
}
return result;
}
private isDefaultBranchPush(
defaultBranch: String,
event: BitbucketServerEvents.RefsChangedEvent,
): boolean {
return event.changes.some(c => defaultBranch === c.ref.displayId);
}
/**
* Checks if the provider is able to handle events
* @returns Boolean
*/
private canHandleEvents(): boolean {
if (this.catalogApi && this.auth) {
return true;
}
if (!this.eventConfigErrorThrown) {
this.eventConfigErrorThrown = true;
throw new Error(
`${this.getProviderName()} not well configured to handle repo:push. Missing CatalogApi and/or AuthService.`,
);
}
return false;
}
private async getLocationEntity(
event: BitbucketServerEvents.RefsChangedEvent,
): Promise<Entity[]> {
const client = BitbucketServerClient.fromConfig({
config: this.integration.config,
});
const result: Entity[] = [];
try {
const repository = await client.getRepository({
projectKey: event.repository.project.key,
repo: event.repository.slug,
});
for await (const entity of this.parser({
client,
logger: this.logger,
location: {
type: 'url',
target: `${repository.links.self[0].href}${this.config.catalogPath}`,
presence: 'optional',
},
})) {
entity.metadata.annotations![
this.targetAnnotation
] = `${repository.links.self[0].href}${this.config.catalogPath}`;
if (entity.metadata.annotations === undefined) {
entity.metadata.annotations = {};
}
if (repository.defaultBranch === undefined) {
const defaultBranchResponse = await client.getDefaultBranch({
repo: repository.slug,
projectKey: event.repository.project.key,
});
entity.metadata.annotations[this.defaultBranchAnnotation] =
defaultBranchResponse.displayId;
} else {
entity.metadata.annotations[this.defaultBranchAnnotation] =
repository.defaultBranch;
}
result.push(entity);
}
} catch (error: any) {
if (error.name === 'NotFoundError') {
this.logger.error(error.message);
}
}
return result;
}
private async onRepoPush(
event: BitbucketServerEvents.RefsChangedEvent,
): Promise<void> {
if (!this.canHandleEvents()) {
this.logger.error(
'Bitbucket Server catalog entity provider is not set up to handle events. Missing authService or catalogApi.',
);
return;
}
if (!this.connection) {
throw new Error('Not initialized');
}
const repoSlug = event.repository.slug;
const catalogRepoUrl: string = `https://${this.config.host}/projects/${event.repository.project.key}/repos/${repoSlug}/browse${this.config.catalogPath}`;
this.logger.info(`handle repo:push event for ${catalogRepoUrl}`);
const targets = await this.getLocationEntity(event);
if (targets.length === 0) {
this.logger.error('Failed to create location entity.');
return;
}
const existing = await this.findExistingLocations(
catalogRepoUrl,
await this.auth!.getOwnServiceCredentials(),
);
const stillExisting: LocationEntity[] = [];
const removed: DeferredEntity[] = [];
existing.forEach(item => {
if (
targets.find(
value =>
value.metadata.annotations![this.targetAnnotation] ===
item.spec.target,
)
) {
stillExisting.push(item);
} else {
removed.push({
locationKey: this.getProviderName(),
entity: item,
});
}
});
const added = await this.getAddedEntities(targets, existing);
if (
stillExisting.length > 0 &&
stillExisting[0].metadata.annotations![this.defaultBranchAnnotation] !==
undefined &&
!this.isDefaultBranchPush(
stillExisting[0].metadata.annotations![this.defaultBranchAnnotation],
event,
)
) {
return;
} else if (
added.length > 0 &&
added[0].entity.metadata.annotations![this.defaultBranchAnnotation] !==
undefined &&
!this.isDefaultBranchPush(
added[0].entity.metadata.annotations![this.defaultBranchAnnotation],
event,
)
) {
return;
} else if (
removed.length > 0 &&
removed[0].entity.metadata.annotations![this.defaultBranchAnnotation] !==
undefined &&
!this.isDefaultBranchPush(
removed[0].entity.metadata.annotations![this.defaultBranchAnnotation],
event,
)
) {
return;
}
const promises: Promise<void>[] = [
this.connection.refresh({
keys: stillExisting.map(entity => `url:${entity.spec.target}`),
}),
];
if (added.length > 0 || removed.length > 0) {
promises.push(
this.connection.applyMutation({
type: 'delta',
added: added,
removed: removed,
}),
);
}
await Promise.all(promises);
return;
}
private async getAddedEntities(
targets: Entity[],
existing: LocationEntity[],
): Promise<DeferredEntity[]> {
const added: DeferredEntity[] = toDeferredEntities(
targets.filter(
target =>
!existing.find(
item =>
item.spec.target ===
target.metadata.annotations![this.targetAnnotation],
),
),
this.getProviderName(),
);
return added;
}
private async findExistingLocations(
catalogRepoUrl: string,
credentials: BackstageCredentials,
): Promise<LocationEntity[]> {
const filter: Record<string, string> = {};
filter.kind = 'Location';
filter[`metadata.annotations.${this.targetAnnotation}`] = catalogRepoUrl;
return this.catalogApi!.getEntities({ filter }, { credentials }).then(
result => result.items,
) as Promise<LocationEntity[]>;
}
}
export function toDeferredEntities(
targets: Entity[],
locationKey: string,
): DeferredEntity[] {
return targets.map(entity => {
return {
locationKey,
entity,
};
});
}
@@ -48,10 +48,8 @@ export function readProviderConfigs(
// simple/single config variant
return [readProviderConfig(DEFAULT_PROVIDER_ID, providersConfig)];
}
return providersConfig.keys().map(id => {
const providerConfig = providersConfig.getConfig(id);
return readProviderConfig(id, providerConfig);
});
}
@@ -14,13 +14,11 @@
* limitations under the License.
*/
import {
LocationSpec,
locationSpecToLocationEntity,
} from '@backstage/plugin-catalog-node';
import { locationSpecToLocationEntity } from '@backstage/plugin-catalog-node';
import { Entity } from '@backstage/catalog-model';
import { BitbucketServerClient } from '../lib';
import { LoggerService } from '@backstage/backend-plugin-api';
import { LocationSpec } from '@backstage/plugin-catalog-common';
import { BitbucketServerClient } from '../lib';
/**
* A custom callback that reacts to finding a location by yielding entities.
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
@@ -0,0 +1,36 @@
# events-backend-module-bitbucket-server
Welcome to the `events-backend-module-bitbucket-server` backend plugin!
This package is a module for the `events-backend` backend plugin
and extends the event system with an `BitbucketServerEventRouter`.
The event router will subscribe to the topic `bitbucketServer`
and route the events to more concrete topics based on the value
of the provided `x-event-key` metadata field.
Examples:
| x-event-key | topic |
| ------------------- | ----------------------------------- |
| `repo:refs_changed` | `bitbucketServer.repo:refs_changed` |
| `repo:modified` | `bitbucketServer.repo:modified` |
Please find all possible webhook event types at the
[official documentation](https://confluence.atlassian.com/bitbucketserver/event-payload-938025882.html).
## Installation
Install the [`events-backend` plugin](../events-backend/README.md).
Install this module:
```bash
# From your Backstage root directory
yarn add --cwd packages/backend @backstage/plugin-events-backend-module-bitbucket-server
```
```ts
// packages/backend/src/index.ts
backend.add(import('@backstage/plugin-events-backend-module-bitbucket-server'));
```
@@ -0,0 +1,9 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: backstage-plugin-events-backend-module-bitbucket-server
title: '@backstage/plugin-events-backend-module-bitbucket-server'
spec:
lifecycle: experimental
type: backstage-backend-plugin-module
owner: maintainers
@@ -0,0 +1,53 @@
{
"name": "@backstage/plugin-events-backend-module-bitbucket-server",
"version": "0.0.0",
"backstage": {
"role": "backend-plugin-module",
"pluginId": "events",
"pluginPackage": "@backstage/plugin-events-backend"
},
"publishConfig": {
"access": "public"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/events-backend-module-bitbucket-server"
},
"license": "Apache-2.0",
"exports": {
".": "./src/index.ts",
"./package.json": "./package.json"
},
"main": "src/index.ts",
"types": "src/index.ts",
"typesVersions": {
"*": {
"package.json": [
"package.json"
]
}
},
"files": [
"dist"
],
"scripts": {
"build": "backstage-cli package build",
"clean": "backstage-cli package clean",
"lint": "backstage-cli package lint",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack",
"start": "backstage-cli package start",
"test": "backstage-cli package test"
},
"dependencies": {
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/plugin-events-node": "workspace:^"
},
"devDependencies": {
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"@backstage/plugin-events-backend-test-utils": "workspace:^"
}
}
@@ -0,0 +1,11 @@
## API Report File for "@backstage/plugin-events-backend-module-bitbucket-server"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { BackendFeature } from '@backstage/backend-plugin-api';
// @public
const eventsModuleBitbucketServerEventRouter: BackendFeature;
export default eventsModuleBitbucketServerEventRouter;
```
@@ -0,0 +1,23 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* The module "bitbucket-server" for the Backstage backend plugin "events-backend"
* adding an event router for Bitbucket Server.
*
* @packageDocumentation
*/
export { eventsModuleBitbucketServerEventRouter as default } from './service/eventsModuleBitbucketServerEventRouter';
@@ -0,0 +1,53 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TestEventsService } from '@backstage/plugin-events-backend-test-utils';
import { BitbucketServerEventRouter } from './BitbucketServerEventRouter';
describe('BitbucketServerEventRouter', () => {
const events = new TestEventsService();
const eventRouter = new BitbucketServerEventRouter({ events });
const topic = 'bitbucketServer';
const eventPayload = { test: 'payload' };
const metadata = { 'x-event-key': 'test:type' };
beforeEach(() => {
events.reset();
});
it('subscribed to topic', () => {
eventRouter.subscribe();
expect(events.subscribed).toHaveLength(1);
expect(events.subscribed[0].id).toEqual('BitbucketServerEventRouter');
expect(events.subscribed[0].topics).toEqual([topic]);
});
it('no x-event-key', () => {
eventRouter.onEvent({ topic, eventPayload });
expect(events.published).toEqual([]);
});
it('with x-event-key', () => {
eventRouter.onEvent({ topic, eventPayload, metadata });
expect(events.published.length).toBe(1);
expect(events.published[0].topic).toEqual('bitbucketServer.test:type');
expect(events.published[0].eventPayload).toEqual(eventPayload);
expect(events.published[0].metadata).toEqual(metadata);
});
});
@@ -0,0 +1,45 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
EventParams,
EventsService,
SubTopicEventRouter,
} from '@backstage/plugin-events-node';
/**
* Subscribes to the generic `bitbucketServer` topic
* and publishes the events under the more concrete sub-topic
* depending on the `x-event-key` provided.
*
* @public
*/
export class BitbucketServerEventRouter extends SubTopicEventRouter {
constructor(options: { events: EventsService }) {
super({
events: options.events,
topic: 'bitbucketServer',
});
}
protected getSubscriberId(): string {
return 'BitbucketServerEventRouter';
}
protected determineSubTopic(params: EventParams): string | undefined {
return params.metadata?.['x-event-key'] as string | undefined;
}
}
@@ -0,0 +1,44 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createBackendModule } from '@backstage/backend-plugin-api';
import { eventsServiceRef } from '@backstage/plugin-events-node';
import { BitbucketServerEventRouter } from '../router/BitbucketServerEventRouter';
/**
* Module for the events-backend plugin, adding an event router for Bitbucket Server.
*
* Registers the `BitbucketServerEventRouter`.
*
* @public
*/
export const eventsModuleBitbucketServerEventRouter = createBackendModule({
pluginId: 'events',
moduleId: 'bitbucket-server-event-router',
register(env) {
env.registerInit({
deps: {
events: eventsServiceRef,
},
async init({ events }) {
const eventRouter = new BitbucketServerEventRouter({
events,
});
await eventRouter.subscribe();
},
});
},
});
@@ -0,0 +1,41 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createServiceFactory } from '@backstage/backend-plugin-api';
import { startTestBackend } from '@backstage/backend-test-utils';
import { TestEventsService } from '@backstage/plugin-events-backend-test-utils';
import { eventsServiceRef } from '@backstage/plugin-events-node';
import { eventsModuleBitbucketServerEventRouter } from './eventsModuleBitbucketServerEventRouter';
describe('eventsModuleBitbucketServerEventRouter', () => {
it('should be correctly wired and set up', async () => {
const events = new TestEventsService();
const eventsServiceFactory = createServiceFactory({
service: eventsServiceRef,
deps: {},
async factory({}) {
return events;
},
});
await startTestBackend({
features: [eventsServiceFactory, eventsModuleBitbucketServerEventRouter],
});
expect(events.subscribed).toHaveLength(1);
expect(events.subscribed[0].id).toEqual('BitbucketServerEventRouter');
});
});
@@ -1,5 +1,5 @@
/*
* Copyright 2023 The Backstage Authors
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,8 +14,4 @@
* limitations under the License.
*/
import { default as feature } from './module';
/** @alpha */
const _feature = feature;
export default _feature;
export {};
+16
View File
@@ -5536,12 +5536,16 @@ __metadata:
dependencies:
"@backstage/backend-plugin-api": "workspace:^"
"@backstage/backend-test-utils": "workspace:^"
"@backstage/catalog-client": "workspace:^"
"@backstage/catalog-model": "workspace:^"
"@backstage/cli": "workspace:^"
"@backstage/config": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/integration": "workspace:^"
"@backstage/plugin-catalog-common": "workspace:^"
"@backstage/plugin-catalog-node": "workspace:^"
"@backstage/plugin-events-backend-test-utils": "workspace:^"
"@backstage/plugin-events-node": "workspace:^"
luxon: ^3.0.0
msw: ^1.0.0
p-throttle: ^4.1.1
@@ -6356,6 +6360,18 @@ __metadata:
languageName: unknown
linkType: soft
"@backstage/plugin-events-backend-module-bitbucket-server@workspace:plugins/events-backend-module-bitbucket-server":
version: 0.0.0-use.local
resolution: "@backstage/plugin-events-backend-module-bitbucket-server@workspace:plugins/events-backend-module-bitbucket-server"
dependencies:
"@backstage/backend-plugin-api": "workspace:^"
"@backstage/backend-test-utils": "workspace:^"
"@backstage/cli": "workspace:^"
"@backstage/plugin-events-backend-test-utils": "workspace:^"
"@backstage/plugin-events-node": "workspace:^"
languageName: unknown
linkType: soft
"@backstage/plugin-events-backend-module-gerrit@workspace:plugins/events-backend-module-gerrit":
version: 0.0.0-use.local
resolution: "@backstage/plugin-events-backend-module-gerrit@workspace:plugins/events-backend-module-gerrit"