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:
@@ -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;
|
||||
}
|
||||
+10
-3
@@ -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);
|
||||
});
|
||||
|
||||
+26
-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();
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user