diff --git a/changelog.d/1061.feature b/changelog.d/1061.feature new file mode 100644 index 000000000..959069a29 --- /dev/null +++ b/changelog.d/1061.feature @@ -0,0 +1 @@ +Add support for GitLab 'pipeline' event notifications. Thanks to @madjidDer, @ManilDF, @Hakim-Lakrout and @leguye! \ No newline at end of file diff --git a/docs/usage/room_configuration/gitlab_project.md b/docs/usage/room_configuration/gitlab_project.md index 1f6f0962a..8ec1f60f3 100644 --- a/docs/usage/room_configuration/gitlab_project.md +++ b/docs/usage/room_configuration/gitlab_project.md @@ -54,3 +54,8 @@ the events marked as default below will be enabled. Otherwise, this is ignored. - release.created \* - tag_push \* - wiki \* +- pipeline \* + - pipeline.triggered \* + - pipeline.canceled \* + - pipeline.failed \* + - pipeline.success \* diff --git a/spec/gitlab-pipeline.spec.ts b/spec/gitlab-pipeline.spec.ts new file mode 100644 index 000000000..2451fa0d8 --- /dev/null +++ b/spec/gitlab-pipeline.spec.ts @@ -0,0 +1,297 @@ +import { E2ESetupTestTimeout, E2ETestEnv } from "./util/e2e-test"; +import { describe, test, beforeAll, afterAll, expect } from "vitest"; +import { createHmac, randomUUID } from "crypto"; +import { + GitLabRepoConnection, + GitLabRepoConnectionState, +} from "../src/Connections"; +import { MessageEventContent } from "matrix-bot-sdk"; +import { getBridgeApi } from "./util/bridge-api"; +import { waitFor } from "./util/helpers"; +import { Server, createServer } from "http"; + +describe("GitLab - Pipeline Event", () => { + let testEnv: E2ETestEnv; + let gitlabServer: Server; + const webhooksPort = 9801 + E2ETestEnv.workerId; + const gitlabPort = 9901 + E2ETestEnv.workerId; + + beforeAll(async () => { + gitlabServer = createServer((req, res) => { + if (req.method === "GET" && req.url?.includes("/projects")) { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ id: 1234 })); + } else { + console.log("Unknown GitLab request", req.method, req.url); + res.writeHead(404); + res.end(); + } + }).listen(gitlabPort); + + testEnv = await E2ETestEnv.createTestEnv({ + matrixLocalparts: ["user"], + config: { + gitlab: { + webhook: { + secret: "mysecret", + }, + instances: { + test: { + url: `http://localhost:${gitlabPort}`, + }, + }, + }, + widgets: { + publicUrl: `http://localhost:${webhooksPort}`, + }, + listeners: [ + { + port: webhooksPort, + bindAddress: "0.0.0.0", + resources: ["webhooks", "widgets"], + }, + ], + }, + }); + await testEnv.setUp(); + }, E2ESetupTestTimeout); + + afterAll(() => { + gitlabServer?.close(); + return testEnv?.tearDown(); + }); + + const waitForMessages = ( + user: any, + roomId: string, + botMxid: string, + expectedCount: number, + timeoutMs: number = 10000, + ): Promise => { + return new Promise((resolve, reject) => { + const receivedMessages: MessageEventContent[] = []; + const timeout = setTimeout(() => { + cleanup(); + reject( + new Error( + `Timeout: Expected ${expectedCount} messages, got ${receivedMessages.length}`, + ), + ); + }, timeoutMs); + + const messageHandler = (eventRoomId: string, event: any) => { + if ( + eventRoomId === roomId && + event.sender === botMxid && + event.content?.msgtype === "m.notice" + ) { + receivedMessages.push(event.content); + if (receivedMessages.length >= expectedCount) { + cleanup(); + resolve(receivedMessages); + } + } + }; + + const cleanup = () => { + clearTimeout(timeout); + user.off("room.message", messageHandler); + }; + + user.on("room.message", messageHandler); + }); + }; + + test( + "should handle GitLab pipeline success event with one messages", + async () => { + const user = testEnv.getUser("user"); + const bridgeApi = await getBridgeApi( + testEnv.opts.config?.widgets?.publicUrl!, + user, + ); + const testRoomId = await user.createRoom({ + name: "Pipeline Test Room", + invite: [testEnv.botMxid], + }); + await user.setUserPowerLevel(testEnv.botMxid, testRoomId, 50); + await user.waitForRoomJoin({ + sender: testEnv.botMxid, + roomId: testRoomId, + }); + + await testEnv.app.appservice.botClient.sendStateEvent( + testRoomId, + GitLabRepoConnection.CanonicalEventType, + "my-test-pipeline", + { + instance: "test", + path: "org/project", + enableHooks: ["pipeline"], + } satisfies GitLabRepoConnectionState, + ); + + await waitFor( + async () => + (await bridgeApi.getConnectionsForRoom(testRoomId)).length === 1, + ); + + const messagesPromise = waitForMessages( + user, + testRoomId, + testEnv.botMxid, + 1, + ); + + const webhookPayload = JSON.stringify({ + object_kind: "pipeline", + object_attributes: { + id: 123456, + status: "success", + ref: "main", + url: "https://gitlab.example.com/org/project/-/pipelines/123456", + duration: 300, + finished_at: "2025-01-01T12:00:00Z", + }, + project: { + id: 1234, + name: "project", + path_with_namespace: "org/project", + web_url: "https://gitlab.example.com/org/project", + }, + user: { + id: 1, + name: "Alice Doe", + username: "alice", + email: "alice@example.com", + }, + commit: { + id: "abcd1234567890", + message: "Add new feature", + author_name: "Alice Doe", + author_email: "alice@example.com", + }, + }); + + const hmac = createHmac("sha256", "mysecret"); + hmac.write(webhookPayload); + hmac.end(); + + const req = await fetch(`http://localhost:${webhooksPort}/`, { + method: "POST", + headers: { + "X-Gitlab-Event": "Pipeline Hook", + "X-Gitlab-Token": "mysecret", + "X-Hub-Signature-256": `sha256=${hmac.read().toString("hex")}`, + "Content-Type": "application/json", + }, + body: webhookPayload, + }); + + expect(req.status).toBe(200); + expect(await req.text()).toBe("OK"); + + const receivedMessages = await messagesPromise; + + expect(receivedMessages.length).toBe(1); + + const triggeredMessage = receivedMessages[0]; + expect(triggeredMessage.body.toLowerCase()).toContain("success"); + }, + E2ESetupTestTimeout, + ); + + test("should only send triggered message for running pipeline", async () => { + const user = testEnv.getUser("user"); + const bridgeApi = await getBridgeApi( + testEnv.opts.config?.widgets?.publicUrl!, + user, + ); + const testRoomId = await user.createRoom({ + name: "Pipeline Running Test Room", + invite: [testEnv.botMxid], + }); + await user.setUserPowerLevel(testEnv.botMxid, testRoomId, 50); + await user.waitForRoomJoin({ sender: testEnv.botMxid, roomId: testRoomId }); + + await testEnv.app.appservice.botClient.sendStateEvent( + testRoomId, + GitLabRepoConnection.CanonicalEventType, + "my-test-pipeline-running", + { + instance: "test", + path: "org/project", + enableHooks: ["pipeline"], + } satisfies GitLabRepoConnectionState, + ); + + await waitFor( + async () => + (await bridgeApi.getConnectionsForRoom(testRoomId)).length === 1, + ); + + const receivedMessages: MessageEventContent[] = []; + const messageHandler = (roomId: string, event: any) => { + if (roomId === testRoomId && event.sender === testEnv.botMxid) { + receivedMessages.push(event.content); + } + }; + user.on("room.message", messageHandler); + + const webhookPayload = JSON.stringify({ + object_kind: "pipeline", + object_attributes: { + id: 999888, + status: "running", + ref: "main", + url: "https://gitlab.example.com/org/project/-/pipelines/999888", + duration: null, + finished_at: null, + }, + project: { + id: 1234, + name: "project", + path_with_namespace: "org/project", + web_url: "https://gitlab.example.com/org/project", + }, + user: { + id: 4, + name: "David Wilson", + username: "david", + email: "david@example.com", + }, + commit: { + id: "mnop3456789012", + message: "Start new feature", + author_name: "David Wilson", + author_email: "david@example.com", + }, + }); + + const hmac = createHmac("sha256", "mysecret"); + hmac.write(webhookPayload); + hmac.end(); + + const req = await fetch(`http://localhost:${webhooksPort}/`, { + method: "POST", + headers: { + "X-Gitlab-Event": "Pipeline Hook", + "X-Gitlab-Token": "mysecret", + "X-Hub-Signature-256": `sha256=${hmac.read().toString("hex")}`, + "Content-Type": "application/json", + }, + body: webhookPayload, + }); + + expect(req.status).toBe(200); + expect(await req.text()).toBe("OK"); + + await waitFor(async () => receivedMessages.length >= 1, 3000); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + expect(receivedMessages.length).toBe(1); + const triggeredMessage = receivedMessages[0]; + expect(triggeredMessage.body.toLowerCase()).toContain("triggered"); + }); +}); diff --git a/src/Bridge.ts b/src/Bridge.ts index 594dfe41e..880e8b780 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -45,6 +45,7 @@ import { IGitLabWebhookReleaseEvent, IGitLabWebhookTagPushEvent, IGitLabWebhookWikiPageEvent, + IGitLabWebhookPipelineEvent, } from "./gitlab/WebhookTypes"; import { JiraIssueEvent, @@ -614,6 +615,42 @@ export class Bridge { (c, data) => c.onWikiPageEvent(data), ); + this.bindHandlerToQueue( + "gitlab.pipeline.running", + (data) => + connManager.getConnectionsForGitLabRepo( + data.project.path_with_namespace, + ), + (c, data) => c.onPipelineTriggered(data), + ); + + this.bindHandlerToQueue( + "gitlab.pipeline.success", + (data) => + connManager.getConnectionsForGitLabRepo( + data.project.path_with_namespace, + ), + (c, data) => c.onPipelineSuccess(data), + ); + + this.bindHandlerToQueue( + "gitlab.pipeline.failed", + (data) => + connManager.getConnectionsForGitLabRepo( + data.project.path_with_namespace, + ), + (c, data) => c.onPipelineFailed(data), + ); + + this.bindHandlerToQueue( + "gitlab.pipeline.canceled", + (data) => + connManager.getConnectionsForGitLabRepo( + data.project.path_with_namespace, + ), + (c, data) => c.onPipelineCanceled(data), + ); + this.queue.on( "notifications.user.events", async (msg) => { diff --git a/src/Connections/GitlabRepo.ts b/src/Connections/GitlabRepo.ts index 7c0c5647d..d6cc70376 100644 --- a/src/Connections/GitlabRepo.ts +++ b/src/Connections/GitlabRepo.ts @@ -14,6 +14,7 @@ import { IGitLabWebhookReleaseEvent, IGitLabWebhookTagPushEvent, IGitLabWebhookWikiPageEvent, + IGitLabWebhookPipelineEvent, } from "../gitlab/WebhookTypes"; import { CommandConnection } from "./CommandConnection"; import { @@ -99,7 +100,12 @@ type AllowedEventsNames = | "wiki" | `wiki.${string}` | "release" - | "release.created"; + | "release.created" + | "pipeline" + | "pipeline.running" + | "pipeline.success" + | "pipeline.failed" + | "pipeline.canceled"; const AllowedEvents: AllowedEventsNames[] = [ "merge_request.open", @@ -116,6 +122,11 @@ const AllowedEvents: AllowedEventsNames[] = [ "wiki", "release", "release.created", + "pipeline", + "pipeline.running", + "pipeline.success", + "pipeline.failed", + "pipeline.canceled", ]; const DefaultHooks = AllowedEvents; @@ -973,6 +984,89 @@ ${data.description}`; }); } + public async onPipelineTriggered(event: IGitLabWebhookPipelineEvent) { + if (this.hookFilter.shouldSkip("pipeline", "pipeline.running")) { + return; + } + + log.info( + `onPipelineTriggered ${this.roomId} ${this.instance.url}/${this.path}`, + ); + const { ref } = event.object_attributes; + + const triggerText = `Pipeline triggered on branch \`${ref}\` for project ${event.project.name} by ${event.user.username}`; + const triggerHtml = `Pipeline triggered on branch ${ref} for project ${event.project.name} by ${event.user.username}`; + await this.intent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: triggerText, + formatted_body: triggerHtml, + format: "org.matrix.custom.html", + }); + } + + public async onPipelineSuccess(event: IGitLabWebhookPipelineEvent) { + if (this.hookFilter.shouldSkip("pipeline", "pipeline.success")) { + return; + } + + log.info( + `onPipelineSuccess ${this.roomId} ${this.instance.url}/${this.path}`, + ); + const { ref, duration } = event.object_attributes; + + const contentText = `Pipeline SUCCESS on branch \`${ref}\` for project ${event.project.name} by ${event.user.username} - Duration: ${duration ?? "?"}s`; + const contentHtml = `Pipeline SUCCESS on branch ${ref} for project ${event.project.name} by ${event.user.username} - ${duration != null ? `- Duration: ${duration}s` : ""}`; + + await this.intent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: contentText, + formatted_body: contentHtml, + format: "org.matrix.custom.html", + }); + } + + public async onPipelineFailed(event: IGitLabWebhookPipelineEvent) { + if (this.hookFilter.shouldSkip("pipeline", "pipeline.failed")) { + return; + } + + log.info( + `onPipelineFailed ${this.roomId} ${this.instance.url}/${this.path}`, + ); + const { ref, duration } = event.object_attributes; + + const contentText = `Pipeline FAILED on branch \`${ref}\` for project ${event.project.name} by ${event.user.username} - Duration: ${duration ?? "?"}s`; + const contentHtml = `Pipeline FAILED on branch ${ref} for project ${event.project.name} by ${event.user.username} - ${duration != null ? `- Duration: ${duration}s` : ""}`; + + await this.intent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: contentText, + formatted_body: contentHtml, + format: "org.matrix.custom.html", + }); + } + + public async onPipelineCanceled(event: IGitLabWebhookPipelineEvent) { + if (this.hookFilter.shouldSkip("pipeline", "pipeline.canceled")) { + return; + } + + log.info( + `onPipelineCanceled ${this.roomId} ${this.instance.url}/${this.path}`, + ); + const { ref, duration } = event.object_attributes; + + const contentText = `Pipeline CANCELED on branch \`${ref}\` for project ${event.project.name} by ${event.user.username} - Duration: ${duration ?? "?"}s`; + const contentHtml = `Pipeline CANCELED on branch ${ref} for project ${event.project.name} by ${event.user.username} - ${duration != null ? `- Duration: ${duration}s` : ""}`; + + await this.intent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: contentText, + formatted_body: contentHtml, + format: "org.matrix.custom.html", + }); + } + private async renderDebouncedMergeRequest( uniqueId: string, mergeRequest: IGitlabMergeRequest, diff --git a/src/gitlab/Router.ts b/src/gitlab/Router.ts index 7c07ade0e..bf7fd4856 100644 --- a/src/gitlab/Router.ts +++ b/src/gitlab/Router.ts @@ -6,6 +6,7 @@ import { IGitLabWebhookEvent, IGitLabWebhookIssueStateEvent, IGitLabWebhookMREvent, + IGitLabWebhookPipelineEvent, IGitLabWebhookReleaseEvent, } from "./WebhookTypes"; import { BridgeConfigGitLab } from "../config/sections"; @@ -67,6 +68,10 @@ export class GitLabWebhooksRouter { return `gitlab.release.${action}`; } else if (body.object_kind === "push") { return `gitlab.push`; + } else if (body.object_kind === "pipeline") { + const pipelineEvent = body as unknown as IGitLabWebhookPipelineEvent; + const status = pipelineEvent.object_attributes?.status?.toLowerCase(); + return `gitlab.pipeline.${status}`; } else { return null; } diff --git a/src/gitlab/WebhookTypes.ts b/src/gitlab/WebhookTypes.ts index b091a9b95..4db4c7776 100644 --- a/src/gitlab/WebhookTypes.ts +++ b/src/gitlab/WebhookTypes.ts @@ -200,6 +200,7 @@ export interface IGitLabWebhookNoteEvent { object_attributes: IGitLabNote; merge_request?: IGitlabMergeRequest; } + export interface IGitLabWebhookIssueStateEvent { user: IGitlabUser; event_type: string; @@ -217,3 +218,25 @@ export interface IGitLabWebhookIssueStateEvent { description: string; }; } + +export interface IGitLabWebhookPipelineEvent { + object_kind: "pipeline"; + user: { + name: string; + username: string; + avatar_url: string; + }; + project: { + name: string; + web_url: string; + path_with_namespace: string; + }; + object_attributes: { + id: number; + status: string; + ref: string; + duration: number; + created_at: string; + finished_at: string; + }; +} diff --git a/tests/connections/GitlabRepoTest.ts b/tests/connections/GitlabRepoTest.ts index 303bb1bd6..68c3154a3 100644 --- a/tests/connections/GitlabRepoTest.ts +++ b/tests/connections/GitlabRepoTest.ts @@ -15,6 +15,7 @@ import { IGitlabProject, IGitlabUser, IGitLabWebhookNoteEvent, + IGitLabWebhookPipelineEvent, } from "../../src/gitlab/WebhookTypes"; const ROOM_ID = "!foo:bar"; @@ -76,6 +77,28 @@ const GITLAB_MR_COMMENT: IGitLabWebhookNoteEvent = { }, }; +const GITLAB_PIPELINE_EVENT: IGitLabWebhookPipelineEvent = { + object_kind: "pipeline", + user: { + name: "Test User", + username: "testuser", + avatar_url: "", + }, + project: { + name: "Test Project", + web_url: "https://gitlab.example.com/test/project", + path_with_namespace: "test/project", + }, + object_attributes: { + id: 1, + status: "success", + ref: "main", + duration: 120, + created_at: "2025-05-20T10:00:00Z", + finished_at: "2025-05-20T10:02:00Z", + }, +}; + const COMMENT_DEBOUNCE_MS = 25; function createConnection( @@ -339,7 +362,6 @@ describe("GitLabRepoConnection", () => { await connection.onMergeRequestOpened( GITLAB_ISSUE_CREATED_PAYLOAD as never, ); - // Statement text. intent.expectEventBodyContains("**alice** opened a new MR", 0); intent.expectEventBodyContains( GITLAB_ISSUE_CREATED_PAYLOAD.object_attributes.url, @@ -363,7 +385,6 @@ describe("GitLabRepoConnection", () => { }, ], } as never); - // ..or issues with no labels await connection.onMergeRequestOpened( GITLAB_ISSUE_CREATED_PAYLOAD as never, ); @@ -400,4 +421,137 @@ describe("GitLabRepoConnection", () => { intent.expectEventBodyContains("**alice** opened a new MR", 0); }); }); + + describe("onPipelineEvent", () => { + let baseEvent: IGitLabWebhookPipelineEvent; + + beforeEach(() => { + baseEvent = { + ...GITLAB_PIPELINE_EVENT, + object_attributes: { + ...GITLAB_PIPELINE_EVENT.object_attributes, + }, + }; + }); + + it("should skip onPipelineTriggered if hook is disabled", async () => { + const { connection, intent } = createConnection({ enableHooks: [] }); + await connection.onPipelineTriggered({ + ...baseEvent, + object_attributes: { + ...baseEvent.object_attributes, + status: "running", + }, + }); + intent.expectNoEvent(); + }); + + it("should skip onPipelineSuccess if hook is disabled", async () => { + const { connection, intent } = createConnection({ enableHooks: [] }); + await connection.onPipelineSuccess({ + ...baseEvent, + object_attributes: { + ...baseEvent.object_attributes, + status: "success", + }, + }); + intent.expectNoEvent(); + }); + + it("should skip onPipelineFailed if hook is disabled", async () => { + const { connection, intent } = createConnection({ enableHooks: [] }); + await connection.onPipelineFailed({ + ...baseEvent, + object_attributes: { + ...baseEvent.object_attributes, + status: "failed", + }, + }); + intent.expectNoEvent(); + }); + + it("should skip onPipelineCanceled if hook is disabled", async () => { + const { connection, intent } = createConnection({ enableHooks: [] }); + await connection.onPipelineCanceled({ + ...baseEvent, + object_attributes: { + ...baseEvent.object_attributes, + status: "canceled", + }, + }); + intent.expectNoEvent(); + }); + + it("should send only the triggered message if pipeline just started", async () => { + const { connection, intent } = createConnection({ + enableHooks: ["pipeline"], + }); + await connection.onPipelineTriggered({ + ...baseEvent, + object_attributes: { + ...baseEvent.object_attributes, + status: "running", // pipeline just started + }, + }); + + intent.expectEventBodyContains("Pipeline triggered", 0); + }); + + it("should send final success message (green)", async () => { + const { connection, intent } = createConnection({ + enableHooks: ["pipeline"], + }); + + await connection.onPipelineSuccess({ + ...baseEvent, + object_attributes: { + ...baseEvent.object_attributes, + status: "success", + }, + }); + + expect(intent.sentEvents[0].content.body).to.include("SUCCESS"); + expect(intent.sentEvents[0].content.formatted_body).to.include( + 'SUCCESS', + ); + }); + + it("should send canceled message (gray)", async () => { + const { connection, intent } = createConnection({ + enableHooks: ["pipeline"], + }); + + await connection.onPipelineCanceled({ + ...baseEvent, + object_attributes: { + ...baseEvent.object_attributes, + status: "canceled", + }, + }); + + expect(intent.sentEvents[0].content.body).to.include("CANCELED"); + expect(intent.sentEvents[0].content.formatted_body).to.include( + 'CANCELED', + ); + }); + + it("should send failed message (red)", async () => { + const { connection, intent } = createConnection({ + enableHooks: ["pipeline"], + }); + + await connection.onPipelineFailed({ + ...baseEvent, + object_attributes: { + ...baseEvent.object_attributes, + status: "failed", + }, + }); + + expect(intent.sentEvents[0].content.body).to.include("FAILED"); + expect(intent.sentEvents[0].content.formatted_body).to.include( + 'FAILED', + ); + }); + }); }); diff --git a/web/components/roomConfig/GitlabRepoConfig.tsx b/web/components/roomConfig/GitlabRepoConfig.tsx index 371a6cccb..e33dcc828 100644 --- a/web/components/roomConfig/GitlabRepoConfig.tsx +++ b/web/components/roomConfig/GitlabRepoConfig.tsx @@ -289,6 +289,47 @@ const ConnectionConfiguration: FunctionComponent< > Releases + + Pipelines + +
    + + Success + + + Failed + + + Running + + + Canceled + +