feat(catalog-backend-module-azure): add Azure DevOps webhook SCM event analyzer

Signed-off-by: Lokesh Kaki <lokeshkaki1@gmail.com>
This commit is contained in:
Lokesh Kaki
2026-03-15 19:08:28 -05:00
parent 1e2c57ebb0
commit baa269f33f
2 changed files with 773 additions and 0 deletions
@@ -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<string, unknown>) => ({
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',
});
});
});
});
@@ -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<string, unknown>;
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<string, PushPathState>,
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<AnalyzeAzureDevOpsWebhookEventResult> {
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 ?? '<unknown>';
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<string, PushPathState>();
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<AnalyzeAzureDevOpsWebhookEventResult> {
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<AnalyzeAzureDevOpsWebhookEventResult> {
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,
};
}