diff --git a/.changeset/nine-falcons-repeat.md b/.changeset/nine-falcons-repeat.md new file mode 100644 index 0000000000..a67e925343 --- /dev/null +++ b/.changeset/nine-falcons-repeat.md @@ -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. diff --git a/docs/integrations/bitbucketServer/discovery.md b/docs/integrations/bitbucketServer/discovery.md index 9ba6c7a96d..7947913182 100644 --- a/docs/integrations/bitbucketServer/discovery.md +++ b/docs/integrations/bitbucketServer/discovery.md @@ -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: + +- +- +- + ## Configuration To use the entity provider, you'll need a [Bitbucket Server integration set up](locations.md). diff --git a/plugins/catalog-backend-module-bitbucket-server/package.json b/plugins/catalog-backend-module-bitbucket-server/package.json index 0198a5ae66..7cb2d6a5da 100644 --- a/plugins/catalog-backend-module-bitbucket-server/package.json +++ b/plugins/catalog-backend-module-bitbucket-server/package.json @@ -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" }, diff --git a/plugins/catalog-backend-module-bitbucket-server/report-alpha.api.md b/plugins/catalog-backend-module-bitbucket-server/report-alpha.api.md deleted file mode 100644 index 06212c3c60..0000000000 --- a/plugins/catalog-backend-module-bitbucket-server/report-alpha.api.md +++ /dev/null @@ -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) -``` diff --git a/plugins/catalog-backend-module-bitbucket-server/report.api.md b/plugins/catalog-backend-module-bitbucket-server/report.api.md index 0da9b78139..7cb13e651f 100644 --- a/plugins/catalog-backend-module-bitbucket-server/report.api.md +++ b/plugins/catalog-backend-module-bitbucket-server/report.api.md @@ -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; + // (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; @@ -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) diff --git a/plugins/catalog-backend-module-bitbucket-server/src/index.ts b/plugins/catalog-backend-module-bitbucket-server/src/index.ts index f05c9ca0e9..722be03d7a 100644 --- a/plugins/catalog-backend-module-bitbucket-server/src/index.ts +++ b/plugins/catalog-backend-module-bitbucket-server/src/index.ts @@ -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'; diff --git a/plugins/catalog-backend-module-bitbucket-server/src/lib/BitbucketServerClient.test.ts b/plugins/catalog-backend-module-bitbucket-server/src/lib/BitbucketServerClient.test.ts index 6abcede9de..fbb4054390 100644 --- a/plugins/catalog-backend-module-bitbucket-server/src/lib/BitbucketServerClient.test.ts +++ b/plugins/catalog-backend-module-bitbucket-server/src/lib/BitbucketServerClient.test.ts @@ -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); + }); }); diff --git a/plugins/catalog-backend-module-bitbucket-server/src/lib/BitbucketServerClient.ts b/plugins/catalog-backend-module-bitbucket-server/src/lib/BitbucketServerClient.ts index 3dd61d64e7..70e791c992 100644 --- a/plugins/catalog-backend-module-bitbucket-server/src/lib/BitbucketServerClient.ts +++ b/plugins/catalog-backend-module-bitbucket-server/src/lib/BitbucketServerClient.ts @@ -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 { + 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 }): { diff --git a/plugins/catalog-backend-module-bitbucket-server/src/lib/index.ts b/plugins/catalog-backend-module-bitbucket-server/src/lib/index.ts index 8b72c9335b..4533cb1305 100644 --- a/plugins/catalog-backend-module-bitbucket-server/src/lib/index.ts +++ b/plugins/catalog-backend-module-bitbucket-server/src/lib/index.ts @@ -20,6 +20,9 @@ export type { BitbucketServerListOptions, } from './BitbucketServerClient'; export type { + BitbucketServerDefaultBranch, BitbucketServerRepository, + BitbucketServerEvents, BitbucketServerProject, } from './types'; +// export * from './types'; diff --git a/plugins/catalog-backend-module-bitbucket-server/src/lib/types.ts b/plugins/catalog-backend-module-bitbucket-server/src/lib/types.ts index 381eecb493..efdf3d7cf8 100644 --- a/plugins/catalog-backend-module-bitbucket-server/src/lib/types.ts +++ b/plugins/catalog-backend-module-bitbucket-server/src/lib/types.ts @@ -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, +}; diff --git a/plugins/catalog-backend-module-bitbucket-server/src/module/catalogModuleBitbucketServerEntityProvider.test.ts b/plugins/catalog-backend-module-bitbucket-server/src/module/catalogModuleBitbucketServerEntityProvider.test.ts index fbd4fa7a36..f9345170ed 100644 --- a/plugins/catalog-backend-module-bitbucket-server/src/module/catalogModuleBitbucketServerEntityProvider.test.ts +++ b/plugins/catalog-backend-module-bitbucket-server/src/module/catalogModuleBitbucketServerEntityProvider.test.ts @@ -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 | 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); }); }); diff --git a/plugins/catalog-backend-module-bitbucket-server/src/module/catalogModuleBitbucketServerEntityProvider.ts b/plugins/catalog-backend-module-bitbucket-server/src/module/catalogModuleBitbucketServerEntityProvider.ts index 2c78d219d9..d66ef4e2a2 100644 --- a/plugins/catalog-backend-module-bitbucket-server/src/module/catalogModuleBitbucketServerEntityProvider.ts +++ b/plugins/catalog-backend-module-bitbucket-server/src/module/catalogModuleBitbucketServerEntityProvider.ts @@ -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); diff --git a/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerEntityProvider.test.ts b/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerEntityProvider.test.ts index 05e0ea6a01..ba4e3e0411 100644 --- a/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerEntityProvider.test.ts +++ b/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerEntityProvider.test.ts @@ -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 { 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 }, + 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 }, + 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); + }); }); diff --git a/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerEntityProvider.ts b/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerEntityProvider.ts index 46f492d64c..b462f8756a 100644 --- a/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerEntityProvider.ts +++ b/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerEntityProvider.ts @@ -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; 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 { 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 { + 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 { + 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[] = [ + 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 { + 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 { + const filter: Record = {}; + filter.kind = 'Location'; + filter[`metadata.annotations.${this.targetAnnotation}`] = catalogRepoUrl; + + return this.catalogApi!.getEntities({ filter }, { credentials }).then( + result => result.items, + ) as Promise; + } +} + +export function toDeferredEntities( + targets: Entity[], + locationKey: string, +): DeferredEntity[] { + return targets.map(entity => { + return { + locationKey, + entity, + }; + }); } diff --git a/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerEntityProviderConfig.ts b/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerEntityProviderConfig.ts index 018ff23069..3b921b6bab 100644 --- a/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerEntityProviderConfig.ts +++ b/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerEntityProviderConfig.ts @@ -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); }); } diff --git a/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerLocationParser.ts b/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerLocationParser.ts index 6aea23387e..1c48d5e7bd 100644 --- a/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerLocationParser.ts +++ b/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerLocationParser.ts @@ -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. diff --git a/plugins/events-backend-module-bitbucket-server/.eslintrc.js b/plugins/events-backend-module-bitbucket-server/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/events-backend-module-bitbucket-server/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/events-backend-module-bitbucket-server/README.md b/plugins/events-backend-module-bitbucket-server/README.md new file mode 100644 index 0000000000..4950015010 --- /dev/null +++ b/plugins/events-backend-module-bitbucket-server/README.md @@ -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')); +``` diff --git a/plugins/events-backend-module-bitbucket-server/catalog-info.yaml b/plugins/events-backend-module-bitbucket-server/catalog-info.yaml new file mode 100644 index 0000000000..d2a04ca2a8 --- /dev/null +++ b/plugins/events-backend-module-bitbucket-server/catalog-info.yaml @@ -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 diff --git a/plugins/events-backend-module-bitbucket-server/package.json b/plugins/events-backend-module-bitbucket-server/package.json new file mode 100644 index 0000000000..29945bd16f --- /dev/null +++ b/plugins/events-backend-module-bitbucket-server/package.json @@ -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:^" + } +} diff --git a/plugins/events-backend-module-bitbucket-server/report.api.md b/plugins/events-backend-module-bitbucket-server/report.api.md new file mode 100644 index 0000000000..913556cfe9 --- /dev/null +++ b/plugins/events-backend-module-bitbucket-server/report.api.md @@ -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; +``` diff --git a/plugins/events-backend-module-bitbucket-server/src/index.ts b/plugins/events-backend-module-bitbucket-server/src/index.ts new file mode 100644 index 0000000000..8a765ecfdf --- /dev/null +++ b/plugins/events-backend-module-bitbucket-server/src/index.ts @@ -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'; diff --git a/plugins/events-backend-module-bitbucket-server/src/router/BitbucketServerEventRouter.test.ts b/plugins/events-backend-module-bitbucket-server/src/router/BitbucketServerEventRouter.test.ts new file mode 100644 index 0000000000..6fdb4020f3 --- /dev/null +++ b/plugins/events-backend-module-bitbucket-server/src/router/BitbucketServerEventRouter.test.ts @@ -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); + }); +}); diff --git a/plugins/events-backend-module-bitbucket-server/src/router/BitbucketServerEventRouter.ts b/plugins/events-backend-module-bitbucket-server/src/router/BitbucketServerEventRouter.ts new file mode 100644 index 0000000000..45031cc5f3 --- /dev/null +++ b/plugins/events-backend-module-bitbucket-server/src/router/BitbucketServerEventRouter.ts @@ -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; + } +} diff --git a/plugins/events-backend-module-bitbucket-server/src/service/eventsModuleBitbucketServerEventRouter.ts b/plugins/events-backend-module-bitbucket-server/src/service/eventsModuleBitbucketServerEventRouter.ts new file mode 100644 index 0000000000..2a8a26358b --- /dev/null +++ b/plugins/events-backend-module-bitbucket-server/src/service/eventsModuleBitbucketServerEventRouter.ts @@ -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(); + }, + }); + }, +}); diff --git a/plugins/events-backend-module-bitbucket-server/src/service/eventsModuleBitbucketServerRouter.test.ts b/plugins/events-backend-module-bitbucket-server/src/service/eventsModuleBitbucketServerRouter.test.ts new file mode 100644 index 0000000000..a66c000089 --- /dev/null +++ b/plugins/events-backend-module-bitbucket-server/src/service/eventsModuleBitbucketServerRouter.test.ts @@ -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'); + }); +}); diff --git a/plugins/catalog-backend-module-bitbucket-server/src/alpha.ts b/plugins/events-backend-module-bitbucket-server/src/setupTests.ts similarity index 78% rename from plugins/catalog-backend-module-bitbucket-server/src/alpha.ts rename to plugins/events-backend-module-bitbucket-server/src/setupTests.ts index cba672ce49..d3232290a7 100644 --- a/plugins/catalog-backend-module-bitbucket-server/src/alpha.ts +++ b/plugins/events-backend-module-bitbucket-server/src/setupTests.ts @@ -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 {}; diff --git a/yarn.lock b/yarn.lock index afe662d7ae..506c43493d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"