feat(catalog-backend-module-bitbucket-cloud): add Bitbucket Cloud SCM event translation and bridge wiring

Signed-off-by: Lokesh Kaki <lokeshkaki1@gmail.com>
This commit is contained in:
Lokesh Kaki
2026-03-17 21:42:22 -05:00
parent 423d675d97
commit f215863743
10 changed files with 742 additions and 2 deletions
@@ -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.
@@ -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:^",
@@ -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<AnalyzeBitbucketCloudWebhookEventResult>;
// @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;
@@ -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';
@@ -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<void> | 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<void> {
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;
}
}
@@ -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',
});
});
});
});
@@ -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<string, unknown>;
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<AnalyzeBitbucketCloudWebhookEventResult> {
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<AnalyzeBitbucketCloudWebhookEventResult> {
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<AnalyzeBitbucketCloudWebhookEventResult> {
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<AnalyzeBitbucketCloudWebhookEventResult> {
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<AnalyzeBitbucketCloudWebhookEventResult> {
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 };
}
}
@@ -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);
});
});
@@ -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();
});
},
});
},
+1
View File
@@ -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:^"