From 0cd53934b031bcf582ac34589ef6dbbcf9f92093 Mon Sep 17 00:00:00 2001 From: Lokesh Kaki Date: Sun, 15 Mar 2026 19:08:33 -0500 Subject: [PATCH] feat(catalog-backend-module-azure): add Azure DevOps SCM events bridge wiring Signed-off-by: Lokesh Kaki --- .../src/events/AzureDevOpsScmEventsBridge.ts | 115 ++++++++++++++++++ .../catalogModuleAzureDevOpsEntityProvider.ts | 28 ++++- 2 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 plugins/catalog-backend-module-azure/src/events/AzureDevOpsScmEventsBridge.ts diff --git a/plugins/catalog-backend-module-azure/src/events/AzureDevOpsScmEventsBridge.ts b/plugins/catalog-backend-module-azure/src/events/AzureDevOpsScmEventsBridge.ts new file mode 100644 index 0000000000..2f03a1b080 --- /dev/null +++ b/plugins/catalog-backend-module-azure/src/events/AzureDevOpsScmEventsBridge.ts @@ -0,0 +1,115 @@ +/* + * 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 { analyzeAzureDevOpsWebhookEvent } from './analyzeAzureDevOpsWebhookEvent'; + +/** + * Takes Azure DevOps webhook events, analyzes them, and publishes them as + * catalog SCM events that entity providers and others can subscribe to. + */ +export class AzureDevOpsScmEventsBridge { + 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-azure-devops-scm-events-bridge', + topics: ['azureDevOps'], + onEvent: this.#onEvent.bind(this), + }); + } + + async stop() { + this.#shuttingDown = true; + await this.#pendingPublish; + } + + async #onEvent(params: EventParams): Promise { + const eventPayload = params.eventPayload as + | { eventType?: string } + | undefined; + const eventType = eventPayload?.eventType; + if (!eventType || !eventPayload) { + return; + } + + while (this.#pendingPublish) { + await this.#pendingPublish; + } + + if (this.#shuttingDown) { + this.#logger.warn( + `Skipping Azure DevOps 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 analyzeAzureDevOpsWebhookEvent( + eventType, + 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 Azure DevOps webhook event of type "${eventType}" on topic "${params.topic}" because it is ignored: ${output.reason}`, + ); + } else if (output.result === 'aborted') { + this.#logger.warn( + `Skipping Azure DevOps 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 Azure DevOps webhook event of type "${eventType}" on topic "${params.topic}" because it is unsupported: ${output.event}`, + ); + } + } catch (error) { + this.#logger.warn( + `Failed to handle Azure DevOps webhook event of type "${eventType}"`, + error, + ); + } finally { + this.#pendingPublish = undefined; + } + }); + + await this.#pendingPublish; + } +} diff --git a/plugins/catalog-backend-module-azure/src/module/catalogModuleAzureDevOpsEntityProvider.ts b/plugins/catalog-backend-module-azure/src/module/catalogModuleAzureDevOpsEntityProvider.ts index fff597c165..b7ad3cf6e6 100644 --- a/plugins/catalog-backend-module-azure/src/module/catalogModuleAzureDevOpsEntityProvider.ts +++ b/plugins/catalog-backend-module-azure/src/module/catalogModuleAzureDevOpsEntityProvider.ts @@ -19,10 +19,13 @@ import { createBackendModule, } from '@backstage/backend-plugin-api'; import { catalogProcessingExtensionPoint } from '@backstage/plugin-catalog-node'; +import { catalogScmEventsServiceRef } from '@backstage/plugin-catalog-node/alpha'; +import { eventsServiceRef } from '@backstage/plugin-events-node'; import { AzureBlobStorageEntityProvider, AzureDevOpsEntityProvider, } from '../providers'; +import { AzureDevOpsScmEventsBridge } from '../events/AzureDevOpsScmEventsBridge'; /** * Registers the AzureDevOpsEntityProvider with the catalog processing extension point. @@ -39,8 +42,19 @@ export const catalogModuleAzureEntityProvider = createBackendModule({ catalog: catalogProcessingExtensionPoint, logger: coreServices.logger, scheduler: coreServices.scheduler, + events: eventsServiceRef, + catalogScmEvents: catalogScmEventsServiceRef, + lifecycle: coreServices.lifecycle, }, - async init({ config, catalog, logger, scheduler }) { + async init({ + config, + catalog, + logger, + scheduler, + events, + catalogScmEvents, + lifecycle, + }) { // Check for Azure Blob Storage provider configuration and register it if (config.has('catalog.providers.azureBlob')) { catalog.addEntityProvider( @@ -60,6 +74,18 @@ export const catalogModuleAzureEntityProvider = createBackendModule({ }), ); } + + const bridge = new AzureDevOpsScmEventsBridge({ + logger, + events, + catalogScmEvents, + }); + lifecycle.addStartupHook(async () => { + await bridge.start(); + }); + lifecycle.addShutdownHook(async () => { + await bridge.stop(); + }); }, }); },