Add Bitbucket Server Event Integration

Signed-off-by: David Lilienfeld <davidlilienfeld4@gmail.com>
This commit is contained in:
David Lilienfeld
2023-08-27 18:36:03 -04:00
parent 753c1a1c78
commit 7b3ed9b928
29 changed files with 1715 additions and 45 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.
+95 -1
View File
@@ -26,7 +26,47 @@ 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
// optional if you want HTTP endpojnts to receive external events
// backend.add(import('@backstage/plugin-events-backend/alpha'));
// 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/alpha'));
backend.add(
import('@backstage/plugin-events-backend-module-bitbucket-server/alpha'),
);
backend.add(
import('@backstage/plugin-catalog-backend-module-bitbucket-server/alpha'),
);
```
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>
### Installation with Legacy Backend System
#### Installation without Events Support
You will have to add the entity provider in the catalog initialization code of your
backend. The provider is not installed by default, therefore you have to add a
dependency to `@backstage/plugin-catalog-backend-module-bitbucket-server` to your backend
package.
```bash
# From your Backstage root directory
yarn --cwd packages/backend add @backstage/plugin-catalog-backend-module-bitbucket-server
```
And then add the entity provider to your catalog builder:
```ts title="packages/backend/src/index.ts"
backend.add(import('@backstage/plugin-catalog-backend'));
@@ -37,6 +77,60 @@ backend.add(
/* highlight-add-end */
```
#### Installation with Events Support
Please follow the installation instructions at
- <https://github.com/backstage/backstage/tree/master/plugins/events-backend/README.md>
- <https://github.com/backstage/backstage/tree/master/plugins/events-backend-module-bitbucket-server/README.md>
Additionally, 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)
- Bitbucket Server events webhook url should be set to `{backstageBaseUrl}/api/events/http/bitbucketServer`
- [via an AWS SQS queue](https://github.com/backstage/backstage/tree/master/plugins/events-backend-module-aws-sqs/README.md)
Set up your provider
```ts title="packages/backend/src/plugins/catalog.ts"
import { CatalogBuilder } from '@backstage/plugin-catalog-backend';
/* highlight-add-start */
import { BitbucketServerEntityProvider } from '@backstage/plugin-catalog-backend-module-bitbucket-server';
/* highlight-add-end */
import { ScaffolderEntitiesProcessor } from '@backstage/plugin-scaffolder-backend';
import { Router } from 'express';
import { PluginEnvironment } from '../types';
export default async function createPlugin(
env: PluginEnvironment,
): Promise<Router> {
const builder = await CatalogBuilder.create(env);
builder.addProcessor(new ScaffolderEntitiesProcessor());
/* highlight-add-start */
const bitbucketServerProvider = BitbucketServerEntityProvider.fromConfig(
env.config,
{
catalogApi: new CatalogClient({ discoveryApi: env.discovery }),
logger: env.logger,
scheduler: env.scheduler,
events: env.events,
tokenManager: env.tokenManager,
},
);
env.eventBroker.subscribe(bitbucketServerProvider);
builder.addEntityProvider(bitbucketServerProvider);
/* highlight-add-end */
const { processingEngine, router } = await builder.build();
await processingEngine.start();
return router;
}
```
**Attention:**
`catalogApi` and `tokenManager` are required at this variant
compared to the one without events support.
## Configuration
To use the entity provider, you'll need a [Bitbucket Server integration set up](locations.md).
@@ -51,17 +51,25 @@
},
"dependencies": {
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/backend-tasks": "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:^",
"p-throttle": "^4.1.1",
"@backstage/plugin-events-node": "workspace:^",
"@types/node-fetch": "^2.5.12",
"node-fetch": "^2.7.0",
"uuid": "^11.0.0"
},
"devDependencies": {
"@backstage/backend-common": "workspace:^",
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"@backstage/plugin-events-backend-test-utils": "workspace:^",
"luxon": "^3.0.0",
"msw": "^1.0.0"
},
@@ -5,14 +5,18 @@
```ts
import { BackendFeature } from '@backstage/backend-plugin-api';
import { BitbucketServerIntegrationConfig } from '@backstage/integration';
import { CatalogApi } from '@backstage/catalog-client';
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';
import { TaskRunner } from '@backstage/backend-tasks';
import { TokenManager } from '@backstage/backend-common';
// @public
export class BitbucketServerClient {
@@ -22,6 +26,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 +56,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 +74,12 @@ export class BitbucketServerEntityProvider implements EntityProvider {
config: Config,
options: {
logger: LoggerService;
events?: EventsService;
parser?: BitbucketServerLocationParser;
schedule?: SchedulerServiceTaskRunner;
scheduler?: SchedulerService;
schedule?: TaskRunner;
scheduler?: PluginTaskScheduler;
catalogApi?: CatalogApi;
tokenManager?: TokenManager;
},
): BitbucketServerEntityProvider[];
getProviderName(): string;
@@ -65,6 +87,52 @@ export class BitbucketServerEntityProvider implements EntityProvider {
refresh(logger: LoggerService): Promise<void>;
}
// @public (undocumented)
export namespace BitbucketServerEvents {
// (undocumented)
export type Actor = {
name?: string;
id: number;
};
// (undocumented)
export type Change = {
ref: {
id: string;
displayId: string;
type: string;
};
};
// (undocumented)
export interface Event {
// (undocumented)
eventKey: string;
}
// (undocumented)
export interface RefsChangedEvent extends Event {
// (undocumented)
actor: Actor;
// (undocumented)
changes: Change[];
// (undocumented)
commits: undefined;
// (undocumented)
date: string;
// (undocumented)
repository: Repository;
// (undocumented)
ToCommit: undefined;
}
// (undocumented)
export type Repository = {
slug: string;
id: number;
name: string;
project: BitbucketServerProject;
};
{
}
}
// @public (undocumented)
export type BitbucketServerListOptions = {
[key: string]: number | undefined;
@@ -108,6 +176,7 @@ export type BitbucketServerRepository = {
}[]
>;
archived: boolean;
defaultBranch: string;
};
// @public (undocumented)
@@ -21,12 +21,7 @@
*/
export { default } from './module';
export { BitbucketServerClient } from './lib';
export type {
BitbucketServerProject,
BitbucketServerRepository,
BitbucketServerPagedResponse,
BitbucketServerListOptions,
} from './lib';
export * 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,7 +18,6 @@ import {
BitbucketServerIntegrationConfig,
getBitbucketServerRequestOptions,
} from '@backstage/integration';
import { BitbucketServerProject, BitbucketServerRepository } from './types';
import pThrottle from 'p-throttle';
// 1 per second
@@ -32,6 +31,15 @@ const throttledFetch = throttle(
return await fetch(url, options);
},
);
import {
BitbucketServerDefaultBranch,
BitbucketServerRepository,
} from './index';
import { BitbucketServerProject } from './types';
import { NotFoundError } from '@backstage/errors';
import { ResponseError } from '@backstage/errors';
/**
* A client for interacting with a Bitbucket Server instance
@@ -92,7 +100,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 }): {
@@ -163,7 +199,9 @@ export type BitbucketServerPagedResponse<T> = {
values: T[];
nextPageStart: number;
};
/**
* @public
*/
export async function* paginated(
request: (
options: BitbucketServerListOptions,
@@ -19,7 +19,4 @@ export type {
BitbucketServerPagedResponse,
BitbucketServerListOptions,
} from './BitbucketServerClient';
export type {
BitbucketServerRepository,
BitbucketServerProject,
} from './types';
export * from './types';
@@ -15,7 +15,7 @@
*/
/** @public */
export type BitbucketServerRepository = {
type BitbucketServerRepository = {
project: {
key: string;
};
@@ -28,9 +28,56 @@ 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;
};
/** @public */
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,35 @@
import { SchedulerServiceTaskScheduleDefinition } from '@backstage/backend-plugin-api';
import { mockServices, startTestBackend } from '@backstage/backend-test-utils';
import { createServiceFactory } from '@backstage/backend-plugin-api';
import { TaskScheduleDefinition } from '@backstage/backend-tasks';
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 { Duration } from 'luxon';
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) {
@@ -60,7 +75,9 @@ describe('catalogModuleBitbucketServerEntityProvider', () => {
};
await startTestBackend({
extensionPoints: [[catalogProcessingExtensionPoint, extensionPoint]],
extensionPoints: [
[catalogProcessingExtensionPoint, catalogExtensionPointImpl],
],
features: [
catalogModuleBitbucketServerEntityProvider,
mockServices.rootConfig.factory({ data: config }),
@@ -72,9 +89,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,12 @@ import {
coreServices,
createBackendModule,
} from '@backstage/backend-plugin-api';
import { catalogProcessingExtensionPoint } from '@backstage/plugin-catalog-node/alpha';
import { BitbucketServerEntityProvider } from '../providers';
import {
catalogProcessingExtensionPoint,
catalogServiceRef,
} from '@backstage/plugin-catalog-node/alpha';
import { eventsServiceRef } from '@backstage/plugin-events-node';
import { BitbucketServerEntityProvider } from '../providers/BitbucketServerEntityProvider';
/**
* @public
@@ -31,14 +35,28 @@ export const catalogModuleBitbucketServerEntityProvider = createBackendModule({
env.registerInit({
deps: {
catalog: catalogProcessingExtensionPoint,
catalogApi: catalogServiceRef,
config: coreServices.rootConfig,
events: eventsServiceRef,
logger: coreServices.logger,
scheduler: coreServices.scheduler,
tokenManager: coreServices.tokenManager,
},
async init({ catalog, config, logger, scheduler }) {
async init({
catalog,
catalogApi,
config,
events,
logger,
scheduler,
tokenManager,
}) {
const providers = BitbucketServerEntityProvider.fromConfig(config, {
catalogApi,
events,
logger,
scheduler,
tokenManager,
});
catalog.addEntityProvider(providers);
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { TokenManager } from '@backstage/backend-common';
import {
SchedulerService,
SchedulerServiceTaskRunner,
@@ -24,11 +25,22 @@ import {
registerMswTestHooks,
} from '@backstage/backend-test-utils';
import { ConfigReader } from '@backstage/config';
import { EntityProviderConnection } from '@backstage/plugin-catalog-node';
import {
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 { CatalogApi } from '@backstage/catalog-client';
import { DefaultEventsService } from '@backstage/plugin-events-node';
class PersistingTaskRunner implements SchedulerServiceTaskRunner {
private tasks: SchedulerServiceTaskInvocationDefinition[] = [];
@@ -57,9 +69,15 @@ function pagedResponse(values: any): BitbucketServerPagedResponse<any> {
const logger = mockServices.logger.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 +111,7 @@ function setupStubs(projects: Project[], baseUrl: string) {
],
},
archived: repo.archived ?? false,
defaultBranch: defaultBranch,
});
}
return res(ctx.json(pagedResponse(response)));
@@ -102,6 +121,107 @@ 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 tokenManager = {
getToken: async () => {
return { token: 'fake-token' };
},
} as any as TokenManager;
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 +277,7 @@ describe('BitbucketServerEntityProvider', () => {
});
const providers = BitbucketServerEntityProvider.fromConfig(config, {
logger,
events,
schedule,
});
@@ -191,6 +312,7 @@ describe('BitbucketServerEntityProvider', () => {
});
const providers = BitbucketServerEntityProvider.fromConfig(config, {
logger,
events,
schedule,
});
@@ -204,7 +326,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 +374,7 @@ describe('BitbucketServerEntityProvider', () => {
{ key: 'other-project', repos: [{ name: 'other-repo' }] },
],
`https://${host}`,
'master',
);
await provider.connect(entityProviderConnection);
@@ -270,6 +392,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 +414,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 +451,7 @@ describe('BitbucketServerEntityProvider', () => {
{ key: 'other-project', repos: [{ name: 'other-repo' }] },
],
`https://${host}`,
'master',
);
await provider.connect(entityProviderConnection);
@@ -345,6 +468,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 +488,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 +566,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 +612,7 @@ describe('BitbucketServerEntityProvider', () => {
{ key: 'other-project', repos: [{ name: 'other-repo' }] },
],
`https://${host}`,
'master',
);
await provider.connect(entityProviderConnection);
@@ -504,6 +629,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 +649,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 +669,360 @@ 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');
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 = {
getEntities: async (
request: { filter: Record<string, string> },
options: { token: string },
): Promise<{ items: Entity[] }> => {
if (
options.token !== 'fake-token' ||
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 as any as CatalogApi,
logger,
schedule,
events,
tokenManager,
})[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 = {
getEntities: async (
request: { filter: Record<string, string> },
options: { token: string },
): Promise<{ items: Entity[] }> => {
if (
options.token !== 'fake-token' ||
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 as any as CatalogApi,
logger,
schedule,
events,
tokenManager,
})[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');
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 = {
getEntities: async (
_request: { filter: Record<string, string> },
_options: { token: string },
): Promise<{ items: Entity[] }> => {
return {
items: [],
};
},
};
const provider = BitbucketServerEntityProvider.fromConfig(config, {
catalogApi: catalogApi as any as CatalogApi,
logger,
schedule,
events,
tokenManager,
})[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 = {
getEntities: async (
_request: { filter: Record<string, string> },
_options: { token: string },
): Promise<{ items: Entity[] }> => {
return {
items: [],
};
},
};
const provider = BitbucketServerEntityProvider.fromConfig(config, {
catalogApi: catalogApi as any as CatalogApi,
logger,
schedule,
events,
tokenManager,
})[0];
await provider.connect(entityProviderConnection);
await events.publish(repoPushEventParams);
expect(entityProviderConnection.refresh).toHaveBeenCalledTimes(0);
expect(entityProviderConnection.applyMutation).toHaveBeenCalledTimes(0);
});
});
@@ -14,7 +14,8 @@
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { PluginTaskScheduler, TaskRunner } from '@backstage/backend-tasks';
import { Entity, LocationEntity } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { InputError } from '@backstage/errors';
import {
@@ -24,6 +25,7 @@ import {
import {
EntityProvider,
EntityProviderConnection,
DeferredEntity,
} from '@backstage/plugin-catalog-node';
import * as uuid from 'uuid';
import { BitbucketServerClient, paginated } from '../lib';
@@ -40,6 +42,12 @@ import {
SchedulerService,
SchedulerServiceTaskRunner,
} from '@backstage/backend-plugin-api';
import { BitbucketServerEvents } from '../lib';
import { EventsService } from '@backstage/plugin-events-node';
import { CatalogApi } from '@backstage/catalog-client';
import { TokenManager } from '@backstage/backend-common';
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?: CatalogApi;
private readonly events?: EventsService;
private readonly tokenManager?: TokenManager;
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;
schedule?: TaskRunner;
scheduler?: PluginTaskScheduler;
catalogApi?: CatalogApi;
tokenManager?: TokenManager;
},
): 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.tokenManager,
);
});
}
@@ -108,6 +128,9 @@ export class BitbucketServerEntityProvider implements EntityProvider {
logger: LoggerService,
taskRunner: SchedulerServiceTaskRunner,
parser?: BitbucketServerLocationParser,
catalogApi?: CatalogApi,
events?: EventsService,
tokenManager?: TokenManager,
) {
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.tokenManager = tokenManager;
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,373 @@ 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;
}
/**
* Checks if the webhook was triggered on a commit to the head branch of a repository
* @param event Bitbucket Server webhook repo:refs_changed event
*/
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 !== undefined &&
this.catalogApi !== null &&
this.tokenManager !== undefined &&
this.tokenManager !== null
) {
return true;
}
if (!this.eventConfigErrorThrown) {
this.eventConfigErrorThrown = true;
throw new Error(
`${this.getProviderName()} not well configured to handle repo:push. Missing CatalogApi and/or TokenManager.`,
);
}
return false;
}
/**
* Creates a Bitbucket Server location entity for the repository that is referenced in the push event passed in.
*
* @param event A Bitbucket Server push event with repository information.
*
* @returns An array of location entities for the repository.
*
* @example
*
* const RefsChangedEvent = {
* "eventKey": "repo:refs_changed",
* "date": "2022-01-01T00:00:00Z",
* "actor": {
* "name": "johndoe",
* "emailAddress": "johndoe@example.com",
* "id": 123,
* "displayName": "John Doe",
* "active": true,
* "slug": "johndoe",
* "type": "NORMAL"
* },
* "repository": {
* "slug": "my-repo",
* "id": 123,
* "name": "My Repository",
* "project": {
* "key": "my-project",
* "id": 456,
* "name": "My Project",
* "description": "My project description",
* "public": true,
* "type": "NORMAL"
* }
* },
* "changes": [
* {
* "ref": {
* "id": "refs/heads/master",
* "displayId": "master",
* "type": "BRANCH"
* },
* "refId": "refs/heads/master",
* "fromHash": "0123456789abcdef0123456789abcdef0123456",
* "toHash": "fedcba9876543210fedcba9876543210fedcba9",
* "type": "UPDATE"
* }
* ]
* };
*
* const locationEntities = await getLocationEntity(RefsChangedEvent);
*
* // locationEntities:
* // [
* // {
* // kind: 'Location',
* // metadata: {
* // name: 'my-repo',
* // namespace: 'my-project',
* // annotations: {
* // 'backstage.io/managed-by-location': 'url/catalog-info-path',
* // 'backstage.io/managed-by-origin-location': 'url/catalog-info-path',
* // 'host/repo-url': 'url',
* // },
* // },
* // spec: {
* // type: 'bitbucket',
* // target: 'url/catalog-info-path',
* // presence: 'optional',
* // },
* // },
* // ]
*/
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;
}
/**
* Finds if there are existing location entities for the repository that was pushed. If there are, it simply refreshes those entities,
* if not, it discovers any entity that was added and removed in the list of entities
* @param event - A Bitbucket Server webhook event for repo:refs_change
*/
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 tokenManager 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 { token } = await this.tokenManager!.getToken();
const existing = await this.findExistingLocations(catalogRepoUrl, token);
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;
}
/**
* Gets the location entities that are to be newly added to the catalog.
* @param targets Location entities for catalog files in the repository that was pushed
* @param existing The location entities in the repository that was pushed that already exist
* @returns Returns all deferred entities that represent location entities that don't exist in the catalog yet
*/
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;
}
/**
* Finds all location entities in the catalog that already have the annotation `metadata.annotations.${this.config.host}/repo-url`
* that is equivalent to @param repoURL\.
* @param repoURL URL for the reposity that the method finds the existing location entities for
* @param token Token from class token manager
*/
private async findExistingLocations(
catalogRepoUrl: string,
token: string,
): Promise<LocationEntity[]> {
const filter: Record<string, string> = {};
filter.kind = 'Location';
filter[`metadata.annotations.${this.targetAnnotation}`] = catalogRepoUrl;
return this.catalogApi!.getEntities({ filter }, { token }).then(
result => result.items,
) as Promise<LocationEntity[]>;
}
// private static toLocationSpec(target: string): LocationSpec {
// return {
// type: 'url',
// target: target,
// presence: 'required',
// };
// }
}
/**
* Converts an array of entities into an array of deferred entities with the provider's name as the location key.
*
* @param targets An array of entities to convert.
*
* @returns An array of deferred entities with the provider's name as the location key.
*
* @example
*
* const entities = [
* { kind: 'Component', namespace: 'default', name: 'my-component' },
* { kind: 'System', namespace: 'default', name: 'my-system' },
* { kind: 'API', namespace: 'default', name: 'my-api' },
* ];
*
* const deferredEntities = toDeferredEntities(entities);
*
* // deferredEntities:
* // [
* // { locationKey: 'my-provider', entity: { kind: 'Component', namespace: 'default', name: 'my-component' } },
* // { locationKey: 'my-provider', entity: { kind: 'System', namespace: 'default', name: 'my-system' } },
* // { locationKey: 'my-provider', entity: { kind: 'API', namespace: 'default', name: 'my-api' } },
* // ]
*/
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,48 @@
# 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/alpha'),
);
```
### Legacy Backend System
```ts
// packages/backend/src/plugins/events.ts
const eventRouter = new BitbucketCloudEventRouter({
events: env.events,
});
await eventRouter.subscribe();
```
@@ -0,0 +1,14 @@
## 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';
// @alpha
const eventsModuleBitbucketServerEventRouter: () => BackendFeature;
export default eventsModuleBitbucketServerEventRouter;
export { eventsModuleBitbucketServerEventRouter };
// (No @packageDocumentation comment for this package)
```
@@ -0,0 +1,18 @@
## 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 { EventParams } from '@backstage/plugin-events-node';
import { EventsService } from '@backstage/plugin-events-node';
import { SubTopicEventRouter } from '@backstage/plugin-events-node';
// @public
export class BitbucketServerEventRouter extends SubTopicEventRouter {
constructor(options: { events: EventsService });
// (undocumented)
protected determineSubTopic(params: EventParams): string | undefined;
// (undocumented)
protected getSubscriberId(): string;
}
```
@@ -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,55 @@
{
"name": "@backstage/plugin-events-backend-module-bitbucket-server",
"version": "0.1.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public"
},
"exports": {
".": "./src/index.ts",
"./alpha": "./src/alpha.ts",
"./package.json": "./package.json"
},
"typesVersions": {
"*": {
"alpha": [
"src/alpha.ts"
],
"package.json": [
"package.json"
]
}
},
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/events-backend-module-bitbucket-server"
},
"backstage": {
"role": "backend-plugin-module"
},
"scripts": {
"start": "backstage-cli package start",
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"clean": "backstage-cli package clean",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack"
},
"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:^",
"supertest": "^6.1.3"
},
"files": [
"dist"
]
}
@@ -0,0 +1,18 @@
/*
* Copyright 2023 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.
*/
export { eventsModuleBitbucketServerEventRouter } from './service/eventsModuleBitbucketServerEventRouter';
export { eventsModuleBitbucketServerEventRouter as default } from './service/eventsModuleBitbucketServerEventRouter';
@@ -0,0 +1,24 @@
/*
* 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 { BitbucketServerEventRouter } from './router/BitbucketServerEventRouter';
@@ -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`.
*
* @alpha
*/
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,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 { 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');
});
});
@@ -0,0 +1,17 @@
/*
* 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.
*/
export {};
+15
View File
@@ -5739,11 +5739,13 @@ __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:^"
luxon: ^3.0.0
msw: ^1.0.0
@@ -6557,6 +6559,19 @@ __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:^"
supertest: ^6.1.3
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"