From baa269f33f89cbff129e54bc0fa4c61f30de5974 Mon Sep 17 00:00:00 2001 From: Lokesh Kaki Date: Sun, 15 Mar 2026 19:08:28 -0500 Subject: [PATCH 01/10] feat(catalog-backend-module-azure): add Azure DevOps webhook SCM event analyzer Signed-off-by: Lokesh Kaki --- .../analyzeAzureDevOpsWebhookEvent.test.ts | 256 +++++++++ .../events/analyzeAzureDevOpsWebhookEvent.ts | 517 ++++++++++++++++++ 2 files changed, 773 insertions(+) create mode 100644 plugins/catalog-backend-module-azure/src/events/analyzeAzureDevOpsWebhookEvent.test.ts create mode 100644 plugins/catalog-backend-module-azure/src/events/analyzeAzureDevOpsWebhookEvent.ts diff --git a/plugins/catalog-backend-module-azure/src/events/analyzeAzureDevOpsWebhookEvent.test.ts b/plugins/catalog-backend-module-azure/src/events/analyzeAzureDevOpsWebhookEvent.test.ts new file mode 100644 index 0000000000..9edeb1602d --- /dev/null +++ b/plugins/catalog-backend-module-azure/src/events/analyzeAzureDevOpsWebhookEvent.test.ts @@ -0,0 +1,256 @@ +/* + * 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 { analyzeAzureDevOpsWebhookEvent } from './analyzeAzureDevOpsWebhookEvent'; + +const isRelevantPath = (path: string): boolean => path.endsWith('.yaml'); + +const baseRepository = { + id: 'repo-id', + name: 'example-repo', + defaultBranch: 'refs/heads/main', + remoteUrl: 'https://dev.azure.com/example-org/example-project/_git/example-repo', +}; + +const withPushEvent = (resource: Record) => ({ + eventType: 'git.push', + resource, +}); + +describe('analyzeAzureDevOpsWebhookEvent', () => { + describe('git.push', () => { + it('translates file add, edit, delete, and rename operations to catalog scm events', async () => { + await expect( + analyzeAzureDevOpsWebhookEvent( + 'git.push', + withPushEvent({ + repository: baseRepository, + refUpdates: [{ name: 'refs/heads/main' }], + commits: [ + { + commitId: '1111111111111111111111111111111111111111', + url: `${baseRepository.remoteUrl}/commit/1111111111111111111111111111111111111111`, + changes: [ + { + changeType: 'add', + item: { path: '/catalog-info.yaml' }, + }, + { + changeType: 'edit', + item: { path: '/service.yaml' }, + }, + { + changeType: 'delete', + item: { path: '/obsolete.yaml' }, + }, + { + changeType: 'rename', + originalPath: '/old-name.yaml', + item: { path: '/new-name.yaml' }, + }, + { + changeType: 'rename', + originalPath: '/catalog-out.yaml', + item: { path: '/docs/readme.md' }, + }, + { + changeType: 'rename', + originalPath: '/docs/intro.md', + item: { path: '/catalog-in.yaml' }, + }, + ], + }, + ], + }), + { isRelevantPath }, + ), + ).resolves.toEqual({ + result: 'ok', + events: [ + { + type: 'location.created', + url: `${baseRepository.remoteUrl}?path=/catalog-info.yaml&version=GBmain`, + context: { + commitUrl: `${baseRepository.remoteUrl}/commit/1111111111111111111111111111111111111111`, + }, + }, + { + type: 'location.updated', + url: `${baseRepository.remoteUrl}?path=/service.yaml&version=GBmain`, + context: { + commitUrl: `${baseRepository.remoteUrl}/commit/1111111111111111111111111111111111111111`, + }, + }, + { + type: 'location.deleted', + url: `${baseRepository.remoteUrl}?path=/obsolete.yaml&version=GBmain`, + context: { + commitUrl: `${baseRepository.remoteUrl}/commit/1111111111111111111111111111111111111111`, + }, + }, + { + type: 'location.moved', + fromUrl: `${baseRepository.remoteUrl}?path=/old-name.yaml&version=GBmain`, + toUrl: `${baseRepository.remoteUrl}?path=/new-name.yaml&version=GBmain`, + context: { + commitUrl: `${baseRepository.remoteUrl}/commit/1111111111111111111111111111111111111111`, + }, + }, + { + type: 'location.deleted', + url: `${baseRepository.remoteUrl}?path=/catalog-out.yaml&version=GBmain`, + context: { + commitUrl: `${baseRepository.remoteUrl}/commit/1111111111111111111111111111111111111111`, + }, + }, + { + type: 'location.created', + url: `${baseRepository.remoteUrl}?path=/catalog-in.yaml&version=GBmain`, + context: { + commitUrl: `${baseRepository.remoteUrl}/commit/1111111111111111111111111111111111111111`, + }, + }, + ], + }); + }); + + it('ignores non-default-branch pushes', async () => { + await expect( + analyzeAzureDevOpsWebhookEvent( + 'git.push', + withPushEvent({ + repository: baseRepository, + refUpdates: [{ name: 'refs/heads/feature-branch' }], + commits: [{ commitId: 'a', changes: [] }], + }), + { isRelevantPath }, + ), + ).resolves.toEqual({ + result: 'ignored', + reason: + 'Azure DevOps push event did not target the default branch, expected "refs/heads/main": https://dev.azure.com/example-org/example-project/_git/example-repo', + }); + }); + + it('ignores pushes without file-level change data', async () => { + await expect( + analyzeAzureDevOpsWebhookEvent( + 'git.push', + withPushEvent({ + repository: baseRepository, + refUpdates: [{ name: 'refs/heads/main' }], + commits: [{ commitId: 'a' }], + url: 'https://dev.azure.com/example-org/example-project/_apis/repos/git/repositories/repo-id/pushes/10', + }), + { isRelevantPath }, + ), + ).resolves.toEqual({ + result: 'ignored', + reason: + 'Azure DevOps push event did not affect any relevant paths: https://dev.azure.com/example-org/example-project/_apis/repos/git/repositories/repo-id/pushes/10', + }); + }); + }); + + describe('git.repo.*', () => { + it('translates repository created events', async () => { + await expect( + analyzeAzureDevOpsWebhookEvent( + 'git.repo.created', + { resource: { repository: baseRepository } }, + { isRelevantPath }, + ), + ).resolves.toEqual({ + result: 'ok', + events: [{ type: 'repository.created', url: baseRepository.remoteUrl }], + }); + }); + + it('translates repository deleted events', async () => { + await expect( + analyzeAzureDevOpsWebhookEvent( + 'git.repo.deleted', + { resource: { repository: baseRepository } }, + { isRelevantPath }, + ), + ).resolves.toEqual({ + result: 'ok', + events: [{ type: 'repository.deleted', url: baseRepository.remoteUrl }], + }); + }); + + it('translates repository status changed events', async () => { + await expect( + analyzeAzureDevOpsWebhookEvent( + 'git.repo.statuschanged', + { resource: { repository: baseRepository } }, + { isRelevantPath }, + ), + ).resolves.toEqual({ + result: 'ok', + events: [{ type: 'repository.updated', url: baseRepository.remoteUrl }], + }); + }); + + it('translates repository renamed events', async () => { + await expect( + analyzeAzureDevOpsWebhookEvent( + 'git.repo.renamed', + { + resource: { + oldName: 'legacy-repo', + repository: baseRepository, + }, + }, + { isRelevantPath }, + ), + ).resolves.toEqual({ + result: 'ok', + events: [ + { + type: 'repository.moved', + fromUrl: + 'https://dev.azure.com/example-org/example-project/_git/legacy-repo', + toUrl: baseRepository.remoteUrl, + }, + ], + }); + }); + }); + + describe('general behavior', () => { + it('throws on non-object payloads', async () => { + await expect( + analyzeAzureDevOpsWebhookEvent('git.push', undefined, { + isRelevantPath, + }), + ).rejects.toThrow('Azure DevOps webhook event payload is not an object'); + }); + + it('returns unsupported events', async () => { + await expect( + analyzeAzureDevOpsWebhookEvent( + 'git.pullrequest.created', + { resource: {} }, + { isRelevantPath }, + ), + ).resolves.toEqual({ + result: 'unsupported-event', + event: 'git.pullrequest.created', + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/catalog-backend-module-azure/src/events/analyzeAzureDevOpsWebhookEvent.ts b/plugins/catalog-backend-module-azure/src/events/analyzeAzureDevOpsWebhookEvent.ts new file mode 100644 index 0000000000..054eb310f7 --- /dev/null +++ b/plugins/catalog-backend-module-azure/src/events/analyzeAzureDevOpsWebhookEvent.ts @@ -0,0 +1,517 @@ +/* + * 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'; + +export interface AnalyzeAzureDevOpsWebhookEventOptions { + isRelevantPath: (path: string) => boolean; +} + +export type AnalyzeAzureDevOpsWebhookEventResult = + | { + result: 'unsupported-event'; + event: string; + } + | { + result: 'ignored'; + reason: string; + } + | { + result: 'aborted'; + reason: string; + } + | { + result: 'ok'; + events: CatalogScmEvent[]; + }; + +type JsonObject = Record; + +type AzureRepository = { + name?: string; + defaultBranch?: string; + remoteUrl?: string; +}; + +type AzurePushRefUpdate = { + name?: string; +}; + +type AzurePushCommit = { + commitId?: string; + url?: string; + changes?: AzurePushCommitChange[]; + added?: string[]; + removed?: string[]; + modified?: string[]; +}; + +type AzurePushCommitChange = { + changeType?: string; + item?: { + path?: string; + originalPath?: string; + }; + path?: string; + newPath?: string; + oldPath?: string; + originalPath?: string; + sourceServerItem?: string; +}; + +type PushPathState = + | { + type: 'added'; + commit: AzurePushCommit; + } + | { + type: 'removed'; + commit: AzurePushCommit; + } + | { + type: 'changed'; + commit: AzurePushCommit; + } + | { + type: 'renamed'; + fromPath: string; + commit: AzurePushCommit; + }; + +type NormalizedPushChange = + | { + type: 'added'; + path: string; + } + | { + type: 'removed'; + path: string; + } + | { + type: 'changed'; + path: string; + } + | { + type: 'renamed'; + fromPath: string; + toPath: string; + }; + +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 normalizePath(path: string | undefined): string | undefined { + if (!path) { + return undefined; + } + return path.startsWith('/') ? path : `/${path}`; +} + +function branchNameFromRef(ref: string | undefined): string | undefined { + if (!ref) { + return undefined; + } + return ref.replace(/^refs\/heads\//, ''); +} + +function getRepository(resource: JsonObject | undefined): AzureRepository { + const repository = asObject(resource?.repository); + return { + name: asString(repository?.name), + defaultBranch: asString(repository?.defaultBranch), + remoteUrl: asString(repository?.remoteUrl), + }; +} + +function toLocationUrl(options: { + remoteUrl: string | undefined; + path: string; + branchRef: string | undefined; +}): string | undefined { + if (!options.remoteUrl) { + return undefined; + } + + const branch = branchNameFromRef(options.branchRef); + const branchSuffix = branch ? `&version=GB${branch}` : ''; + return encodeURI(`${options.remoteUrl}?path=${options.path}${branchSuffix}`); +} + +function toCommitUrl( + repository: AzureRepository, + commit: AzurePushCommit, +): string | undefined { + if (commit.url) { + return commit.url; + } + if (repository.remoteUrl && commit.commitId) { + return `${repository.remoteUrl}/commit/${commit.commitId}`; + } + return undefined; +} + +function toCatalogScmEventForPathState(options: { + repository: AzureRepository; + branchRef: string | undefined; + path: string; + pathState: PushPathState; + isRelevantPath: (path: string) => boolean; +}): CatalogScmEvent[] { + const { repository, branchRef, path, pathState, isRelevantPath } = options; + const commitUrl = toCommitUrl(repository, pathState.commit); + const context = commitUrl ? { commitUrl } : undefined; + + if (pathState.type === 'renamed') { + const fromRelevant = isRelevantPath(pathState.fromPath); + const toRelevant = isRelevantPath(path); + const fromUrl = toLocationUrl({ + remoteUrl: repository.remoteUrl, + path: pathState.fromPath, + branchRef, + }); + const toUrl = toLocationUrl({ + remoteUrl: repository.remoteUrl, + path, + branchRef, + }); + + if (fromRelevant && toRelevant && fromUrl && toUrl) { + return [{ type: 'location.moved', fromUrl, toUrl, context }]; + } + if (fromRelevant && !toRelevant && fromUrl) { + return [{ type: 'location.deleted', url: fromUrl, context }]; + } + if (!fromRelevant && toRelevant && toUrl) { + return [{ type: 'location.created', url: toUrl, context }]; + } + return []; + } + + if (!isRelevantPath(path)) { + return []; + } + + const url = toLocationUrl({ + remoteUrl: repository.remoteUrl, + path, + branchRef, + }); + if (!url) { + return []; + } + + if (pathState.type === 'added') { + return [{ type: 'location.created', url, context }]; + } + if (pathState.type === 'removed') { + return [{ type: 'location.deleted', url, context }]; + } + + return [{ type: 'location.updated', url, context }]; +} + +function normalizePushCommitChanges( + commit: AzurePushCommit, +): NormalizedPushChange[] { + const normalized: NormalizedPushChange[] = []; + + for (const path of commit.added ?? []) { + const normalizedPath = normalizePath(path); + if (normalizedPath) { + normalized.push({ type: 'added', path: normalizedPath }); + } + } + + for (const path of commit.removed ?? []) { + const normalizedPath = normalizePath(path); + if (normalizedPath) { + normalized.push({ type: 'removed', path: normalizedPath }); + } + } + + for (const path of commit.modified ?? []) { + const normalizedPath = normalizePath(path); + if (normalizedPath) { + normalized.push({ type: 'changed', path: normalizedPath }); + } + } + + for (const change of commit.changes ?? []) { + const changeType = change.changeType?.toLowerCase() ?? ''; + const toPath = normalizePath(change.item?.path ?? change.path ?? change.newPath); + const fromPath = normalizePath( + change.originalPath ?? + change.item?.originalPath ?? + change.oldPath ?? + change.sourceServerItem, + ); + + if (changeType.includes('rename') && fromPath && toPath) { + normalized.push({ type: 'renamed', fromPath, toPath }); + continue; + } + + if (changeType.includes('add') && toPath) { + normalized.push({ type: 'added', path: toPath }); + continue; + } + + if (changeType.includes('delete') && (toPath ?? fromPath)) { + normalized.push({ type: 'removed', path: toPath ?? fromPath! }); + continue; + } + + if ( + (changeType.includes('edit') || + changeType.includes('modify') || + changeType.includes('update')) && + (toPath ?? fromPath) + ) { + normalized.push({ type: 'changed', path: toPath ?? fromPath! }); + } + } + + return normalized; +} + +function applyPushChange( + state: Map, + change: NormalizedPushChange, + commit: AzurePushCommit, +) { + if (change.type === 'renamed') { + const previous = state.get(change.fromPath); + state.delete(change.fromPath); + + let next: PushPathState | undefined; + if (!previous) { + next = { type: 'renamed', fromPath: change.fromPath, commit }; + } else if (previous.type === 'added') { + next = { type: 'added', commit }; + } else if (previous.type === 'changed') { + next = { type: 'renamed', fromPath: change.fromPath, commit }; + } else if (previous.type === 'renamed') { + next = { type: 'renamed', fromPath: previous.fromPath, commit }; + } + + if (next) { + state.set(change.toPath, next); + } + return; + } + + const previous = state.get(change.path); + + if (change.type === 'added') { + if (!previous) { + state.set(change.path, { type: 'added', commit }); + } else if (previous.type === 'removed') { + state.set(change.path, { type: 'changed', commit }); + } + return; + } + + if (change.type === 'removed') { + if (!previous) { + state.set(change.path, { type: 'removed', commit }); + } else if (previous.type === 'added') { + state.delete(change.path); + } else if (previous.type === 'changed') { + state.set(change.path, { type: 'removed', commit }); + } else if (previous.type === 'renamed') { + state.delete(change.path); + state.set(previous.fromPath, { type: 'removed', commit }); + } + return; + } + + if (!previous) { + state.set(change.path, { type: 'changed', commit }); + } +} + +function replaceRepoNameInRemoteUrl( + remoteUrl: string | undefined, + repoName: string | undefined, +): string | undefined { + if (!remoteUrl || !repoName) { + return undefined; + } + const match = remoteUrl.match(/^(.*\/_git\/)([^/?#]+)(.*)$/); + if (!match) { + return undefined; + } + return `${match[1]}${repoName}${match[3]}`; +} + +async function onPushEvent( + eventPayload: JsonObject, + options: AnalyzeAzureDevOpsWebhookEventOptions, +): Promise { + const resource = asObject(eventPayload.resource); + const repository = getRepository(resource); + const refUpdates = (resource?.refUpdates as AzurePushRefUpdate[] | undefined) ?? []; + const commits = (resource?.commits as AzurePushCommit[] | undefined) ?? []; + const contextUrl = asString(resource?.url) ?? repository.remoteUrl ?? ''; + + if (commits.length === 0) { + return { + result: 'ignored', + reason: `Azure DevOps push event does not contain commits: ${contextUrl}`, + }; + } + + if (repository.defaultBranch) { + const updatesToDefaultBranch = refUpdates.filter( + update => update.name === repository.defaultBranch, + ); + if (updatesToDefaultBranch.length === 0) { + return { + result: 'ignored', + reason: `Azure DevOps push event did not target the default branch, expected "${repository.defaultBranch}": ${contextUrl}`, + }; + } + } + + const state = new Map(); + + for (const commit of commits) { + const changes = normalizePushCommitChanges(commit); + for (const change of changes) { + applyPushChange(state, change, commit); + } + } + + if (state.size === 0) { + return { + result: 'ignored', + reason: `Azure DevOps push event did not affect any relevant paths: ${contextUrl}`, + }; + } + + const branchRef = + repository.defaultBranch ?? asString(refUpdates[0]?.name) ?? undefined; + + const events = Array.from(state.entries()).flatMap(([path, pathState]) => + toCatalogScmEventForPathState({ + repository, + branchRef, + path, + pathState, + isRelevantPath: options.isRelevantPath, + }), + ); + + if (events.length === 0) { + return { + result: 'ignored', + reason: `Azure DevOps push event did not affect any relevant paths: ${contextUrl}`, + }; + } + + return { result: 'ok', events }; +} + +async function onRepositoryEvent( + eventType: string, + eventPayload: JsonObject, +): Promise { + const resource = asObject(eventPayload.resource); + const repository = getRepository(resource); + const toUrl = repository.remoteUrl; + + if (eventType === 'git.repo.created' && toUrl) { + return { + result: 'ok', + events: [{ type: 'repository.created', url: toUrl }], + }; + } + + if (eventType === 'git.repo.deleted' && toUrl) { + return { + result: 'ok', + events: [{ type: 'repository.deleted', url: toUrl }], + }; + } + + if (eventType === 'git.repo.statuschanged' && toUrl) { + return { + result: 'ok', + events: [{ type: 'repository.updated', url: toUrl }], + }; + } + + if (eventType === 'git.repo.renamed' && toUrl) { + const oldName = asString(resource?.oldName); + const fromUrl = replaceRepoNameInRemoteUrl(toUrl, oldName); + if (!fromUrl) { + return { + result: 'ignored', + reason: 'Azure DevOps repository renamed event is missing oldName', + }; + } + + return { + result: 'ok', + events: [{ type: 'repository.moved', fromUrl, toUrl }], + }; + } + + if (eventType.startsWith('git.repo.')) { + return { + result: 'unsupported-event', + event: eventType, + }; + } + + return { + result: 'unsupported-event', + event: eventType, + }; +} + +export async function analyzeAzureDevOpsWebhookEvent( + eventType: string, + eventPayload: unknown, + options: AnalyzeAzureDevOpsWebhookEventOptions, +): Promise { + const payload = asObject(eventPayload); + if (!payload) { + throw new InputError('Azure DevOps webhook event payload is not an object'); + } + + if (eventType === 'git.push') { + return await onPushEvent(payload, options); + } + + if (eventType.startsWith('git.repo.')) { + return await onRepositoryEvent(eventType, payload); + } + + return { + result: 'unsupported-event', + event: eventType, + }; +} From 0cd53934b031bcf582ac34589ef6dbbcf9f92093 Mon Sep 17 00:00:00 2001 From: Lokesh Kaki Date: Sun, 15 Mar 2026 19:08:33 -0500 Subject: [PATCH 02/10] 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(); + }); }, }); }, From 31ce5da2eaec18b10e03d20d2edaeb121b5b9741 Mon Sep 17 00:00:00 2001 From: Lokesh Kaki Date: Sun, 15 Mar 2026 19:08:36 -0500 Subject: [PATCH 03/10] feat(catalog-backend-module-azure): export Azure webhook analyzer and add changeset Signed-off-by: Lokesh Kaki --- .changeset/thin-lies-deliver.md | 5 +++++ plugins/catalog-backend-module-azure/src/index.ts | 1 + 2 files changed, 6 insertions(+) create mode 100644 .changeset/thin-lies-deliver.md diff --git a/.changeset/thin-lies-deliver.md b/.changeset/thin-lies-deliver.md new file mode 100644 index 0000000000..22ca304f96 --- /dev/null +++ b/.changeset/thin-lies-deliver.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend-module-azure': patch +--- + +Add Azure DevOps SCM event translation layer for instant catalog reprocessing. diff --git a/plugins/catalog-backend-module-azure/src/index.ts b/plugins/catalog-backend-module-azure/src/index.ts index dbbdff67ce..f1c5c66c48 100644 --- a/plugins/catalog-backend-module-azure/src/index.ts +++ b/plugins/catalog-backend-module-azure/src/index.ts @@ -23,3 +23,4 @@ export { default } from './module'; export { AzureDevOpsDiscoveryProcessor } from './processors'; export { AzureDevOpsEntityProvider } from './providers'; +export { analyzeAzureDevOpsWebhookEvent } from './events/analyzeAzureDevOpsWebhookEvent'; From bf7aeb5e31584bb700df921ca056cfc97f615473 Mon Sep 17 00:00:00 2001 From: Lokesh Kaki Date: Sun, 15 Mar 2026 20:12:12 -0500 Subject: [PATCH 04/10] fix(catalog-backend-module-azure): fix ReDoS regex, URL encoding, error message, and missing deps Signed-off-by: Lokesh Kaki --- .../catalog-backend-module-azure/package.json | 2 ++ .../events/analyzeAzureDevOpsWebhookEvent.ts | 33 +++++++++++++++---- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/plugins/catalog-backend-module-azure/package.json b/plugins/catalog-backend-module-azure/package.json index 9c8de87ffb..51e87bdd06 100644 --- a/plugins/catalog-backend-module-azure/package.json +++ b/plugins/catalog-backend-module-azure/package.json @@ -55,9 +55,11 @@ "@azure/storage-blob": "^12.5.0", "@backstage/backend-plugin-api": "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:^", "uuid": "^11.0.0" }, "devDependencies": { diff --git a/plugins/catalog-backend-module-azure/src/events/analyzeAzureDevOpsWebhookEvent.ts b/plugins/catalog-backend-module-azure/src/events/analyzeAzureDevOpsWebhookEvent.ts index 054eb310f7..10bf5e75d7 100644 --- a/plugins/catalog-backend-module-azure/src/events/analyzeAzureDevOpsWebhookEvent.ts +++ b/plugins/catalog-backend-module-azure/src/events/analyzeAzureDevOpsWebhookEvent.ts @@ -154,9 +154,18 @@ function toLocationUrl(options: { return undefined; } + const url = new URL(options.remoteUrl); const branch = branchNameFromRef(options.branchRef); - const branchSuffix = branch ? `&version=GB${branch}` : ''; - return encodeURI(`${options.remoteUrl}?path=${options.path}${branchSuffix}`); + // Encode each path segment individually to protect against special chars while + // preserving '/' separators, which is what Azure DevOps expects in the path param. + const encodedPath = options.path + .split('/') + .map(encodeURIComponent) + .join('/'); + url.search = branch + ? `path=${encodedPath}&version=GB${encodeURIComponent(branch)}` + : `path=${encodedPath}`; + return url.toString(); } function toCommitUrl( @@ -359,11 +368,16 @@ function replaceRepoNameInRemoteUrl( if (!remoteUrl || !repoName) { return undefined; } - const match = remoteUrl.match(/^(.*\/_git\/)([^/?#]+)(.*)$/); - if (!match) { + const gitMarker = '/_git/'; + const gitIdx = remoteUrl.indexOf(gitMarker); + if (gitIdx === -1) { return undefined; } - return `${match[1]}${repoName}${match[3]}`; + const prefix = remoteUrl.slice(0, gitIdx + gitMarker.length); + const rest = remoteUrl.slice(gitIdx + gitMarker.length); + const endIdx = rest.search(/[/?#]/); + const suffix = endIdx === -1 ? '' : rest.slice(endIdx); + return `${prefix}${repoName}${suffix}`; } async function onPushEvent( @@ -465,11 +479,18 @@ async function onRepositoryEvent( if (eventType === 'git.repo.renamed' && toUrl) { const oldName = asString(resource?.oldName); + if (!oldName) { + return { + result: 'ignored', + reason: 'Azure DevOps repository renamed event is missing oldName', + }; + } const fromUrl = replaceRepoNameInRemoteUrl(toUrl, oldName); if (!fromUrl) { return { result: 'ignored', - reason: 'Azure DevOps repository renamed event is missing oldName', + reason: + 'Azure DevOps repository renamed event has an unexpected repository.remoteUrl format', }; } From 121690f95cea00c6533f2615e821111c4d289237 Mon Sep 17 00:00:00 2001 From: Lokesh Kaki Date: Sun, 15 Mar 2026 20:34:18 -0500 Subject: [PATCH 05/10] chore: update yarn.lock for azure scm events deps Signed-off-by: Lokesh Kaki --- yarn.lock | 2 ++ 1 file changed, 2 insertions(+) diff --git a/yarn.lock b/yarn.lock index 32b09cc5e3..db4e5b1e50 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4474,9 +4474,11 @@ __metadata: "@backstage/backend-test-utils": "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-node": "workspace:^" msw: "npm:^1.0.0" uuid: "npm:^11.0.0" languageName: unknown From 308b36ffad8a7b920c1653df8bf8c9cf76a5229a Mon Sep 17 00:00:00 2001 From: Lokesh Kaki Date: Sun, 15 Mar 2026 21:15:13 -0500 Subject: [PATCH 06/10] fix(catalog-backend-module-azure): fix URL encoding alignment and move analyzer to alpha export Signed-off-by: Lokesh Kaki --- .../catalog-backend-module-azure/src/alpha.ts | 2 ++ .../analyzeAzureDevOpsWebhookEvent.test.ts | 34 +++++++++++++++++++ .../events/analyzeAzureDevOpsWebhookEvent.ts | 12 ++----- .../catalog-backend-module-azure/src/index.ts | 1 - 4 files changed, 39 insertions(+), 10 deletions(-) diff --git a/plugins/catalog-backend-module-azure/src/alpha.ts b/plugins/catalog-backend-module-azure/src/alpha.ts index cba672ce49..3e2354114f 100644 --- a/plugins/catalog-backend-module-azure/src/alpha.ts +++ b/plugins/catalog-backend-module-azure/src/alpha.ts @@ -19,3 +19,5 @@ import { default as feature } from './module'; /** @alpha */ const _feature = feature; export default _feature; + +export { analyzeAzureDevOpsWebhookEvent } from './events/analyzeAzureDevOpsWebhookEvent'; diff --git a/plugins/catalog-backend-module-azure/src/events/analyzeAzureDevOpsWebhookEvent.test.ts b/plugins/catalog-backend-module-azure/src/events/analyzeAzureDevOpsWebhookEvent.test.ts index 9edeb1602d..77e30946cc 100644 --- a/plugins/catalog-backend-module-azure/src/events/analyzeAzureDevOpsWebhookEvent.test.ts +++ b/plugins/catalog-backend-module-azure/src/events/analyzeAzureDevOpsWebhookEvent.test.ts @@ -127,6 +127,40 @@ describe('analyzeAzureDevOpsWebhookEvent', () => { }); }); + it('does not double-encode branch names containing slashes', async () => { + const repoWithSlashBranch = { + ...baseRepository, + defaultBranch: 'refs/heads/feature/my-branch', + }; + await expect( + analyzeAzureDevOpsWebhookEvent( + 'git.push', + withPushEvent({ + repository: repoWithSlashBranch, + refUpdates: [{ name: 'refs/heads/feature/my-branch' }], + commits: [ + { + commitId: 'abc', + changes: [ + { changeType: 'add', item: { path: '/catalog-info.yaml' } }, + ], + }, + ], + }), + { isRelevantPath }, + ), + ).resolves.toEqual({ + result: 'ok', + events: [ + { + type: 'location.created', + url: `${baseRepository.remoteUrl}?path=/catalog-info.yaml&version=GBfeature/my-branch`, + context: { commitUrl: `${baseRepository.remoteUrl}/commit/abc` }, + }, + ], + }); + }); + it('ignores non-default-branch pushes', async () => { await expect( analyzeAzureDevOpsWebhookEvent( diff --git a/plugins/catalog-backend-module-azure/src/events/analyzeAzureDevOpsWebhookEvent.ts b/plugins/catalog-backend-module-azure/src/events/analyzeAzureDevOpsWebhookEvent.ts index 10bf5e75d7..0840c8b1d3 100644 --- a/plugins/catalog-backend-module-azure/src/events/analyzeAzureDevOpsWebhookEvent.ts +++ b/plugins/catalog-backend-module-azure/src/events/analyzeAzureDevOpsWebhookEvent.ts @@ -156,16 +156,10 @@ function toLocationUrl(options: { const url = new URL(options.remoteUrl); const branch = branchNameFromRef(options.branchRef); - // Encode each path segment individually to protect against special chars while - // preserving '/' separators, which is what Azure DevOps expects in the path param. - const encodedPath = options.path - .split('/') - .map(encodeURIComponent) - .join('/'); url.search = branch - ? `path=${encodedPath}&version=GB${encodeURIComponent(branch)}` - : `path=${encodedPath}`; - return url.toString(); + ? `path=${options.path}&version=GB${branch}` + : `path=${options.path}`; + return encodeURI(url.toString()); } function toCommitUrl( diff --git a/plugins/catalog-backend-module-azure/src/index.ts b/plugins/catalog-backend-module-azure/src/index.ts index f1c5c66c48..dbbdff67ce 100644 --- a/plugins/catalog-backend-module-azure/src/index.ts +++ b/plugins/catalog-backend-module-azure/src/index.ts @@ -23,4 +23,3 @@ export { default } from './module'; export { AzureDevOpsDiscoveryProcessor } from './processors'; export { AzureDevOpsEntityProvider } from './providers'; -export { analyzeAzureDevOpsWebhookEvent } from './events/analyzeAzureDevOpsWebhookEvent'; From 5bc3a400d5e6f257806dff617a8a77664257e3b4 Mon Sep 17 00:00:00 2001 From: Lokesh Kaki Date: Tue, 17 Mar 2026 21:13:59 -0500 Subject: [PATCH 07/10] fix(catalog-backend-module-azure): fix prettier formatting, export alpha types, and add API reports Signed-off-by: Lokesh Kaki --- .../report-alpha.api.md | 32 +++++++++++++++ .../catalog-backend-module-azure/src/alpha.ts | 6 ++- .../analyzeAzureDevOpsWebhookEvent.test.ts | 5 ++- .../events/analyzeAzureDevOpsWebhookEvent.ts | 39 +++++++++++++++++-- 4 files changed, 76 insertions(+), 6 deletions(-) diff --git a/plugins/catalog-backend-module-azure/report-alpha.api.md b/plugins/catalog-backend-module-azure/report-alpha.api.md index 3336b07e09..35853db61c 100644 --- a/plugins/catalog-backend-module-azure/report-alpha.api.md +++ b/plugins/catalog-backend-module-azure/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 analyzeAzureDevOpsWebhookEvent( + eventType: string, + eventPayload: unknown, + options: AnalyzeAzureDevOpsWebhookEventOptions, +): Promise; + +// @alpha +export interface AnalyzeAzureDevOpsWebhookEventOptions { + isRelevantPath: (path: string) => boolean; +} + +// @alpha +export type AnalyzeAzureDevOpsWebhookEventResult = + | { + 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-azure/src/alpha.ts b/plugins/catalog-backend-module-azure/src/alpha.ts index 3e2354114f..14a7fe1379 100644 --- a/plugins/catalog-backend-module-azure/src/alpha.ts +++ b/plugins/catalog-backend-module-azure/src/alpha.ts @@ -20,4 +20,8 @@ import { default as feature } from './module'; const _feature = feature; export default _feature; -export { analyzeAzureDevOpsWebhookEvent } from './events/analyzeAzureDevOpsWebhookEvent'; +export { + analyzeAzureDevOpsWebhookEvent, + type AnalyzeAzureDevOpsWebhookEventOptions, + type AnalyzeAzureDevOpsWebhookEventResult, +} from './events/analyzeAzureDevOpsWebhookEvent'; diff --git a/plugins/catalog-backend-module-azure/src/events/analyzeAzureDevOpsWebhookEvent.test.ts b/plugins/catalog-backend-module-azure/src/events/analyzeAzureDevOpsWebhookEvent.test.ts index 77e30946cc..4a520aa5fb 100644 --- a/plugins/catalog-backend-module-azure/src/events/analyzeAzureDevOpsWebhookEvent.test.ts +++ b/plugins/catalog-backend-module-azure/src/events/analyzeAzureDevOpsWebhookEvent.test.ts @@ -22,7 +22,8 @@ const baseRepository = { id: 'repo-id', name: 'example-repo', defaultBranch: 'refs/heads/main', - remoteUrl: 'https://dev.azure.com/example-org/example-project/_git/example-repo', + remoteUrl: + 'https://dev.azure.com/example-org/example-project/_git/example-repo', }; const withPushEvent = (resource: Record) => ({ @@ -287,4 +288,4 @@ describe('analyzeAzureDevOpsWebhookEvent', () => { }); }); }); -}); \ No newline at end of file +}); diff --git a/plugins/catalog-backend-module-azure/src/events/analyzeAzureDevOpsWebhookEvent.ts b/plugins/catalog-backend-module-azure/src/events/analyzeAzureDevOpsWebhookEvent.ts index 0840c8b1d3..0dfa1c0f99 100644 --- a/plugins/catalog-backend-module-azure/src/events/analyzeAzureDevOpsWebhookEvent.ts +++ b/plugins/catalog-backend-module-azure/src/events/analyzeAzureDevOpsWebhookEvent.ts @@ -17,10 +17,28 @@ import { InputError } from '@backstage/errors'; import { CatalogScmEvent } from '@backstage/plugin-catalog-node/alpha'; +/** + * Options for {@link analyzeAzureDevOpsWebhookEvent}. + * @alpha + */ export interface AnalyzeAzureDevOpsWebhookEventOptions { + /** + * 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 an Azure DevOps 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 AnalyzeAzureDevOpsWebhookEventResult = | { result: 'unsupported-event'; @@ -263,7 +281,9 @@ function normalizePushCommitChanges( for (const change of commit.changes ?? []) { const changeType = change.changeType?.toLowerCase() ?? ''; - const toPath = normalizePath(change.item?.path ?? change.path ?? change.newPath); + const toPath = normalizePath( + change.item?.path ?? change.path ?? change.newPath, + ); const fromPath = normalizePath( change.originalPath ?? change.item?.originalPath ?? @@ -380,9 +400,11 @@ async function onPushEvent( ): Promise { const resource = asObject(eventPayload.resource); const repository = getRepository(resource); - const refUpdates = (resource?.refUpdates as AzurePushRefUpdate[] | undefined) ?? []; + const refUpdates = + (resource?.refUpdates as AzurePushRefUpdate[] | undefined) ?? []; const commits = (resource?.commits as AzurePushCommit[] | undefined) ?? []; - const contextUrl = asString(resource?.url) ?? repository.remoteUrl ?? ''; + const contextUrl = + asString(resource?.url) ?? repository.remoteUrl ?? ''; if (commits.length === 0) { return { @@ -507,6 +529,17 @@ async function onRepositoryEvent( }; } +/** + * Analyzes an Azure DevOps webhook event and translates it into zero or more + * catalog SCM events that entity providers can act on. + * + * Supported event types: + * - `git.push` — translates file-level adds, modifications, and deletions on + * the default branch into catalog SCM events for paths matching + * `isRelevantPath`. + * + * @alpha + */ export async function analyzeAzureDevOpsWebhookEvent( eventType: string, eventPayload: unknown, From ffb5ff47e20dd6e7fdd3c6a8381597383625e380 Mon Sep 17 00:00:00 2001 From: Lokesh Kaki Date: Tue, 17 Mar 2026 22:16:07 -0500 Subject: [PATCH 08/10] fix(catalog-backend-module-azure): conditionally wire SCM events bridge, fix URL encoding and error messages Signed-off-by: Lokesh Kaki --- .../events/analyzeAzureDevOpsWebhookEvent.ts | 30 ++++++++++--------- .../catalogModuleAzureDevOpsEntityProvider.ts | 27 ++++++++++------- 2 files changed, 32 insertions(+), 25 deletions(-) diff --git a/plugins/catalog-backend-module-azure/src/events/analyzeAzureDevOpsWebhookEvent.ts b/plugins/catalog-backend-module-azure/src/events/analyzeAzureDevOpsWebhookEvent.ts index 0dfa1c0f99..9b7f246e12 100644 --- a/plugins/catalog-backend-module-azure/src/events/analyzeAzureDevOpsWebhookEvent.ts +++ b/plugins/catalog-backend-module-azure/src/events/analyzeAzureDevOpsWebhookEvent.ts @@ -172,12 +172,17 @@ function toLocationUrl(options: { return undefined; } - const url = new URL(options.remoteUrl); + // Match the URL format produced by AzureDevOpsEntityProvider.createObjectUrl + // which uses encodeURI on the full URL string with path and version as raw + // query parameter values. Using URL/searchParams would produce different + // encoding (e.g. %2F for slashes in branch names) and fail to match existing + // catalog location targets. const branch = branchNameFromRef(options.branchRef); - url.search = branch - ? `path=${options.path}&version=GB${branch}` - : `path=${options.path}`; - return encodeURI(url.toString()); + let fullUrl = `${options.remoteUrl}?path=${options.path}`; + if (branch) { + fullUrl += `&version=GB${branch}`; + } + return encodeURI(fullUrl); } function toCommitUrl( @@ -495,18 +500,15 @@ async function onRepositoryEvent( if (eventType === 'git.repo.renamed' && toUrl) { const oldName = asString(resource?.oldName); - if (!oldName) { - return { - result: 'ignored', - reason: 'Azure DevOps repository renamed event is missing oldName', - }; - } - const fromUrl = replaceRepoNameInRemoteUrl(toUrl, oldName); + const fromUrl = oldName + ? replaceRepoNameInRemoteUrl(toUrl, oldName) + : undefined; if (!fromUrl) { return { result: 'ignored', - reason: - 'Azure DevOps repository renamed event has an unexpected repository.remoteUrl format', + reason: oldName + ? 'Azure DevOps repository renamed event has an unexpected repository.remoteUrl format' + : 'Azure DevOps repository renamed event is missing oldName', }; } diff --git a/plugins/catalog-backend-module-azure/src/module/catalogModuleAzureDevOpsEntityProvider.ts b/plugins/catalog-backend-module-azure/src/module/catalogModuleAzureDevOpsEntityProvider.ts index b7ad3cf6e6..c3475de239 100644 --- a/plugins/catalog-backend-module-azure/src/module/catalogModuleAzureDevOpsEntityProvider.ts +++ b/plugins/catalog-backend-module-azure/src/module/catalogModuleAzureDevOpsEntityProvider.ts @@ -75,17 +75,22 @@ export const catalogModuleAzureEntityProvider = createBackendModule({ ); } - const bridge = new AzureDevOpsScmEventsBridge({ - logger, - events, - catalogScmEvents, - }); - lifecycle.addStartupHook(async () => { - await bridge.start(); - }); - lifecycle.addShutdownHook(async () => { - await bridge.stop(); - }); + // Only wire up the SCM events bridge when Azure DevOps provider + // configuration is present — Azure Blob Storage users should not be + // required to handle Azure DevOps webhook events. + if (config.has('catalog.providers.azureDevOps')) { + const bridge = new AzureDevOpsScmEventsBridge({ + logger, + events, + catalogScmEvents, + }); + lifecycle.addStartupHook(async () => { + await bridge.start(); + }); + lifecycle.addShutdownHook(async () => { + await bridge.stop(); + }); + } }, }); }, From 39d27eea8775eac03f62c4964866c77a9782d3e1 Mon Sep 17 00:00:00 2001 From: Lokesh Kaki Date: Tue, 17 Mar 2026 22:33:37 -0500 Subject: [PATCH 09/10] chore(changeset): rename Azure SCM events changeset to match naming convention Signed-off-by: Lokesh Kaki --- .changeset/{thin-lies-deliver.md => azure-scm-events-layer.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .changeset/{thin-lies-deliver.md => azure-scm-events-layer.md} (100%) diff --git a/.changeset/thin-lies-deliver.md b/.changeset/azure-scm-events-layer.md similarity index 100% rename from .changeset/thin-lies-deliver.md rename to .changeset/azure-scm-events-layer.md From 86466d29557558d4379a1f29ace702b6e6488443 Mon Sep 17 00:00:00 2001 From: Lokesh Kaki Date: Tue, 17 Mar 2026 22:50:07 -0500 Subject: [PATCH 10/10] fix(catalog-backend-module-azure): omit version param from location URLs to match provider format, complete JSDoc Signed-off-by: Lokesh Kaki --- .../analyzeAzureDevOpsWebhookEvent.test.ts | 26 +++++------- .../events/analyzeAzureDevOpsWebhookEvent.ts | 40 +++++++------------ 2 files changed, 26 insertions(+), 40 deletions(-) diff --git a/plugins/catalog-backend-module-azure/src/events/analyzeAzureDevOpsWebhookEvent.test.ts b/plugins/catalog-backend-module-azure/src/events/analyzeAzureDevOpsWebhookEvent.test.ts index 4a520aa5fb..943e7ac9f1 100644 --- a/plugins/catalog-backend-module-azure/src/events/analyzeAzureDevOpsWebhookEvent.test.ts +++ b/plugins/catalog-backend-module-azure/src/events/analyzeAzureDevOpsWebhookEvent.test.ts @@ -83,43 +83,43 @@ describe('analyzeAzureDevOpsWebhookEvent', () => { events: [ { type: 'location.created', - url: `${baseRepository.remoteUrl}?path=/catalog-info.yaml&version=GBmain`, + url: `${baseRepository.remoteUrl}?path=/catalog-info.yaml`, context: { commitUrl: `${baseRepository.remoteUrl}/commit/1111111111111111111111111111111111111111`, }, }, { type: 'location.updated', - url: `${baseRepository.remoteUrl}?path=/service.yaml&version=GBmain`, + url: `${baseRepository.remoteUrl}?path=/service.yaml`, context: { commitUrl: `${baseRepository.remoteUrl}/commit/1111111111111111111111111111111111111111`, }, }, { type: 'location.deleted', - url: `${baseRepository.remoteUrl}?path=/obsolete.yaml&version=GBmain`, + url: `${baseRepository.remoteUrl}?path=/obsolete.yaml`, context: { commitUrl: `${baseRepository.remoteUrl}/commit/1111111111111111111111111111111111111111`, }, }, { type: 'location.moved', - fromUrl: `${baseRepository.remoteUrl}?path=/old-name.yaml&version=GBmain`, - toUrl: `${baseRepository.remoteUrl}?path=/new-name.yaml&version=GBmain`, + fromUrl: `${baseRepository.remoteUrl}?path=/old-name.yaml`, + toUrl: `${baseRepository.remoteUrl}?path=/new-name.yaml`, context: { commitUrl: `${baseRepository.remoteUrl}/commit/1111111111111111111111111111111111111111`, }, }, { type: 'location.deleted', - url: `${baseRepository.remoteUrl}?path=/catalog-out.yaml&version=GBmain`, + url: `${baseRepository.remoteUrl}?path=/catalog-out.yaml`, context: { commitUrl: `${baseRepository.remoteUrl}/commit/1111111111111111111111111111111111111111`, }, }, { type: 'location.created', - url: `${baseRepository.remoteUrl}?path=/catalog-in.yaml&version=GBmain`, + url: `${baseRepository.remoteUrl}?path=/catalog-in.yaml`, context: { commitUrl: `${baseRepository.remoteUrl}/commit/1111111111111111111111111111111111111111`, }, @@ -128,17 +128,13 @@ describe('analyzeAzureDevOpsWebhookEvent', () => { }); }); - it('does not double-encode branch names containing slashes', async () => { - const repoWithSlashBranch = { - ...baseRepository, - defaultBranch: 'refs/heads/feature/my-branch', - }; + it('omits version parameter to match default provider URL format', async () => { await expect( analyzeAzureDevOpsWebhookEvent( 'git.push', withPushEvent({ - repository: repoWithSlashBranch, - refUpdates: [{ name: 'refs/heads/feature/my-branch' }], + repository: baseRepository, + refUpdates: [{ name: 'refs/heads/main' }], commits: [ { commitId: 'abc', @@ -155,7 +151,7 @@ describe('analyzeAzureDevOpsWebhookEvent', () => { events: [ { type: 'location.created', - url: `${baseRepository.remoteUrl}?path=/catalog-info.yaml&version=GBfeature/my-branch`, + url: `${baseRepository.remoteUrl}?path=/catalog-info.yaml`, context: { commitUrl: `${baseRepository.remoteUrl}/commit/abc` }, }, ], diff --git a/plugins/catalog-backend-module-azure/src/events/analyzeAzureDevOpsWebhookEvent.ts b/plugins/catalog-backend-module-azure/src/events/analyzeAzureDevOpsWebhookEvent.ts index 9b7f246e12..284c9837a8 100644 --- a/plugins/catalog-backend-module-azure/src/events/analyzeAzureDevOpsWebhookEvent.ts +++ b/plugins/catalog-backend-module-azure/src/events/analyzeAzureDevOpsWebhookEvent.ts @@ -147,13 +147,6 @@ function normalizePath(path: string | undefined): string | undefined { return path.startsWith('/') ? path : `/${path}`; } -function branchNameFromRef(ref: string | undefined): string | undefined { - if (!ref) { - return undefined; - } - return ref.replace(/^refs\/heads\//, ''); -} - function getRepository(resource: JsonObject | undefined): AzureRepository { const repository = asObject(resource?.repository); return { @@ -166,7 +159,6 @@ function getRepository(resource: JsonObject | undefined): AzureRepository { function toLocationUrl(options: { remoteUrl: string | undefined; path: string; - branchRef: string | undefined; }): string | undefined { if (!options.remoteUrl) { return undefined; @@ -174,14 +166,15 @@ function toLocationUrl(options: { // Match the URL format produced by AzureDevOpsEntityProvider.createObjectUrl // which uses encodeURI on the full URL string with path and version as raw - // query parameter values. Using URL/searchParams would produce different - // encoding (e.g. %2F for slashes in branch names) and fail to match existing - // catalog location targets. - const branch = branchNameFromRef(options.branchRef); - let fullUrl = `${options.remoteUrl}?path=${options.path}`; - if (branch) { - fullUrl += `&version=GB${branch}`; - } + // query parameter values. + // + // The version parameter is intentionally omitted here because the entity + // provider only includes it when the user explicitly configures a `branch` + // in the provider config. Since we cannot know at analysis time whether a + // branch was configured, omitting version matches the default provider + // behavior and avoids URL mismatches that would prevent SCM events from + // triggering catalog refreshes. + const fullUrl = `${options.remoteUrl}?path=${options.path}`; return encodeURI(fullUrl); } @@ -200,12 +193,11 @@ function toCommitUrl( function toCatalogScmEventForPathState(options: { repository: AzureRepository; - branchRef: string | undefined; path: string; pathState: PushPathState; isRelevantPath: (path: string) => boolean; }): CatalogScmEvent[] { - const { repository, branchRef, path, pathState, isRelevantPath } = options; + const { repository, path, pathState, isRelevantPath } = options; const commitUrl = toCommitUrl(repository, pathState.commit); const context = commitUrl ? { commitUrl } : undefined; @@ -215,12 +207,10 @@ function toCatalogScmEventForPathState(options: { const fromUrl = toLocationUrl({ remoteUrl: repository.remoteUrl, path: pathState.fromPath, - branchRef, }); const toUrl = toLocationUrl({ remoteUrl: repository.remoteUrl, path, - branchRef, }); if (fromRelevant && toRelevant && fromUrl && toUrl) { @@ -242,7 +232,6 @@ function toCatalogScmEventForPathState(options: { const url = toLocationUrl({ remoteUrl: repository.remoteUrl, path, - branchRef, }); if (!url) { return []; @@ -446,13 +435,9 @@ async function onPushEvent( }; } - const branchRef = - repository.defaultBranch ?? asString(refUpdates[0]?.name) ?? undefined; - const events = Array.from(state.entries()).flatMap(([path, pathState]) => toCatalogScmEventForPathState({ repository, - branchRef, path, pathState, isRelevantPath: options.isRelevantPath, @@ -539,6 +524,11 @@ async function onRepositoryEvent( * - `git.push` — translates file-level adds, modifications, and deletions on * the default branch into catalog SCM events for paths matching * `isRelevantPath`. + * - `git.repo.created` — emits a `repository.created` event. + * - `git.repo.deleted` — emits a `repository.deleted` event. + * - `git.repo.statuschanged` — emits a `repository.updated` event. + * - `git.repo.renamed` — emits a `repository.moved` event with the old and + * new repository URLs. * * @alpha */