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. diff --git a/plugins/catalog-backend-module-gitlab/package.json b/plugins/catalog-backend-module-gitlab/package.json index 4b3318e15d..0a240200ec 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/report-alpha.api.md b/plugins/catalog-backend-module-gitlab/report-alpha.api.md index e8ef479d82..b02e99363d 100644 --- a/plugins/catalog-backend-module-gitlab/report-alpha.api.md +++ b/plugins/catalog-backend-module-gitlab/report-alpha.api.md @@ -4,6 +4,40 @@ ```ts import { BackendFeature } from '@backstage/backend-plugin-api'; +import { CatalogScmEvent } from '@backstage/plugin-catalog-node/alpha'; +import { LoggerService } from '@backstage/backend-plugin-api'; + +// @alpha +export function analyzeGitLabWebhookEvent( + eventType: string, + eventPayload: unknown, + options: AnalyzeWebhookEventOptions, +): Promise; + +// @alpha +export interface AnalyzeWebhookEventOptions { + isRelevantPath: (path: string) => boolean; + logger?: LoggerService; +} + +// @alpha +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 4c49bb9de9..aaf5096742 100644 --- a/plugins/catalog-backend-module-gitlab/src/alpha.ts +++ b/plugins/catalog-backend-module-gitlab/src/alpha.ts @@ -19,3 +19,9 @@ import { catalogModuleGitlabDiscoveryEntityProvider } from './module/catalogModu /** @alpha */ const _feature = catalogModuleGitlabDiscoveryEntityProvider; export default _feature; + +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 new file mode 100644 index 0000000000..1e44ffcf72 --- /dev/null +++ b/plugins/catalog-backend-module-gitlab/src/events/GitLabScmEventsBridge.ts @@ -0,0 +1,149 @@ +/* + * 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; + } + + if (this.#shuttingDown) { + this.#logger.warn( + `Skipping GitLab webhook event of type "${eventType}" on topic "${params.topic}" because the bridge is shutting down`, + ); + return; + } + + const previous = this.#pendingPublish ?? Promise.resolve(); + const current = previous.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 { + // no-op; chain handles ordering + } + }); + this.#pendingPublish = current; + await current; + } +} 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..73c702c790 --- /dev/null +++ b/plugins/catalog-backend-module-gitlab/src/events/analyzeGitLabWebhookEvent.test.ts @@ -0,0 +1,264 @@ +/* + * 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 add and delete in the same commit as separate events', 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", + }, + "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", + } + `); + }); + }); + + 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); + }); +}); 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..f733d3b07d --- /dev/null +++ b/plugins/catalog-backend-module-gitlab/src/events/analyzeGitLabWebhookEvent.ts @@ -0,0 +1,521 @@ +/* + * 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; + +/** + * 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; +} + +/** + * 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'; + 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: '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 '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; + } + 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); +} + +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); + } + + for (const path of added) { + applyAddedPath(pathState, path, commitUrl); + } + + for (const path of removed) { + 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', + }; +} + +/** + * 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, + options: AnalyzeWebhookEventOptions, +): Promise { + if (!isObject(eventPayload)) { + throw new InputError('GitLab webhook event payload is not an object'); + } + + let result: AnalyzeWebhookEventResult; + + if (eventType === 'push') { + 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 (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; +} 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); }); 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(); + }); }, }); }, diff --git a/yarn.lock b/yarn.lock index 8465247de6..8cb6e9af4d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4951,6 +4951,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:^"