diff --git a/.changeset/bitbucket-cloud-scm-events-layer.md b/.changeset/bitbucket-cloud-scm-events-layer.md new file mode 100644 index 0000000000..711a744dc9 --- /dev/null +++ b/.changeset/bitbucket-cloud-scm-events-layer.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend-module-bitbucket-cloud': patch +--- + +Added Bitbucket Cloud SCM event translation layer for the catalog backend module. The module now subscribes to Bitbucket Cloud webhook events and translates them into generic catalog SCM events, enabling instant catalog reprocessing when repositories are pushed to, renamed, transferred, or deleted. The `analyzeBitbucketCloudWebhookEvent` function is exported from the alpha entry point for custom integrations. diff --git a/plugins/catalog-backend-module-bitbucket-cloud/package.json b/plugins/catalog-backend-module-bitbucket-cloud/package.json index bda38d6ffe..9d9bd38a52 100644 --- a/plugins/catalog-backend-module-bitbucket-cloud/package.json +++ b/plugins/catalog-backend-module-bitbucket-cloud/package.json @@ -54,6 +54,7 @@ "@backstage/backend-plugin-api": "workspace:^", "@backstage/catalog-model": "workspace:^", "@backstage/config": "workspace:^", + "@backstage/errors": "workspace:^", "@backstage/integration": "workspace:^", "@backstage/plugin-bitbucket-cloud-common": "workspace:^", "@backstage/plugin-catalog-common": "workspace:^", diff --git a/plugins/catalog-backend-module-bitbucket-cloud/report-alpha.api.md b/plugins/catalog-backend-module-bitbucket-cloud/report-alpha.api.md index ae6ba51eff..31762b83a2 100644 --- a/plugins/catalog-backend-module-bitbucket-cloud/report-alpha.api.md +++ b/plugins/catalog-backend-module-bitbucket-cloud/report-alpha.api.md @@ -4,6 +4,32 @@ ```ts import { BackendFeature } from '@backstage/backend-plugin-api'; +import { CatalogScmEvent } from '@backstage/plugin-catalog-node/alpha'; + +// @alpha +export function analyzeBitbucketCloudWebhookEvent( + eventType: string, + eventPayload: unknown, +): Promise; + +// @alpha +export type AnalyzeBitbucketCloudWebhookEventResult = + | { + result: 'unsupported-event'; + event: string; + } + | { + result: 'ignored'; + reason: string; + } + | { + result: 'aborted'; + reason: string; + } + | { + result: 'ok'; + events: CatalogScmEvent[]; + }; // @alpha (undocumented) const _feature: BackendFeature; diff --git a/plugins/catalog-backend-module-bitbucket-cloud/src/alpha.ts b/plugins/catalog-backend-module-bitbucket-cloud/src/alpha.ts index cba672ce49..a4945aff93 100644 --- a/plugins/catalog-backend-module-bitbucket-cloud/src/alpha.ts +++ b/plugins/catalog-backend-module-bitbucket-cloud/src/alpha.ts @@ -19,3 +19,8 @@ import { default as feature } from './module'; /** @alpha */ const _feature = feature; export default _feature; + +export { + analyzeBitbucketCloudWebhookEvent, + type AnalyzeBitbucketCloudWebhookEventResult, +} from './events/analyzeBitbucketCloudWebhookEvent'; diff --git a/plugins/catalog-backend-module-bitbucket-cloud/src/events/BitbucketCloudScmEventsBridge.ts b/plugins/catalog-backend-module-bitbucket-cloud/src/events/BitbucketCloudScmEventsBridge.ts new file mode 100644 index 0000000000..16d7c2fc5c --- /dev/null +++ b/plugins/catalog-backend-module-bitbucket-cloud/src/events/BitbucketCloudScmEventsBridge.ts @@ -0,0 +1,117 @@ +/* + * Copyright 2026 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 { LoggerService } from '@backstage/backend-plugin-api'; +import { CatalogScmEventsService } from '@backstage/plugin-catalog-node/alpha'; +import { EventParams, EventsService } from '@backstage/plugin-events-node'; +import { analyzeBitbucketCloudWebhookEvent } from './analyzeBitbucketCloudWebhookEvent'; + +/** + * Takes Bitbucket Cloud webhook events, analyzes them, and publishes them as + * catalog SCM events that entity providers and others can subscribe to. + */ +export class BitbucketCloudScmEventsBridge { + readonly #logger: LoggerService; + readonly #events: EventsService; + readonly #catalogScmEvents: CatalogScmEventsService; + #shuttingDown: boolean; + #pendingPublish: Promise | undefined; + + constructor(options: { + logger: LoggerService; + events: EventsService; + catalogScmEvents: CatalogScmEventsService; + }) { + this.#logger = options.logger; + this.#events = options.events; + this.#catalogScmEvents = options.catalogScmEvents; + this.#shuttingDown = false; + } + + async start() { + await this.#events.subscribe({ + id: 'catalog-bitbucket-cloud-scm-events-bridge', + topics: ['bitbucketCloud'], + onEvent: this.#onEvent.bind(this), + }); + } + + async stop() { + this.#shuttingDown = true; + await this.#pendingPublish; + } + + async #onEvent(params: EventParams): Promise { + const eventType = + (params.metadata?.['x-event-key'] as string | undefined) ?? + this.#extractEventTypeFromTopic(params.topic); + if (!eventType || !params.eventPayload) { + return; + } + + while (this.#pendingPublish) { + await this.#pendingPublish; + } + + if (this.#shuttingDown) { + this.#logger.warn( + `Skipping Bitbucket Cloud webhook event of type "${eventType}" on topic "${params.topic}" because the bridge is shutting down`, + ); + return; + } + + this.#pendingPublish = Promise.resolve().then(async () => { + try { + const output = await analyzeBitbucketCloudWebhookEvent( + eventType, + params.eventPayload, + ); + + if (output.result === 'ok') { + await this.#catalogScmEvents.publish(output.events); + } else if (output.result === 'ignored') { + this.#logger.debug( + `Skipping Bitbucket Cloud webhook event of type "${eventType}" on topic "${params.topic}" because it is ignored: ${output.reason}`, + ); + } else if (output.result === 'aborted') { + this.#logger.warn( + `Skipping Bitbucket Cloud webhook event of type "${eventType}" on topic "${params.topic}" because it is aborted: ${output.reason}`, + ); + } else if (output.result === 'unsupported-event') { + this.#logger.debug( + `Skipping Bitbucket Cloud webhook event of type "${eventType}" on topic "${params.topic}" because it is unsupported: ${output.event}`, + ); + } + } catch (error) { + this.#logger.warn( + `Failed to handle Bitbucket Cloud webhook event of type "${eventType}"`, + error, + ); + } finally { + this.#pendingPublish = undefined; + } + }); + + await this.#pendingPublish; + } + + #extractEventTypeFromTopic(topic: string): string | undefined { + if (topic.startsWith('bitbucketCloud.')) { + return topic.slice('bitbucketCloud.'.length); + } + return undefined; + } +} diff --git a/plugins/catalog-backend-module-bitbucket-cloud/src/events/analyzeBitbucketCloudWebhookEvent.test.ts b/plugins/catalog-backend-module-bitbucket-cloud/src/events/analyzeBitbucketCloudWebhookEvent.test.ts new file mode 100644 index 0000000000..6ddb8f15a9 --- /dev/null +++ b/plugins/catalog-backend-module-bitbucket-cloud/src/events/analyzeBitbucketCloudWebhookEvent.test.ts @@ -0,0 +1,262 @@ +/* + * Copyright 2026 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 { analyzeBitbucketCloudWebhookEvent } from './analyzeBitbucketCloudWebhookEvent'; + +const baseRepository = { + type: 'repository', + full_name: 'test-ws/test-repo', + links: { + html: { + href: 'https://bitbucket.org/test-ws/test-repo', + }, + }, + workspace: { + type: 'workspace', + slug: 'test-ws', + }, +}; + +describe('analyzeBitbucketCloudWebhookEvent', () => { + describe('repo:push', () => { + it('emits repository.updated for a push event', async () => { + await expect( + analyzeBitbucketCloudWebhookEvent('repo:push', { + actor: { type: 'user' }, + repository: baseRepository, + push: { changes: [] }, + }), + ).resolves.toEqual({ + result: 'ok', + events: [ + { + type: 'repository.updated', + url: 'https://bitbucket.org/test-ws/test-repo', + }, + ], + }); + }); + + it('aborts when repository URL is missing', async () => { + await expect( + analyzeBitbucketCloudWebhookEvent('repo:push', { + actor: { type: 'user' }, + repository: { type: 'repository' }, + push: { changes: [] }, + }), + ).resolves.toEqual({ + result: 'aborted', + reason: + 'Bitbucket Cloud repo:push event did not include repository.links.html.href', + }); + }); + }); + + describe('repo:updated', () => { + it('emits repository.moved when the URL changes', async () => { + await expect( + analyzeBitbucketCloudWebhookEvent('repo:updated', { + actor: { type: 'user' }, + repository: { + ...baseRepository, + full_name: 'test-ws/test-repo-new', + links: { + html: { + href: 'https://bitbucket.org/test-ws/test-repo-new', + }, + }, + }, + changes: { + name: { new: 'test-repo-new', old: 'test-repo-old' }, + full_name: { + new: 'test-ws/test-repo-new', + old: 'test-ws/test-repo-old', + }, + links: { + new: { + html: { + href: 'https://bitbucket.org/test-ws/test-repo-new', + }, + }, + old: { + html: { + href: 'https://bitbucket.org/test-ws/test-repo-old', + }, + }, + }, + }, + }), + ).resolves.toEqual({ + result: 'ok', + events: [ + { + type: 'repository.moved', + fromUrl: 'https://bitbucket.org/test-ws/test-repo-old', + toUrl: 'https://bitbucket.org/test-ws/test-repo-new', + }, + ], + }); + }); + + it('falls back to full_name for old URL when links.old is missing', async () => { + await expect( + analyzeBitbucketCloudWebhookEvent('repo:updated', { + actor: { type: 'user' }, + repository: { + ...baseRepository, + full_name: 'test-ws/test-repo-new', + links: { + html: { + href: 'https://bitbucket.org/test-ws/test-repo-new', + }, + }, + }, + changes: { + full_name: { + new: 'test-ws/test-repo-new', + old: 'test-ws/test-repo-old', + }, + }, + }), + ).resolves.toEqual({ + result: 'ok', + events: [ + { + type: 'repository.moved', + fromUrl: 'https://bitbucket.org/test-ws/test-repo-old', + toUrl: 'https://bitbucket.org/test-ws/test-repo-new', + }, + ], + }); + }); + + it('emits repository.updated when no URL change is detected', async () => { + await expect( + analyzeBitbucketCloudWebhookEvent('repo:updated', { + actor: { type: 'user' }, + repository: baseRepository, + changes: { + description: { new: 'new desc', old: 'old desc' }, + }, + }), + ).resolves.toEqual({ + result: 'ok', + events: [ + { + type: 'repository.updated', + url: 'https://bitbucket.org/test-ws/test-repo', + }, + ], + }); + }); + }); + + describe('repo:transfer', () => { + it('emits repository.moved when transferred to a new workspace', async () => { + await expect( + analyzeBitbucketCloudWebhookEvent('repo:transfer', { + actor: { type: 'user' }, + repository: { + ...baseRepository, + full_name: 'new-ws/test-repo', + links: { + html: { + href: 'https://bitbucket.org/new-ws/test-repo', + }, + }, + workspace: { + type: 'workspace', + slug: 'new-ws', + }, + }, + changes: { + full_name: { + new: 'new-ws/test-repo', + old: 'test-ws/test-repo', + }, + links: { + new: { + html: { + href: 'https://bitbucket.org/new-ws/test-repo', + }, + }, + old: { + html: { + href: 'https://bitbucket.org/test-ws/test-repo', + }, + }, + }, + }, + }), + ).resolves.toEqual({ + result: 'ok', + events: [ + { + type: 'repository.moved', + fromUrl: 'https://bitbucket.org/test-ws/test-repo', + toUrl: 'https://bitbucket.org/new-ws/test-repo', + }, + ], + }); + }); + }); + + describe('repo:deleted', () => { + it('emits repository.deleted', async () => { + await expect( + analyzeBitbucketCloudWebhookEvent('repo:deleted', { + actor: { type: 'user' }, + repository: baseRepository, + }), + ).resolves.toEqual({ + result: 'ok', + events: [ + { + type: 'repository.deleted', + url: 'https://bitbucket.org/test-ws/test-repo', + }, + ], + }); + }); + }); + + describe('general behavior', () => { + it('throws on non-object payloads', async () => { + await expect( + analyzeBitbucketCloudWebhookEvent('repo:push', undefined), + ).rejects.toThrow( + 'Bitbucket Cloud webhook event payload is not an object', + ); + + await expect( + analyzeBitbucketCloudWebhookEvent('repo:push', []), + ).rejects.toThrow( + 'Bitbucket Cloud webhook event payload is not an object', + ); + }); + + it('returns unsupported-event for unknown event types', async () => { + await expect( + analyzeBitbucketCloudWebhookEvent('pullrequest:created', { + actor: { type: 'user' }, + }), + ).resolves.toEqual({ + result: 'unsupported-event', + event: 'pullrequest:created', + }); + }); + }); +}); diff --git a/plugins/catalog-backend-module-bitbucket-cloud/src/events/analyzeBitbucketCloudWebhookEvent.ts b/plugins/catalog-backend-module-bitbucket-cloud/src/events/analyzeBitbucketCloudWebhookEvent.ts new file mode 100644 index 0000000000..68ce66d03d --- /dev/null +++ b/plugins/catalog-backend-module-bitbucket-cloud/src/events/analyzeBitbucketCloudWebhookEvent.ts @@ -0,0 +1,242 @@ +/* + * Copyright 2026 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 { InputError } from '@backstage/errors'; +import { CatalogScmEvent } from '@backstage/plugin-catalog-node/alpha'; + +/** + * The result of analyzing a Bitbucket Cloud webhook event. + * + * - `ok` — one or more catalog SCM events were produced. + * - `ignored` — the event was valid but not relevant. + * - `aborted` — the event could not be fully processed due to missing data. + * - `unsupported-event` — the event type is not handled by this analyzer. + * + * @alpha + */ +export type AnalyzeBitbucketCloudWebhookEventResult = + | { + result: 'unsupported-event'; + event: string; + } + | { + result: 'ignored'; + reason: string; + } + | { + result: 'aborted'; + reason: string; + } + | { + result: 'ok'; + events: CatalogScmEvent[]; + }; + +type JsonObject = Record; + +function asObject(value: unknown): JsonObject | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + return value as JsonObject; +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +function getRepositoryUrl(payload: JsonObject): string | undefined { + const repository = asObject(payload.repository); + if (!repository) { + return undefined; + } + const links = asObject(repository.links); + const html = asObject(links?.html); + return asString(html?.href); +} + +function getOldRepositoryUrl(payload: JsonObject): string | undefined { + const changes = asObject(payload.changes); + if (!changes) { + return undefined; + } + + const linksChange = asObject(changes.links); + if (linksChange) { + const oldLinks = asObject(linksChange.old); + const html = asObject(oldLinks?.html); + const href = asString(html?.href); + if (href) { + return href; + } + } + + const fullNameChange = asObject(changes.full_name); + const oldFullName = asString(fullNameChange?.old); + if (oldFullName) { + return `https://bitbucket.org/${oldFullName}`; + } + + return undefined; +} + +async function onPushEvent( + payload: JsonObject, +): Promise { + const repositoryUrl = getRepositoryUrl(payload); + + if (!repositoryUrl) { + return { + result: 'aborted', + reason: + 'Bitbucket Cloud repo:push event did not include repository.links.html.href', + }; + } + + return { + result: 'ok', + events: [{ type: 'repository.updated', url: repositoryUrl }], + }; +} + +async function onRepoUpdatedEvent( + payload: JsonObject, +): Promise { + const repositoryUrl = getRepositoryUrl(payload); + const oldRepositoryUrl = getOldRepositoryUrl(payload); + + if (!repositoryUrl) { + return { + result: 'aborted', + reason: + 'Bitbucket Cloud repo:updated event did not include repository.links.html.href', + }; + } + + if (oldRepositoryUrl && oldRepositoryUrl !== repositoryUrl) { + return { + result: 'ok', + events: [ + { + type: 'repository.moved', + fromUrl: oldRepositoryUrl, + toUrl: repositoryUrl, + }, + ], + }; + } + + return { + result: 'ok', + events: [{ type: 'repository.updated', url: repositoryUrl }], + }; +} + +async function onRepoTransferEvent( + payload: JsonObject, +): Promise { + const repositoryUrl = getRepositoryUrl(payload); + const oldRepositoryUrl = getOldRepositoryUrl(payload); + + if (!repositoryUrl) { + return { + result: 'aborted', + reason: + 'Bitbucket Cloud repo:transfer event did not include repository.links.html.href', + }; + } + + if (oldRepositoryUrl && oldRepositoryUrl !== repositoryUrl) { + return { + result: 'ok', + events: [ + { + type: 'repository.moved', + fromUrl: oldRepositoryUrl, + toUrl: repositoryUrl, + }, + ], + }; + } + + return { + result: 'ok', + events: [{ type: 'repository.updated', url: repositoryUrl }], + }; +} + +async function onRepoDeletedEvent( + payload: JsonObject, +): Promise { + const repositoryUrl = getRepositoryUrl(payload); + + if (!repositoryUrl) { + return { + result: 'aborted', + reason: + 'Bitbucket Cloud repo:deleted event did not include repository.links.html.href', + }; + } + + return { + result: 'ok', + events: [{ type: 'repository.deleted', url: repositoryUrl }], + }; +} + +/** + * Analyzes a Bitbucket Cloud webhook event and translates it into zero or more + * catalog SCM events that entity providers can act on. + * + * Bitbucket Cloud push payloads do not include file-level change data, so only + * repository-level events are produced (unlike GitLab and Azure DevOps + * analyzers which can emit fine-grained `location.*` events). + * + * Supported event types: + * - `repo:push` — emits a `repository.updated` event to trigger catalog + * refresh for the repository. + * - `repo:updated` — translates repository renames into `repository.moved` + * events, or emits `repository.updated` for other metadata changes. + * - `repo:transfer` — translates repository transfers into `repository.moved` + * events. + * - `repo:deleted` — emits a `repository.deleted` event. + * + * @alpha + */ +export async function analyzeBitbucketCloudWebhookEvent( + eventType: string, + eventPayload: unknown, +): Promise { + const payload = asObject(eventPayload); + if (!payload) { + throw new InputError( + 'Bitbucket Cloud webhook event payload is not an object', + ); + } + + switch (eventType) { + case 'repo:push': + return onPushEvent(payload); + case 'repo:updated': + return onRepoUpdatedEvent(payload); + case 'repo:transfer': + return onRepoTransferEvent(payload); + case 'repo:deleted': + return onRepoDeletedEvent(payload); + default: + return { result: 'unsupported-event', event: eventType }; + } +} diff --git a/plugins/catalog-backend-module-bitbucket-cloud/src/module/catalogModuleBitbucketCloudEntityProvider.test.ts b/plugins/catalog-backend-module-bitbucket-cloud/src/module/catalogModuleBitbucketCloudEntityProvider.test.ts index 50fa5796ac..888db3bd9d 100644 --- a/plugins/catalog-backend-module-bitbucket-cloud/src/module/catalogModuleBitbucketCloudEntityProvider.test.ts +++ b/plugins/catalog-backend-module-bitbucket-cloud/src/module/catalogModuleBitbucketCloudEntityProvider.test.ts @@ -88,8 +88,13 @@ describe('catalogModuleBitbucketCloudEntityProvider', () => { 'bitbucketCloud-provider:default', ); await provider.connect(connection); - expect(events.subscribed).toHaveLength(1); - expect(events.subscribed[0].id).toEqual('bitbucketCloud-provider:default'); + expect(events.subscribed).toHaveLength(2); + expect(events.subscribed.map(s => s.id)).toContain( + 'bitbucketCloud-provider:default', + ); + expect(events.subscribed.map(s => s.id)).toContain( + 'catalog-bitbucket-cloud-scm-events-bridge', + ); expect(runner).toHaveBeenCalledTimes(1); }); }); diff --git a/plugins/catalog-backend-module-bitbucket-cloud/src/module/catalogModuleBitbucketCloudEntityProvider.ts b/plugins/catalog-backend-module-bitbucket-cloud/src/module/catalogModuleBitbucketCloudEntityProvider.ts index 7cd839bb63..222453fb32 100644 --- a/plugins/catalog-backend-module-bitbucket-cloud/src/module/catalogModuleBitbucketCloudEntityProvider.ts +++ b/plugins/catalog-backend-module-bitbucket-cloud/src/module/catalogModuleBitbucketCloudEntityProvider.ts @@ -20,8 +20,10 @@ import { } from '@backstage/backend-plugin-api'; import { catalogProcessingExtensionPoint } from '@backstage/plugin-catalog-node'; import { catalogServiceRef } from '@backstage/plugin-catalog-node'; +import { catalogScmEventsServiceRef } from '@backstage/plugin-catalog-node/alpha'; import { eventsServiceRef } from '@backstage/plugin-events-node'; import { BitbucketCloudEntityProvider } from '../providers/BitbucketCloudEntityProvider'; +import { BitbucketCloudScmEventsBridge } from '../events/BitbucketCloudScmEventsBridge'; /** * @public @@ -39,6 +41,8 @@ export const catalogModuleBitbucketCloudEntityProvider = createBackendModule({ events: eventsServiceRef, logger: coreServices.logger, scheduler: coreServices.scheduler, + catalogScmEvents: catalogScmEventsServiceRef, + lifecycle: coreServices.lifecycle, }, async init({ auth, @@ -48,6 +52,8 @@ export const catalogModuleBitbucketCloudEntityProvider = createBackendModule({ events, logger, scheduler, + catalogScmEvents, + lifecycle, }) { const providers = BitbucketCloudEntityProvider.fromConfig(config, { auth, @@ -58,6 +64,18 @@ export const catalogModuleBitbucketCloudEntityProvider = createBackendModule({ }); catalogProcessing.addEntityProvider(providers); + + const bridge = new BitbucketCloudScmEventsBridge({ + logger, + events, + catalogScmEvents, + }); + lifecycle.addStartupHook(async () => { + await bridge.start(); + }); + lifecycle.addShutdownHook(async () => { + await bridge.stop(); + }); }, }); }, diff --git a/yarn.lock b/yarn.lock index 821675739b..57236f1253 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4803,6 +4803,7 @@ __metadata: "@backstage/catalog-model": "workspace:^" "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" + "@backstage/errors": "workspace:^" "@backstage/integration": "workspace:^" "@backstage/plugin-bitbucket-cloud-common": "workspace:^" "@backstage/plugin-catalog-common": "workspace:^"