Merge pull request #33362 from lokeshkaki/feat/gitlab-scm-events

feat(catalog-backend-module-gitlab): add GitLab SCM event translation layer for catalog reprocessing
This commit is contained in:
Fredrik Adelöw
2026-03-31 14:59:18 +02:00
committed by GitHub
10 changed files with 1017 additions and 4 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:^",
@@ -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<AnalyzeWebhookEventResult>;
// @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;
@@ -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';
@@ -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<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;
}
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;
}
}
@@ -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);
});
});
@@ -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<string, unknown>;
/**
* 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<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;
}
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);
}
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);
}
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<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',
};
}
/**
* 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<AnalyzeWebhookEventResult> {
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;
}
@@ -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);
});
@@ -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();
});
},
});
},