From f2158637435a3d99add283be9708b96f506a54d1 Mon Sep 17 00:00:00 2001 From: Lokesh Kaki Date: Tue, 17 Mar 2026 21:42:22 -0500 Subject: [PATCH 1/2] feat(catalog-backend-module-bitbucket-cloud): add Bitbucket Cloud SCM event translation and bridge wiring Signed-off-by: Lokesh Kaki --- .../bitbucket-cloud-scm-events-layer.md | 5 + .../package.json | 1 + .../report-alpha.api.md | 32 ++ .../src/alpha.ts | 6 + .../events/BitbucketCloudScmEventsBridge.ts | 121 +++++++ .../analyzeBitbucketCloudWebhookEvent.test.ts | 299 ++++++++++++++++++ .../analyzeBitbucketCloudWebhookEvent.ts | 252 +++++++++++++++ ...ModuleBitbucketCloudEntityProvider.test.ts | 9 +- ...talogModuleBitbucketCloudEntityProvider.ts | 18 ++ yarn.lock | 1 + 10 files changed, 742 insertions(+), 2 deletions(-) create mode 100644 .changeset/bitbucket-cloud-scm-events-layer.md create mode 100644 plugins/catalog-backend-module-bitbucket-cloud/src/events/BitbucketCloudScmEventsBridge.ts create mode 100644 plugins/catalog-backend-module-bitbucket-cloud/src/events/analyzeBitbucketCloudWebhookEvent.test.ts create mode 100644 plugins/catalog-backend-module-bitbucket-cloud/src/events/analyzeBitbucketCloudWebhookEvent.ts 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 62b4543fff..c7e2fada14 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..67c166912d 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,38 @@ ```ts import { BackendFeature } from '@backstage/backend-plugin-api'; +import { CatalogScmEvent } from '@backstage/plugin-catalog-node/alpha'; + +// @alpha +export function analyzeBitbucketCloudWebhookEvent( + eventType: string, + eventPayload: unknown, + _options: AnalyzeBitbucketCloudWebhookEventOptions, +): Promise; + +// @alpha +export interface AnalyzeBitbucketCloudWebhookEventOptions { + isRelevantPath: (path: string) => boolean; +} + +// @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..19212727a4 100644 --- a/plugins/catalog-backend-module-bitbucket-cloud/src/alpha.ts +++ b/plugins/catalog-backend-module-bitbucket-cloud/src/alpha.ts @@ -19,3 +19,9 @@ import { default as feature } from './module'; /** @alpha */ const _feature = feature; export default _feature; + +export { + analyzeBitbucketCloudWebhookEvent, + type AnalyzeBitbucketCloudWebhookEventOptions, + 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..4ae59f7ed7 --- /dev/null +++ b/plugins/catalog-backend-module-bitbucket-cloud/src/events/BitbucketCloudScmEventsBridge.ts @@ -0,0 +1,121 @@ +/* + * 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, + { + isRelevantPath: path => + path.endsWith('.yaml') || path.endsWith('.yml'), + }, + ); + + 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..fbbd7c9a4f --- /dev/null +++ b/plugins/catalog-backend-module-bitbucket-cloud/src/events/analyzeBitbucketCloudWebhookEvent.test.ts @@ -0,0 +1,299 @@ +/* + * 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 isRelevantPath = (path: string): boolean => + path.endsWith('.yaml') || path.endsWith('.yml'); + +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: [] }, + }, + { isRelevantPath }, + ), + ).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: [] }, + }, + { isRelevantPath }, + ), + ).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', + }, + }, + }, + }, + }, + { isRelevantPath }, + ), + ).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', + }, + }, + }, + { isRelevantPath }, + ), + ).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' }, + }, + }, + { isRelevantPath }, + ), + ).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', + }, + }, + }, + }, + }, + { isRelevantPath }, + ), + ).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, + }, + { isRelevantPath }, + ), + ).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, { + isRelevantPath, + }), + ).rejects.toThrow( + 'Bitbucket Cloud webhook event payload is not an object', + ); + + await expect( + analyzeBitbucketCloudWebhookEvent('repo:push', [], { + isRelevantPath, + }), + ).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' } }, + { isRelevantPath }, + ), + ).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..93b1b41934 --- /dev/null +++ b/plugins/catalog-backend-module-bitbucket-cloud/src/events/analyzeBitbucketCloudWebhookEvent.ts @@ -0,0 +1,252 @@ +/* + * 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'; + +/** + * Options for {@link analyzeBitbucketCloudWebhookEvent}. + * @alpha + */ +export interface AnalyzeBitbucketCloudWebhookEventOptions { + /** + * Predicate that returns true for file paths that are relevant to the + * catalog (e.g. paths ending in `.yaml` or `.yml`). + */ + isRelevantPath: (path: string) => boolean; +} + +/** + * 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. + * + * Supported event types: + * - `repo:push` — emits a `repository.updated` event to trigger catalog + * refresh for the repository. Bitbucket Cloud push payloads do not include + * file-level change data, so only repository-level events are produced. + * - `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, + _options: AnalyzeBitbucketCloudWebhookEventOptions, +): 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 fd201d7eea..6c3e8bf24f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4792,6 +4792,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:^" From deb9a8374687c6d27ebb66ed411c295b79b342f4 Mon Sep 17 00:00:00 2001 From: Lokesh Kaki Date: Tue, 17 Mar 2026 22:31:07 -0500 Subject: [PATCH 2/2] fix(catalog-backend-module-bitbucket-cloud): remove unused isRelevantPath options parameter from analyzer Signed-off-by: Lokesh Kaki --- .../report-alpha.api.md | 6 - .../src/alpha.ts | 1 - .../events/BitbucketCloudScmEventsBridge.ts | 4 - .../analyzeBitbucketCloudWebhookEvent.test.ts | 215 ++++++++---------- .../analyzeBitbucketCloudWebhookEvent.ts | 20 +- 5 files changed, 94 insertions(+), 152 deletions(-) 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 67c166912d..31762b83a2 100644 --- a/plugins/catalog-backend-module-bitbucket-cloud/report-alpha.api.md +++ b/plugins/catalog-backend-module-bitbucket-cloud/report-alpha.api.md @@ -10,14 +10,8 @@ import { CatalogScmEvent } from '@backstage/plugin-catalog-node/alpha'; export function analyzeBitbucketCloudWebhookEvent( eventType: string, eventPayload: unknown, - _options: AnalyzeBitbucketCloudWebhookEventOptions, ): Promise; -// @alpha -export interface AnalyzeBitbucketCloudWebhookEventOptions { - isRelevantPath: (path: string) => boolean; -} - // @alpha export type AnalyzeBitbucketCloudWebhookEventResult = | { diff --git a/plugins/catalog-backend-module-bitbucket-cloud/src/alpha.ts b/plugins/catalog-backend-module-bitbucket-cloud/src/alpha.ts index 19212727a4..a4945aff93 100644 --- a/plugins/catalog-backend-module-bitbucket-cloud/src/alpha.ts +++ b/plugins/catalog-backend-module-bitbucket-cloud/src/alpha.ts @@ -22,6 +22,5 @@ export default _feature; export { analyzeBitbucketCloudWebhookEvent, - type AnalyzeBitbucketCloudWebhookEventOptions, 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 index 4ae59f7ed7..16d7c2fc5c 100644 --- a/plugins/catalog-backend-module-bitbucket-cloud/src/events/BitbucketCloudScmEventsBridge.ts +++ b/plugins/catalog-backend-module-bitbucket-cloud/src/events/BitbucketCloudScmEventsBridge.ts @@ -78,10 +78,6 @@ export class BitbucketCloudScmEventsBridge { const output = await analyzeBitbucketCloudWebhookEvent( eventType, params.eventPayload, - { - isRelevantPath: path => - path.endsWith('.yaml') || path.endsWith('.yml'), - }, ); if (output.result === 'ok') { 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 index fbbd7c9a4f..6ddb8f15a9 100644 --- a/plugins/catalog-backend-module-bitbucket-cloud/src/events/analyzeBitbucketCloudWebhookEvent.test.ts +++ b/plugins/catalog-backend-module-bitbucket-cloud/src/events/analyzeBitbucketCloudWebhookEvent.test.ts @@ -16,9 +16,6 @@ import { analyzeBitbucketCloudWebhookEvent } from './analyzeBitbucketCloudWebhookEvent'; -const isRelevantPath = (path: string): boolean => - path.endsWith('.yaml') || path.endsWith('.yml'); - const baseRepository = { type: 'repository', full_name: 'test-ws/test-repo', @@ -37,15 +34,11 @@ 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: [] }, - }, - { isRelevantPath }, - ), + analyzeBitbucketCloudWebhookEvent('repo:push', { + actor: { type: 'user' }, + repository: baseRepository, + push: { changes: [] }, + }), ).resolves.toEqual({ result: 'ok', events: [ @@ -59,15 +52,11 @@ describe('analyzeBitbucketCloudWebhookEvent', () => { it('aborts when repository URL is missing', async () => { await expect( - analyzeBitbucketCloudWebhookEvent( - 'repo:push', - { - actor: { type: 'user' }, - repository: { type: 'repository' }, - push: { changes: [] }, - }, - { isRelevantPath }, - ), + analyzeBitbucketCloudWebhookEvent('repo:push', { + actor: { type: 'user' }, + repository: { type: 'repository' }, + push: { changes: [] }, + }), ).resolves.toEqual({ result: 'aborted', reason: @@ -79,41 +68,37 @@ describe('analyzeBitbucketCloudWebhookEvent', () => { 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: { + 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', }, }, - }, - 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', - }, + old: { + html: { + href: 'https://bitbucket.org/test-ws/test-repo-old', }, }, }, }, - { isRelevantPath }, - ), + }), ).resolves.toEqual({ result: 'ok', events: [ @@ -128,28 +113,24 @@ describe('analyzeBitbucketCloudWebhookEvent', () => { 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', + 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', }, }, }, - { isRelevantPath }, - ), + changes: { + full_name: { + new: 'test-ws/test-repo-new', + old: 'test-ws/test-repo-old', + }, + }, + }), ).resolves.toEqual({ result: 'ok', events: [ @@ -164,17 +145,13 @@ describe('analyzeBitbucketCloudWebhookEvent', () => { 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' }, - }, + analyzeBitbucketCloudWebhookEvent('repo:updated', { + actor: { type: 'user' }, + repository: baseRepository, + changes: { + description: { new: 'new desc', old: 'old desc' }, }, - { isRelevantPath }, - ), + }), ).resolves.toEqual({ result: 'ok', events: [ @@ -190,44 +167,40 @@ describe('analyzeBitbucketCloudWebhookEvent', () => { 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: { + 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', }, }, - 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', - }, + old: { + html: { + href: 'https://bitbucket.org/test-ws/test-repo', }, }, }, }, - { isRelevantPath }, - ), + }), ).resolves.toEqual({ result: 'ok', events: [ @@ -244,14 +217,10 @@ describe('analyzeBitbucketCloudWebhookEvent', () => { describe('repo:deleted', () => { it('emits repository.deleted', async () => { await expect( - analyzeBitbucketCloudWebhookEvent( - 'repo:deleted', - { - actor: { type: 'user' }, - repository: baseRepository, - }, - { isRelevantPath }, - ), + analyzeBitbucketCloudWebhookEvent('repo:deleted', { + actor: { type: 'user' }, + repository: baseRepository, + }), ).resolves.toEqual({ result: 'ok', events: [ @@ -267,17 +236,13 @@ describe('analyzeBitbucketCloudWebhookEvent', () => { describe('general behavior', () => { it('throws on non-object payloads', async () => { await expect( - analyzeBitbucketCloudWebhookEvent('repo:push', undefined, { - isRelevantPath, - }), + analyzeBitbucketCloudWebhookEvent('repo:push', undefined), ).rejects.toThrow( 'Bitbucket Cloud webhook event payload is not an object', ); await expect( - analyzeBitbucketCloudWebhookEvent('repo:push', [], { - isRelevantPath, - }), + analyzeBitbucketCloudWebhookEvent('repo:push', []), ).rejects.toThrow( 'Bitbucket Cloud webhook event payload is not an object', ); @@ -285,11 +250,9 @@ describe('analyzeBitbucketCloudWebhookEvent', () => { it('returns unsupported-event for unknown event types', async () => { await expect( - analyzeBitbucketCloudWebhookEvent( - 'pullrequest:created', - { actor: { type: 'user' } }, - { isRelevantPath }, - ), + 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 index 93b1b41934..68ce66d03d 100644 --- a/plugins/catalog-backend-module-bitbucket-cloud/src/events/analyzeBitbucketCloudWebhookEvent.ts +++ b/plugins/catalog-backend-module-bitbucket-cloud/src/events/analyzeBitbucketCloudWebhookEvent.ts @@ -17,18 +17,6 @@ import { InputError } from '@backstage/errors'; import { CatalogScmEvent } from '@backstage/plugin-catalog-node/alpha'; -/** - * Options for {@link analyzeBitbucketCloudWebhookEvent}. - * @alpha - */ -export interface AnalyzeBitbucketCloudWebhookEventOptions { - /** - * Predicate that returns true for file paths that are relevant to the - * catalog (e.g. paths ending in `.yaml` or `.yml`). - */ - isRelevantPath: (path: string) => boolean; -} - /** * The result of analyzing a Bitbucket Cloud webhook event. * @@ -213,10 +201,13 @@ async function onRepoDeletedEvent( * 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. Bitbucket Cloud push payloads do not include - * file-level change data, so only repository-level events are produced. + * 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` @@ -228,7 +219,6 @@ async function onRepoDeletedEvent( export async function analyzeBitbucketCloudWebhookEvent( eventType: string, eventPayload: unknown, - _options: AnalyzeBitbucketCloudWebhookEventOptions, ): Promise { const payload = asObject(eventPayload); if (!payload) {