From 5ee1f50a526684fdb76141ece55d745db70a60a4 Mon Sep 17 00:00:00 2001 From: Lokesh Kaki Date: Sun, 15 Mar 2026 19:50:24 -0500 Subject: [PATCH 1/9] feat(catalog-backend-module-gitlab): add GitLab SCM event translation and bridge wiring Signed-off-by: Lokesh Kaki --- .../package.json | 1 + .../src/events/GitLabScmEventsBridge.ts | 152 +++++ .../src/events/analyzeGitLabWebhookEvent.ts | 535 ++++++++++++++++++ .../src/index.ts | 1 + ...alogModuleGitlabDiscoveryEntityProvider.ts | 27 +- 5 files changed, 715 insertions(+), 1 deletion(-) create mode 100644 plugins/catalog-backend-module-gitlab/src/events/GitLabScmEventsBridge.ts create mode 100644 plugins/catalog-backend-module-gitlab/src/events/analyzeGitLabWebhookEvent.ts diff --git a/plugins/catalog-backend-module-gitlab/package.json b/plugins/catalog-backend-module-gitlab/package.json index e7b562c4e5..099ffd8832 100644 --- a/plugins/catalog-backend-module-gitlab/package.json +++ b/plugins/catalog-backend-module-gitlab/package.json @@ -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:^", diff --git a/plugins/catalog-backend-module-gitlab/src/events/GitLabScmEventsBridge.ts b/plugins/catalog-backend-module-gitlab/src/events/GitLabScmEventsBridge.ts new file mode 100644 index 0000000000..34b9b5c9c2 --- /dev/null +++ b/plugins/catalog-backend-module-gitlab/src/events/GitLabScmEventsBridge.ts @@ -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 | 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 { + 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; + } +} \ No newline at end of file diff --git a/plugins/catalog-backend-module-gitlab/src/events/analyzeGitLabWebhookEvent.ts b/plugins/catalog-backend-module-gitlab/src/events/analyzeGitLabWebhookEvent.ts new file mode 100644 index 0000000000..4bea0230f0 --- /dev/null +++ b/plugins/catalog-backend-module-gitlab/src/events/analyzeGitLabWebhookEvent.ts @@ -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; + +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, + 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, + 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, + 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, + 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 { + const project = isObject(event.project) ? event.project : undefined; + const repositoryUrl = asString(project?.web_url); + const contextUrl = repositoryUrl ?? ''; + 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(); + 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 { + 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 { + 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, + }; +} \ No newline at end of file diff --git a/plugins/catalog-backend-module-gitlab/src/index.ts b/plugins/catalog-backend-module-gitlab/src/index.ts index 4f64e76395..0ad28b8a4b 100644 --- a/plugins/catalog-backend-module-gitlab/src/index.ts +++ b/plugins/catalog-backend-module-gitlab/src/index.ts @@ -26,6 +26,7 @@ export { GitlabDiscoveryEntityProvider, GitlabOrgDiscoveryEntityProvider, } from './providers'; +export { analyzeGitLabWebhookEvent } from './events/analyzeGitLabWebhookEvent'; export type { GitLabUser, GitLabGroup, diff --git a/plugins/catalog-backend-module-gitlab/src/module/catalogModuleGitlabDiscoveryEntityProvider.ts b/plugins/catalog-backend-module-gitlab/src/module/catalogModuleGitlabDiscoveryEntityProvider.ts index 4f9626a130..d776e6ecd6 100644 --- a/plugins/catalog-backend-module-gitlab/src/module/catalogModuleGitlabDiscoveryEntityProvider.ts +++ b/plugins/catalog-backend-module-gitlab/src/module/catalogModuleGitlabDiscoveryEntityProvider.ts @@ -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(); + }); }, }); }, From 093b09d5ec8ff799ac47b859ba8b7bef2f572429 Mon Sep 17 00:00:00 2001 From: Lokesh Kaki Date: Sun, 15 Mar 2026 19:50:29 -0500 Subject: [PATCH 2/9] test(catalog-backend-module-gitlab): add analyzer coverage and update module wiring assertions Signed-off-by: Lokesh Kaki --- .../events/analyzeGitLabWebhookEvent.test.ts | 258 ++++++++++++++++++ ...oduleGitlabDiscoveryEntityProvider.test.ts | 13 +- 2 files changed, 268 insertions(+), 3 deletions(-) create mode 100644 plugins/catalog-backend-module-gitlab/src/events/analyzeGitLabWebhookEvent.test.ts diff --git a/plugins/catalog-backend-module-gitlab/src/events/analyzeGitLabWebhookEvent.test.ts b/plugins/catalog-backend-module-gitlab/src/events/analyzeGitLabWebhookEvent.test.ts new file mode 100644 index 0000000000..bda802d67a --- /dev/null +++ b/plugins/catalog-backend-module-gitlab/src/events/analyzeGitLabWebhookEvent.test.ts @@ -0,0 +1,258 @@ +/* + * 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 { mockServices } from '@backstage/backend-test-utils'; +import { InputError } from '@backstage/errors'; +import { analyzeGitLabWebhookEvent } from './analyzeGitLabWebhookEvent'; + +const isRelevantPath = (path: string): boolean => + path.endsWith('.yaml') || path.endsWith('.yml'); + +describe('analyzeGitLabWebhookEvent', () => { + const logger = mockServices.logger.mock(); + + describe('push', () => { + it('handles file add, modify, and delete', async () => { + const payload = { + object_kind: 'push', + ref: 'refs/heads/main', + project: { + web_url: 'https://gitlab.example.com/group-a/repo-a', + path_with_namespace: 'group-a/repo-a', + default_branch: 'main', + }, + commits: [ + { + id: 'c1', + added: ['catalog-info.yaml'], + modified: ['docs/catalog-info.yml'], + removed: [], + }, + { + id: 'c2', + added: [], + modified: [], + removed: ['old/catalog-info.yaml'], + }, + ], + }; + + await expect( + analyzeGitLabWebhookEvent('push', payload, { + logger, + isRelevantPath, + }), + ).resolves.toMatchInlineSnapshot(` + { + "events": [ + { + "context": { + "commitUrl": "https://gitlab.example.com/group-a/repo-a/-/commit/c1", + }, + "type": "location.updated", + "url": "https://gitlab.example.com/group-a/repo-a/-/blob/main/docs/catalog-info.yml", + }, + { + "context": { + "commitUrl": "https://gitlab.example.com/group-a/repo-a/-/commit/c1", + }, + "type": "location.created", + "url": "https://gitlab.example.com/group-a/repo-a/-/blob/main/catalog-info.yaml", + }, + { + "context": { + "commitUrl": "https://gitlab.example.com/group-a/repo-a/-/commit/c2", + }, + "type": "location.deleted", + "url": "https://gitlab.example.com/group-a/repo-a/-/blob/main/old/catalog-info.yaml", + }, + ], + "result": "ok", + } + `); + }); + + it('handles file rename as location move', async () => { + const payload = { + object_kind: 'push', + ref: 'refs/heads/main', + project: { + web_url: 'https://gitlab.example.com/group-a/repo-a', + path_with_namespace: 'group-a/repo-a', + default_branch: 'main', + }, + commits: [ + { + id: 'c3', + added: ['new/catalog-info.yaml'], + modified: [], + removed: ['old/catalog-info.yaml'], + }, + ], + }; + + await expect( + analyzeGitLabWebhookEvent('push', payload, { + logger, + isRelevantPath, + }), + ).resolves.toMatchInlineSnapshot(` + { + "events": [ + { + "context": { + "commitUrl": "https://gitlab.example.com/group-a/repo-a/-/commit/c3", + }, + "fromUrl": "https://gitlab.example.com/group-a/repo-a/-/blob/main/old/catalog-info.yaml", + "toUrl": "https://gitlab.example.com/group-a/repo-a/-/blob/main/new/catalog-info.yaml", + "type": "location.moved", + }, + ], + "result": "ok", + } + `); + }); + }); + + describe('repository_update', () => { + it('handles repository rename as repository move', async () => { + const payload = { + object_kind: 'repository_update', + event_name: 'project_rename', + old_path_with_namespace: 'group-a/repo-a-old', + project: { + web_url: 'https://gitlab.example.com/group-a/repo-a', + path_with_namespace: 'group-a/repo-a', + }, + }; + + await expect( + analyzeGitLabWebhookEvent('repository_update', payload, { + logger, + isRelevantPath, + }), + ).resolves.toMatchInlineSnapshot(` + { + "events": [ + { + "fromUrl": "https://gitlab.example.com/group-a/repo-a-old", + "toUrl": "https://gitlab.example.com/group-a/repo-a", + "type": "repository.moved", + }, + ], + "result": "ok", + } + `); + }); + + it('handles repository transfer as repository move', async () => { + const payload = { + object_kind: 'repository_update', + event_name: 'project_transfer', + project: { + web_url: 'https://gitlab.example.com/group-b/repo-a', + path_with_namespace: 'group-b/repo-a', + }, + changes: { + path_with_namespace: { + from: 'group-a/repo-a', + to: 'group-b/repo-a', + }, + }, + }; + + await expect( + analyzeGitLabWebhookEvent('repository_update', payload, { + logger, + isRelevantPath, + }), + ).resolves.toMatchInlineSnapshot(` + { + "events": [ + { + "fromUrl": "https://gitlab.example.com/group-a/repo-a", + "toUrl": "https://gitlab.example.com/group-b/repo-a", + "type": "repository.moved", + }, + ], + "result": "ok", + } + `); + }); + + it('handles repository delete', async () => { + const payload = { + object_kind: 'repository_update', + event_name: 'project_destroy', + project: { + web_url: 'https://gitlab.example.com/group-a/repo-a', + path_with_namespace: 'group-a/repo-a', + }, + }; + + await expect( + analyzeGitLabWebhookEvent('repository_update', payload, { + logger, + isRelevantPath, + }), + ).resolves.toMatchInlineSnapshot(` + { + "events": [ + { + "type": "repository.deleted", + "url": "https://gitlab.example.com/group-a/repo-a", + }, + ], + "result": "ok", + } + `); + }); + }); + + it('returns unsupported-event for unsupported event types', async () => { + await expect( + analyzeGitLabWebhookEvent( + 'merge_request', + { + object_kind: 'merge_request', + }, + { + logger, + isRelevantPath, + }, + ), + ).resolves.toEqual({ + result: 'unsupported-event', + event: 'merge_request', + }); + }); + + it('throws on malformed payloads', async () => { + await expect( + analyzeGitLabWebhookEvent('push', undefined, { + logger, + isRelevantPath, + }), + ).rejects.toBeInstanceOf(InputError); + + await expect( + analyzeGitLabWebhookEvent('push', [], { + logger, + isRelevantPath, + }), + ).rejects.toBeInstanceOf(InputError); + }); +}); \ No newline at end of file diff --git a/plugins/catalog-backend-module-gitlab/src/module/catalogModuleGitlabDiscoveryEntityProvider.test.ts b/plugins/catalog-backend-module-gitlab/src/module/catalogModuleGitlabDiscoveryEntityProvider.test.ts index b99fc6e2da..e664ecdada 100644 --- a/plugins/catalog-backend-module-gitlab/src/module/catalogModuleGitlabDiscoveryEntityProvider.test.ts +++ b/plugins/catalog-backend-module-gitlab/src/module/catalogModuleGitlabDiscoveryEntityProvider.test.ts @@ -100,9 +100,16 @@ describe('catalogModuleGitlabDiscoveryEntityProvider', () => { 'GitlabDiscoveryEntityProvider:test-id', ); await provider.connect(connection); - expect(events.subscribed).toHaveLength(1); - expect(events.subscribed[0].id).toEqual( - 'GitlabDiscoveryEntityProvider:test-id', + expect(events.subscribed).toHaveLength(2); + expect(events.subscribed).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'catalog-gitlab-scm-events-bridge', + }), + expect.objectContaining({ + id: 'GitlabDiscoveryEntityProvider:test-id', + }), + ]), ); expect(runner).toHaveBeenCalledTimes(1); }); From 54a830018115ff7345b882f33bb56ff376957d04 Mon Sep 17 00:00:00 2001 From: Lokesh Kaki Date: Sun, 15 Mar 2026 19:50:33 -0500 Subject: [PATCH 3/9] chore(changeset): add patch changeset for GitLab SCM event translation layer Signed-off-by: Lokesh Kaki --- .changeset/gitlab-scm-events-layer.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/gitlab-scm-events-layer.md diff --git a/.changeset/gitlab-scm-events-layer.md b/.changeset/gitlab-scm-events-layer.md new file mode 100644 index 0000000000..24349d01b1 --- /dev/null +++ b/.changeset/gitlab-scm-events-layer.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend-module-gitlab': patch +--- + +Add GitLab SCM event translation layer for instant catalog reprocessing. From 17b97abca63a42de901f8716eb96d5bc51303ba5 Mon Sep 17 00:00:00 2001 From: Lokesh Kaki Date: Sun, 15 Mar 2026 20:28:07 -0500 Subject: [PATCH 4/9] fix(catalog-backend-module-gitlab): move analyzer to alpha export, make logger optional, fix type cast Signed-off-by: Lokesh Kaki --- plugins/catalog-backend-module-gitlab/src/alpha.ts | 2 ++ .../src/events/analyzeGitLabWebhookEvent.ts | 4 ++-- plugins/catalog-backend-module-gitlab/src/index.ts | 1 - 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/plugins/catalog-backend-module-gitlab/src/alpha.ts b/plugins/catalog-backend-module-gitlab/src/alpha.ts index 4c49bb9de9..0b29a09ba9 100644 --- a/plugins/catalog-backend-module-gitlab/src/alpha.ts +++ b/plugins/catalog-backend-module-gitlab/src/alpha.ts @@ -19,3 +19,5 @@ import { catalogModuleGitlabDiscoveryEntityProvider } from './module/catalogModu /** @alpha */ const _feature = catalogModuleGitlabDiscoveryEntityProvider; export default _feature; + +export { analyzeGitLabWebhookEvent } from './events/analyzeGitLabWebhookEvent'; diff --git a/plugins/catalog-backend-module-gitlab/src/events/analyzeGitLabWebhookEvent.ts b/plugins/catalog-backend-module-gitlab/src/events/analyzeGitLabWebhookEvent.ts index 4bea0230f0..6b2d44ddd6 100644 --- a/plugins/catalog-backend-module-gitlab/src/events/analyzeGitLabWebhookEvent.ts +++ b/plugins/catalog-backend-module-gitlab/src/events/analyzeGitLabWebhookEvent.ts @@ -22,7 +22,7 @@ import { WebhookPushEventSchema } from '@gitbeaker/rest'; type StringRecord = Record; export interface AnalyzeWebhookEventOptions { - logger: LoggerService; + logger?: LoggerService; isRelevantPath: (path: string) => boolean; } @@ -521,7 +521,7 @@ export async function analyzeGitLabWebhookEvent( } if (eventType === 'push') { - return onPushEvent(eventPayload as WebhookPushEventSchema, options); + return onPushEvent(eventPayload as unknown as WebhookPushEventSchema, options); } if (eventType === 'repository_update') { diff --git a/plugins/catalog-backend-module-gitlab/src/index.ts b/plugins/catalog-backend-module-gitlab/src/index.ts index 0ad28b8a4b..4f64e76395 100644 --- a/plugins/catalog-backend-module-gitlab/src/index.ts +++ b/plugins/catalog-backend-module-gitlab/src/index.ts @@ -26,7 +26,6 @@ export { GitlabDiscoveryEntityProvider, GitlabOrgDiscoveryEntityProvider, } from './providers'; -export { analyzeGitLabWebhookEvent } from './events/analyzeGitLabWebhookEvent'; export type { GitLabUser, GitLabGroup, From 0b0d8fa7f164fea369c63f208711e33a884c8bc6 Mon Sep 17 00:00:00 2001 From: Lokesh Kaki Date: Sun, 15 Mar 2026 20:34:36 -0500 Subject: [PATCH 5/9] chore: update yarn.lock for gitlab scm events deps Signed-off-by: Lokesh Kaki --- yarn.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/yarn.lock b/yarn.lock index 32b09cc5e3..1adcb16cb6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4669,6 +4669,7 @@ __metadata: "@backstage/catalog-model": "workspace:^" "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" + "@backstage/errors": "workspace:^" "@backstage/integration": "workspace:^" "@backstage/plugin-catalog-common": "workspace:^" "@backstage/plugin-catalog-node": "workspace:^" From 98316359294e02d7258ef8f454733813ee0a500c Mon Sep 17 00:00:00 2001 From: Lokesh Kaki Date: Sun, 15 Mar 2026 21:19:59 -0500 Subject: [PATCH 6/9] fix(catalog-backend-module-gitlab): fix bridge race condition, remove unused logger and dead PathState variant Signed-off-by: Lokesh Kaki --- .../src/events/GitLabScmEventsBridge.ts | 13 +++----- .../src/events/analyzeGitLabWebhookEvent.ts | 31 +++++++++---------- 2 files changed, 19 insertions(+), 25 deletions(-) diff --git a/plugins/catalog-backend-module-gitlab/src/events/GitLabScmEventsBridge.ts b/plugins/catalog-backend-module-gitlab/src/events/GitLabScmEventsBridge.ts index 34b9b5c9c2..1809b31156 100644 --- a/plugins/catalog-backend-module-gitlab/src/events/GitLabScmEventsBridge.ts +++ b/plugins/catalog-backend-module-gitlab/src/events/GitLabScmEventsBridge.ts @@ -99,10 +99,6 @@ export class GitLabScmEventsBridge { 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`, @@ -110,7 +106,8 @@ export class GitLabScmEventsBridge { return; } - this.#pendingPublish = Promise.resolve().then(async () => { + const previous = this.#pendingPublish ?? Promise.resolve(); + const current = previous.then(async () => { try { const output = await analyzeGitLabWebhookEvent( eventType, @@ -143,10 +140,10 @@ export class GitLabScmEventsBridge { error, ); } finally { - this.#pendingPublish = undefined; + // no-op; chain handles ordering } }); - - await this.#pendingPublish; + this.#pendingPublish = current; + await current; } } \ No newline at end of file diff --git a/plugins/catalog-backend-module-gitlab/src/events/analyzeGitLabWebhookEvent.ts b/plugins/catalog-backend-module-gitlab/src/events/analyzeGitLabWebhookEvent.ts index 6b2d44ddd6..77f11f6432 100644 --- a/plugins/catalog-backend-module-gitlab/src/events/analyzeGitLabWebhookEvent.ts +++ b/plugins/catalog-backend-module-gitlab/src/events/analyzeGitLabWebhookEvent.ts @@ -53,10 +53,6 @@ type PathState = type: 'removed'; commitUrl?: string; } - | { - type: 'modified'; - commitUrl?: string; - } | { type: 'renamed'; fromPath: string; @@ -173,12 +169,6 @@ function pathStateToCatalogScmEvent( url: toBlobUrl(path), context, }; - case 'modified': - return { - type: 'location.updated', - url: toBlobUrl(path), - context, - }; case 'renamed': return { type: 'location.moved', @@ -520,16 +510,23 @@ export async function analyzeGitLabWebhookEvent( throw new InputError('GitLab webhook event payload is not an object'); } + let result: AnalyzeWebhookEventResult; + if (eventType === 'push') { - return onPushEvent(eventPayload as unknown as WebhookPushEventSchema, options); + result = await onPushEvent(eventPayload as unknown as WebhookPushEventSchema, options); + } else if (eventType === 'repository_update') { + result = await onRepositoryUpdateEvent(eventPayload as GitLabRepositoryUpdateEvent); + } else { + result = { result: 'unsupported-event', event: eventType }; } - if (eventType === 'repository_update') { - return onRepositoryUpdateEvent(eventPayload as GitLabRepositoryUpdateEvent); + if (result.result === 'ignored') { + options.logger?.debug(`GitLab webhook event ignored: ${result.reason}`); + } else if (result.result === 'aborted') { + options.logger?.debug(`GitLab webhook event aborted: ${result.reason}`); + } else if (result.result === 'unsupported-event') { + options.logger?.debug(`GitLab webhook event unsupported: ${result.event}`); } - return { - result: 'unsupported-event', - event: eventType, - }; + return result; } \ No newline at end of file From 1759bfefc817bd7ac0e95a7dcf535b774d63b03a Mon Sep 17 00:00:00 2001 From: Lokesh Kaki Date: Tue, 17 Mar 2026 19:35:43 -0500 Subject: [PATCH 7/9] fix(catalog-backend-module-gitlab): fix prettier formatting, export alpha types, and add API reports - Run prettier on changed files (GitLabScmEventsBridge, analyzeGitLabWebhookEvent, test files) - Export AnalyzeWebhookEventOptions and AnalyzeWebhookEventResult from alpha entry point - Add @alpha JSDoc tags to exported types and function - Regenerate report-alpha.api.md with updated API surface Signed-off-by: Lokesh Kaki --- .../report-alpha.api.md | 36 +++++++++++++++++++ .../src/alpha.ts | 6 +++- .../src/events/GitLabScmEventsBridge.ts | 2 +- .../events/analyzeGitLabWebhookEvent.test.ts | 2 +- .../src/events/analyzeGitLabWebhookEvent.ts | 31 ++++++++++------ 5 files changed, 64 insertions(+), 13 deletions(-) diff --git a/plugins/catalog-backend-module-gitlab/report-alpha.api.md b/plugins/catalog-backend-module-gitlab/report-alpha.api.md index e8ef479d82..00e452642e 100644 --- a/plugins/catalog-backend-module-gitlab/report-alpha.api.md +++ b/plugins/catalog-backend-module-gitlab/report-alpha.api.md @@ -4,6 +4,42 @@ ```ts import { BackendFeature } from '@backstage/backend-plugin-api'; +import { CatalogScmEvent } from '@backstage/plugin-catalog-node/alpha'; +import { LoggerService } from '@backstage/backend-plugin-api'; + +// @alpha (undocumented) +export function analyzeGitLabWebhookEvent( + eventType: string, + eventPayload: unknown, + options: AnalyzeWebhookEventOptions, +): Promise; + +// @alpha (undocumented) +export interface AnalyzeWebhookEventOptions { + // (undocumented) + isRelevantPath: (path: string) => boolean; + // (undocumented) + logger?: LoggerService; +} + +// @alpha (undocumented) +export type AnalyzeWebhookEventResult = + | { + 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-gitlab/src/alpha.ts b/plugins/catalog-backend-module-gitlab/src/alpha.ts index 0b29a09ba9..aaf5096742 100644 --- a/plugins/catalog-backend-module-gitlab/src/alpha.ts +++ b/plugins/catalog-backend-module-gitlab/src/alpha.ts @@ -20,4 +20,8 @@ import { catalogModuleGitlabDiscoveryEntityProvider } from './module/catalogModu const _feature = catalogModuleGitlabDiscoveryEntityProvider; export default _feature; -export { analyzeGitLabWebhookEvent } from './events/analyzeGitLabWebhookEvent'; +export { + analyzeGitLabWebhookEvent, + type AnalyzeWebhookEventOptions, + type AnalyzeWebhookEventResult, +} from './events/analyzeGitLabWebhookEvent'; diff --git a/plugins/catalog-backend-module-gitlab/src/events/GitLabScmEventsBridge.ts b/plugins/catalog-backend-module-gitlab/src/events/GitLabScmEventsBridge.ts index 1809b31156..1e44ffcf72 100644 --- a/plugins/catalog-backend-module-gitlab/src/events/GitLabScmEventsBridge.ts +++ b/plugins/catalog-backend-module-gitlab/src/events/GitLabScmEventsBridge.ts @@ -146,4 +146,4 @@ export class GitLabScmEventsBridge { this.#pendingPublish = current; await current; } -} \ No newline at end of file +} diff --git a/plugins/catalog-backend-module-gitlab/src/events/analyzeGitLabWebhookEvent.test.ts b/plugins/catalog-backend-module-gitlab/src/events/analyzeGitLabWebhookEvent.test.ts index bda802d67a..8e792f1822 100644 --- a/plugins/catalog-backend-module-gitlab/src/events/analyzeGitLabWebhookEvent.test.ts +++ b/plugins/catalog-backend-module-gitlab/src/events/analyzeGitLabWebhookEvent.test.ts @@ -255,4 +255,4 @@ describe('analyzeGitLabWebhookEvent', () => { }), ).rejects.toBeInstanceOf(InputError); }); -}); \ No newline at end of file +}); diff --git a/plugins/catalog-backend-module-gitlab/src/events/analyzeGitLabWebhookEvent.ts b/plugins/catalog-backend-module-gitlab/src/events/analyzeGitLabWebhookEvent.ts index 77f11f6432..2ca7a14a6a 100644 --- a/plugins/catalog-backend-module-gitlab/src/events/analyzeGitLabWebhookEvent.ts +++ b/plugins/catalog-backend-module-gitlab/src/events/analyzeGitLabWebhookEvent.ts @@ -21,11 +21,13 @@ import { WebhookPushEventSchema } from '@gitbeaker/rest'; type StringRecord = Record; +/** @alpha */ export interface AnalyzeWebhookEventOptions { logger?: LoggerService; isRelevantPath: (path: string) => boolean; } +/** @alpha */ export type AnalyzeWebhookEventResult = | { result: 'unsupported-event'; @@ -135,9 +137,10 @@ function extractBranchName(ref?: string): string | undefined { return ref.slice('refs/heads/'.length); } -function getCommitUrl(commit: GitLabPushCommit, repositoryUrl?: string): - | string - | undefined { +function getCommitUrl( + commit: GitLabPushCommit, + repositoryUrl?: string, +): string | undefined { if (commit.url) { return commit.url; } @@ -297,9 +300,9 @@ async function onPushEvent( } } - const commits = (Array.isArray(event.commits) - ? event.commits - : []) as GitLabPushCommit[]; + const commits = ( + Array.isArray(event.commits) ? event.commits : [] + ) as GitLabPushCommit[]; if (!commits.length) { return { @@ -427,7 +430,9 @@ function getPreviousRepositoryUrl( return toRepositoryUrl(baseUrl, oldPathWithNamespace); } -function isRepositoryDeletionEvent(event: GitLabRepositoryUpdateEvent): boolean { +function isRepositoryDeletionEvent( + event: GitLabRepositoryUpdateEvent, +): boolean { const eventName = asString(event.event_name)?.toLowerCase() ?? ''; const action = asString(event.action)?.toLowerCase() ?? ''; @@ -501,6 +506,7 @@ async function onRepositoryUpdateEvent( }; } +/** @alpha */ export async function analyzeGitLabWebhookEvent( eventType: string, eventPayload: unknown, @@ -513,9 +519,14 @@ export async function analyzeGitLabWebhookEvent( let result: AnalyzeWebhookEventResult; if (eventType === 'push') { - result = await onPushEvent(eventPayload as unknown as WebhookPushEventSchema, options); + result = await onPushEvent( + eventPayload as unknown as WebhookPushEventSchema, + options, + ); } else if (eventType === 'repository_update') { - result = await onRepositoryUpdateEvent(eventPayload as GitLabRepositoryUpdateEvent); + result = await onRepositoryUpdateEvent( + eventPayload as GitLabRepositoryUpdateEvent, + ); } else { result = { result: 'unsupported-event', event: eventType }; } @@ -529,4 +540,4 @@ export async function analyzeGitLabWebhookEvent( } return result; -} \ No newline at end of file +} From 9af9fc1a85340bed776459f2988d8de1005c2b61 Mon Sep 17 00:00:00 2001 From: Lokesh Kaki Date: Tue, 17 Mar 2026 20:00:48 -0500 Subject: [PATCH 8/9] fix(catalog-backend-module-gitlab): fix push event rename heuristic and add API docs Signed-off-by: Lokesh Kaki --- .../events/analyzeGitLabWebhookEvent.test.ts | 14 ++- .../src/events/analyzeGitLabWebhookEvent.ts | 92 +++++++------------ 2 files changed, 45 insertions(+), 61 deletions(-) diff --git a/plugins/catalog-backend-module-gitlab/src/events/analyzeGitLabWebhookEvent.test.ts b/plugins/catalog-backend-module-gitlab/src/events/analyzeGitLabWebhookEvent.test.ts index 8e792f1822..73c702c790 100644 --- a/plugins/catalog-backend-module-gitlab/src/events/analyzeGitLabWebhookEvent.test.ts +++ b/plugins/catalog-backend-module-gitlab/src/events/analyzeGitLabWebhookEvent.test.ts @@ -85,7 +85,7 @@ describe('analyzeGitLabWebhookEvent', () => { `); }); - it('handles file rename as location move', async () => { + it('handles file add and delete in the same commit as separate events', async () => { const payload = { object_kind: 'push', ref: 'refs/heads/main', @@ -116,9 +116,15 @@ describe('analyzeGitLabWebhookEvent', () => { "context": { "commitUrl": "https://gitlab.example.com/group-a/repo-a/-/commit/c3", }, - "fromUrl": "https://gitlab.example.com/group-a/repo-a/-/blob/main/old/catalog-info.yaml", - "toUrl": "https://gitlab.example.com/group-a/repo-a/-/blob/main/new/catalog-info.yaml", - "type": "location.moved", + "type": "location.created", + "url": "https://gitlab.example.com/group-a/repo-a/-/blob/main/new/catalog-info.yaml", + }, + { + "context": { + "commitUrl": "https://gitlab.example.com/group-a/repo-a/-/commit/c3", + }, + "type": "location.deleted", + "url": "https://gitlab.example.com/group-a/repo-a/-/blob/main/old/catalog-info.yaml", }, ], "result": "ok", diff --git a/plugins/catalog-backend-module-gitlab/src/events/analyzeGitLabWebhookEvent.ts b/plugins/catalog-backend-module-gitlab/src/events/analyzeGitLabWebhookEvent.ts index 2ca7a14a6a..f733d3b07d 100644 --- a/plugins/catalog-backend-module-gitlab/src/events/analyzeGitLabWebhookEvent.ts +++ b/plugins/catalog-backend-module-gitlab/src/events/analyzeGitLabWebhookEvent.ts @@ -21,13 +21,31 @@ import { WebhookPushEventSchema } from '@gitbeaker/rest'; type StringRecord = Record; -/** @alpha */ +/** + * Options for {@link analyzeGitLabWebhookEvent}. + * @alpha + */ export interface AnalyzeWebhookEventOptions { + /** Optional logger for debug output when events are ignored or unsupported. */ logger?: LoggerService; + /** + * 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; } -/** @alpha */ +/** + * The result of analyzing a GitLab webhook event. + * + * - `ok` — one or more catalog SCM events were produced. + * - `ignored` — the event was valid but not relevant (e.g. push to a + * non-default branch, or no catalog files affected). + * - `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 AnalyzeWebhookEventResult = | { result: 'unsupported-event'; @@ -55,11 +73,6 @@ type PathState = type: 'removed'; commitUrl?: string; } - | { - type: 'renamed'; - fromPath: string; - commitUrl?: string; - } | { type: 'changed'; commitUrl?: string; @@ -172,13 +185,6 @@ function pathStateToCatalogScmEvent( url: toBlobUrl(path), context, }; - case 'renamed': - return { - type: 'location.moved', - fromUrl: toBlobUrl(event.fromPath), - toUrl: toBlobUrl(path), - context, - }; case 'changed': return { type: 'location.updated', @@ -226,13 +232,6 @@ function applyRemovedPath( 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); } @@ -253,34 +252,6 @@ function applyModifiedPath( pathState.set(path, previous); } -function applyRenamedPath( - pathState: Map, - 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, @@ -328,16 +299,11 @@ async function onPushEvent( 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)) { + for (const path of added) { applyAddedPath(pathState, path, commitUrl); } - for (const path of removed.slice(renamePairs)) { + for (const path of removed) { applyRemovedPath(pathState, path, commitUrl); } } @@ -506,7 +472,19 @@ async function onRepositoryUpdateEvent( }; } -/** @alpha */ +/** + * Analyzes a GitLab webhook event and translates it into zero or more catalog + * SCM events that entity providers can act on. + * + * Supported event types: + * - `push` — translates file-level adds, modifications, and deletions on the + * default branch into `location.created`, `location.updated`, and + * `location.deleted` events for paths matching `isRelevantPath`. + * - `repository_update` — translates repository renames, transfers, and + * deletions into `repository.moved` and `repository.deleted` events. + * + * @alpha + */ export async function analyzeGitLabWebhookEvent( eventType: string, eventPayload: unknown, From 47d475113cff9b899b0c0ca507414df18bd1183b Mon Sep 17 00:00:00 2001 From: Lokesh Kaki Date: Tue, 17 Mar 2026 20:27:18 -0500 Subject: [PATCH 9/9] fix(catalog-backend-module-gitlab): regenerate API report after JSDoc additions Signed-off-by: Lokesh Kaki --- plugins/catalog-backend-module-gitlab/report-alpha.api.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/plugins/catalog-backend-module-gitlab/report-alpha.api.md b/plugins/catalog-backend-module-gitlab/report-alpha.api.md index 00e452642e..b02e99363d 100644 --- a/plugins/catalog-backend-module-gitlab/report-alpha.api.md +++ b/plugins/catalog-backend-module-gitlab/report-alpha.api.md @@ -7,22 +7,20 @@ import { BackendFeature } from '@backstage/backend-plugin-api'; import { CatalogScmEvent } from '@backstage/plugin-catalog-node/alpha'; import { LoggerService } from '@backstage/backend-plugin-api'; -// @alpha (undocumented) +// @alpha export function analyzeGitLabWebhookEvent( eventType: string, eventPayload: unknown, options: AnalyzeWebhookEventOptions, ): Promise; -// @alpha (undocumented) +// @alpha export interface AnalyzeWebhookEventOptions { - // (undocumented) isRelevantPath: (path: string) => boolean; - // (undocumented) logger?: LoggerService; } -// @alpha (undocumented) +// @alpha export type AnalyzeWebhookEventResult = | { result: 'unsupported-event';