diff --git a/.changeset/azure-scm-events-layer.md b/.changeset/azure-scm-events-layer.md new file mode 100644 index 0000000000..22ca304f96 --- /dev/null +++ b/.changeset/azure-scm-events-layer.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/package.json b/plugins/catalog-backend-module-azure/package.json index 2c067d0ccb..754bf1c9a5 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/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 cba672ce49..14a7fe1379 100644 --- a/plugins/catalog-backend-module-azure/src/alpha.ts +++ b/plugins/catalog-backend-module-azure/src/alpha.ts @@ -19,3 +19,9 @@ import { default as feature } from './module'; /** @alpha */ const _feature = feature; export default _feature; + +export { + analyzeAzureDevOpsWebhookEvent, + type AnalyzeAzureDevOpsWebhookEventOptions, + type AnalyzeAzureDevOpsWebhookEventResult, +} from './events/analyzeAzureDevOpsWebhookEvent'; 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/events/analyzeAzureDevOpsWebhookEvent.test.ts b/plugins/catalog-backend-module-azure/src/events/analyzeAzureDevOpsWebhookEvent.test.ts new file mode 100644 index 0000000000..943e7ac9f1 --- /dev/null +++ b/plugins/catalog-backend-module-azure/src/events/analyzeAzureDevOpsWebhookEvent.test.ts @@ -0,0 +1,287 @@ +/* + * 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`, + context: { + commitUrl: `${baseRepository.remoteUrl}/commit/1111111111111111111111111111111111111111`, + }, + }, + { + type: 'location.updated', + url: `${baseRepository.remoteUrl}?path=/service.yaml`, + context: { + commitUrl: `${baseRepository.remoteUrl}/commit/1111111111111111111111111111111111111111`, + }, + }, + { + type: 'location.deleted', + url: `${baseRepository.remoteUrl}?path=/obsolete.yaml`, + context: { + commitUrl: `${baseRepository.remoteUrl}/commit/1111111111111111111111111111111111111111`, + }, + }, + { + type: 'location.moved', + 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`, + context: { + commitUrl: `${baseRepository.remoteUrl}/commit/1111111111111111111111111111111111111111`, + }, + }, + { + type: 'location.created', + url: `${baseRepository.remoteUrl}?path=/catalog-in.yaml`, + context: { + commitUrl: `${baseRepository.remoteUrl}/commit/1111111111111111111111111111111111111111`, + }, + }, + ], + }); + }); + + it('omits version parameter to match default provider URL format', async () => { + await expect( + analyzeAzureDevOpsWebhookEvent( + 'git.push', + withPushEvent({ + repository: baseRepository, + refUpdates: [{ name: 'refs/heads/main' }], + 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`, + context: { commitUrl: `${baseRepository.remoteUrl}/commit/abc` }, + }, + ], + }); + }); + + 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', + }); + }); + }); +}); 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..284c9837a8 --- /dev/null +++ b/plugins/catalog-backend-module-azure/src/events/analyzeAzureDevOpsWebhookEvent.ts @@ -0,0 +1,557 @@ +/* + * 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 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'; + 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 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; +}): string | undefined { + if (!options.remoteUrl) { + return undefined; + } + + // 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. + // + // 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); +} + +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; + path: string; + pathState: PushPathState; + isRelevantPath: (path: string) => boolean; +}): CatalogScmEvent[] { + const { repository, 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, + }); + const toUrl = toLocationUrl({ + remoteUrl: repository.remoteUrl, + path, + }); + + 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, + }); + 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 gitMarker = '/_git/'; + const gitIdx = remoteUrl.indexOf(gitMarker); + if (gitIdx === -1) { + return undefined; + } + 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( + 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 events = Array.from(state.entries()).flatMap(([path, pathState]) => + toCatalogScmEventForPathState({ + repository, + 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 = oldName + ? replaceRepoNameInRemoteUrl(toUrl, oldName) + : undefined; + if (!fromUrl) { + return { + result: 'ignored', + reason: oldName + ? 'Azure DevOps repository renamed event has an unexpected repository.remoteUrl format' + : '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, + }; +} + +/** + * 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`. + * - `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 + */ +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, + }; +} diff --git a/plugins/catalog-backend-module-azure/src/module/catalogModuleAzureDevOpsEntityProvider.ts b/plugins/catalog-backend-module-azure/src/module/catalogModuleAzureDevOpsEntityProvider.ts index fff597c165..c3475de239 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,23 @@ export const catalogModuleAzureEntityProvider = createBackendModule({ }), ); } + + // 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(); + }); + } }, }); }, diff --git a/yarn.lock b/yarn.lock index 8cb6e9af4d..6105535ae9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4757,9 +4757,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