feat(catalog-backend-module-gitlab): add GitLab SCM event translation and bridge wiring

Signed-off-by: Lokesh Kaki <lokeshkaki1@gmail.com>
This commit is contained in:
Lokesh Kaki
2026-03-15 19:50:24 -05:00
parent 1e2c57ebb0
commit 5ee1f50a52
5 changed files with 715 additions and 1 deletions
@@ -55,6 +55,7 @@
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/catalog-model": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/integration": "workspace:^",
"@backstage/plugin-catalog-common": "workspace:^",
"@backstage/plugin-catalog-node": "workspace:^",
@@ -0,0 +1,152 @@
/*
* 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 { analyzeGitLabWebhookEvent } from './analyzeGitLabWebhookEvent';
function determineEventType(params: EventParams): string | undefined {
const payload = params.eventPayload;
if (
payload &&
typeof payload === 'object' &&
!Array.isArray(payload) &&
typeof (payload as { object_kind?: unknown }).object_kind === 'string'
) {
return (payload as { object_kind: string }).object_kind;
}
const eventName =
payload &&
typeof payload === 'object' &&
!Array.isArray(payload) &&
typeof (payload as { event_name?: unknown }).event_name === 'string'
? (payload as { event_name: string }).event_name
: undefined;
if (eventName) {
return eventName;
}
const metadataType = params.metadata?.['x-gitlab-event'];
if (typeof metadataType === 'string' && metadataType.trim()) {
return metadataType
.trim()
.toLowerCase()
.replace(/\s+hook$/, '')
.replace(/\s+/g, '_');
}
if (params.topic.startsWith('gitlab.')) {
return params.topic.slice('gitlab.'.length);
}
return undefined;
}
/**
* Takes GitLab webhook events, analyzes them, and publishes them as catalog
* SCM events that entity providers and others can subscribe to.
*/
export class GitLabScmEventsBridge {
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-gitlab-scm-events-bridge',
topics: ['gitlab'],
onEvent: this.#onEvent.bind(this),
});
}
async stop() {
this.#shuttingDown = true;
await this.#pendingPublish;
}
async #onEvent(params: EventParams): Promise<void> {
const eventType = determineEventType(params);
if (!eventType || !params.eventPayload) {
return;
}
while (this.#pendingPublish) {
await this.#pendingPublish;
}
if (this.#shuttingDown) {
this.#logger.warn(
`Skipping GitLab 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 analyzeGitLabWebhookEvent(
eventType,
params.eventPayload,
{
logger: this.#logger,
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 GitLab webhook event of type "${eventType}" on topic "${params.topic}" because it is ignored: ${output.reason}`,
);
} else if (output.result === 'aborted') {
this.#logger.warn(
`Skipping GitLab 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 GitLab webhook event of type "${eventType}" on topic "${params.topic}" because it is unsupported: ${output.event}`,
);
}
} catch (error) {
this.#logger.warn(
`Failed to handle GitLab webhook event of type "${eventType}"`,
error,
);
} finally {
this.#pendingPublish = undefined;
}
});
await this.#pendingPublish;
}
}
@@ -0,0 +1,535 @@
/*
* 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 { InputError } from '@backstage/errors';
import { CatalogScmEvent } from '@backstage/plugin-catalog-node/alpha';
import { WebhookPushEventSchema } from '@gitbeaker/rest';
type StringRecord = Record<string, unknown>;
export interface AnalyzeWebhookEventOptions {
logger: LoggerService;
isRelevantPath: (path: string) => boolean;
}
export type AnalyzeWebhookEventResult =
| {
result: 'unsupported-event';
event: string;
}
| {
result: 'ignored';
reason: string;
}
| {
result: 'aborted';
reason: string;
}
| {
result: 'ok';
events: CatalogScmEvent[];
};
type PathState =
| {
type: 'added';
commitUrl?: string;
}
| {
type: 'removed';
commitUrl?: string;
}
| {
type: 'modified';
commitUrl?: string;
}
| {
type: 'renamed';
fromPath: string;
commitUrl?: string;
}
| {
type: 'changed';
commitUrl?: string;
};
type GitLabPushCommit = {
id?: string;
url?: string;
added?: string[];
removed?: string[];
modified?: string[];
};
type ChangeDescriptor = {
from?: unknown;
to?: unknown;
old?: unknown;
new?: unknown;
previous?: unknown;
current?: unknown;
before?: unknown;
after?: unknown;
};
type GitLabRepositoryUpdateEvent = {
object_kind?: string;
event_name?: string;
action?: string;
deleted_at?: string | null;
path_with_namespace?: string;
old_path_with_namespace?: string;
project?: {
web_url?: string;
path_with_namespace?: string;
deleted_at?: string | null;
};
changes?: {
web_url?: ChangeDescriptor;
path_with_namespace?: ChangeDescriptor;
old_path_with_namespace?: ChangeDescriptor;
deleted_at?: ChangeDescriptor;
};
};
function isObject(value: unknown): value is StringRecord {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function asString(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined;
}
function getFromChange(change?: ChangeDescriptor): string | undefined {
return (
asString(change?.from) ??
asString(change?.old) ??
asString(change?.previous) ??
asString(change?.before)
);
}
function getToChange(change?: ChangeDescriptor): string | undefined {
return (
asString(change?.to) ??
asString(change?.new) ??
asString(change?.current) ??
asString(change?.after)
);
}
function extractBranchName(ref?: string): string | undefined {
if (!ref || !ref.startsWith('refs/heads/')) {
return undefined;
}
return ref.slice('refs/heads/'.length);
}
function getCommitUrl(commit: GitLabPushCommit, repositoryUrl?: string):
| string
| undefined {
if (commit.url) {
return commit.url;
}
if (commit.id && repositoryUrl) {
return `${repositoryUrl}/-/commit/${commit.id}`;
}
return undefined;
}
function pathStateToCatalogScmEvent(
path: string,
event: PathState,
repositoryUrl: string,
branch: string,
): CatalogScmEvent {
const toBlobUrl = (p: string) => `${repositoryUrl}/-/blob/${branch}/${p}`;
const context = event.commitUrl ? { commitUrl: event.commitUrl } : undefined;
switch (event.type) {
case 'added':
return {
type: 'location.created',
url: toBlobUrl(path),
context,
};
case 'removed':
return {
type: 'location.deleted',
url: toBlobUrl(path),
context,
};
case 'modified':
return {
type: 'location.updated',
url: toBlobUrl(path),
context,
};
case 'renamed':
return {
type: 'location.moved',
fromUrl: toBlobUrl(event.fromPath),
toUrl: toBlobUrl(path),
context,
};
case 'changed':
return {
type: 'location.updated',
url: toBlobUrl(path),
context,
};
default:
// @ts-expect-error Intentionally expected, to check for exhaustive checking of the types
throw new Error(`Unknown file event type: ${event.type}`);
}
}
function applyAddedPath(
pathState: Map<string, PathState>,
path: string,
commitUrl: string | undefined,
) {
const previous = pathState.get(path);
if (!previous) {
pathState.set(path, { type: 'added', commitUrl });
return;
}
if (previous.type === 'removed') {
pathState.set(path, { type: 'changed', commitUrl });
return;
}
pathState.set(path, previous);
}
function applyRemovedPath(
pathState: Map<string, PathState>,
path: string,
commitUrl: string | undefined,
) {
const previous = pathState.get(path);
if (!previous) {
pathState.set(path, { type: 'removed', commitUrl });
return;
}
if (previous.type === 'added') {
pathState.delete(path);
return;
}
if (previous.type === 'changed') {
pathState.set(path, { type: 'removed', commitUrl });
return;
}
if (previous.type === 'renamed') {
if (!pathState.has(previous.fromPath)) {
pathState.set(previous.fromPath, { type: 'removed', commitUrl });
}
pathState.delete(path);
return;
}
pathState.set(path, previous);
}
function applyModifiedPath(
pathState: Map<string, PathState>,
path: string,
commitUrl: string | undefined,
) {
const previous = pathState.get(path);
if (!previous) {
pathState.set(path, { type: 'changed', commitUrl });
return;
}
if (previous.type === 'removed') {
pathState.set(path, previous);
return;
}
pathState.set(path, previous);
}
function applyRenamedPath(
pathState: Map<string, PathState>,
fromPath: string,
toPath: string,
commitUrl: string | undefined,
) {
const previous = pathState.get(fromPath);
pathState.delete(fromPath);
if (!previous) {
pathState.set(toPath, { type: 'renamed', fromPath, commitUrl });
return;
}
if (previous.type === 'added') {
pathState.set(toPath, { type: 'added', commitUrl });
return;
}
if (previous.type === 'renamed') {
pathState.set(toPath, {
type: 'renamed',
fromPath: previous.fromPath,
commitUrl,
});
return;
}
pathState.set(toPath, { type: 'renamed', fromPath, commitUrl });
}
async function onPushEvent(
event: WebhookPushEventSchema,
options: AnalyzeWebhookEventOptions,
): Promise<AnalyzeWebhookEventResult> {
const project = isObject(event.project) ? event.project : undefined;
const repositoryUrl = asString(project?.web_url);
const contextUrl = repositoryUrl ?? '<unknown>';
const defaultBranch = asString(project?.default_branch);
if (defaultBranch) {
const expectedRef = `refs/heads/${defaultBranch}`;
if (event.ref !== expectedRef) {
return {
result: 'ignored',
reason: `GitLab push event did not target the default branch, found "${event.ref}" but expected "${expectedRef}": ${contextUrl}`,
};
}
}
const commits = (Array.isArray(event.commits)
? event.commits
: []) as GitLabPushCommit[];
if (!commits.length) {
return {
result: 'ignored',
reason: `GitLab push event did not contain any commits: ${contextUrl}`,
};
}
const pathState = new Map<string, PathState>();
let hasRelevantPaths = false;
for (const commit of commits) {
const commitUrl = getCommitUrl(commit, repositoryUrl);
const added = (commit.added ?? []).filter(options.isRelevantPath);
const modified = (commit.modified ?? []).filter(options.isRelevantPath);
const removed = (commit.removed ?? []).filter(options.isRelevantPath);
if (added.length || modified.length || removed.length) {
hasRelevantPaths = true;
}
for (const path of modified) {
applyModifiedPath(pathState, path, commitUrl);
}
const renamePairs = Math.min(added.length, removed.length);
for (let i = 0; i < renamePairs; i++) {
applyRenamedPath(pathState, removed[i], added[i], commitUrl);
}
for (const path of added.slice(renamePairs)) {
applyAddedPath(pathState, path, commitUrl);
}
for (const path of removed.slice(renamePairs)) {
applyRemovedPath(pathState, path, commitUrl);
}
}
if (!hasRelevantPaths) {
return {
result: 'ignored',
reason: `GitLab push event did not affect any relevant paths: ${contextUrl}`,
};
}
if (!repositoryUrl) {
return {
result: 'aborted',
reason: 'GitLab push event did not include project.web_url',
};
}
const branch = defaultBranch ?? extractBranchName(event.ref) ?? 'main';
return {
result: 'ok',
events: Array.from(pathState.entries()).map(([path, e]) =>
pathStateToCatalogScmEvent(path, e, repositoryUrl, branch),
),
};
}
function getOrigin(url: string): string | undefined {
try {
return new URL(url).origin;
} catch {
return undefined;
}
}
function toRepositoryUrl(baseUrl: string, pathWithNamespace: string): string {
return `${baseUrl}/${pathWithNamespace}`;
}
function getCurrentRepositoryUrl(
event: GitLabRepositoryUpdateEvent,
): string | undefined {
const projectUrl = asString(event.project?.web_url);
if (projectUrl) {
return projectUrl;
}
return getToChange(event.changes?.web_url);
}
function getPreviousRepositoryUrl(
event: GitLabRepositoryUpdateEvent,
currentRepositoryUrl?: string,
): string | undefined {
const changedUrl = getFromChange(event.changes?.web_url);
if (changedUrl) {
return changedUrl;
}
const oldPathWithNamespace =
asString(event.old_path_with_namespace) ??
getFromChange(event.changes?.path_with_namespace) ??
getFromChange(event.changes?.old_path_with_namespace);
if (!oldPathWithNamespace) {
return undefined;
}
const projectPathWithNamespace = asString(event.project?.path_with_namespace);
const projectUrl = asString(event.project?.web_url);
if (
currentRepositoryUrl &&
projectPathWithNamespace &&
currentRepositoryUrl.endsWith(`/${projectPathWithNamespace}`)
) {
const prefix = currentRepositoryUrl.slice(
0,
-projectPathWithNamespace.length - 1,
);
return toRepositoryUrl(prefix, oldPathWithNamespace);
}
const baseUrl =
(projectUrl && getOrigin(projectUrl)) ||
(currentRepositoryUrl && getOrigin(currentRepositoryUrl));
if (!baseUrl) {
return undefined;
}
return toRepositoryUrl(baseUrl, oldPathWithNamespace);
}
function isRepositoryDeletionEvent(event: GitLabRepositoryUpdateEvent): boolean {
const eventName = asString(event.event_name)?.toLowerCase() ?? '';
const action = asString(event.action)?.toLowerCase() ?? '';
if (
eventName.includes('destroy') ||
eventName.includes('delete') ||
action.includes('destroy') ||
action.includes('delete') ||
action.includes('remove')
) {
return true;
}
if (event.deleted_at || event.project?.deleted_at) {
return true;
}
return Boolean(getToChange(event.changes?.deleted_at));
}
async function onRepositoryUpdateEvent(
event: GitLabRepositoryUpdateEvent,
): Promise<AnalyzeWebhookEventResult> {
const currentRepositoryUrl = getCurrentRepositoryUrl(event);
const previousRepositoryUrl = getPreviousRepositoryUrl(
event,
currentRepositoryUrl,
);
if (isRepositoryDeletionEvent(event)) {
const repositoryUrl = currentRepositoryUrl ?? previousRepositoryUrl;
if (!repositoryUrl) {
return {
result: 'ignored',
reason:
'GitLab repository_update event did not include sufficient data for repository deletion handling',
};
}
return {
result: 'ok',
events: [
{
type: 'repository.deleted',
url: repositoryUrl,
},
],
};
}
if (
previousRepositoryUrl &&
currentRepositoryUrl &&
previousRepositoryUrl !== currentRepositoryUrl
) {
return {
result: 'ok',
events: [
{
type: 'repository.moved',
fromUrl: previousRepositoryUrl,
toUrl: currentRepositoryUrl,
},
],
};
}
return {
result: 'ignored',
reason: 'GitLab repository_update event did not contain supported changes',
};
}
export async function analyzeGitLabWebhookEvent(
eventType: string,
eventPayload: unknown,
options: AnalyzeWebhookEventOptions,
): Promise<AnalyzeWebhookEventResult> {
if (!isObject(eventPayload)) {
throw new InputError('GitLab webhook event payload is not an object');
}
if (eventType === 'push') {
return onPushEvent(eventPayload as WebhookPushEventSchema, options);
}
if (eventType === 'repository_update') {
return onRepositoryUpdateEvent(eventPayload as GitLabRepositoryUpdateEvent);
}
return {
result: 'unsupported-event',
event: eventType,
};
}
@@ -26,6 +26,7 @@ export {
GitlabDiscoveryEntityProvider,
GitlabOrgDiscoveryEntityProvider,
} from './providers';
export { analyzeGitLabWebhookEvent } from './events/analyzeGitLabWebhookEvent';
export type {
GitLabUser,
GitLabGroup,
@@ -19,7 +19,9 @@ 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 { GitLabScmEventsBridge } from '../events/GitLabScmEventsBridge';
import { GitlabDiscoveryEntityProvider } from '../providers';
/**
@@ -36,11 +38,21 @@ export const catalogModuleGitlabDiscoveryEntityProvider = createBackendModule({
deps: {
config: coreServices.rootConfig,
catalog: catalogProcessingExtensionPoint,
catalogScmEvents: catalogScmEventsServiceRef,
logger: coreServices.logger,
scheduler: coreServices.scheduler,
events: eventsServiceRef,
lifecycle: coreServices.lifecycle,
},
async init({ config, catalog, logger, scheduler, events }) {
async init({
config,
catalog,
catalogScmEvents,
logger,
scheduler,
events,
lifecycle,
}) {
const gitlabDiscoveryEntityProvider =
GitlabDiscoveryEntityProvider.fromConfig(config, {
logger,
@@ -48,6 +60,19 @@ export const catalogModuleGitlabDiscoveryEntityProvider = createBackendModule({
scheduler,
});
catalog.addEntityProvider(gitlabDiscoveryEntityProvider);
const bridge = new GitLabScmEventsBridge({
logger,
events,
catalogScmEvents,
});
lifecycle.addStartupHook(async () => {
await bridge.start();
});
lifecycle.addShutdownHook(async () => {
await bridge.stop();
});
},
});
},