Merge pull request #33361 from lokeshkaki/feat/azure-scm-events

feat(catalog-backend-module-azure): add Azure DevOps SCM event translation layer
This commit is contained in:
Fredrik Adelöw
2026-03-31 15:03:26 +02:00
committed by GitHub
9 changed files with 1038 additions and 1 deletions
@@ -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": {
@@ -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<AnalyzeAzureDevOpsWebhookEventResult>;
// @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;
@@ -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';
@@ -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<void> | 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<void> {
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;
}
}
@@ -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<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`,
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',
});
});
});
});
@@ -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<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 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<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 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<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 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<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 = 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<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,
};
}
@@ -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();
});
}
},
});
},