From baa269f33f89cbff129e54bc0fa4c61f30de5974 Mon Sep 17 00:00:00 2001 From: Lokesh Kaki Date: Sun, 15 Mar 2026 19:08:28 -0500 Subject: [PATCH] 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, + }; +}