From 2d477e1b5d2881567d71309424ae747f9ffc16b9 Mon Sep 17 00:00:00 2001 From: madjidDer Date: Tue, 20 May 2025 12:48:58 +0200 Subject: [PATCH 01/27] Support GitLab pipeline events --- src/Bridge.ts | 16 ++++-- src/Connections/GitlabRepo.ts | 49 +++++++++++++++++-- src/HookFilter.ts | 3 +- src/Webhooks.ts | 2 + src/gitlab/WebhookTypes.ts | 23 +++++++++ .../roomConfig/GitlabRepoConfig.tsx | 7 +++ 6 files changed, 93 insertions(+), 7 deletions(-) diff --git a/src/Bridge.ts b/src/Bridge.ts index 65de7d9ff..fe5d60c2e 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -47,6 +47,7 @@ import { IGitLabWebhookReleaseEvent, IGitLabWebhookTagPushEvent, IGitLabWebhookWikiPageEvent, + IGitLabWebhookPipelineEvent, } from "./gitlab/WebhookTypes"; import { JiraIssueEvent, @@ -614,6 +615,15 @@ export class Bridge { (c, data) => c.onWikiPageEvent(data), ); + this.bindHandlerToQueue( + "gitlab.pipeline", + (data) => + connManager.getConnectionsForGitLabRepo( + data.project.path_with_namespace, + ), + (c, data) => c.onPipelineEvent(data), + ); + this.queue.on( "notifications.user.events", async (msg) => { @@ -683,9 +693,9 @@ export class Bridge { return [ ...(iid ? connManager.getConnectionsForGitLabIssueWebhook( - data.repository.homepage, - iid, - ) + data.repository.homepage, + iid, + ) : []), ...connManager.getConnectionsForGitLabRepo( data.project.path_with_namespace, diff --git a/src/Connections/GitlabRepo.ts b/src/Connections/GitlabRepo.ts index ffb3ecbbf..78bf9c874 100644 --- a/src/Connections/GitlabRepo.ts +++ b/src/Connections/GitlabRepo.ts @@ -15,6 +15,7 @@ import { IGitLabWebhookReleaseEvent, IGitLabWebhookTagPushEvent, IGitLabWebhookWikiPageEvent, + IGitLabWebhookPipelineEvent, } from "../gitlab/WebhookTypes"; import { CommandConnection } from "./CommandConnection"; import { @@ -97,7 +98,8 @@ type AllowedEventsNames = | "wiki" | `wiki.${string}` | "release" - | "release.created"; + | "release.created" + | "pipeline"; const AllowedEvents: AllowedEventsNames[] = [ "merge_request.open", @@ -114,7 +116,11 @@ const AllowedEvents: AllowedEventsNames[] = [ "wiki", "release", "release.created", + "pipeline", ]; +// +// | "pipeline"; + const DefaultHooks = AllowedEvents; @@ -186,8 +192,7 @@ export interface GitLabTargetFilter { @Connection export class GitLabRepoConnection extends CommandConnection - implements IConnection -{ + implements IConnection { static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.gitlab.repository"; static readonly LegacyCanonicalEventType = @@ -971,6 +976,44 @@ ${data.description}`; }); } + public async onPipelineEvent(event: IGitLabWebhookPipelineEvent) { + + /*console.log("HOOK FILTER CHECK:", { + enabledHooks: this.hookFilter.enabledHooks, + shouldSkip: this.hookFilter.shouldSkip("pipeline"), + });*/ + + + if (this.hookFilter.shouldSkip("pipeline")) { + //console.log(">>> [qaqah] Skipping pipeline event due to filter."); + return; + } + + //console.log(">>> onPipelineEvent data:", this.hookFilter.enabledHooks); + + log.info(`onPipelineEvent ${this.roomId} ${this.instance.url}/${this.path}`); + + const { + status, + ref, + duration, + created_at, + finished_at, + } = event.object_attributes; + + const content = `Pipeline **${status.toUpperCase()}** on branch \`${ref}\` for project [${event.project.name}](${event.project.web_url}) triggered by **${event.user.username}** + \nDuration: ${duration ?? "?"}s + \nStarted: ${created_at} + \nFinished: ${finished_at}`; + + await this.intent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: content, + formatted_body: md.render(content), + format: "org.matrix.custom.html", + }); + } + private async renderDebouncedMergeRequest( uniqueId: string, mergeRequest: IGitlabMergeRequest, diff --git a/src/HookFilter.ts b/src/HookFilter.ts index 573a257d4..daf693c6f 100644 --- a/src/HookFilter.ts +++ b/src/HookFilter.ts @@ -20,10 +20,11 @@ export class HookFilter { return [...resultHookSet]; } - constructor(public enabledHooks: T[] = []) {} + constructor(public enabledHooks: T[] = []) { } public shouldSkip(...hookName: T[]) { // Should skip if all of the hook names are missing + //console.log("→ shouldSkip called with", hookName, "vs", this.enabledHooks); return hookName.every((name) => !this.enabledHooks.includes(name)); } } diff --git a/src/Webhooks.ts b/src/Webhooks.ts index e3e9826a0..a8460527b 100644 --- a/src/Webhooks.ts +++ b/src/Webhooks.ts @@ -182,6 +182,8 @@ export class Webhooks extends EventEmitter { return `gitlab.release.${action}`; } else if (body.object_kind === "push") { return `gitlab.push`; + } else if (body.object_kind === "pipeline") { + return "gitlab.pipeline"; } else { return null; } diff --git a/src/gitlab/WebhookTypes.ts b/src/gitlab/WebhookTypes.ts index b091a9b95..61352a7ab 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; + }; +} \ No newline at end of file diff --git a/web/components/roomConfig/GitlabRepoConfig.tsx b/web/components/roomConfig/GitlabRepoConfig.tsx index 371a6cccb..e76a8cccb 100644 --- a/web/components/roomConfig/GitlabRepoConfig.tsx +++ b/web/components/roomConfig/GitlabRepoConfig.tsx @@ -272,6 +272,13 @@ const ConnectionConfiguration: FunctionComponent< enabledHooks={enabledHooks} hookEventName="tag_push" onChange={toggleEnabledHook} + > + Pipelines + + Tag pushes From 3b6f188ec5b47201a0f6f9221dea04d4eb376ab0 Mon Sep 17 00:00:00 2001 From: ManilDf Date: Thu, 22 May 2025 17:33:06 +0200 Subject: [PATCH 02/27] Tests --- spec/gitlab-pipeline.spec.ts | 150 ++++++++++++++++++ tests/connections/GitlabRepoTest.ts | 238 +++++++++++++++++++++++++++- 2 files changed, 386 insertions(+), 2 deletions(-) create mode 100644 spec/gitlab-pipeline.spec.ts diff --git a/spec/gitlab-pipeline.spec.ts b/spec/gitlab-pipeline.spec.ts new file mode 100644 index 000000000..a167d0ece --- /dev/null +++ b/spec/gitlab-pipeline.spec.ts @@ -0,0 +1,150 @@ +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(); + }); + + test("should be able to handle a GitLab pipeline event", 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 webhookNotice = user.waitForRoomEvent({ + eventType: "m.room.message", + sender: testEnv.botMxid, + roomId: testRoomId, + }); + + 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 { body } = (await webhookNotice).data.content; + expect(body.toLowerCase()).toContain("alice"); + expect(body.toLowerCase()).toContain("pipeline"); + expect(body.toLowerCase()).toContain("success"); + expect(body.toLowerCase()).toContain("main"); + }); +}); \ No newline at end of file diff --git a/tests/connections/GitlabRepoTest.ts b/tests/connections/GitlabRepoTest.ts index 44152f890..c02f9821b 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,217 @@ describe("GitLabRepoConnection", () => { intent.expectEventBodyContains("**alice** opened a new MR", 0); }); }); + + describe("onPipelineEvent", () => { + it("should handle a pipeline event", async () => { + const { connection, intent } = createConnection({ + enableHooks: ["pipeline"], + }); + + await connection.onPipelineEvent(GITLAB_PIPELINE_EVENT); + + intent.expectEventBodyContains("**SUCCESS** on branch `main`", 0); + intent.expectEventBodyContains("Test Project", 0); + intent.expectEventBodyContains("testuser", 0); + intent.expectEventBodyContains("Duration: 120s", 0); + }); + + it("should skip the pipeline event if hook is not enabled", async () => { + const { connection, intent } = createConnection({ + enableHooks: ["push"], // pipeline not enabled + }); + + await connection.onPipelineEvent(GITLAB_PIPELINE_EVENT); + intent.expectNoEvent(); + }); + + it("should skip the pipeline event if hook is explicitly excluded", async () => { + const { connection, intent } = createConnection({ + enableHooks: [], + }); + + await connection.onPipelineEvent(GITLAB_PIPELINE_EVENT); + intent.expectNoEvent(); + }); + + it('01 - should handle status ""success"" with correct hook (expect event)', async () => { + const { connection, intent } = createConnection({ + enableHooks: ['pipeline'], + }); + + const customEvent = { + ...GITLAB_PIPELINE_EVENT, + object_attributes: { + ...GITLAB_PIPELINE_EVENT.object_attributes, + status: "success", + }, + }; + + await connection.onPipelineEvent(customEvent); + intent.expectEventBodyContains("**SUCCESS**", 0); + intent.expectEventBodyContains("Pipeline", 0); + intent.expectEventBodyContains("branch `main`", 0); + intent.expectEventBodyContains("[Test Project](https://gitlab.example.com/test/project)", 0); + intent.expectEventBodyContains("**testuser**", 0); + intent.expectEventBodyContains("Duration: 120s", 0); + + }); + + it('02 - should handle status "success" with wrong hook (expect no event)', async () => { + const { connection, intent } = createConnection({ enableHooks: ['push'] }); + const customEvent = { + ...GITLAB_PIPELINE_EVENT, + object_attributes: { ...GITLAB_PIPELINE_EVENT.object_attributes, status: "success" }, + }; + await connection.onPipelineEvent(customEvent); + intent.expectNoEvent(); + }); + + it('03 - should handle status "success" with no hooks (expect no event)', async () => { + const { connection, intent } = createConnection({ enableHooks: [] }); + const customEvent = { + ...GITLAB_PIPELINE_EVENT, + object_attributes: { ...GITLAB_PIPELINE_EVENT.object_attributes, status: "success" }, + }; + await connection.onPipelineEvent(customEvent); + intent.expectNoEvent(); + }); + + it('04 - should handle status "failed" with correct hook (expect event)', async () => { + const { connection, intent } = createConnection({ enableHooks: ['pipeline'] }); + const customEvent = { + ...GITLAB_PIPELINE_EVENT, + object_attributes: { ...GITLAB_PIPELINE_EVENT.object_attributes, status: "failed" }, + }; + await connection.onPipelineEvent(customEvent); + intent.expectEventBodyContains("**FAILED**", 0); + intent.expectEventBodyContains("branch `main`", 0); + intent.expectEventBodyContains("[Test Project](https://gitlab.example.com/test/project)", 0); + intent.expectEventBodyContains("**testuser**", 0); + intent.expectEventBodyContains("Duration: 120s", 0); + }); + + it('05 - should handle status "failed" with wrong hook (expect no event)', async () => { + const { connection, intent } = createConnection({ enableHooks: ['push'] }); + const customEvent = { + ...GITLAB_PIPELINE_EVENT, + object_attributes: { ...GITLAB_PIPELINE_EVENT.object_attributes, status: "failed" }, + }; + await connection.onPipelineEvent(customEvent); + intent.expectNoEvent(); + }); + + it('06 - should handle status "failed" with no hooks (expect no event)', async () => { + const { connection, intent } = createConnection({ enableHooks: [] }); + const customEvent = { + ...GITLAB_PIPELINE_EVENT, + object_attributes: { ...GITLAB_PIPELINE_EVENT.object_attributes, status: "failed" }, + }; + await connection.onPipelineEvent(customEvent); + intent.expectNoEvent(); + }); + + it('07 - should handle status "canceled" with correct hook (expect event)', async () => { + const { connection, intent } = createConnection({ enableHooks: ['pipeline'] }); + const customEvent = { + ...GITLAB_PIPELINE_EVENT, + object_attributes: { ...GITLAB_PIPELINE_EVENT.object_attributes, status: "canceled" }, + }; + await connection.onPipelineEvent(customEvent); + intent.expectEventBodyContains("**CANCELED**", 0); + intent.expectEventBodyContains("branch `main`", 0); + intent.expectEventBodyContains("[Test Project](https://gitlab.example.com/test/project)", 0); + intent.expectEventBodyContains("**testuser**", 0); + intent.expectEventBodyContains("Duration: 120s", 0); + }); + + it('08 - should handle status "canceled" with wrong hook (expect no event)', async () => { + const { connection, intent } = createConnection({ enableHooks: ['push'] }); + const customEvent = { + ...GITLAB_PIPELINE_EVENT, + object_attributes: { ...GITLAB_PIPELINE_EVENT.object_attributes, status: "canceled" }, + }; + await connection.onPipelineEvent(customEvent); + intent.expectNoEvent(); + }); + + it('09 - should handle status "canceled" with no hooks (expect no event)', async () => { + const { connection, intent } = createConnection({ enableHooks: [] }); + const customEvent = { + ...GITLAB_PIPELINE_EVENT, + object_attributes: { ...GITLAB_PIPELINE_EVENT.object_attributes, status: "canceled" }, + }; + await connection.onPipelineEvent(customEvent); + intent.expectNoEvent(); + }); + + it('10 - should handle status "running" with correct hook (expect event)', async () => { + const { connection, intent } = createConnection({ enableHooks: ['pipeline'] }); + const customEvent = { + ...GITLAB_PIPELINE_EVENT, + object_attributes: { ...GITLAB_PIPELINE_EVENT.object_attributes, status: "running" }, + }; + await connection.onPipelineEvent(customEvent); + intent.expectEventBodyContains("**RUNNING**", 0); + intent.expectEventBodyContains("branch `main`", 0); + intent.expectEventBodyContains("[Test Project](https://gitlab.example.com/test/project)", 0); + intent.expectEventBodyContains("**testuser**", 0); + intent.expectEventBodyContains("Duration: 120s", 0); + }); + + it('11 - should handle status "running" with wrong hook (expect no event)', async () => { + const { connection, intent } = createConnection({ enableHooks: ['push'] }); + const customEvent = { + ...GITLAB_PIPELINE_EVENT, + object_attributes: { ...GITLAB_PIPELINE_EVENT.object_attributes, status: "running" }, + }; + await connection.onPipelineEvent(customEvent); + intent.expectNoEvent(); + }); + + it('12 - should handle status "running" with no hooks (expect no event)', async () => { + const { connection, intent } = createConnection({ enableHooks: [] }); + const customEvent = { + ...GITLAB_PIPELINE_EVENT, + object_attributes: { ...GITLAB_PIPELINE_EVENT.object_attributes, status: "running" }, + }; + await connection.onPipelineEvent(customEvent); + intent.expectNoEvent(); + }); + + it('13 - should handle status "manual" with correct hook (expect event)', async () => { + const { connection, intent } = createConnection({ enableHooks: ['pipeline'] }); + const customEvent = { + ...GITLAB_PIPELINE_EVENT, + object_attributes: { ...GITLAB_PIPELINE_EVENT.object_attributes, status: "manual" }, + }; + await connection.onPipelineEvent(customEvent); + intent.expectEventBodyContains("**MANUAL**", 0); + intent.expectEventBodyContains("branch `main`", 0); + intent.expectEventBodyContains("[Test Project](https://gitlab.example.com/test/project)", 0); + intent.expectEventBodyContains("**testuser**", 0); + intent.expectEventBodyContains("Duration: 120s", 0); + }); + + it('14 - should handle status "manual" with wrong hook (expect no event)', async () => { + const { connection, intent } = createConnection({ enableHooks: ['push'] }); + const customEvent = { + ...GITLAB_PIPELINE_EVENT, + object_attributes: { ...GITLAB_PIPELINE_EVENT.object_attributes, status: "manual" }, + }; + await connection.onPipelineEvent(customEvent); + intent.expectNoEvent(); + }); + + it('15 - should handle status "manual" with no hooks (expect no event)', async () => { + const { connection, intent } = createConnection({ enableHooks: [] }); + const customEvent = { + ...GITLAB_PIPELINE_EVENT, + object_attributes: { ...GITLAB_PIPELINE_EVENT.object_attributes, status: "manual" }, + }; + await connection.onPipelineEvent(customEvent); + intent.expectNoEvent(); + }); + + }); }); From f784f733a8044be46646332c7b078cb69a74c42e Mon Sep 17 00:00:00 2001 From: madjidDer Date: Tue, 27 May 2025 09:43:45 +0200 Subject: [PATCH 03/27] test: refactor pipeline event tests using loop --- tests/connections/GitlabRepoTest.ts | 243 +++++----------------------- 1 file changed, 37 insertions(+), 206 deletions(-) diff --git a/tests/connections/GitlabRepoTest.ts b/tests/connections/GitlabRepoTest.ts index c02f9821b..0beccf805 100644 --- a/tests/connections/GitlabRepoTest.ts +++ b/tests/connections/GitlabRepoTest.ts @@ -423,215 +423,46 @@ describe("GitLabRepoConnection", () => { }); describe("onPipelineEvent", () => { - it("should handle a pipeline event", async () => { - const { connection, intent } = createConnection({ - enableHooks: ["pipeline"], - }); - - await connection.onPipelineEvent(GITLAB_PIPELINE_EVENT); - - intent.expectEventBodyContains("**SUCCESS** on branch `main`", 0); - intent.expectEventBodyContains("Test Project", 0); - intent.expectEventBodyContains("testuser", 0); - intent.expectEventBodyContains("Duration: 120s", 0); - }); - - it("should skip the pipeline event if hook is not enabled", async () => { - const { connection, intent } = createConnection({ - enableHooks: ["push"], // pipeline not enabled - }); - - await connection.onPipelineEvent(GITLAB_PIPELINE_EVENT); - intent.expectNoEvent(); - }); - - it("should skip the pipeline event if hook is explicitly excluded", async () => { - const { connection, intent } = createConnection({ - enableHooks: [], - }); + const statuses = ["success", "failed", "canceled", "running", "manual"]; + + statuses.forEach((status) => { + describe(`Status "${status}"`, () => { + it(`should handle status "${status}" with correct hook (expect event)`, async () => { + const { connection, intent } = createConnection({ enableHooks: ["pipeline"] }); + const customEvent = { + ...GITLAB_PIPELINE_EVENT, + object_attributes: { ...GITLAB_PIPELINE_EVENT.object_attributes, status }, + }; + await connection.onPipelineEvent(customEvent); + + intent.expectEventBodyContains(`**${status.toUpperCase()}**`, 0); + intent.expectEventBodyContains("branch `main`", 0); + intent.expectEventBodyContains("[Test Project](https://gitlab.example.com/test/project)", 0); + intent.expectEventBodyContains("**testuser**", 0); + intent.expectEventBodyContains("Duration: 120s", 0); + }); - await connection.onPipelineEvent(GITLAB_PIPELINE_EVENT); - intent.expectNoEvent(); - }); + it(`should handle status "${status}" with wrong hook (expect no event)`, async () => { + const { connection, intent } = createConnection({ enableHooks: ["push"] }); + const customEvent = { + ...GITLAB_PIPELINE_EVENT, + object_attributes: { ...GITLAB_PIPELINE_EVENT.object_attributes, status }, + }; + await connection.onPipelineEvent(customEvent); + intent.expectNoEvent(); + }); - it('01 - should handle status ""success"" with correct hook (expect event)', async () => { - const { connection, intent } = createConnection({ - enableHooks: ['pipeline'], + it(`should handle status "${status}" with no hooks (expect no event)`, async () => { + const { connection, intent } = createConnection({ enableHooks: [] }); + const customEvent = { + ...GITLAB_PIPELINE_EVENT, + object_attributes: { ...GITLAB_PIPELINE_EVENT.object_attributes, status }, + }; + await connection.onPipelineEvent(customEvent); + intent.expectNoEvent(); + }); }); - - const customEvent = { - ...GITLAB_PIPELINE_EVENT, - object_attributes: { - ...GITLAB_PIPELINE_EVENT.object_attributes, - status: "success", - }, - }; - - await connection.onPipelineEvent(customEvent); - intent.expectEventBodyContains("**SUCCESS**", 0); - intent.expectEventBodyContains("Pipeline", 0); - intent.expectEventBodyContains("branch `main`", 0); - intent.expectEventBodyContains("[Test Project](https://gitlab.example.com/test/project)", 0); - intent.expectEventBodyContains("**testuser**", 0); - intent.expectEventBodyContains("Duration: 120s", 0); - - }); - - it('02 - should handle status "success" with wrong hook (expect no event)', async () => { - const { connection, intent } = createConnection({ enableHooks: ['push'] }); - const customEvent = { - ...GITLAB_PIPELINE_EVENT, - object_attributes: { ...GITLAB_PIPELINE_EVENT.object_attributes, status: "success" }, - }; - await connection.onPipelineEvent(customEvent); - intent.expectNoEvent(); - }); - - it('03 - should handle status "success" with no hooks (expect no event)', async () => { - const { connection, intent } = createConnection({ enableHooks: [] }); - const customEvent = { - ...GITLAB_PIPELINE_EVENT, - object_attributes: { ...GITLAB_PIPELINE_EVENT.object_attributes, status: "success" }, - }; - await connection.onPipelineEvent(customEvent); - intent.expectNoEvent(); - }); - - it('04 - should handle status "failed" with correct hook (expect event)', async () => { - const { connection, intent } = createConnection({ enableHooks: ['pipeline'] }); - const customEvent = { - ...GITLAB_PIPELINE_EVENT, - object_attributes: { ...GITLAB_PIPELINE_EVENT.object_attributes, status: "failed" }, - }; - await connection.onPipelineEvent(customEvent); - intent.expectEventBodyContains("**FAILED**", 0); - intent.expectEventBodyContains("branch `main`", 0); - intent.expectEventBodyContains("[Test Project](https://gitlab.example.com/test/project)", 0); - intent.expectEventBodyContains("**testuser**", 0); - intent.expectEventBodyContains("Duration: 120s", 0); }); - - it('05 - should handle status "failed" with wrong hook (expect no event)', async () => { - const { connection, intent } = createConnection({ enableHooks: ['push'] }); - const customEvent = { - ...GITLAB_PIPELINE_EVENT, - object_attributes: { ...GITLAB_PIPELINE_EVENT.object_attributes, status: "failed" }, - }; - await connection.onPipelineEvent(customEvent); - intent.expectNoEvent(); - }); - - it('06 - should handle status "failed" with no hooks (expect no event)', async () => { - const { connection, intent } = createConnection({ enableHooks: [] }); - const customEvent = { - ...GITLAB_PIPELINE_EVENT, - object_attributes: { ...GITLAB_PIPELINE_EVENT.object_attributes, status: "failed" }, - }; - await connection.onPipelineEvent(customEvent); - intent.expectNoEvent(); - }); - - it('07 - should handle status "canceled" with correct hook (expect event)', async () => { - const { connection, intent } = createConnection({ enableHooks: ['pipeline'] }); - const customEvent = { - ...GITLAB_PIPELINE_EVENT, - object_attributes: { ...GITLAB_PIPELINE_EVENT.object_attributes, status: "canceled" }, - }; - await connection.onPipelineEvent(customEvent); - intent.expectEventBodyContains("**CANCELED**", 0); - intent.expectEventBodyContains("branch `main`", 0); - intent.expectEventBodyContains("[Test Project](https://gitlab.example.com/test/project)", 0); - intent.expectEventBodyContains("**testuser**", 0); - intent.expectEventBodyContains("Duration: 120s", 0); - }); - - it('08 - should handle status "canceled" with wrong hook (expect no event)', async () => { - const { connection, intent } = createConnection({ enableHooks: ['push'] }); - const customEvent = { - ...GITLAB_PIPELINE_EVENT, - object_attributes: { ...GITLAB_PIPELINE_EVENT.object_attributes, status: "canceled" }, - }; - await connection.onPipelineEvent(customEvent); - intent.expectNoEvent(); - }); - - it('09 - should handle status "canceled" with no hooks (expect no event)', async () => { - const { connection, intent } = createConnection({ enableHooks: [] }); - const customEvent = { - ...GITLAB_PIPELINE_EVENT, - object_attributes: { ...GITLAB_PIPELINE_EVENT.object_attributes, status: "canceled" }, - }; - await connection.onPipelineEvent(customEvent); - intent.expectNoEvent(); - }); - - it('10 - should handle status "running" with correct hook (expect event)', async () => { - const { connection, intent } = createConnection({ enableHooks: ['pipeline'] }); - const customEvent = { - ...GITLAB_PIPELINE_EVENT, - object_attributes: { ...GITLAB_PIPELINE_EVENT.object_attributes, status: "running" }, - }; - await connection.onPipelineEvent(customEvent); - intent.expectEventBodyContains("**RUNNING**", 0); - intent.expectEventBodyContains("branch `main`", 0); - intent.expectEventBodyContains("[Test Project](https://gitlab.example.com/test/project)", 0); - intent.expectEventBodyContains("**testuser**", 0); - intent.expectEventBodyContains("Duration: 120s", 0); - }); - - it('11 - should handle status "running" with wrong hook (expect no event)', async () => { - const { connection, intent } = createConnection({ enableHooks: ['push'] }); - const customEvent = { - ...GITLAB_PIPELINE_EVENT, - object_attributes: { ...GITLAB_PIPELINE_EVENT.object_attributes, status: "running" }, - }; - await connection.onPipelineEvent(customEvent); - intent.expectNoEvent(); - }); - - it('12 - should handle status "running" with no hooks (expect no event)', async () => { - const { connection, intent } = createConnection({ enableHooks: [] }); - const customEvent = { - ...GITLAB_PIPELINE_EVENT, - object_attributes: { ...GITLAB_PIPELINE_EVENT.object_attributes, status: "running" }, - }; - await connection.onPipelineEvent(customEvent); - intent.expectNoEvent(); - }); - - it('13 - should handle status "manual" with correct hook (expect event)', async () => { - const { connection, intent } = createConnection({ enableHooks: ['pipeline'] }); - const customEvent = { - ...GITLAB_PIPELINE_EVENT, - object_attributes: { ...GITLAB_PIPELINE_EVENT.object_attributes, status: "manual" }, - }; - await connection.onPipelineEvent(customEvent); - intent.expectEventBodyContains("**MANUAL**", 0); - intent.expectEventBodyContains("branch `main`", 0); - intent.expectEventBodyContains("[Test Project](https://gitlab.example.com/test/project)", 0); - intent.expectEventBodyContains("**testuser**", 0); - intent.expectEventBodyContains("Duration: 120s", 0); - }); - - it('14 - should handle status "manual" with wrong hook (expect no event)', async () => { - const { connection, intent } = createConnection({ enableHooks: ['push'] }); - const customEvent = { - ...GITLAB_PIPELINE_EVENT, - object_attributes: { ...GITLAB_PIPELINE_EVENT.object_attributes, status: "manual" }, - }; - await connection.onPipelineEvent(customEvent); - intent.expectNoEvent(); - }); - - it('15 - should handle status "manual" with no hooks (expect no event)', async () => { - const { connection, intent } = createConnection({ enableHooks: [] }); - const customEvent = { - ...GITLAB_PIPELINE_EVENT, - object_attributes: { ...GITLAB_PIPELINE_EVENT.object_attributes, status: "manual" }, - }; - await connection.onPipelineEvent(customEvent); - intent.expectNoEvent(); - }); - }); + }); From 5595e181dadee432fb8bb86ea2239f49886ad64d Mon Sep 17 00:00:00 2001 From: madjidDer Date: Tue, 27 May 2025 17:45:03 +0200 Subject: [PATCH 04/27] ignore intermediate pipeline statuses --- src/Connections/GitlabRepo.ts | 55 ++++++++++++++++++++--------------- 1 file changed, 31 insertions(+), 24 deletions(-) diff --git a/src/Connections/GitlabRepo.ts b/src/Connections/GitlabRepo.ts index 78bf9c874..7cb747490 100644 --- a/src/Connections/GitlabRepo.ts +++ b/src/Connections/GitlabRepo.ts @@ -977,43 +977,50 @@ ${data.description}`; } public async onPipelineEvent(event: IGitLabWebhookPipelineEvent) { - - /*console.log("HOOK FILTER CHECK:", { - enabledHooks: this.hookFilter.enabledHooks, - shouldSkip: this.hookFilter.shouldSkip("pipeline"), - });*/ - - if (this.hookFilter.shouldSkip("pipeline")) { - //console.log(">>> [qaqah] Skipping pipeline event due to filter."); return; } - - //console.log(">>> onPipelineEvent data:", this.hookFilter.enabledHooks); - log.info(`onPipelineEvent ${this.roomId} ${this.instance.url}/${this.path}`); - const { status, ref, duration, - created_at, - finished_at, } = event.object_attributes; - const content = `Pipeline **${status.toUpperCase()}** on branch \`${ref}\` for project [${event.project.name}](${event.project.web_url}) triggered by **${event.user.username}** - \nDuration: ${duration ?? "?"}s - \nStarted: ${created_at} - \nFinished: ${finished_at}`; + const statusUpper = status.toUpperCase(); + const statusHtml = statusUpper === "SUCCESS" + ? `${statusUpper}` + : statusUpper === "FAILED" + ? `${statusUpper}` + : `${statusUpper}`; + + if (statusUpper === "PENDING") { + 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", + }); + } - await this.intent.sendEvent(this.roomId, { - msgtype: "m.notice", - body: content, - formatted_body: md.render(content), - format: "org.matrix.custom.html", - }); + else if (statusUpper === "RUNNING") { + } + else { + const contentText = `Pipeline ${statusUpper} on branch \`${ref}\` for project ${event.project.name} by ${event.user.username} - Duration: ${duration ?? "?"}s`; + const contentHtml = `Pipeline ${statusHtml} on branch ${ref} for project ${event.project.name} by ${event.user.username} - 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, From 45b8bfad987e4b9c3f98e6eed7d03629ce37b031 Mon Sep 17 00:00:00 2001 From: ManilDf Date: Wed, 28 May 2025 01:02:38 +0200 Subject: [PATCH 05/27] Update pipeline tests and tweak message formatting --- src/Connections/GitlabRepo.ts | 30 +++++---- tests/connections/GitlabRepoTest.ts | 94 +++++++++++++++++------------ 2 files changed, 71 insertions(+), 53 deletions(-) diff --git a/src/Connections/GitlabRepo.ts b/src/Connections/GitlabRepo.ts index 7cb747490..0c0c2860d 100644 --- a/src/Connections/GitlabRepo.ts +++ b/src/Connections/GitlabRepo.ts @@ -988,26 +988,24 @@ ${data.description}`; } = event.object_attributes; const statusUpper = status.toUpperCase(); - const statusHtml = statusUpper === "SUCCESS" + + 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", + }); + + if (["SUCCESS", "FAILED", "CANCELED"].includes(statusUpper)) { + + const statusHtml = statusUpper === "SUCCESS" ? `${statusUpper}` - : statusUpper === "FAILED" + : statusUpper === "FAILED" || "CANCELED" ? `${statusUpper}` : `${statusUpper}`; - if (statusUpper === "PENDING") { - 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", - }); - } - - else if (statusUpper === "RUNNING") { - } - else { const contentText = `Pipeline ${statusUpper} on branch \`${ref}\` for project ${event.project.name} by ${event.user.username} - Duration: ${duration ?? "?"}s`; const contentHtml = `Pipeline ${statusHtml} on branch ${ref} for project ${event.project.name} by ${event.user.username} - Duration: ${duration ?? "?"}s`; diff --git a/tests/connections/GitlabRepoTest.ts b/tests/connections/GitlabRepoTest.ts index 0beccf805..412f4da73 100644 --- a/tests/connections/GitlabRepoTest.ts +++ b/tests/connections/GitlabRepoTest.ts @@ -422,47 +422,67 @@ describe("GitLabRepoConnection", () => { }); }); - describe("onPipelineEvent", () => { - const statuses = ["success", "failed", "canceled", "running", "manual"]; - - statuses.forEach((status) => { - describe(`Status "${status}"`, () => { - it(`should handle status "${status}" with correct hook (expect event)`, async () => { - const { connection, intent } = createConnection({ enableHooks: ["pipeline"] }); - const customEvent = { - ...GITLAB_PIPELINE_EVENT, - object_attributes: { ...GITLAB_PIPELINE_EVENT.object_attributes, status }, - }; - await connection.onPipelineEvent(customEvent); - - intent.expectEventBodyContains(`**${status.toUpperCase()}**`, 0); - intent.expectEventBodyContains("branch `main`", 0); - intent.expectEventBodyContains("[Test Project](https://gitlab.example.com/test/project)", 0); - intent.expectEventBodyContains("**testuser**", 0); - intent.expectEventBodyContains("Duration: 120s", 0); - }); + describe("onPipelineEvent", () => { + const baseEvent = { + ...GITLAB_PIPELINE_EVENT, + object_attributes: { + ...GITLAB_PIPELINE_EVENT.object_attributes, + }, + }; - it(`should handle status "${status}" with wrong hook (expect no event)`, async () => { - const { connection, intent } = createConnection({ enableHooks: ["push"] }); - const customEvent = { - ...GITLAB_PIPELINE_EVENT, - object_attributes: { ...GITLAB_PIPELINE_EVENT.object_attributes, status }, - }; - await connection.onPipelineEvent(customEvent); - intent.expectNoEvent(); - }); + it("should skip event if hook is disabled", async () => { + const { connection, intent } = createConnection({ enableHooks: [] }); + await connection.onPipelineEvent({ ...baseEvent, object_attributes: { ...baseEvent.object_attributes, status: "success" } }); + intent.expectNoEvent(); + }); - it(`should handle status "${status}" with no hooks (expect no event)`, async () => { - const { connection, intent } = createConnection({ enableHooks: [] }); - const customEvent = { - ...GITLAB_PIPELINE_EVENT, - object_attributes: { ...GITLAB_PIPELINE_EVENT.object_attributes, status }, - }; - await connection.onPipelineEvent(customEvent); - intent.expectNoEvent(); - }); + it("should send only the triggered message if pipeline just started", async () => { + const { connection, intent } = createConnection({ enableHooks: ["pipeline"] }); + await connection.onPipelineEvent({ + ...baseEvent, + object_attributes: { + ...baseEvent.object_attributes, + status: "pending", // pipeline just started + }, + }); + + intent.expectEventBodyContains("Pipeline triggered", 0); + }); + + it("should send triggered and final success message (green)", async () => { + const { connection, intent } = createConnection({ enableHooks: ["pipeline"] }); + + await connection.onPipelineEvent({ + ...baseEvent, + object_attributes: { + ...baseEvent.object_attributes, + status: "success", + }, }); + + expect(intent.sentEvents[0].content.body).to.include("triggered"); + + expect(intent.sentEvents[1].content.body).to.include("SUCCESS"); + expect(intent.sentEvents[1].content.formatted_body).to.include('SUCCESS'); }); + + it("should send triggered and final failed message (red)", async () => { + const { connection, intent } = createConnection({ enableHooks: ["pipeline"] }); + + await connection.onPipelineEvent({ + ...baseEvent, + object_attributes: { + ...baseEvent.object_attributes, + status: "failed", + }, + }); + + expect(intent.sentEvents[0].content.body).to.include("triggered"); + + expect(intent.sentEvents[1].content.body).to.include("FAILED"); + expect(intent.sentEvents[1].content.formatted_body).to.include('FAILED'); + }); + }); }); From b93b0bdd304c97634abef84ab60020c375f4f20b Mon Sep 17 00:00:00 2001 From: ManilDf Date: Thu, 29 May 2025 14:49:18 +0200 Subject: [PATCH 06/27] Add e2e Tests --- spec/gitlab-pipeline.spec.ts | 150 ++++++++++++++++++++++++++++++++--- 1 file changed, 139 insertions(+), 11 deletions(-) diff --git a/spec/gitlab-pipeline.spec.ts b/spec/gitlab-pipeline.spec.ts index a167d0ece..c471ff54c 100644 --- a/spec/gitlab-pipeline.spec.ts +++ b/spec/gitlab-pipeline.spec.ts @@ -58,7 +58,40 @@ describe("GitLab - Pipeline Event", () => { return testEnv?.tearDown(); }); - test("should be able to handle a GitLab pipeline event", async () => { + 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 both messages", async () => { const user = testEnv.getUser("user"); const bridgeApi = await getBridgeApi( testEnv.opts.config?.widgets?.publicUrl!, @@ -87,11 +120,7 @@ describe("GitLab - Pipeline Event", () => { (await bridgeApi.getConnectionsForRoom(testRoomId)).length === 1, ); - const webhookNotice = user.waitForRoomEvent({ - eventType: "m.room.message", - sender: testEnv.botMxid, - roomId: testRoomId, - }); + const messagesPromise = waitForMessages(user, testRoomId, testEnv.botMxid, 2); const webhookPayload = JSON.stringify({ object_kind: "pipeline", @@ -141,10 +170,109 @@ describe("GitLab - Pipeline Event", () => { expect(req.status).toBe(200); expect(await req.text()).toBe("OK"); - const { body } = (await webhookNotice).data.content; - expect(body.toLowerCase()).toContain("alice"); - expect(body.toLowerCase()).toContain("pipeline"); - expect(body.toLowerCase()).toContain("success"); - expect(body.toLowerCase()).toContain("main"); + const receivedMessages = await messagesPromise; + + expect(receivedMessages.length).toBe(2); + + const triggeredMessage = receivedMessages[0]; + expect(triggeredMessage.body.toLowerCase()).toContain("triggered"); + + const successMessage = receivedMessages[1]; + expect(successMessage.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"); }); }); \ No newline at end of file From a7b9a06df803be4d087fc2fc71df94d3356abc09 Mon Sep 17 00:00:00 2001 From: ManilDf Date: Thu, 29 May 2025 14:50:00 +0200 Subject: [PATCH 07/27] handle CANCELED status and deduplicate notifications --- src/Connections/GitlabRepo.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/Connections/GitlabRepo.ts b/src/Connections/GitlabRepo.ts index 0c0c2860d..d34f55eff 100644 --- a/src/Connections/GitlabRepo.ts +++ b/src/Connections/GitlabRepo.ts @@ -565,6 +565,8 @@ export class GitLabRepoConnection private readonly grantChecker; private readonly commentDebounceMs: number; + private notifiedPipelines = new Set(); + constructor( roomId: string, stateKey: string, @@ -977,18 +979,21 @@ ${data.description}`; } public async onPipelineEvent(event: IGitLabWebhookPipelineEvent) { + if (this.hookFilter.shouldSkip("pipeline")) { return; } + log.info(`onPipelineEvent ${this.roomId} ${this.instance.url}/${this.path}`); const { status, ref, duration, + id: pipelineId, } = event.object_attributes; const statusUpper = status.toUpperCase(); - + if (!this.notifiedPipelines.has(pipelineId)) { 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, { @@ -997,14 +1002,19 @@ ${data.description}`; formatted_body: triggerHtml, format: "org.matrix.custom.html", }); + + this.notifiedPipelines.add(pipelineId); + } if (["SUCCESS", "FAILED", "CANCELED"].includes(statusUpper)) { const statusHtml = statusUpper === "SUCCESS" ? `${statusUpper}` - : statusUpper === "FAILED" || "CANCELED" + : statusUpper === "FAILED" ? `${statusUpper}` - : `${statusUpper}`; + : statusUpper === "CANCELED" + ? `${statusUpper}` + : `${statusUpper}`; const contentText = `Pipeline ${statusUpper} on branch \`${ref}\` for project ${event.project.name} by ${event.user.username} - Duration: ${duration ?? "?"}s`; const contentHtml = `Pipeline ${statusHtml} on branch ${ref} for project ${event.project.name} by ${event.user.username} - Duration: ${duration ?? "?"}s`; @@ -1015,6 +1025,7 @@ ${data.description}`; formatted_body: contentHtml, format: "org.matrix.custom.html", }); + this.notifiedPipelines.delete(pipelineId); } } From 13deb0a64692744a6aeb868d09076d0a0f95aa95 Mon Sep 17 00:00:00 2001 From: madjidDer Date: Fri, 30 May 2025 14:54:56 +0200 Subject: [PATCH 08/27] docs: mention GitLab pipeline event in supported hooks --- docs/usage/room_configuration/gitlab_project.md | 5 +++++ 1 file changed, 5 insertions(+) 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 \* From 165cb88765e5d97928e8f26b3f69e2b13163f422 Mon Sep 17 00:00:00 2001 From: madjidDer Date: Fri, 30 May 2025 15:09:20 +0200 Subject: [PATCH 09/27] Add changelog Signed-off-by: madjidDer --- changelog.d/1061.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1061.feature diff --git a/changelog.d/1061.feature b/changelog.d/1061.feature new file mode 100644 index 000000000..7fff9a766 --- /dev/null +++ b/changelog.d/1061.feature @@ -0,0 +1 @@ +Adds support for GitLab 'pipeline' events via Hookshot. Thanks to @madjidDer, @ManilDF, and @leguye! \ No newline at end of file From eeff431df4a35e0f5f9288698931a0d7ed24f23d Mon Sep 17 00:00:00 2001 From: madjidDer Date: Fri, 30 May 2025 15:15:42 +0200 Subject: [PATCH 10/27] remove comments Signed-off-by: madjidDer --- src/Connections/GitlabRepo.ts | 35 ++++++++++++++++------------------- src/HookFilter.ts | 1 - 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/src/Connections/GitlabRepo.ts b/src/Connections/GitlabRepo.ts index d34f55eff..085a03678 100644 --- a/src/Connections/GitlabRepo.ts +++ b/src/Connections/GitlabRepo.ts @@ -118,9 +118,6 @@ const AllowedEvents: AllowedEventsNames[] = [ "release.created", "pipeline", ]; -// -// | "pipeline"; - const DefaultHooks = AllowedEvents; @@ -989,32 +986,32 @@ ${data.description}`; status, ref, duration, - id: pipelineId, + id: pipelineId, } = event.object_attributes; const statusUpper = status.toUpperCase(); if (!this.notifiedPipelines.has(pipelineId)) { - 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", - }); - + 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", + }); + this.notifiedPipelines.add(pipelineId); } if (["SUCCESS", "FAILED", "CANCELED"].includes(statusUpper)) { const statusHtml = statusUpper === "SUCCESS" - ? `${statusUpper}` - : statusUpper === "FAILED" - ? `${statusUpper}` - : statusUpper === "CANCELED" - ? `${statusUpper}` - : `${statusUpper}`; + ? `${statusUpper}` + : statusUpper === "FAILED" + ? `${statusUpper}` + : statusUpper === "CANCELED" + ? `${statusUpper}` + : `${statusUpper}`; const contentText = `Pipeline ${statusUpper} on branch \`${ref}\` for project ${event.project.name} by ${event.user.username} - Duration: ${duration ?? "?"}s`; const contentHtml = `Pipeline ${statusHtml} on branch ${ref} for project ${event.project.name} by ${event.user.username} - Duration: ${duration ?? "?"}s`; diff --git a/src/HookFilter.ts b/src/HookFilter.ts index daf693c6f..a0356c064 100644 --- a/src/HookFilter.ts +++ b/src/HookFilter.ts @@ -24,7 +24,6 @@ export class HookFilter { public shouldSkip(...hookName: T[]) { // Should skip if all of the hook names are missing - //console.log("→ shouldSkip called with", hookName, "vs", this.enabledHooks); return hookName.every((name) => !this.enabledHooks.includes(name)); } } From 0559e537669ea20511e910f52ac3e70fb1bc55a9 Mon Sep 17 00:00:00 2001 From: madjidDer Date: Sun, 1 Jun 2025 23:40:20 +0200 Subject: [PATCH 11/27] Fix lint issues Signed-off-by: madjidDer --- spec/gitlab-pipeline.spec.ts | 498 +++++++++++++++------------- src/Bridge.ts | 6 +- src/Connections/GitlabRepo.ts | 32 +- src/HookFilter.ts | 2 +- src/gitlab/WebhookTypes.ts | 2 +- tests/connections/GitlabRepoTest.ts | 48 ++- 6 files changed, 312 insertions(+), 276 deletions(-) diff --git a/spec/gitlab-pipeline.spec.ts b/spec/gitlab-pipeline.spec.ts index c471ff54c..fb0751129 100644 --- a/spec/gitlab-pipeline.spec.ts +++ b/spec/gitlab-pipeline.spec.ts @@ -1,278 +1,300 @@ 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 { + 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; + 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); + 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"], - }, - ], + testEnv = await E2ETestEnv.createTestEnv({ + matrixLocalparts: ["user"], + config: { + gitlab: { + webhook: { + secret: "mysecret", + }, + instances: { + test: { + url: `http://localhost:${gitlabPort}`, }, - }); - await testEnv.setUp(); - }, E2ESetupTestTimeout); - - afterAll(() => { - gitlabServer?.close(); - return testEnv?.tearDown(); + }, + }, + widgets: { + publicUrl: `http://localhost:${webhooksPort}`, + }, + listeners: [ + { + port: webhooksPort, + bindAddress: "0.0.0.0", + resources: ["webhooks", "widgets"], + }, + ], + }, }); + await testEnv.setUp(); + }, E2ESetupTestTimeout); - 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); + afterAll(() => { + gitlabServer?.close(); + return testEnv?.tearDown(); + }); - 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 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 cleanup = () => { - clearTimeout(timeout); - user.off("room.message", messageHandler); - }; + 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); + } + } + }; - user.on("room.message", messageHandler); - }); - }; + const cleanup = () => { + clearTimeout(timeout); + user.off("room.message", messageHandler); + }; - test("should handle GitLab pipeline success event with both 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 }); + user.on("room.message", messageHandler); + }); + }; - await testEnv.app.appservice.botClient.sendStateEvent( - testRoomId, - GitLabRepoConnection.CanonicalEventType, - "my-test-pipeline", - { - instance: "test", - path: "org/project", - enableHooks: ["pipeline"], - } satisfies GitLabRepoConnectionState, - ); + test( + "should handle GitLab pipeline success event with both 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 waitFor( - async () => - (await bridgeApi.getConnectionsForRoom(testRoomId)).length === 1, - ); + await testEnv.app.appservice.botClient.sendStateEvent( + testRoomId, + GitLabRepoConnection.CanonicalEventType, + "my-test-pipeline", + { + instance: "test", + path: "org/project", + enableHooks: ["pipeline"], + } satisfies GitLabRepoConnectionState, + ); - const messagesPromise = waitForMessages(user, testRoomId, testEnv.botMxid, 2); + await waitFor( + async () => + (await bridgeApi.getConnectionsForRoom(testRoomId)).length === 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 messagesPromise = waitForMessages( + user, + testRoomId, + testEnv.botMxid, + 2, + ); - const hmac = createHmac("sha256", "mysecret"); - hmac.write(webhookPayload); - hmac.end(); + 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 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, - }); + const hmac = createHmac("sha256", "mysecret"); + hmac.write(webhookPayload); + hmac.end(); - expect(req.status).toBe(200); - expect(await req.text()).toBe("OK"); + 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, + }); - const receivedMessages = await messagesPromise; + expect(req.status).toBe(200); + expect(await req.text()).toBe("OK"); - expect(receivedMessages.length).toBe(2); + const receivedMessages = await messagesPromise; - const triggeredMessage = receivedMessages[0]; - expect(triggeredMessage.body.toLowerCase()).toContain("triggered"); + expect(receivedMessages.length).toBe(2); - const successMessage = receivedMessages[1]; - expect(successMessage.body.toLowerCase()).toContain("success"); + const triggeredMessage = receivedMessages[0]; + expect(triggeredMessage.body.toLowerCase()).toContain("triggered"); - }, E2ESetupTestTimeout); + const successMessage = receivedMessages[1]; + expect(successMessage.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 }); + 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 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, - ); + 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 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 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 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, - }); + 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"); + 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)); + await waitFor(async () => receivedMessages.length >= 1, 3000); - expect(receivedMessages.length).toBe(1); - const triggeredMessage = receivedMessages[0]; - expect(triggeredMessage.body.toLowerCase()).toContain("triggered"); - }); -}); \ No newline at end of file + 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 e1be35a13..31e825fd7 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -692,9 +692,9 @@ export class Bridge { return [ ...(iid ? connManager.getConnectionsForGitLabIssueWebhook( - data.repository.homepage, - iid, - ) + data.repository.homepage, + iid, + ) : []), ...connManager.getConnectionsForGitLabRepo( data.project.path_with_namespace, diff --git a/src/Connections/GitlabRepo.ts b/src/Connections/GitlabRepo.ts index 085a03678..095cebad9 100644 --- a/src/Connections/GitlabRepo.ts +++ b/src/Connections/GitlabRepo.ts @@ -189,7 +189,8 @@ export interface GitLabTargetFilter { @Connection export class GitLabRepoConnection extends CommandConnection - implements IConnection { + implements IConnection +{ static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.gitlab.repository"; static readonly LegacyCanonicalEventType = @@ -976,18 +977,14 @@ ${data.description}`; } public async onPipelineEvent(event: IGitLabWebhookPipelineEvent) { - if (this.hookFilter.shouldSkip("pipeline")) { return; } - log.info(`onPipelineEvent ${this.roomId} ${this.instance.url}/${this.path}`); - const { - status, - ref, - duration, - id: pipelineId, - } = event.object_attributes; + log.info( + `onPipelineEvent ${this.roomId} ${this.instance.url}/${this.path}`, + ); + const { status, ref, duration, id: pipelineId } = event.object_attributes; const statusUpper = status.toUpperCase(); if (!this.notifiedPipelines.has(pipelineId)) { @@ -1004,14 +1001,14 @@ ${data.description}`; } if (["SUCCESS", "FAILED", "CANCELED"].includes(statusUpper)) { - - const statusHtml = statusUpper === "SUCCESS" - ? `${statusUpper}` - : statusUpper === "FAILED" - ? `${statusUpper}` - : statusUpper === "CANCELED" - ? `${statusUpper}` - : `${statusUpper}`; + const statusHtml = + statusUpper === "SUCCESS" + ? `${statusUpper}` + : statusUpper === "FAILED" + ? `${statusUpper}` + : statusUpper === "CANCELED" + ? `${statusUpper}` + : `${statusUpper}`; const contentText = `Pipeline ${statusUpper} on branch \`${ref}\` for project ${event.project.name} by ${event.user.username} - Duration: ${duration ?? "?"}s`; const contentHtml = `Pipeline ${statusHtml} on branch ${ref} for project ${event.project.name} by ${event.user.username} - Duration: ${duration ?? "?"}s`; @@ -1026,7 +1023,6 @@ ${data.description}`; } } - private async renderDebouncedMergeRequest( uniqueId: string, mergeRequest: IGitlabMergeRequest, diff --git a/src/HookFilter.ts b/src/HookFilter.ts index a0356c064..573a257d4 100644 --- a/src/HookFilter.ts +++ b/src/HookFilter.ts @@ -20,7 +20,7 @@ export class HookFilter { return [...resultHookSet]; } - constructor(public enabledHooks: T[] = []) { } + constructor(public enabledHooks: T[] = []) {} public shouldSkip(...hookName: T[]) { // Should skip if all of the hook names are missing diff --git a/src/gitlab/WebhookTypes.ts b/src/gitlab/WebhookTypes.ts index 61352a7ab..4db4c7776 100644 --- a/src/gitlab/WebhookTypes.ts +++ b/src/gitlab/WebhookTypes.ts @@ -239,4 +239,4 @@ export interface IGitLabWebhookPipelineEvent { created_at: string; finished_at: string; }; -} \ No newline at end of file +} diff --git a/tests/connections/GitlabRepoTest.ts b/tests/connections/GitlabRepoTest.ts index 412f4da73..49e2adf19 100644 --- a/tests/connections/GitlabRepoTest.ts +++ b/tests/connections/GitlabRepoTest.ts @@ -422,22 +422,34 @@ describe("GitLabRepoConnection", () => { }); }); - describe("onPipelineEvent", () => { - const baseEvent = { - ...GITLAB_PIPELINE_EVENT, - object_attributes: { - ...GITLAB_PIPELINE_EVENT.object_attributes, - }, - }; + describe("onPipelineEvent", () => { + let baseEvent; + + beforeEach(() => { + baseEvent = { + ...GITLAB_PIPELINE_EVENT, + object_attributes: { + ...GITLAB_PIPELINE_EVENT.object_attributes, + }, + }; + }); it("should skip event if hook is disabled", async () => { const { connection, intent } = createConnection({ enableHooks: [] }); - await connection.onPipelineEvent({ ...baseEvent, object_attributes: { ...baseEvent.object_attributes, status: "success" } }); + await connection.onPipelineEvent({ + ...baseEvent, + object_attributes: { + ...baseEvent.object_attributes, + status: "success", + }, + }); intent.expectNoEvent(); }); it("should send only the triggered message if pipeline just started", async () => { - const { connection, intent } = createConnection({ enableHooks: ["pipeline"] }); + const { connection, intent } = createConnection({ + enableHooks: ["pipeline"], + }); await connection.onPipelineEvent({ ...baseEvent, object_attributes: { @@ -450,7 +462,9 @@ describe("GitLabRepoConnection", () => { }); it("should send triggered and final success message (green)", async () => { - const { connection, intent } = createConnection({ enableHooks: ["pipeline"] }); + const { connection, intent } = createConnection({ + enableHooks: ["pipeline"], + }); await connection.onPipelineEvent({ ...baseEvent, @@ -463,11 +477,15 @@ describe("GitLabRepoConnection", () => { expect(intent.sentEvents[0].content.body).to.include("triggered"); expect(intent.sentEvents[1].content.body).to.include("SUCCESS"); - expect(intent.sentEvents[1].content.formatted_body).to.include('SUCCESS'); + expect(intent.sentEvents[1].content.formatted_body).to.include( + 'SUCCESS', + ); }); it("should send triggered and final failed message (red)", async () => { - const { connection, intent } = createConnection({ enableHooks: ["pipeline"] }); + const { connection, intent } = createConnection({ + enableHooks: ["pipeline"], + }); await connection.onPipelineEvent({ ...baseEvent, @@ -480,9 +498,9 @@ describe("GitLabRepoConnection", () => { expect(intent.sentEvents[0].content.body).to.include("triggered"); expect(intent.sentEvents[1].content.body).to.include("FAILED"); - expect(intent.sentEvents[1].content.formatted_body).to.include('FAILED'); + expect(intent.sentEvents[1].content.formatted_body).to.include( + 'FAILED', + ); }); - }); - }); From f960f233a369b1aed3167ff395cfdd91868a2899 Mon Sep 17 00:00:00 2001 From: Manil Diaf <152652065+ManilDf@users.noreply.github.com> Date: Fri, 6 Jun 2025 20:53:20 +0200 Subject: [PATCH 12/27] Update changelog.d/1061.feature Co-authored-by: Will Hunt Signed-off-by: Manil Diaf <152652065+ManilDf@users.noreply.github.com> --- changelog.d/1061.feature | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/1061.feature b/changelog.d/1061.feature index 7fff9a766..352822b9c 100644 --- a/changelog.d/1061.feature +++ b/changelog.d/1061.feature @@ -1 +1 @@ -Adds support for GitLab 'pipeline' events via Hookshot. Thanks to @madjidDer, @ManilDF, and @leguye! \ No newline at end of file +Add support for GitLab 'pipeline' event notifications. Thanks to @madjidDer, @ManilDF, and @leguye! \ No newline at end of file From 92f24b528d1085a6b6eaf5a2b5afc865bf60f98a Mon Sep 17 00:00:00 2001 From: ManilDf Date: Fri, 6 Jun 2025 21:28:06 +0200 Subject: [PATCH 13/27] Fix tests by adding missing type --- tests/connections/GitlabRepoTest.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/connections/GitlabRepoTest.ts b/tests/connections/GitlabRepoTest.ts index 49e2adf19..a941cc035 100644 --- a/tests/connections/GitlabRepoTest.ts +++ b/tests/connections/GitlabRepoTest.ts @@ -423,7 +423,8 @@ describe("GitLabRepoConnection", () => { }); describe("onPipelineEvent", () => { - let baseEvent; + + let baseEvent: IGitLabWebhookPipelineEvent; beforeEach(() => { baseEvent = { From 2fe82a5db0a197d0d1c5c86d856d7c5619fee4a8 Mon Sep 17 00:00:00 2001 From: ManilDf Date: Fri, 6 Jun 2025 23:02:53 +0200 Subject: [PATCH 14/27] refactor: add first handler for pipeline.success --- spec/gitlab-pipeline.spec.ts | 11 ++++------- src/Bridge.ts | 9 +++++++++ src/Connections/GitlabRepo.ts | 30 +++++++++++++++++++++++++----- src/Webhooks.ts | 6 ++++++ 4 files changed, 44 insertions(+), 12 deletions(-) diff --git a/spec/gitlab-pipeline.spec.ts b/spec/gitlab-pipeline.spec.ts index fb0751129..2451fa0d8 100644 --- a/spec/gitlab-pipeline.spec.ts +++ b/spec/gitlab-pipeline.spec.ts @@ -103,7 +103,7 @@ describe("GitLab - Pipeline Event", () => { }; test( - "should handle GitLab pipeline success event with both messages", + "should handle GitLab pipeline success event with one messages", async () => { const user = testEnv.getUser("user"); const bridgeApi = await getBridgeApi( @@ -140,7 +140,7 @@ describe("GitLab - Pipeline Event", () => { user, testRoomId, testEnv.botMxid, - 2, + 1, ); const webhookPayload = JSON.stringify({ @@ -193,13 +193,10 @@ describe("GitLab - Pipeline Event", () => { const receivedMessages = await messagesPromise; - expect(receivedMessages.length).toBe(2); + expect(receivedMessages.length).toBe(1); const triggeredMessage = receivedMessages[0]; - expect(triggeredMessage.body.toLowerCase()).toContain("triggered"); - - const successMessage = receivedMessages[1]; - expect(successMessage.body.toLowerCase()).toContain("success"); + expect(triggeredMessage.body.toLowerCase()).toContain("success"); }, E2ESetupTestTimeout, ); diff --git a/src/Bridge.ts b/src/Bridge.ts index 31e825fd7..96b592e13 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -614,6 +614,15 @@ export class Bridge { (c, data) => c.onWikiPageEvent(data), ); + this.bindHandlerToQueue( + "gitlab.pipeline.success", + (data) => + connManager.getConnectionsForGitLabRepo( + data.project.path_with_namespace, + ), + (c, data) => c.onPipelineSuccess(data), + ); + this.bindHandlerToQueue( "gitlab.pipeline", (data) => diff --git a/src/Connections/GitlabRepo.ts b/src/Connections/GitlabRepo.ts index 095cebad9..11f5054e7 100644 --- a/src/Connections/GitlabRepo.ts +++ b/src/Connections/GitlabRepo.ts @@ -99,7 +99,8 @@ type AllowedEventsNames = | `wiki.${string}` | "release" | "release.created" - | "pipeline"; + | "pipeline" + | "pipeline.success"; const AllowedEvents: AllowedEventsNames[] = [ "merge_request.open", @@ -117,6 +118,7 @@ const AllowedEvents: AllowedEventsNames[] = [ "release", "release.created", "pipeline", + "pipeline.success", ]; const DefaultHooks = AllowedEvents; @@ -1000,11 +1002,9 @@ ${data.description}`; this.notifiedPipelines.add(pipelineId); } - if (["SUCCESS", "FAILED", "CANCELED"].includes(statusUpper)) { + if (["FAILED", "CANCELED"].includes(statusUpper)) { const statusHtml = - statusUpper === "SUCCESS" - ? `${statusUpper}` - : statusUpper === "FAILED" + statusUpper === "FAILED" ? `${statusUpper}` : statusUpper === "CANCELED" ? `${statusUpper}` @@ -1023,6 +1023,26 @@ ${data.description}`; } } + 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: ${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/Webhooks.ts b/src/Webhooks.ts index a8460527b..6badd32c0 100644 --- a/src/Webhooks.ts +++ b/src/Webhooks.ts @@ -10,6 +10,7 @@ import { IGitLabWebhookEvent, IGitLabWebhookIssueStateEvent, IGitLabWebhookMREvent, + IGitLabWebhookPipelineEvent, IGitLabWebhookReleaseEvent, } from "./gitlab/WebhookTypes"; import { @@ -183,6 +184,11 @@ export class Webhooks extends EventEmitter { } else if (body.object_kind === "push") { return `gitlab.push`; } else if (body.object_kind === "pipeline") { + const pipeline_event = (body as unknown as IGitLabWebhookPipelineEvent) + const status = pipeline_event.object_attributes?.status?.toLowerCase(); + if (status === "success") { + return "gitlab.pipeline.success"; + } return "gitlab.pipeline"; } else { return null; From 0112b90315a1660fa6c295c6c5459ab56a02ef34 Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Fri, 6 Jun 2025 13:58:28 +0100 Subject: [PATCH 15/27] Ping homeserver on startup and warn about configuration errors (#1062) * Add warning for failing to reach HS * Update types * fixup test * tweaks * lint --- changelog.d/1062.feature | 1 + package.json | 2 +- spec/permissions.spec.ts | 3 +-- spec/util/e2e-test.ts | 15 +-------------- spec/util/homerunner.ts | 2 -- src/Bridge.ts | 17 +++++++++++++++++ yarn.lock | 8 ++++---- 7 files changed, 25 insertions(+), 23 deletions(-) create mode 100644 changelog.d/1062.feature diff --git a/changelog.d/1062.feature b/changelog.d/1062.feature new file mode 100644 index 000000000..592409ea9 --- /dev/null +++ b/changelog.d/1062.feature @@ -0,0 +1 @@ +Hookshot will now ping the homeserver on startup to ensure it can be reached. diff --git a/package.json b/package.json index c954ca35e..2f2aa68fc 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "jira-client": "^8.2.2", "markdown-it": "^14.0.0", "matrix-appservice-bridge": "^9.0.1", - "matrix-bot-sdk": "npm:@vector-im/matrix-bot-sdk@0.7.1-element.8", + "matrix-bot-sdk": "npm:@vector-im/matrix-bot-sdk@0.7.1-element.9", "matrix-widget-api": "^1.10.0", "micromatch": "^4.0.8", "mime": "^4.0.4", diff --git a/spec/permissions.spec.ts b/spec/permissions.spec.ts index 791c4496a..05d874c2b 100644 --- a/spec/permissions.spec.ts +++ b/spec/permissions.spec.ts @@ -60,8 +60,7 @@ describe("Permissions test", () => { sender: testEnv.botMxid, roomId, }); - // XXX: Missing type - expect((data.content as any)["reason"]).to.equal( + expect(data.content.reason).to.equal( "You do not have permission to invite this bot.", ); diff --git a/spec/util/e2e-test.ts b/spec/util/e2e-test.ts index 941c46b71..8d55a3830 100644 --- a/spec/util/e2e-test.ts +++ b/spec/util/e2e-test.ts @@ -377,6 +377,7 @@ export class E2ETestEnv { } const registration: IAppserviceRegistration = { + id: "hookshot", as_token: homeserver.asToken, hs_token: homeserver.hsToken, sender_localpart: "hookshot", @@ -462,10 +463,6 @@ export class E2ETestEnv { private readonly dir: string, ) { const appService = app.appservice; - // Setup the appservice ping endpoint - appService.expressAppInstance.post("/_matrix/app/v1/ping", (_req, res) => - res.status(200).send({}), - ); // Patch the "begin" function to expose host ports, and ping the appservice // The reason we don't do this unconditionally, is that if we never start the appservice, @@ -478,16 +475,6 @@ export class E2ETestEnv { // It looks like having the port forwarder setup before // we actually start the appservice sometimes causes issues await TestContainers.exposeHostPorts(config.bridge.port); - - // Ask the HS to ping the appservice. - // TODO: Because of crypto reasons, the appservice bot client might not be a "true" appservice session - // but instead a crypto session. For this reason we need to do a raw request. - new MatrixClient(homeserver.url, homeserver.asToken).doRequest( - "POST", - `/_matrix/client/v1/appservice/hookshot/ping`, - null, - {}, - ); }; } diff --git a/spec/util/homerunner.ts b/spec/util/homerunner.ts index e4ffe8cf4..176e84a84 100644 --- a/spec/util/homerunner.ts +++ b/spec/util/homerunner.ts @@ -2,13 +2,11 @@ import { MatrixClient, MemoryStorageProvider, RustSdkCryptoStorageProvider, - RustSdkCryptoStoreType, } from "matrix-bot-sdk"; import { createHash, createHmac } from "crypto"; import { E2ETestMatrixClient, E2ETestMatrixClientOpts } from "./e2e-test"; import path from "node:path"; import { createContainers, TestContainerNetwork } from "./containers"; -import { TestContainers } from "testcontainers"; export interface TestHomeServer { url: string; diff --git a/src/Bridge.ts b/src/Bridge.ts index 96b592e13..2ec8bad1b 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -11,6 +11,7 @@ import { EventKind, PowerLevelsEvent, Intent, + MatrixError, } from "matrix-bot-sdk"; import BotUsersManager from "./managers/BotUsersManager"; import { @@ -1187,6 +1188,22 @@ export class Bridge { this.listener.bindResource("webhooks", webhookHandler.expressRouter); await this.as.begin(); + log.info(`Pinging homeserver to validate connection`); + try { + await this.as.pingHomeserver(); + } catch (ex) { + if (ex instanceof MatrixError && ex.errcode === "M_CONNECTION_FAILED") { + log.error( + "**WARNING**: The homeserver reports it is unable to contact Hookshot. This will render Hookshot unusable until fixed." + + "Verify that the bridge.port / bridge.bindAddress in the config is reachable. Consult your homeserver documentation for appservice configuration.", + ); + } else { + log.warn( + "Homeserver was pinged but was unable to validate the connection. You may be running a unsupported configuration.", + ex, + ); + } + } log.info( `Bridge is now ready. Found ${this.connectionManager.size} connections`, ); diff --git a/yarn.lock b/yarn.lock index 634ed26c3..1289eb664 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5919,10 +5919,10 @@ matrix-appservice@^2.0.0: js-yaml "^4.1.0" morgan "^1.10.0" -"matrix-bot-sdk@npm:@vector-im/matrix-bot-sdk@0.7.1-element.8": - version "0.7.1-element.8" - resolved "https://registry.yarnpkg.com/@vector-im/matrix-bot-sdk/-/matrix-bot-sdk-0.7.1-element.8.tgz#fffbbd7044834ba1e35778200203977b6ee6f07d" - integrity sha512-dvQpkeit+RhbyiSUgOVLwqCaS1rVi0ScWt5JbqM0NrOkR0xWaMUF+Lu8/PdsV7vRgfgh09mprBSjIFvHdoQgcg== +"matrix-bot-sdk@npm:@vector-im/matrix-bot-sdk@0.7.1-element.9": + version "0.7.1-element.9" + resolved "https://registry.yarnpkg.com/@vector-im/matrix-bot-sdk/-/matrix-bot-sdk-0.7.1-element.9.tgz#63c03b76e2330bc0c9faf6607bdce636eabac7ab" + integrity sha512-Jmvo85Z2waX64ZtoHHG6tfguAf1UA2adPKIGwxDKY+4zgLqQjo4WoirU0DTHbLR1P72nlMJ0uBdryNSnfg80ng== dependencies: "@matrix-org/matrix-sdk-crypto-nodejs" "0.3.0-beta.1" "@types/express" "^4.17.21" From e708a23d9ee6b4a61ca8cd3cb4982ddd8be9ce8c Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Fri, 6 Jun 2025 15:35:48 +0100 Subject: [PATCH 16/27] Add option to omit the hook payload data from matrix events from generic hooks (#1004) * Add an option at the config and state level to disable hook bodies. * add a test * changelog * no fake requried * default to state, then config for data inclusion * default to including webhook state * allow undefined * update new config * update tests * document --- changelog.d/1004.feature | 1 + config.sample.yml | 1 + docs/setup/webhooks.md | 3 ++ spec/generic-hooks.spec.ts | 56 ++++++++++++++++++++++++++++- src/Connections/GenericHook.ts | 22 ++++++++++-- src/config/sections/GenericHooks.ts | 3 ++ 6 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 changelog.d/1004.feature diff --git a/changelog.d/1004.feature b/changelog.d/1004.feature new file mode 100644 index 000000000..c0317f945 --- /dev/null +++ b/changelog.d/1004.feature @@ -0,0 +1 @@ +Add an option in the config to disable hook bodies in Matrix messages. diff --git a/config.sample.yml b/config.sample.yml index a70efff34..6d8c4c5ad 100644 --- a/config.sample.yml +++ b/config.sample.yml @@ -118,6 +118,7 @@ listeners: # sendExpiryNotice: false # requireExpiryTime: false # maxExpiryTime: 30d +# includeHookBody: true #figma: # # (Optional) Configure this to enable Figma support diff --git a/docs/setup/webhooks.md b/docs/setup/webhooks.md index d01baa652..a61b91396 100644 --- a/docs/setup/webhooks.md +++ b/docs/setup/webhooks.md @@ -41,6 +41,9 @@ webhook requests from `https://example.com/mywebhookspath` to the bridge (on `/w respond with a 200 as soon as the webhook has entered processing (`false`) while others prefer to know if the resulting Matrix message has been sent (`true`). By default this is `false`. +`includeHookBody` will determine whether the bridge will include the full webhook request body +(`uk.half-shot.hookshot.webhook_data`) inside the Matrix event. By default this is `true`. + `enableHttpGet` means that webhooks can be triggered by `GET` requests, in addition to `POST` and `PUT`. This was previously on by default, but is now disabled due to concerns mentioned below. diff --git a/spec/generic-hooks.spec.ts b/spec/generic-hooks.spec.ts index 13678cb12..f9125a68a 100644 --- a/spec/generic-hooks.spec.ts +++ b/spec/generic-hooks.spec.ts @@ -3,7 +3,15 @@ import { E2ETestEnv, E2ETestMatrixClient, } from "./util/e2e-test"; -import { describe, test, beforeAll, afterAll, expect, vitest } from "vitest"; +import { + describe, + test, + beforeAll, + afterAll, + afterEach, + expect, + vitest, +} from "vitest"; import { GenericHookConnection } from "../src/Connections"; import { TextualMessageEventContent } from "matrix-bot-sdk"; import { add } from "date-fns/add"; @@ -78,6 +86,10 @@ describe("Inbound (Generic) Webhooks", () => { return testEnv?.tearDown(); }); + afterEach(() => { + vitest.useRealTimers(); + }); + test("should be able to create a new webhook and handle an incoming request.", async () => { const user = testEnv.getUser("user"); const roomId = await user.createRoom({ name: "My Test Webhooks room" }); @@ -159,4 +171,46 @@ describe("Inbound (Generic) Webhooks", () => { error: "This hook has expired", }); }); + test("should allow disabling hook data in matrix events.", async () => { + const user = testEnv.getUser("user"); + const roomId = await user.createRoom({ name: "My Test Webhooks room" }); + const okMsg = user.waitForRoomEvent({ + eventType: "m.room.message", + sender: testEnv.botMxid, + roomId, + }); + const url = await createInboundConnection(user, testEnv.botMxid, roomId); + expect((await okMsg).data.content.body).toEqual( + "Room configured to bridge webhooks. See admin room for secret url.", + ); + + await user.sendStateEvent( + roomId, + GenericHookConnection.CanonicalEventType, + "test", + { + ...(await user.getRoomStateEvent( + roomId, + GenericHookConnection.CanonicalEventType, + "test", + )), + includeHookBody: false, + }, + ); + + const expectedMsg = user.waitForRoomEvent({ + eventType: "m.room.message", + sender: testEnv.botMxid, + roomId, + }); + const req = await fetch(url, { + method: "PUT", + body: "Hello world", + }); + expect(req.status).toEqual(200); + expect(await req.json()).toEqual({ ok: true }); + expect( + (await expectedMsg).data.content["uk.half-shot.hookshot.webhook_data"], + ).toBeUndefined(); + }); }); diff --git a/src/Connections/GenericHook.ts b/src/Connections/GenericHook.ts index 5eb04b7cd..41aa20760 100644 --- a/src/Connections/GenericHook.ts +++ b/src/Connections/GenericHook.ts @@ -41,6 +41,11 @@ export interface GenericHookConnectionState extends IConnectionState { */ waitForComplete?: boolean | undefined; + /** + * Should the Matrix event include the `uk.half-shot.hookshot.webhook_data` property. + */ + includeHookBody?: boolean; + /** * If the webhook has an expriation date, then the date at which the webhook is no longer value * (in UTC) time. @@ -164,6 +169,7 @@ export class GenericHookConnection transformationFunction, waitForComplete, expirationDate: expirationDateStr, + includeHookBody, } = state; if (!name) { throw new ApiError("Missing name", ErrCode.BadValue); @@ -180,6 +186,12 @@ export class GenericHookConnection ErrCode.BadValue, ); } + if (includeHookBody !== undefined && typeof includeHookBody !== "boolean") { + throw new ApiError( + "'includeHookBody' must be a boolean", + ErrCode.BadValue, + ); + } // Use !=, not !==, to check for both undefined and null if (transformationFunction != undefined) { if (!WebhookTransformer.canTransform) { @@ -217,6 +229,7 @@ export class GenericHookConnection transformationFunction: transformationFunction || undefined, waitForComplete, expirationDate, + includeHookBody, }; } @@ -639,7 +652,10 @@ export class GenericHookConnection ); // Matrix cannot handle float data, so make sure we parse out any floats. - const safeData = GenericHookConnection.sanitiseObjectForMatrixJSON(data); + const safeData = + (this.state.includeHookBody ?? this.config.includeHookBody) + ? GenericHookConnection.sanitiseObjectForMatrixJSON(data) + : undefined; await this.messageClient.sendMatrixMessage( this.roomId, @@ -652,7 +668,9 @@ export class GenericHookConnection ? { "m.mentions": content.mentions } : undefined), format: "org.matrix.custom.html", - "uk.half-shot.hookshot.webhook_data": safeData, + ...(safeData + ? { "uk.half-shot.hookshot.webhook_data": safeData } + : undefined), }, "m.room.message", sender, diff --git a/src/config/sections/GenericHooks.ts b/src/config/sections/GenericHooks.ts index 14bfa04c9..06d3bdb7a 100644 --- a/src/config/sections/GenericHooks.ts +++ b/src/config/sections/GenericHooks.ts @@ -18,6 +18,7 @@ export interface BridgeGenericWebhooksConfigYAML { maxExpiryTime?: string; sendExpiryNotice?: boolean; requireExpiryTime?: boolean; + includeHookBody?: boolean; } export class BridgeConfigGenericWebhooks { @@ -39,6 +40,7 @@ export class BridgeConfigGenericWebhooks { public readonly requireExpiryTime: boolean; // Public facing value for config generator public readonly maxExpiryTime?: string; + public readonly includeHookBody: boolean; constructor(yaml: BridgeGenericWebhooksConfigYAML) { this.enabled = yaml.enabled || false; @@ -46,6 +48,7 @@ export class BridgeConfigGenericWebhooks { this.enableHttpGet = yaml.enableHttpGet || false; this.sendExpiryNotice = yaml.sendExpiryNotice || false; this.requireExpiryTime = yaml.requireExpiryTime || false; + this.includeHookBody = yaml.includeHookBody ?? true; try { this.parsedUrlPrefix = makePrefixedUrl(yaml.urlPrefix); this.urlPrefix = () => { From 25364a601f290c3d40daa295eebe8f3120dceb6e Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Fri, 6 Jun 2025 15:36:02 +0100 Subject: [PATCH 17/27] Migrate GitHub, GitLab, and JIRA webhook path from `/` to `//webhook` (#1063) * Refactor and migrate webhook paths forGitHub * changelog * begin moving Gitlab * Test both paths on GitHub * Add tests for GitLab. * Move JIRA webhook handling to router * Update docs with new webhook info. * Use /webhook for path * lint --- changelog.d/1063.misc | 1 + docs/SUMMARY.md | 1 - docs/advanced/provisioning.md | 5 - docs/setup.md | 10 +- docs/setup/github.md | 6 +- docs/setup/gitlab.md | 6 +- docs/setup/jira.md | 6 +- spec/github.spec.ts | 170 +++---- spec/gitlab.spec.ts | 146 ++++++ spec/jira.spec.ts | 128 ++--- src/Bridge.ts | 12 +- src/Webhooks.ts | 406 ++------------- src/github/Router.ts | 491 ++++++++++--------- src/gitlab/Router.ts | 112 +++++ src/jira/Router.ts | 36 +- src/notifications/UserNotificationWatcher.ts | 20 +- src/openproject/Router.ts | 9 - 17 files changed, 798 insertions(+), 767 deletions(-) create mode 100644 changelog.d/1063.misc delete mode 100644 docs/advanced/provisioning.md create mode 100644 spec/gitlab.spec.ts create mode 100644 src/gitlab/Router.ts diff --git a/changelog.d/1063.misc b/changelog.d/1063.misc new file mode 100644 index 000000000..9200bbd76 --- /dev/null +++ b/changelog.d/1063.misc @@ -0,0 +1 @@ +GitHub and GitLab webhook requests should now be directed to /github and /gitlab respectively. `/` and `/oauth` is now deprecated and will be removed in a future release. diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 0dde03836..7a2dc5118 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -28,7 +28,6 @@ # 🥼 Advanced -- [Provisioning](./advanced/provisioning.md) - [Workers](./advanced/workers.md) - [🔒 Encryption](./advanced/encryption.md) - [🪀 Widgets](./advanced/widgets.md) diff --git a/docs/advanced/provisioning.md b/docs/advanced/provisioning.md deleted file mode 100644 index 8e0485201..000000000 --- a/docs/advanced/provisioning.md +++ /dev/null @@ -1,5 +0,0 @@ -# Provisioning - -This section is not complete yet for end users. For developers, you can read the documentation for the API below: - -{{#include ../../src/provisioning/api.md}} diff --git a/docs/setup.md b/docs/setup.md index b14c14111..e4c5d2e55 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -231,8 +231,14 @@ This will pass all requests at `/widgetapi` to Hookshot. In terms of API endpoints: -- The `webhooks` resource handles resources under `/`, so it should be on its own listener. - Note that OAuth requests also go through this listener. Previous versions of the bridge listened for requests on `/` rather than `/webhook`. While this behaviour will continue to work, administators are advised to use `/webhook`. +- The `webhooks` resource handles incoming webhooks for various services. Currently, these are: + - `/github` for GitHub + - `/gitlab` for GitLab + - `/jira` for JIRA + - `/webhooks` for Generic Webhooks + - `/openproject` for OpenProject + - `/figma` for Figma. + - `/openproject` for OpenProject. - The `metrics` resource handles resources under `/metrics`. - The `provisioning` resource handles resources under `/v1/...`. - The `widgets` resource handles resources under `/widgetapi/v1...`. This may only be bound to **one** listener at present. diff --git a/docs/setup/github.md b/docs/setup/github.md index 07b324a0c..9f52206f5 100644 --- a/docs/setup/github.md +++ b/docs/setup/github.md @@ -6,7 +6,11 @@ This bridge requires a [GitHub App](https://github.com/settings/apps/new). You w ### Webhook -The **Webhook URL** should point to the public address of your hookshot instance, at the `/` path. +
+Previously Hookshot supported / as the public path for webhook delivery. This path is now deprecated and /github/webhook should be used wherever possible. +
+ +The **Webhook URL** should point to the public address of your hookshot instance, at the `/github/webhook` path. You **MUST** also provide a secret, which should match the `github.webhook.secret` value in your config. ### Permissions diff --git a/docs/setup/gitlab.md b/docs/setup/gitlab.md index d802e4bac..2c27c74b9 100644 --- a/docs/setup/gitlab.md +++ b/docs/setup/gitlab.md @@ -23,7 +23,11 @@ to specify an instance. You should generate a webhook `secret` (e.g. `pwgen -n 64 -s 1`) and then use this as your "Secret token" when adding webhooks. -The `publicUrl` must be the URL where GitLab webhook events are received (i.e. the path to `/` +
+Previously Hookshot supported / as the public path for webhook delivery. This path is now deprecated and /gitlab/webhook should be used wherever possible. +
+ +The `publicUrl` must be the URL where GitLab webhook events are received (i.e. the path to `/gitlab/webhook` for your `webhooks` listener).
diff --git a/docs/setup/jira.md b/docs/setup/jira.md index 08f71a288..235c12e5c 100644 --- a/docs/setup/jira.md +++ b/docs/setup/jira.md @@ -4,6 +4,10 @@ This should be done for the JIRA instance you wish to bridge. The setup steps vary for Cloud and Enterprise (on-premise). +
+Previously Hookshot supported / as the public path for webhook delivery. This path is now deprecated and /jira should be used wherever possible. +
+ ### Cloud See https://support.atlassian.com/jira-cloud-administration/docs/manage-webhooks/ for documentation on how to setup webhooks. @@ -15,7 +19,7 @@ Hookshot **requires** that you use a secret. Please copy the generated secret va You need to go to the `WebHooks` configuration page under Settings > System. Note that this may require administrative access to the JIRA instance. -Next, add a webhook that points to `/` on the public webhooks address for hookshot. You must also include a +Next, add a webhook that points to `/jira/webhook` on the public webhooks address for hookshot. You must also include a secret value by appending `?secret=your-webhook-secret`. The secret value can be anything, but should be reasonably secure and should also be stored in the `config.yml` file. diff --git a/spec/github.spec.ts b/spec/github.spec.ts index dd4018978..043474377 100644 --- a/spec/github.spec.ts +++ b/spec/github.spec.ts @@ -71,95 +71,101 @@ describe("GitHub", () => { return testEnv?.tearDown(); }); - test("should be able to handle a GitHub event", async () => { - const user = testEnv.getUser("user"); - const bridgeApi = await getBridgeApi( - testEnv.opts.config?.widgets?.publicUrl!, - user, - ); - const testRoomId = await user.createRoom({ - name: "Test room", - invite: [testEnv.botMxid], - }); - await user.setUserPowerLevel(testEnv.botMxid, testRoomId, 50); - await user.waitForRoomJoin({ sender: testEnv.botMxid, roomId: testRoomId }); - // Now hack in a GitHub connection. - await testEnv.app.appservice.botClient.sendStateEvent( - testRoomId, - GitHubRepoConnection.CanonicalEventType, - "my-test", - { - org: "my-org", - repo: "my-repo", - } satisfies GitHubRepoConnectionState, - ); + test.each(["/", "/github/webhook"])( + "should be able to handle a GitHub event (on path %s)", + async (path) => { + const user = testEnv.getUser("user"); + const bridgeApi = await getBridgeApi( + testEnv.opts.config?.widgets?.publicUrl!, + user, + ); + const testRoomId = await user.createRoom({ + name: "Test room", + invite: [testEnv.botMxid], + }); + await user.setUserPowerLevel(testEnv.botMxid, testRoomId, 50); + await user.waitForRoomJoin({ + sender: testEnv.botMxid, + roomId: testRoomId, + }); + // Now hack in a GitHub connection. + await testEnv.app.appservice.botClient.sendStateEvent( + testRoomId, + GitHubRepoConnection.CanonicalEventType, + "my-test", + { + org: "my-org", + repo: "my-repo", + } satisfies GitHubRepoConnectionState, + ); - // Wait for connection to be accepted. - await waitFor( - async () => - (await bridgeApi.getConnectionsForRoom(testRoomId)).length === 1, - ); + // Wait for connection to be accepted. + await waitFor( + async () => + (await bridgeApi.getConnectionsForRoom(testRoomId)).length === 1, + ); - const webhookNotice = user.waitForRoomEvent({ - eventType: "m.room.message", - sender: testEnv.botMxid, - roomId: testRoomId, - }); + const webhookNotice = user.waitForRoomEvent({ + eventType: "m.room.message", + sender: testEnv.botMxid, + roomId: testRoomId, + }); - const webhookPayload = JSON.stringify({ - action: "opened", - number: 1, - pull_request: { - id: 1, - url: "https://api.github.com/repos/my-org/my-repo/pulls/1", - html_url: "https://github.com/my-org/my-repo/pulls/1", + const webhookPayload = JSON.stringify({ + action: "opened", number: 1, - state: "open", - locked: false, - title: "My test pull request", - user: { - login: "alice", + pull_request: { + id: 1, + url: "https://api.github.com/repos/my-org/my-repo/pulls/1", + html_url: "https://github.com/my-org/my-repo/pulls/1", + number: 1, + state: "open", + locked: false, + title: "My test pull request", + user: { + login: "alice", + }, }, - }, - repository: { - id: 1, - html_url: "https://github.com/my-org/my-repo", - name: "my-repo", - full_name: "my-org/my-repo", - owner: { - login: "my-org", + repository: { + id: 1, + html_url: "https://github.com/my-org/my-repo", + name: "my-repo", + full_name: "my-org/my-repo", + owner: { + login: "my-org", + }, }, - }, - sender: { - login: "alice", - }, - }); + sender: { + login: "alice", + }, + }); - const hmac = createHmac( - "sha256", - testEnv.opts.config?.github?.webhook.secret!, - ); - hmac.write(webhookPayload); - hmac.end(); + const hmac = createHmac( + "sha256", + testEnv.opts.config?.github?.webhook.secret!, + ); + hmac.write(webhookPayload); + hmac.end(); - // Send a webhook - const req = await fetch(`http://localhost:${webhooksPort}/`, { - method: "POST", - headers: { - "x-github-event": "pull_request", - "X-Hub-Signature-256": `sha256=${hmac.read().toString("hex")}`, - "X-GitHub-Delivery": randomUUID(), - "Content-Type": "application/json", - }, - body: webhookPayload, - }); - expect(req.status).toBe(200); - expect(await req.text()).toBe("OK"); + // Send a webhook + const req = await fetch(`http://localhost:${webhooksPort}${path}`, { + method: "POST", + headers: { + "x-github-event": "pull_request", + "X-Hub-Signature-256": `sha256=${hmac.read().toString("hex")}`, + "X-GitHub-Delivery": randomUUID(), + "Content-Type": "application/json", + }, + body: webhookPayload, + }); + expect(req.status).toBe(200); + expect(await req.text()).toBe("OK"); - // And await the notice. - const { body } = (await webhookNotice).data.content; - expect(body).toContain("**alice** opened a new PR"); - expect(body).toContain("https://github.com/my-org/my-repo/pulls/1"); - expect(body).toContain("My test pull request"); - }); + // And await the notice. + const { body } = (await webhookNotice).data.content; + expect(body).toContain("**alice** opened a new PR"); + expect(body).toContain("https://github.com/my-org/my-repo/pulls/1"); + expect(body).toContain("My test pull request"); + }, + ); }); diff --git a/spec/gitlab.spec.ts b/spec/gitlab.spec.ts new file mode 100644 index 000000000..cc2c54d9c --- /dev/null +++ b/spec/gitlab.spec.ts @@ -0,0 +1,146 @@ +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 { IGitLabWebhookMREvent } from "../src/gitlab/WebhookTypes"; + +describe("GitLab", () => { + let testEnv: E2ETestEnv; + const webhooksPort = 9500 + E2ETestEnv.workerId; + + beforeAll(async () => { + testEnv = await E2ETestEnv.createTestEnv({ + matrixLocalparts: ["user"], + config: { + gitlab: { + webhook: { + secret: randomUUID(), + }, + instances: { + "example.org": { + url: "http://gitlab.example.org", + }, + }, + }, + widgets: { + publicUrl: `http://localhost:${webhooksPort}`, + }, + listeners: [ + { + port: webhooksPort, + bindAddress: "0.0.0.0", + // Bind to the SAME listener to ensure we don't have conflicts. + resources: ["webhooks", "widgets"], + }, + ], + }, + }); + await testEnv.setUp(); + }, E2ESetupTestTimeout); + + afterAll(() => { + return testEnv?.tearDown(); + }); + + test.each(["/", "/gitlab/webhook"])( + "should be able to handle a GitLab event (on path %s)", + async (path) => { + const user = testEnv.getUser("user"); + const bridgeApi = await getBridgeApi( + testEnv.opts.config?.widgets?.publicUrl!, + user, + ); + const testRoomId = await user.createRoom({ + name: "Test room", + invite: [testEnv.botMxid], + }); + await user.setUserPowerLevel(testEnv.botMxid, testRoomId, 50); + await user.waitForRoomJoin({ + sender: testEnv.botMxid, + roomId: testRoomId, + }); + // Now hack in a GitLab connection. + await testEnv.app.appservice.botClient.sendStateEvent( + testRoomId, + GitLabRepoConnection.CanonicalEventType, + "my-test", + { + instance: "example.org", + path: "my-project", + } satisfies GitLabRepoConnectionState, + ); + + // Wait for connection to be accepted. + await waitFor( + async () => + (await bridgeApi.getConnectionsForRoom(testRoomId)).length === 1, + ); + + const webhookNotice = user.waitForRoomEvent({ + eventType: "m.room.message", + sender: testEnv.botMxid, + roomId: testRoomId, + }); + + const webhookPayload = JSON.stringify({ + object_kind: "merge_request", + event_type: "any", + user: { + username: "alice", + name: "Alice", + email: "alice@example.org", + avatar_url: "foobar", + }, + project: { + path_with_namespace: "my-project", + web_url: "https://gilab.example.org/my-project", + homepage: "foo", + }, + repository: { + name: "example", + homepage: "foo", + url: "https://gilab.example.org", + description: "https://gilab.example.org/my-project", + }, + object_attributes: { + action: "open", + title: "My test MR", + url: "https://gilab.example.org/my-project/-/merge_requests/1", + iid: 0, + author_id: 0, + state: "opened", + labels: [], + }, + labels: [], + changes: {}, + } satisfies IGitLabWebhookMREvent); + + // Send a webhook + const req = await fetch(`http://localhost:${webhooksPort}${path}`, { + method: "POST", + headers: { + "x-gitlab-token": testEnv.opts.config?.gitlab?.webhook.secret!, + "Content-Type": "application/json", + }, + body: webhookPayload, + }); + expect(req.status).toBe(200); + expect(await req.text()).toBe("OK"); + + // And await the notice. + const { body } = (await webhookNotice).data.content; + expect(body).toContain("**alice** opened a new MR"); + expect(body).toContain( + "https://gilab.example.org/my-project/-/merge_requests/1", + ); + expect(body).toContain("My test MR"); + }, + ); +}); diff --git a/spec/jira.spec.ts b/spec/jira.spec.ts index 379630488..68dc2569e 100644 --- a/spec/jira.spec.ts +++ b/spec/jira.spec.ts @@ -113,72 +113,78 @@ describe("JIRA", () => { return testEnv?.tearDown(); }); - test("should be able to handle a JIRA event", async () => { - const user = testEnv.getUser("user"); - const bridgeApi = await getBridgeApi( - testEnv.opts.config?.widgets?.publicUrl!, - user, - ); - const testRoomId = await user.createRoom({ - name: "Test room", - invite: [testEnv.botMxid], - }); - await user.setUserPowerLevel(testEnv.botMxid, testRoomId, 50); - const jiraURL = JIRA_PAYLOAD.issue.fields.project.self; - // Pre-grant connection to allow us to bypass the oauth dance. - await user.waitForRoomJoin({ sender: testEnv.botMxid, roomId: testRoomId }); - const granter = new JiraGrantChecker(testEnv.app.appservice, null as any); - await granter.grantConnection(testRoomId, { - url: jiraURL, - }); - - // "Create" a JIRA connection. - await testEnv.app.appservice.botClient.sendStateEvent( - testRoomId, - JiraProjectConnection.CanonicalEventType, - jiraURL, - { + test.each(["/", "/jira/webhook"])( + "should be able to handle a JIRA event (on path %s)", + async (path) => { + const user = testEnv.getUser("user"); + const bridgeApi = await getBridgeApi( + testEnv.opts.config?.widgets?.publicUrl!, + user, + ); + const testRoomId = await user.createRoom({ + name: "Test room", + invite: [testEnv.botMxid], + }); + await user.setUserPowerLevel(testEnv.botMxid, testRoomId, 50); + const jiraURL = JIRA_PAYLOAD.issue.fields.project.self; + // Pre-grant connection to allow us to bypass the oauth dance. + await user.waitForRoomJoin({ + sender: testEnv.botMxid, + roomId: testRoomId, + }); + const granter = new JiraGrantChecker(testEnv.app.appservice, null as any); + await granter.grantConnection(testRoomId, { url: jiraURL, - } satisfies JiraProjectConnectionState, - ); + }); - await waitFor( - async () => - (await bridgeApi.getConnectionsForRoom(testRoomId)).length === 1, - ); + // "Create" a JIRA connection. + await testEnv.app.appservice.botClient.sendStateEvent( + testRoomId, + JiraProjectConnection.CanonicalEventType, + jiraURL, + { + url: jiraURL, + } satisfies JiraProjectConnectionState, + ); - const webhookNotice = user.waitForRoomEvent({ - eventType: "m.room.message", - sender: testEnv.botMxid, - roomId: testRoomId, - }); + await waitFor( + async () => + (await bridgeApi.getConnectionsForRoom(testRoomId)).length === 1, + ); - const webhookPayload = JSON.stringify(JIRA_PAYLOAD); + const webhookNotice = user.waitForRoomEvent({ + eventType: "m.room.message", + sender: testEnv.botMxid, + roomId: testRoomId, + }); - const hmac = createHmac( - "sha256", - testEnv.opts.config?.jira?.webhook.secret!, - ); - hmac.write(webhookPayload); - hmac.end(); + const webhookPayload = JSON.stringify(JIRA_PAYLOAD); - // Send a webhook - const req = await fetch(`http://localhost:${webhooksPort}/`, { - method: "POST", - headers: { - "X-Hub-Signature": `sha256=${hmac.read().toString("hex")}`, - "x-atlassian-webhook-identifier": randomUUID(), - "Content-Type": "application/json", - }, - body: webhookPayload, - }); - expect(req.status).toBe(200); - expect(await req.text()).toBe("OK"); + const hmac = createHmac( + "sha256", + testEnv.opts.config?.jira?.webhook.secret!, + ); + hmac.write(webhookPayload); + hmac.end(); - // And await the notice. - const { body } = (await webhookNotice).data.content; - expect(body).toContain( - 'Test User created a new JIRA issue [TP-8](https://example.org/browse/TP-8): "Test issue"', - ); - }); + // Send a webhook + const req = await fetch(`http://localhost:${webhooksPort}${path}`, { + method: "POST", + headers: { + "X-Hub-Signature": `sha256=${hmac.read().toString("hex")}`, + "x-atlassian-webhook-identifier": randomUUID(), + "Content-Type": "application/json", + }, + body: webhookPayload, + }); + expect(req.status).toBe(200); + expect(await req.text()).toBe("OK"); + + // And await the notice. + const { body } = (await webhookNotice).data.content; + expect(body).toContain( + 'Test User created a new JIRA issue [TP-8](https://example.org/browse/TP-8): "Test issue"', + ); + }, + ); }); diff --git a/src/Bridge.ts b/src/Bridge.ts index 2ec8bad1b..4f7585da4 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -71,18 +71,18 @@ import { NotificationFilterStateContent, } from "./NotificationFilters"; import { NotificationProcessor } from "./NotificationsProcessor"; -import { - NotificationsEnableEvent, - NotificationsDisableEvent, - Webhooks, -} from "./Webhooks"; +import { Webhooks } from "./Webhooks"; import { GitHubOAuthToken, GitHubOAuthTokenResponse, ProjectsGetResponseData, } from "./github/Types"; import { retry } from "./PromiseUtil"; -import { UserNotificationsEvent } from "./notifications/UserNotificationWatcher"; +import { + NotificationsDisableEvent, + NotificationsEnableEvent, + UserNotificationsEvent, +} from "./notifications/UserNotificationWatcher"; import { UserTokenStore } from "./tokens/UserTokenStore"; import * as GitHubWebhookTypes from "@octokit/webhooks-types"; import { Logger } from "matrix-appservice-bridge"; diff --git a/src/Webhooks.ts b/src/Webhooks.ts index 6badd32c0..fecf28067 100644 --- a/src/Webhooks.ts +++ b/src/Webhooks.ts @@ -1,95 +1,48 @@ -/* eslint-disable camelcase */ import { BridgeConfig } from "./config/Config"; import { Router, default as express, Request, Response } from "express"; import { EventEmitter } from "events"; import { MessageQueue, createMessageQueue } from "./messageQueue"; import { Logger } from "matrix-appservice-bridge"; -import qs from "querystring"; -import axios from "axios"; -import { - IGitLabWebhookEvent, - IGitLabWebhookIssueStateEvent, - IGitLabWebhookMREvent, - IGitLabWebhookPipelineEvent, - IGitLabWebhookReleaseEvent, -} from "./gitlab/WebhookTypes"; -import { - EmitterWebhookEvent, - Webhooks as OctokitWebhooks, -} from "@octokit/webhooks"; -import { IJiraWebhookEvent } from "./jira/WebhookTypes"; import { JiraWebhooksRouter } from "./jira/Router"; -import { GitHubOAuthTokenResponse } from "./github/Types"; import Metrics from "./Metrics"; import { FigmaWebhooksRouter } from "./figma/Router"; import { GenericWebhooksRouter } from "./generic/Router"; -import { GithubInstance } from "./github/GithubInstance"; -import QuickLRU from "@alloc/quick-lru"; -import type { WebhookEventName } from "@octokit/webhooks-types"; import { ApiError, ErrCode } from "./api"; import { OpenProjectWebhooksRouter } from "./openproject/Router"; -import { OAuthRequest } from "./tokens/Oauth"; +import { GitHubWebhooksRouter } from "./github/Router"; +import { GitLabWebhooksRouter } from "./gitlab/Router"; const log = new Logger("Webhooks"); -export interface NotificationsEnableEvent { - userId: string; - roomId: string; - since?: number; - token: string; - filterParticipating: boolean; - type: "github" | "gitlab"; - instanceUrl?: string; -} - -export interface NotificationsDisableEvent { - userId: string; - type: "github" | "gitlab"; - instanceUrl?: string; -} - -export interface OAuthPageParams { - service?: string; - result?: string; - "oauth-kind"?: "account" | "organisation"; - error?: string; - errcode?: ErrCode; -} - -interface GitHubRequestData { - payload: string; - signature: string; -} - -interface WebhooksExpressRequest extends Request { - github?: GitHubRequestData; -} - export class Webhooks extends EventEmitter { public readonly expressRouter = Router(); private readonly queue: MessageQueue; - private readonly ghWebhooks?: OctokitWebhooks; - private readonly handledGuids = new QuickLRU({ - maxAge: 5000, - maxSize: 100, - }); private readonly jira?: JiraWebhooksRouter; + private readonly github?: GitHubWebhooksRouter; + private readonly gitlab?: GitLabWebhooksRouter; constructor(private config: BridgeConfig) { super(); this.expressRouter.use((req, _res, next) => { Metrics.webhooksHttpRequest.inc({ path: req.path, method: req.method }); next(); }); - if (config.github?.webhook.secret) { - this.ghWebhooks = new OctokitWebhooks({ - secret: config.github.webhook.secret, - }); - this.ghWebhooks.onAny((e) => this.onGitHubPayload(e)); - } - // TODO: Move these - this.expressRouter.get("/oauth", this.onGitHubGetOauth.bind(this)); this.queue = createMessageQueue(config.queue); + + if (this.config.github) { + this.github = new GitHubWebhooksRouter( + this.config.github, + this.queue, + this.config.widgets?.parsedPublicUrl, + ); + this.expressRouter.use("/github", this.github.getRouter()); + } + + if (this.config.gitlab) { + this.gitlab = new GitLabWebhooksRouter(this.config.gitlab, this.queue); + this.expressRouter.use("/gitlab", this.gitlab.getRouter()); + } + if (this.config.jira) { this.jira = new JiraWebhooksRouter( this.queue, @@ -136,139 +89,47 @@ export class Webhooks extends EventEmitter { limit: "10mb", }), ); - this.expressRouter.post("/", this.onPayload.bind(this)); - } - public stop() { - if (this.queue.stop) { - this.queue.stop(); - } - } - - private onGitLabPayload(body: IGitLabWebhookEvent) { - if (body.object_kind === "merge_request") { - const action = (body as unknown as IGitLabWebhookMREvent) - .object_attributes.action; - if (!action) { - log.warn( - "Got gitlab.merge_request but no action field, which usually means someone pressed the test webhooks button.", - ); - return null; - } - return `gitlab.merge_request.${action}`; - } else if (body.object_kind === "issue") { - const action = (body as unknown as IGitLabWebhookIssueStateEvent) - .object_attributes.action; - if (!action) { - log.warn( - "Got gitlab.issue but no action field, which usually means someone pressed the test webhooks button.", - ); - return null; - } - return `gitlab.issue.${action}`; - } else if (body.object_kind === "note") { - return `gitlab.note.created`; - } else if (body.object_kind === "tag_push") { - return "gitlab.tag_push"; - } else if (body.object_kind === "wiki_page") { - return "gitlab.wiki_page"; - } else if (body.object_kind === "release") { - const action = (body as unknown as IGitLabWebhookReleaseEvent).action; - if (!action) { - log.warn( - "Got gitlab.release but no action field, which usually means someone pressed the test webhooks button.", - ); - return null; - } - return `gitlab.release.${action}`; - } else if (body.object_kind === "push") { - return `gitlab.push`; - } else if (body.object_kind === "pipeline") { - const pipeline_event = (body as unknown as IGitLabWebhookPipelineEvent) - const status = pipeline_event.object_attributes?.status?.toLowerCase(); - if (status === "success") { - return "gitlab.pipeline.success"; - } - return "gitlab.pipeline"; - } else { - return null; + // LEGACY PATHS. These will be removed in a future version. + this.expressRouter.post("/", this.onPayload.bind(this)); + if (this.github) { + this.expressRouter.get("/oauth", this.github.onGetOAuth.bind(this)); } } - private onJiraPayload(body: IJiraWebhookEvent) { - body.webhookEvent = body.webhookEvent.replace("jira:", ""); - log.debug(`onJiraPayload ${body.webhookEvent}:`, body); - return `jira.${body.webhookEvent}`; - } - - private async onGitHubPayload({ id, name, payload }: EmitterWebhookEvent) { - const action = (payload as unknown as { action: string | undefined }) - .action; - const eventName = `github.${name}${action ? `.${action}` : ""}`; - log.debug(`Got GitHub webhook event ${id} ${eventName}`, payload); - try { - await this.queue.push({ - eventName, - sender: "Webhooks", - data: payload, - }); - } catch (err) { - log.error(`Failed to emit payload ${id}: ${err}`); - } + public stop() { + this.queue.stop?.(); } - private onPayload(req: WebhooksExpressRequest, res: Response) { + private onPayload(req: Request, res: Response) { try { - let eventName: string | null = null; - const body = req.body; - const githubGuid = req.headers["x-github-delivery"] as string | undefined; - if (githubGuid) { - if (!this.ghWebhooks) { + if (GitHubWebhooksRouter.IsRequest(req)) { + if (!this.github) { log.warn( `Not configured for GitHub webhooks, but got a GitHub event`, ); - res.sendStatus(500); - return; - } - res.sendStatus(200); - if (this.handledGuids.has(githubGuid)) { - return; + throw new ApiError("GitHub not configured", ErrCode.DisabledFeature); } - this.handledGuids.set(githubGuid); - const githubData = req.github as GitHubRequestData; - if (!githubData) { - throw Error("Expected github data to be set on request"); + this.github.onWebhook(req, res); + return; + } else if (GitLabWebhooksRouter.IsRequest(req)) { + if (!this.gitlab) { + log.warn( + `Not configured for GitLab webhooks, but got a GitLab event`, + ); + throw new ApiError("GitLab not configured", ErrCode.DisabledFeature); } - this.ghWebhooks - .verifyAndReceive({ - id: githubGuid as string, - name: req.headers["x-github-event"] as WebhookEventName, - payload: githubData.payload, - signature: githubData.signature, - }) - .catch((err) => { - log.error(`Failed handle GitHubEvent: ${err}`); - }); + this.gitlab.onWebhook(req, res); return; - } else if (req.headers["x-gitlab-token"]) { - res.sendStatus(200); - eventName = this.onGitLabPayload(body); } else if (JiraWebhooksRouter.IsJIRARequest(req)) { - res.sendStatus(200); - eventName = this.onJiraPayload(body); - } - if (eventName) { - this.queue - .push({ - eventName, - sender: "GithubWebhooks", - data: body, - }) - .catch((err) => { - log.error(`Failed to emit payload: ${err}`); - }); + if (!this.jira) { + log.warn(`Not configured for JIRA webhooks, but got a JIRA event`); + throw new ApiError("JIRA not configured", ErrCode.DisabledFeature); + } + this.jira.onWebhook(req, res); + return; } else { - log.debug("Unknown event:", req.body); + log.debug("Unknown request:", req.body); throw new ApiError( "Unable to handle webhook payload. Service may not be configured.", ErrCode.Unroutable, @@ -283,182 +144,19 @@ export class Webhooks extends EventEmitter { } } - public async onGitHubGetOauth( - req: Request< - unknown, - unknown, - unknown, - { - error?: string; - error_description?: string; - code?: string; - state?: string; - setup_action?: "install"; - } - >, - res: Response, - ) { - const oauthResultParams: OAuthPageParams = { - service: "github", - }; - - const { setup_action, state } = req.query; - log.info("Got new oauth request", { state, setup_action }); - try { - if (!this.config.github || !this.config.github.oauth) { - throw new ApiError( - "Bridge is not configured with OAuth support", - ErrCode.DisabledFeature, - ); - } - if (req.query.error) { - throw new ApiError( - `GitHub Error: ${req.query.error} ${req.query.error_description}`, - ErrCode.Unknown, - ); - } - if (setup_action === "install") { - // GitHub App successful install. - oauthResultParams["oauth-kind"] = "organisation"; - oauthResultParams.result = "success"; - } else if (setup_action === "request") { - // GitHub App install is pending - oauthResultParams["oauth-kind"] = "organisation"; - oauthResultParams.result = "pending"; - } else if (setup_action) { - // GitHub App install is in another, unknown state. - oauthResultParams["oauth-kind"] = "organisation"; - oauthResultParams.result = setup_action; - } else { - // This is a user account setup flow. - oauthResultParams["oauth-kind"] = "account"; - if (!state) { - throw new ApiError(`Missing state`, ErrCode.BadValue); - } - if (!req.query.code) { - throw new ApiError(`Missing code`, ErrCode.BadValue); - } - const exists = await this.queue.pushWait({ - eventName: "github.oauth.response", - sender: "GithubWebhooks", - data: { - state, - code: req.query.code, - }, - }); - if (!exists) { - throw new ApiError( - `Could not find user which authorised this request. Has it timed out?`, - undefined, - 404, - ); - } - const accessTokenUrl = GithubInstance.generateOAuthUrl( - this.config.github.baseUrl, - "access_token", - { - client_id: this.config.github.oauth.client_id, - client_secret: this.config.github.oauth.client_secret, - code: req.query.code as string, - redirect_uri: this.config.github.oauth.redirect_uri, - state: req.query.state as string, - }, - ); - const accessTokenRes = await axios.post(accessTokenUrl); - const result = qs.parse(accessTokenRes.data) as - | GitHubOAuthTokenResponse - | { error: string; error_description: string; error_uri: string }; - if ("error" in result) { - throw new ApiError( - `GitHub Error: ${result.error} ${result.error_description}`, - ErrCode.Unknown, - ); - } - oauthResultParams.result = "success"; - await this.queue.push({ - eventName: "github.oauth.tokens", - sender: "GithubWebhooks", - data: { ...result, state: req.query.state as string }, - }); - } - } catch (ex) { - if (ex instanceof ApiError) { - oauthResultParams.result = "error"; - oauthResultParams.error = ex.error; - oauthResultParams.errcode = ex.errcode; - } else { - log.error("Failed to handle oauth request:", ex); - return res.status(500).send("Failed to handle oauth request"); - } - } - const oauthUrl = - this.config.widgets && - new URL("oauth.html", this.config.widgets.parsedPublicUrl); - if (oauthUrl) { - // If we're serving widgets, do something prettier. - Object.entries(oauthResultParams).forEach(([key, value]) => - oauthUrl.searchParams.set(key, value), - ); - return res.redirect(oauthUrl.toString()); - } else { - if (oauthResultParams.result === "success") { - return res.send( - `

Your ${oauthResultParams.service} ${oauthResultParams["oauth-kind"]} has been bridged

`, - ); - } else if (oauthResultParams.result === "error") { - return res - .status(500) - .send( - `

There was an error bridging your ${oauthResultParams.service} ${oauthResultParams["oauth-kind"]}. ${oauthResultParams.error} ${oauthResultParams.errcode}

`, - ); - } else { - return res - .status(500) - .send( - `

Your ${oauthResultParams.service} ${oauthResultParams["oauth-kind"]} is in state ${oauthResultParams.result}

`, - ); - } - } - } - private verifyRequest( - req: WebhooksExpressRequest, + req: Request, _res: Response, buffer: Buffer, encoding: BufferEncoding, ): void { - if (this.config.gitlab && req.headers["x-gitlab-token"]) { - if (req.headers["x-gitlab-token"] !== this.config.gitlab.webhook.secret) { - throw new ApiError( - "Could not handle GitLab request. Token did not match.", - ErrCode.BadValue, - ); - } - return; - } else if (this.ghWebhooks && req.headers["x-hub-signature-256"]) { - if (typeof req.headers["x-hub-signature-256"] !== "string") { - throw new ApiError( - "Could not handle GitHub request. Unexpected multiple headers for x-hub-signature-256", - ErrCode.BadValue, - ); - } - try { - const jsonStr = buffer.toString(encoding); - req.github = { - payload: jsonStr, - signature: req.headers["x-hub-signature-256"], - }; - } catch (ex) { - log.warn("GitHub signature could not be decoded", ex); - throw new ApiError( - "Could not handle GitHub request. Signature could not be decoded", - ErrCode.BadValue, - ); - } - return; + // LEGACY. Remove when removing the `/` endpoint. + if (this.gitlab && req.headers["x-gitlab-token"]) { + this.gitlab.verifyRequest(req); + } else if (this.github && req.headers["x-hub-signature-256"]) { + this.github.verifyRequest(req, _res, buffer, encoding); } else if (this.jira && JiraWebhooksRouter.IsJIRARequest(req)) { - this.jira.verifyWebhookRequest(req, buffer); - return; + this.jira.verifyWebhookRequest(req, _res, buffer); } } } diff --git a/src/github/Router.ts b/src/github/Router.ts index 847b527ba..88e9491a9 100644 --- a/src/github/Router.ts +++ b/src/github/Router.ts @@ -1,282 +1,299 @@ -import { Router, Request, Response, NextFunction } from "express"; -import { BridgeConfigGitHub } from "../config/Config"; +import { Request, Response, Router, json } from "express"; +import { MessageQueue } from "../messageQueue"; import { ApiError, ErrCode } from "../api"; -import { UserTokenStore } from "../tokens/UserTokenStore"; import { Logger } from "matrix-appservice-bridge"; +import { OAuthRequest } from "../tokens/Oauth"; +import { BridgeConfigGitHub } from "../config/Config"; +import { + EmitterWebhookEvent, + Webhooks as OctokitWebhooks, +} from "@octokit/webhooks"; +import QuickLRU from "@alloc/quick-lru"; +import { WebhookEventName } from "@octokit/webhooks-types"; import { GithubInstance } from "./GithubInstance"; -import { NAMELESS_ORG_PLACEHOLDER } from "./Types"; +import axios from "axios"; +import { GitHubOAuthTokenResponse } from "./Types"; +import qs from "querystring"; -const log = new Logger("GitHubProvisionerRouter"); -interface GitHubAccountStatus { - loggedIn: boolean; - username?: string; - organisations?: { - name: string; - avatarUrl?: string; - }[]; -} -interface GitHubRepoItem { - name: string; - owner: string; - fullName: string; - description: string | null; - avatarUrl: string; +const log = new Logger("GitHubWebhooksRouter"); + +interface GitHubRequestData { + payload: string; + signature: string; } -interface GitHubRepoResponse { - page: number; - repositories: GitHubRepoItem[]; - changeSelectionUrl?: string; +interface WebhooksExpressRequest extends Request { + github?: GitHubRequestData; } -export class GitHubProvisionerRouter { - constructor( - private readonly config: BridgeConfigGitHub, - private readonly tokenStore: UserTokenStore, - private readonly githubInstance: GithubInstance, - ) {} +export interface OAuthPageParams { + service?: string; + result?: string; + "oauth-kind"?: "account" | "organisation"; + error?: string; + errcode?: ErrCode; +} - public getRouter() { - const router = Router(); - router.get("/oauth", this.onOAuth.bind(this)); - router.get("/account", this.onGetAccount.bind(this)); - router.get( - "/orgs/:orgName/repositories", - this.onGetOrgRepositories.bind(this), - ); - router.get("/repositories", this.onGetRepositories.bind(this)); - return router; +export class GitHubWebhooksRouter { + private readonly ghWebhooks: OctokitWebhooks; + private readonly handledGuids = new QuickLRU({ + maxAge: 5000, + maxSize: 100, + }); + public static IsRequest(req: Request): boolean { + return !!req.headers["x-github-delivery"]; } - private onOAuth( - req: Request, - res: Response<{ user_url: string; org_url: string }>, + constructor( + private readonly config: BridgeConfigGitHub, + private readonly queue: MessageQueue, + private readonly widgetsUrl?: URL, ) { - if (!this.config.oauth) { - throw new ApiError( - "GitHub is not configured to support OAuth", - ErrCode.UnsupportedOperation, - ); - } - const userUrl = GithubInstance.generateOAuthUrl( - this.config.baseUrl, - "authorize", - { - redirect_uri: this.config.oauth.redirect_uri, - client_id: this.config.oauth.client_id, - state: this.tokenStore.createStateForOAuth(req.query.userId), - }, - ); - res.send({ - user_url: userUrl, - org_url: this.githubInstance.newInstallationUrl.toString(), + this.ghWebhooks = new OctokitWebhooks({ + secret: config.webhook.secret, }); + this.ghWebhooks.onAny((e) => this.payloadHandler(e)); } - private async onGetAccount( - req: Request< - undefined, - undefined, - undefined, - { userId: string; page: string; perPage: string } - >, - res: Response, - next: NextFunction, - ) { - const octokit = await this.tokenStore.getOctokitForUser(req.query.userId); - if (!octokit) { - return res.send({ - loggedIn: false, + private async payloadHandler({ id, name, payload }: EmitterWebhookEvent) { + const action = (payload as unknown as { action: string | undefined }) + .action; + const eventName = `github.${name}${action ? `.${action}` : ""}`; + log.debug(`Got GitHub webhook event ${id} ${eventName}`, payload); + try { + await this.queue.push({ + eventName, + sender: "Webhooks", + data: payload, }); + } catch (err) { + log.error(`Failed to emit payload ${id}: ${err}`); } - const organisations = []; - const page = req.query.page ? parseInt(req.query.page) : 1; - const perPage = req.query.perPage ? parseInt(req.query.perPage) : 10; - try { - const installs = await octokit.apps.listInstallationsForAuthenticatedUser( - { page: page, per_page: perPage }, + } + + public verifyRequest( + req: WebhooksExpressRequest, + _res: Response, + buffer: Buffer, + encoding: BufferEncoding, + ): void { + if (typeof req.headers["x-hub-signature-256"] !== "string") { + throw new ApiError( + "Could not handle GitHub request. Unexpected multiple headers for x-hub-signature-256", + ErrCode.BadValue, ); - for (const install of installs.data.installations) { - if (install.account) { - const name = - "login" in install.account - ? install.account.login - : (install.account.name ?? NAMELESS_ORG_PLACEHOLDER); - organisations.push({ - name, // org or user name - avatarUrl: install.account.avatar_url, - }); - } else { - log.debug(`Skipping install ${install.id}, has no attached account`); - } - } + } + try { + const jsonStr = buffer.toString(encoding); + req.github = { + payload: jsonStr, + signature: req.headers["x-hub-signature-256"], + }; } catch (ex) { - log.warn(`Failed to fetch orgs for GitHub user ${req.query.userId}`, ex); - return next( - new ApiError( - "Could not fetch orgs for GitHub user", - ErrCode.AdditionalActionRequired, - ), + log.warn("GitHub signature could not be decoded", ex); + throw new ApiError( + "Could not handle GitHub request. Signature could not be decoded", + ErrCode.BadValue, ); } - return res.send({ - loggedIn: true, - username: await (await octokit.users.getAuthenticated()).data.login, - organisations, - }); } - private async onGetOrgRepositories( - req: Request< - { orgName: string }, - undefined, - undefined, - { userId: string; page: string; perPage: string } - >, - res: Response, - next: NextFunction, + public onWebhook( + req: WebhooksExpressRequest, + res: Response, ) { - const octokit = await this.tokenStore.getOctokitForUser(req.query.userId); - if (!octokit) { - // TODO: Better error? - return next(new ApiError("Not logged in", ErrCode.ForbiddenUser)); + const githubGuid = req.headers["x-github-delivery"]; + const githubEvent = req.headers["x-github-event"]; + if (githubGuid === undefined) { + throw new ApiError( + "GitHub request did not have a x-github-delivery header", + ErrCode.BadValue, + ); } - const ownSelf = await octokit.users.getAuthenticated(); - - const repositories = []; - const page = req.query.page ? parseInt(req.query.page) : 1; - const perPage = req.query.perPage ? parseInt(req.query.perPage) : 10; - try { - let changeInstallUrl: URL | undefined = undefined; - let reposPromise; - - if (ownSelf.data.login === req.params.orgName) { - const userInstallation = - await this.githubInstance.appOctokit.apps.getUserInstallation({ - username: ownSelf.data.login, - }); - reposPromise = octokit.apps.listInstallationReposForAuthenticatedUser({ - page, - installation_id: userInstallation.data.id, - per_page: perPage, - }); - if (userInstallation.data.repository_selection === "selected") { - changeInstallUrl = new URL( - `/settings/installations/${userInstallation.data.id}`, - this.config.baseUrl, - ); - } - } else { - const orgInstallation = - await this.githubInstance.appOctokit.apps.getOrgInstallation({ - org: req.params.orgName, - }); - - // Github will error if the authed user tries to list repos of a disallowed installation, even - // if we got the installation ID from the app's instance. - reposPromise = octokit.apps.listInstallationReposForAuthenticatedUser({ - page, - installation_id: orgInstallation.data.id, - per_page: perPage, - }); - if (orgInstallation.data.repository_selection === "selected") { - changeInstallUrl = new URL( - `/organizations/${req.params.orgName}/settings/installations/${orgInstallation.data.id}`, - this.config.baseUrl, - ); - } - } - const reposRes = await reposPromise; - for (const repo of reposRes.data.repositories) { - repositories.push({ - name: repo.name, - owner: repo.owner.login, - fullName: repo.full_name, - description: repo.description, - avatarUrl: repo.owner.avatar_url, - }); - } + if (typeof githubGuid !== "string") { + throw new ApiError( + "Header x-github-delivery was invalid", + ErrCode.BadValue, + ); + } - return res.send({ - page, - repositories, - changeSelectionUrl: changeInstallUrl?.toString(), - }); - } catch (ex) { - log.warn( - `Failed to fetch accessible repos for ${req.params.orgName} / ${req.query.userId}`, - ex, + if (githubEvent === undefined) { + throw new ApiError( + "GitHub request did not have a x-github-delivery header", + ErrCode.BadValue, ); - return next( - new ApiError( - "Could not fetch accessible repos for GitHub org", - ErrCode.AdditionalActionRequired, - ), + } + + if (typeof githubEvent !== "string") { + throw new ApiError( + "Header x-github-delivery was invalid", + ErrCode.BadValue, ); } + // Send response early. + res.sendStatus(200); + if (this.handledGuids.has(githubGuid)) { + return; + } + this.handledGuids.set(githubGuid); + const githubData = req.github as GitHubRequestData; + if (!githubData) { + throw Error("Expected github data to be set on request"); + } + this.ghWebhooks + .verifyAndReceive({ + id: githubGuid as string, + name: githubEvent as WebhookEventName, + payload: githubData.payload, + signature: githubData.signature, + }) + .catch((err) => { + log.error(`Failed handle GitHubEvent: ${err}`); + }); } - private async onGetRepositories( + public async onGetOAuth( req: Request< - undefined, - undefined, - undefined, - { userId: string; page: string; perPage: string } + unknown, + unknown, + unknown, + { + error?: string; + error_description?: string; + code?: string; + state?: string; + setup_action?: "install"; + } >, - res: Response, - next: NextFunction, + res: Response, ) { - const octokit = await this.tokenStore.getOctokitForUser(req.query.userId); - if (!octokit) { - // TODO: Better error? - return next(new ApiError("Not logged in", ErrCode.ForbiddenUser)); - } + const oauthResultParams: OAuthPageParams = { + service: "github", + }; - const repositories = []; - const page = req.query.page ? parseInt(req.query.page) : 1; - const perPage = req.query.perPage ? parseInt(req.query.perPage) : 10; + const { setup_action: setupAction, state } = req.query; + log.info("Got new oauth request", { state, setupAction }); try { - const userRes = await octokit.users.getAuthenticated(); - const userInstallation = - await this.githubInstance.appOctokit.apps.getUserInstallation({ - username: userRes.data.login, - }); - const orgRes = - await octokit.apps.listInstallationReposForAuthenticatedUser({ - page, - installation_id: userInstallation.data.id, - per_page: perPage, + if (!this.config.oauth) { + throw new ApiError( + "Bridge is not configured with OAuth support", + ErrCode.DisabledFeature, + ); + } + if (req.query.error) { + throw new ApiError( + `GitHub Error: ${req.query.error} ${req.query.error_description}`, + ErrCode.Unknown, + ); + } + if (setupAction === "install") { + // GitHub App successful install. + oauthResultParams["oauth-kind"] = "organisation"; + oauthResultParams.result = "success"; + } else if (setupAction === "request") { + // GitHub App install is pending + oauthResultParams["oauth-kind"] = "organisation"; + oauthResultParams.result = "pending"; + } else if (setupAction) { + // GitHub App install is in another, unknown state. + oauthResultParams["oauth-kind"] = "organisation"; + oauthResultParams.result = setupAction; + } else { + // This is a user account setup flow. + oauthResultParams["oauth-kind"] = "account"; + if (!state) { + throw new ApiError(`Missing state`, ErrCode.BadValue); + } + if (!req.query.code) { + throw new ApiError(`Missing code`, ErrCode.BadValue); + } + const exists = await this.queue.pushWait({ + eventName: "github.oauth.response", + sender: "GithubWebhooks", + data: { + state, + code: req.query.code, + }, }); - for (const repo of orgRes.data.repositories) { - repositories.push({ - name: repo.name, - owner: repo.owner.login, - fullName: repo.full_name, - description: repo.description, - avatarUrl: repo.owner.avatar_url, + if (!exists) { + throw new ApiError( + `Could not find user which authorised this request. Has it timed out?`, + undefined, + 404, + ); + } + const accessTokenUrl = GithubInstance.generateOAuthUrl( + this.config.baseUrl, + "access_token", + { + client_id: this.config.oauth.client_id, + client_secret: this.config.oauth.client_secret, + code: req.query.code as string, + redirect_uri: this.config.oauth.redirect_uri, + state: req.query.state as string, + }, + ); + const accessTokenRes = await axios.post(accessTokenUrl); + const result = qs.parse(accessTokenRes.data) as + | GitHubOAuthTokenResponse + | { error: string; error_description: string; error_uri: string }; + if ("error" in result) { + throw new ApiError( + `GitHub Error: ${result.error} ${result.error_description}`, + ErrCode.Unknown, + ); + } + oauthResultParams.result = "success"; + await this.queue.push({ + eventName: "github.oauth.tokens", + sender: "GithubWebhooks", + data: { ...result, state: req.query.state as string }, }); } - const changeSelectionUrl = new URL( - `/settings/installations/${userInstallation.data.id}`, - this.config.baseUrl, - ).toString(); - - return res.send({ - page, - repositories, - ...(orgRes.data.repository_selection === "selected" && { - changeSelectionUrl, - }), - }); } catch (ex) { - log.warn(`Failed to fetch accessible repos for ${req.query.userId}`, ex); - return next( - new ApiError( - "Could not fetch accessible repos for GitHub user", - ErrCode.AdditionalActionRequired, - ), + if (ex instanceof ApiError) { + oauthResultParams.result = "error"; + oauthResultParams.error = ex.error; + oauthResultParams.errcode = ex.errcode; + } else { + log.error("Failed to handle oauth request:", ex); + return res.status(500).send("Failed to handle oauth request"); + } + } + const oauthUrl = this.widgetsUrl && new URL("oauth.html", this.widgetsUrl); + if (oauthUrl) { + // If we're serving widgets, do something prettier. + Object.entries(oauthResultParams).forEach(([key, value]) => + oauthUrl.searchParams.set(key, value), ); + return res.redirect(oauthUrl.toString()); + } else { + if (oauthResultParams.result === "success") { + return res.send( + `

Your ${oauthResultParams.service} ${oauthResultParams["oauth-kind"]} has been bridged

`, + ); + } else if (oauthResultParams.result === "error") { + return res + .status(500) + .send( + `

There was an error bridging your ${oauthResultParams.service} ${oauthResultParams["oauth-kind"]}. ${oauthResultParams.error} ${oauthResultParams.errcode}

`, + ); + } else { + return res + .status(500) + .send( + `

Your ${oauthResultParams.service} ${oauthResultParams["oauth-kind"]} is in state ${oauthResultParams.result}

`, + ); + } } } + + public getRouter() { + const router = Router(); + router.use(json({ verify: this.verifyRequest.bind(this) })); + router.post("/webhook", this.onWebhook.bind(this)); + router.get("/oauth", this.onGetOAuth.bind(this)); + return router; + } } diff --git a/src/gitlab/Router.ts b/src/gitlab/Router.ts new file mode 100644 index 000000000..de24ba2ad --- /dev/null +++ b/src/gitlab/Router.ts @@ -0,0 +1,112 @@ +import { Request, Response, Router, json } from "express"; +import { MessageQueue } from "../messageQueue"; +import { ApiError, ErrCode } from "../api"; +import { Logger } from "matrix-appservice-bridge"; +import { BridgeConfigGitLab } from "../config/Config"; +import { + IGitLabWebhookEvent, + IGitLabWebhookIssueStateEvent, + IGitLabWebhookMREvent, + IGitLabWebhookReleaseEvent, +} from "./WebhookTypes"; + +const log = new Logger("GitLabWebhooksRouter"); +export interface OAuthPageParams { + service?: string; + result?: string; + "oauth-kind"?: "account" | "organisation"; + error?: string; + errcode?: ErrCode; +} + +export class GitLabWebhooksRouter { + public static IsRequest(req: Request): boolean { + return !!req.headers["x-gitlab-token"]; + } + + constructor( + private readonly config: BridgeConfigGitLab, + private readonly queue: MessageQueue, + ) {} + + private payloadHandler(body: IGitLabWebhookEvent) { + if (body.object_kind === "merge_request") { + const action = (body as unknown as IGitLabWebhookMREvent) + .object_attributes.action; + if (!action) { + log.warn( + "Got gitlab.merge_request but no action field, which usually means someone pressed the test webhooks button.", + ); + return null; + } + return `gitlab.merge_request.${action}`; + } else if (body.object_kind === "issue") { + const action = (body as unknown as IGitLabWebhookIssueStateEvent) + .object_attributes.action; + if (!action) { + log.warn( + "Got gitlab.issue but no action field, which usually means someone pressed the test webhooks button.", + ); + return null; + } + return `gitlab.issue.${action}`; + } else if (body.object_kind === "note") { + return `gitlab.note.created`; + } else if (body.object_kind === "tag_push") { + return "gitlab.tag_push"; + } else if (body.object_kind === "wiki_page") { + return "gitlab.wiki_page"; + } else if (body.object_kind === "release") { + const action = (body as unknown as IGitLabWebhookReleaseEvent).action; + if (!action) { + log.warn( + "Got gitlab.release but no action field, which usually means someone pressed the test webhooks button.", + ); + return null; + } + return `gitlab.release.${action}`; + } else if (body.object_kind === "push") { + return `gitlab.push`; + } else { + return null; + } + } + + public verifyRequest(req: Request): void { + if (typeof req.headers["x-gitlab-token"] !== "string") { + throw new ApiError( + "Could not handle GitHub request. Unexpected multiple headers for x-hub-signature-256", + ErrCode.BadValue, + ); + } + if (req.headers["x-gitlab-token"] !== this.config.webhook.secret) { + throw new ApiError( + "Could not handle GitLab request. Token did not match.", + ErrCode.BadValue, + ); + } + } + + public onWebhook(req: Request, res: Response) { + res.send("OK"); + const eventName = this.payloadHandler(req.body); + if (eventName) { + this.queue + .push({ + eventName, + sender: "GithubWebhooks", + data: req.body, + }) + .catch((err) => { + log.error(`Failed to emit payload: ${err}`); + }); + } + } + + public getRouter() { + const router = Router(); + router.use(json({ verify: this.verifyRequest.bind(this) })); + router.post("/webhook", this.onWebhook.bind(this)); + return router; + } +} diff --git a/src/jira/Router.ts b/src/jira/Router.ts index e8aec85e2..c3fc9270f 100644 --- a/src/jira/Router.ts +++ b/src/jira/Router.ts @@ -8,6 +8,7 @@ import { JiraOAuthRequestOnPrem } from "./OAuth"; import { HookshotJiraApi } from "./Client"; import { createHmac } from "node:crypto"; import { OAuthRequest, OAuthRequestResult } from "../tokens/Oauth"; +import { IJiraWebhookEvent } from "./WebhookTypes"; type JiraOAuthRequestCloud = OAuthRequest; @@ -44,7 +45,11 @@ export class JiraWebhooksRouter { * @throws If the request is invalid * @param req The express request. */ - public verifyWebhookRequest(req: Request, buffer: Buffer): void { + public verifyWebhookRequest( + req: Request, + _res: Response, + buffer: Buffer, + ): void { const querySecret = req.query.secret; const hubSecret = req.headers["x-hub-signature"]?.slice("sha256=".length); if (querySecret) { @@ -167,10 +172,35 @@ export class JiraWebhooksRouter { } } + public onWebhook( + req: Request, + res: Response, + ) { + if (!req.body.webhookEvent) { + throw new ApiError("Missing webhookEvent in body", ErrCode.BadValue); + } + res.send("OK"); + const webhookEvent = req.body.webhookEvent.replace("jira:", ""); + log.debug(`onWebhook ${webhookEvent}:`, req.body); + this.queue + .push({ + eventName: `jira.${webhookEvent}`, + sender: "GithubWebhooks", + data: req.body, + }) + .catch((err) => { + log.error(`Failed to emit payload: ${err}`); + }); + } + public getRouter() { const router = Router(); - router.use(json()); - router.get("/oauth", this.onOAuth.bind(this)); + router.get("/oauth", json(), this.onOAuth.bind(this)); + router.post( + "/webhook", + json({ verify: this.verifyWebhookRequest.bind(this) }), + this.onWebhook.bind(this), + ); return router; } } diff --git a/src/notifications/UserNotificationWatcher.ts b/src/notifications/UserNotificationWatcher.ts index b69cf250d..943a174da 100644 --- a/src/notifications/UserNotificationWatcher.ts +++ b/src/notifications/UserNotificationWatcher.ts @@ -1,7 +1,3 @@ -import { - NotificationsDisableEvent, - NotificationsEnableEvent, -} from "../Webhooks"; import { Logger } from "matrix-appservice-bridge"; import { createMessageQueue, @@ -24,6 +20,22 @@ export interface UserNotificationsEvent { const MIN_INTERVAL_MS = 15000; const FAILURE_THRESHOLD = 50; +export interface NotificationsEnableEvent { + userId: string; + roomId: string; + since?: number; + token: string; + filterParticipating: boolean; + type: "github" | "gitlab"; + instanceUrl?: string; +} + +export interface NotificationsDisableEvent { + userId: string; + type: "github" | "gitlab"; + instanceUrl?: string; +} + const log = new Logger("UserNotificationWatcher"); export class UserNotificationWatcher { diff --git a/src/openproject/Router.ts b/src/openproject/Router.ts index 9a0668c54..bb2df2f36 100644 --- a/src/openproject/Router.ts +++ b/src/openproject/Router.ts @@ -9,15 +9,6 @@ import { OAuthRequest, OAuthRequestResult } from "../tokens/Oauth"; const log = new Logger("OpenProjectWebhooksRouter"); export class OpenProjectWebhooksRouter { - public static IsRequest(req: Request): boolean { - if (req.headers["x-atlassian-webhook-identifier"]) { - return true; // Cloud - } else if (req.headers["user-agent"]?.match(/JIRA/)) { - return true; // JIRA On-prem - } - return false; - } - constructor( private readonly config: BridgeOpenProjectConfig, private readonly queue: MessageQueue, From f00e17bcba88a9ece000f65e6b7ffc830928767f Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Fri, 6 Jun 2025 19:06:14 +0100 Subject: [PATCH 18/27] jira webhook docs fix Signed-off-by: Will Hunt --- docs/setup/jira.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/setup/jira.md b/docs/setup/jira.md index 235c12e5c..d21ca0aab 100644 --- a/docs/setup/jira.md +++ b/docs/setup/jira.md @@ -5,7 +5,7 @@ This should be done for the JIRA instance you wish to bridge. The setup steps vary for Cloud and Enterprise (on-premise).
-Previously Hookshot supported / as the public path for webhook delivery. This path is now deprecated and /jira should be used wherever possible. +Previously Hookshot supported / as the public path for webhook delivery. This path is now deprecated and /jira/webhook should be used wherever possible.
### Cloud From 8ad832c8abb7f6b6428871ca6df232357c5b737b Mon Sep 17 00:00:00 2001 From: madjidDer Date: Tue, 20 May 2025 12:48:58 +0200 Subject: [PATCH 19/27] Support GitLab pipeline events --- src/Bridge.ts | 16 ++---- src/Connections/GitlabRepo.ts | 92 +++++++++++++---------------------- src/HookFilter.ts | 3 +- src/gitlab/WebhookTypes.ts | 3 +- 4 files changed, 41 insertions(+), 73 deletions(-) diff --git a/src/Bridge.ts b/src/Bridge.ts index 4f7585da4..c361432c8 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -48,6 +48,7 @@ import { IGitLabWebhookTagPushEvent, IGitLabWebhookWikiPageEvent, IGitLabWebhookPipelineEvent, + IGitLabWebhookPipelineEvent, } from "./gitlab/WebhookTypes"; import { JiraIssueEvent, @@ -615,15 +616,6 @@ export class Bridge { (c, data) => c.onWikiPageEvent(data), ); - this.bindHandlerToQueue( - "gitlab.pipeline.success", - (data) => - connManager.getConnectionsForGitLabRepo( - data.project.path_with_namespace, - ), - (c, data) => c.onPipelineSuccess(data), - ); - this.bindHandlerToQueue( "gitlab.pipeline", (data) => @@ -702,9 +694,9 @@ export class Bridge { return [ ...(iid ? connManager.getConnectionsForGitLabIssueWebhook( - data.repository.homepage, - iid, - ) + data.repository.homepage, + iid, + ) : []), ...connManager.getConnectionsForGitLabRepo( data.project.path_with_namespace, diff --git a/src/Connections/GitlabRepo.ts b/src/Connections/GitlabRepo.ts index 11f5054e7..9b4d98cc2 100644 --- a/src/Connections/GitlabRepo.ts +++ b/src/Connections/GitlabRepo.ts @@ -16,6 +16,7 @@ import { IGitLabWebhookTagPushEvent, IGitLabWebhookWikiPageEvent, IGitLabWebhookPipelineEvent, + IGitLabWebhookPipelineEvent, } from "../gitlab/WebhookTypes"; import { CommandConnection } from "./CommandConnection"; import { @@ -99,8 +100,7 @@ type AllowedEventsNames = | `wiki.${string}` | "release" | "release.created" - | "pipeline" - | "pipeline.success"; + | "pipeline"; const AllowedEvents: AllowedEventsNames[] = [ "merge_request.open", @@ -118,8 +118,10 @@ const AllowedEvents: AllowedEventsNames[] = [ "release", "release.created", "pipeline", - "pipeline.success", ]; +// +// | "pipeline"; + const DefaultHooks = AllowedEvents; @@ -191,8 +193,7 @@ export interface GitLabTargetFilter { @Connection export class GitLabRepoConnection extends CommandConnection - implements IConnection -{ + implements IConnection { static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.gitlab.repository"; static readonly LegacyCanonicalEventType = @@ -979,69 +980,42 @@ ${data.description}`; } public async onPipelineEvent(event: IGitLabWebhookPipelineEvent) { - if (this.hookFilter.shouldSkip("pipeline")) { - return; - } - log.info( - `onPipelineEvent ${this.roomId} ${this.instance.url}/${this.path}`, - ); - const { status, ref, duration, id: pipelineId } = event.object_attributes; + /*console.log("HOOK FILTER CHECK:", { + enabledHooks: this.hookFilter.enabledHooks, + shouldSkip: this.hookFilter.shouldSkip("pipeline"), + });*/ - const statusUpper = status.toUpperCase(); - if (!this.notifiedPipelines.has(pipelineId)) { - 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", - }); - - this.notifiedPipelines.add(pipelineId); - } - if (["FAILED", "CANCELED"].includes(statusUpper)) { - const statusHtml = - statusUpper === "FAILED" - ? `${statusUpper}` - : statusUpper === "CANCELED" - ? `${statusUpper}` - : `${statusUpper}`; - - const contentText = `Pipeline ${statusUpper} on branch \`${ref}\` for project ${event.project.name} by ${event.user.username} - Duration: ${duration ?? "?"}s`; - const contentHtml = `Pipeline ${statusHtml} on branch ${ref} for project ${event.project.name} by ${event.user.username} - Duration: ${duration ?? "?"}s`; - - await this.intent.sendEvent(this.roomId, { - msgtype: "m.notice", - body: contentText, - formatted_body: contentHtml, - format: "org.matrix.custom.html", - }); - this.notifiedPipelines.delete(pipelineId); + if (this.hookFilter.shouldSkip("pipeline")) { + //console.log(">>> [qaqah] Skipping pipeline event due to filter."); + return; } - } - public async onPipelineSuccess(event: IGitLabWebhookPipelineEvent) { - if (this.hookFilter.shouldSkip("pipeline", "pipeline.success")) { - return; - } + //console.log(">>> onPipelineEvent data:", this.hookFilter.enabledHooks); - log.info(`onPipelineSuccess ${this.roomId} ${this.instance.url}/${this.path}`); - const { ref, duration } = event.object_attributes; + log.info(`onPipelineEvent ${this.roomId} ${this.instance.url}/${this.path}`); - 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: ${duration ?? "?"}s`; + const { + status, + ref, + duration, + created_at, + finished_at, + } = event.object_attributes; - await this.intent.sendEvent(this.roomId, { - msgtype: "m.notice", - body: contentText, - formatted_body: contentHtml, - format: "org.matrix.custom.html", - }); -} + const content = `Pipeline **${status.toUpperCase()}** on branch \`${ref}\` for project [${event.project.name}](${event.project.web_url}) triggered by **${event.user.username}** + \nDuration: ${duration ?? "?"}s + \nStarted: ${created_at} + \nFinished: ${finished_at}`; + await this.intent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: content, + formatted_body: md.render(content), + format: "org.matrix.custom.html", + }); + } private async renderDebouncedMergeRequest( uniqueId: string, diff --git a/src/HookFilter.ts b/src/HookFilter.ts index 573a257d4..daf693c6f 100644 --- a/src/HookFilter.ts +++ b/src/HookFilter.ts @@ -20,10 +20,11 @@ export class HookFilter { return [...resultHookSet]; } - constructor(public enabledHooks: T[] = []) {} + constructor(public enabledHooks: T[] = []) { } public shouldSkip(...hookName: T[]) { // Should skip if all of the hook names are missing + //console.log("→ shouldSkip called with", hookName, "vs", this.enabledHooks); return hookName.every((name) => !this.enabledHooks.includes(name)); } } diff --git a/src/gitlab/WebhookTypes.ts b/src/gitlab/WebhookTypes.ts index 4db4c7776..1332a7247 100644 --- a/src/gitlab/WebhookTypes.ts +++ b/src/gitlab/WebhookTypes.ts @@ -201,6 +201,7 @@ export interface IGitLabWebhookNoteEvent { merge_request?: IGitlabMergeRequest; } + export interface IGitLabWebhookIssueStateEvent { user: IGitlabUser; event_type: string; @@ -239,4 +240,4 @@ export interface IGitLabWebhookPipelineEvent { created_at: string; finished_at: string; }; -} +} \ No newline at end of file From a25430cd831ef8e94e7413213bf4b23b82518046 Mon Sep 17 00:00:00 2001 From: ManilDf Date: Fri, 6 Jun 2025 23:02:53 +0200 Subject: [PATCH 20/27] refactor: add first handler for pipeline.success --- src/Bridge.ts | 9 ++++ src/Connections/GitlabRepo.ts | 77 ++++++++++++++++++++++++++--------- 2 files changed, 66 insertions(+), 20 deletions(-) diff --git a/src/Bridge.ts b/src/Bridge.ts index c361432c8..d642f01ae 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -616,6 +616,15 @@ export class Bridge { (c, data) => c.onWikiPageEvent(data), ); + this.bindHandlerToQueue( + "gitlab.pipeline.success", + (data) => + connManager.getConnectionsForGitLabRepo( + data.project.path_with_namespace, + ), + (c, data) => c.onPipelineSuccess(data), + ); + this.bindHandlerToQueue( "gitlab.pipeline", (data) => diff --git a/src/Connections/GitlabRepo.ts b/src/Connections/GitlabRepo.ts index 9b4d98cc2..e9f521765 100644 --- a/src/Connections/GitlabRepo.ts +++ b/src/Connections/GitlabRepo.ts @@ -100,7 +100,8 @@ type AllowedEventsNames = | `wiki.${string}` | "release" | "release.created" - | "pipeline"; + | "pipeline" + | "pipeline.success"; const AllowedEvents: AllowedEventsNames[] = [ "merge_request.open", @@ -118,6 +119,7 @@ const AllowedEvents: AllowedEventsNames[] = [ "release", "release.created", "pipeline", + "pipeline.success", ]; // // | "pipeline"; @@ -992,31 +994,66 @@ ${data.description}`; return; } - //console.log(">>> onPipelineEvent data:", this.hookFilter.enabledHooks); + log.info( + `onPipelineEvent ${this.roomId} ${this.instance.url}/${this.path}`, + ); + const { status, ref, duration, id: pipelineId } = event.object_attributes; - log.info(`onPipelineEvent ${this.roomId} ${this.instance.url}/${this.path}`); + const statusUpper = status.toUpperCase(); + if (!this.notifiedPipelines.has(pipelineId)) { + 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", + }); - const { - status, - ref, - duration, - created_at, - finished_at, - } = event.object_attributes; + this.notifiedPipelines.add(pipelineId); + } - const content = `Pipeline **${status.toUpperCase()}** on branch \`${ref}\` for project [${event.project.name}](${event.project.web_url}) triggered by **${event.user.username}** - \nDuration: ${duration ?? "?"}s - \nStarted: ${created_at} - \nFinished: ${finished_at}`; + if (["FAILED", "CANCELED"].includes(statusUpper)) { + const statusHtml = + statusUpper === "FAILED" + ? `${statusUpper}` + : statusUpper === "CANCELED" + ? `${statusUpper}` + : `${statusUpper}`; - await this.intent.sendEvent(this.roomId, { - msgtype: "m.notice", - body: content, - formatted_body: md.render(content), - format: "org.matrix.custom.html", - }); + const contentText = `Pipeline ${statusUpper} on branch \`${ref}\` for project ${event.project.name} by ${event.user.username} - Duration: ${duration ?? "?"}s`; + const contentHtml = `Pipeline ${statusHtml} on branch ${ref} for project ${event.project.name} by ${event.user.username} - Duration: ${duration ?? "?"}s`; + + await this.intent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: contentText, + formatted_body: contentHtml, + format: "org.matrix.custom.html", + }); + this.notifiedPipelines.delete(pipelineId); + } + } + + 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: ${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, From 503caa3fd6275be03a2f6f4b25efa8801dc2e702 Mon Sep 17 00:00:00 2001 From: hakim Date: Sat, 7 Jun 2025 14:59:35 +0200 Subject: [PATCH 21/27] move gitlab pipeline mapping to Route.ts --- src/Bridge.ts | 1 - src/Connections/GitlabRepo.ts | 4 ---- src/gitlab/Router.ts | 11 ++++++++++- tests/connections/GitlabRepoTest.ts | 10 +++++----- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/Bridge.ts b/src/Bridge.ts index d642f01ae..bd1b6ec5b 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -48,7 +48,6 @@ import { IGitLabWebhookTagPushEvent, IGitLabWebhookWikiPageEvent, IGitLabWebhookPipelineEvent, - IGitLabWebhookPipelineEvent, } from "./gitlab/WebhookTypes"; import { JiraIssueEvent, diff --git a/src/Connections/GitlabRepo.ts b/src/Connections/GitlabRepo.ts index e9f521765..bab5e2542 100644 --- a/src/Connections/GitlabRepo.ts +++ b/src/Connections/GitlabRepo.ts @@ -16,7 +16,6 @@ import { IGitLabWebhookTagPushEvent, IGitLabWebhookWikiPageEvent, IGitLabWebhookPipelineEvent, - IGitLabWebhookPipelineEvent, } from "../gitlab/WebhookTypes"; import { CommandConnection } from "./CommandConnection"; import { @@ -121,9 +120,6 @@ const AllowedEvents: AllowedEventsNames[] = [ "pipeline", "pipeline.success", ]; -// -// | "pipeline"; - const DefaultHooks = AllowedEvents; diff --git a/src/gitlab/Router.ts b/src/gitlab/Router.ts index de24ba2ad..51f27f477 100644 --- a/src/gitlab/Router.ts +++ b/src/gitlab/Router.ts @@ -7,6 +7,7 @@ import { IGitLabWebhookEvent, IGitLabWebhookIssueStateEvent, IGitLabWebhookMREvent, + IGitLabWebhookPipelineEvent, IGitLabWebhookReleaseEvent, } from "./WebhookTypes"; @@ -67,7 +68,15 @@ export class GitLabWebhooksRouter { return `gitlab.release.${action}`; } else if (body.object_kind === "push") { return `gitlab.push`; - } else { + + } else if (body.object_kind === "pipeline") { + const pipeline_event = (body as unknown as IGitLabWebhookPipelineEvent) + const status = pipeline_event.object_attributes?.status?.toLowerCase(); + if (status === "success") { + return "gitlab.pipeline.success"; + } + return "gitlab.pipeline"; + }else { return null; } } diff --git a/tests/connections/GitlabRepoTest.ts b/tests/connections/GitlabRepoTest.ts index a941cc035..1148e0d3a 100644 --- a/tests/connections/GitlabRepoTest.ts +++ b/tests/connections/GitlabRepoTest.ts @@ -462,12 +462,12 @@ describe("GitLabRepoConnection", () => { intent.expectEventBodyContains("Pipeline triggered", 0); }); - it("should send triggered and final success message (green)", async () => { + it("should send final success message (green)", async () => { const { connection, intent } = createConnection({ enableHooks: ["pipeline"], }); - await connection.onPipelineEvent({ + await connection.onPipelineSuccess({ ...baseEvent, object_attributes: { ...baseEvent.object_attributes, @@ -475,10 +475,10 @@ describe("GitLabRepoConnection", () => { }, }); - expect(intent.sentEvents[0].content.body).to.include("triggered"); + //expect(intent.sentEvents[0].content.body).to.include("triggered"); - expect(intent.sentEvents[1].content.body).to.include("SUCCESS"); - expect(intent.sentEvents[1].content.formatted_body).to.include( + expect(intent.sentEvents[0].content.body).to.include("SUCCESS"); + expect(intent.sentEvents[0].content.formatted_body).to.include( 'SUCCESS', ); }); From a46ae17708e30736140afece04c7a5137291f44a Mon Sep 17 00:00:00 2001 From: hakim Date: Mon, 9 Jun 2025 11:45:35 +0200 Subject: [PATCH 22/27] Fix lint issues --- src/Bridge.ts | 6 ++-- src/Connections/GitlabRepo.ts | 46 ++++++++++++++--------------- src/HookFilter.ts | 2 +- src/gitlab/Router.ts | 6 ++-- src/gitlab/WebhookTypes.ts | 3 +- tests/connections/GitlabRepoTest.ts | 1 - 6 files changed, 31 insertions(+), 33 deletions(-) diff --git a/src/Bridge.ts b/src/Bridge.ts index bd1b6ec5b..4f7585da4 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -702,9 +702,9 @@ export class Bridge { return [ ...(iid ? connManager.getConnectionsForGitLabIssueWebhook( - data.repository.homepage, - iid, - ) + data.repository.homepage, + iid, + ) : []), ...connManager.getConnectionsForGitLabRepo( data.project.path_with_namespace, diff --git a/src/Connections/GitlabRepo.ts b/src/Connections/GitlabRepo.ts index bab5e2542..8a2f2272e 100644 --- a/src/Connections/GitlabRepo.ts +++ b/src/Connections/GitlabRepo.ts @@ -191,7 +191,8 @@ export interface GitLabTargetFilter { @Connection export class GitLabRepoConnection extends CommandConnection - implements IConnection { + implements IConnection +{ static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.gitlab.repository"; static readonly LegacyCanonicalEventType = @@ -978,13 +979,11 @@ ${data.description}`; } public async onPipelineEvent(event: IGitLabWebhookPipelineEvent) { - /*console.log("HOOK FILTER CHECK:", { enabledHooks: this.hookFilter.enabledHooks, shouldSkip: this.hookFilter.shouldSkip("pipeline"), });*/ - if (this.hookFilter.shouldSkip("pipeline")) { //console.log(">>> [qaqah] Skipping pipeline event due to filter."); return; @@ -1011,11 +1010,11 @@ ${data.description}`; if (["FAILED", "CANCELED"].includes(statusUpper)) { const statusHtml = - statusUpper === "FAILED" - ? `${statusUpper}` - : statusUpper === "CANCELED" - ? `${statusUpper}` - : `${statusUpper}`; + statusUpper === "FAILED" + ? `${statusUpper}` + : statusUpper === "CANCELED" + ? `${statusUpper}` + : `${statusUpper}`; const contentText = `Pipeline ${statusUpper} on branch \`${ref}\` for project ${event.project.name} by ${event.user.username} - Duration: ${duration ?? "?"}s`; const contentHtml = `Pipeline ${statusHtml} on branch ${ref} for project ${event.project.name} by ${event.user.username} - Duration: ${duration ?? "?"}s`; @@ -1031,24 +1030,25 @@ ${data.description}`; } 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; + if (this.hookFilter.shouldSkip("pipeline", "pipeline.success")) { + return; + } - 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: ${duration ?? "?"}s`; + log.info( + `onPipelineSuccess ${this.roomId} ${this.instance.url}/${this.path}`, + ); + const { ref, duration } = event.object_attributes; - await this.intent.sendEvent(this.roomId, { - msgtype: "m.notice", - body: contentText, - formatted_body: contentHtml, - format: "org.matrix.custom.html", - }); -} + 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: ${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, diff --git a/src/HookFilter.ts b/src/HookFilter.ts index daf693c6f..a96868f1a 100644 --- a/src/HookFilter.ts +++ b/src/HookFilter.ts @@ -20,7 +20,7 @@ export class HookFilter { return [...resultHookSet]; } - constructor(public enabledHooks: T[] = []) { } + constructor(public enabledHooks: T[] = []) {} public shouldSkip(...hookName: T[]) { // Should skip if all of the hook names are missing diff --git a/src/gitlab/Router.ts b/src/gitlab/Router.ts index bd3fea6d5..5640374e9 100644 --- a/src/gitlab/Router.ts +++ b/src/gitlab/Router.ts @@ -69,13 +69,13 @@ export class GitLabWebhooksRouter { } else if (body.object_kind === "push") { return `gitlab.push`; } else if (body.object_kind === "pipeline") { - const pipeline_event = (body as unknown as IGitLabWebhookPipelineEvent) - const status = pipeline_event.object_attributes?.status?.toLowerCase(); + const pipelineEevent = body as unknown as IGitLabWebhookPipelineEvent; + const status = pipelineEevent.object_attributes?.status?.toLowerCase(); if (status === "success") { return "gitlab.pipeline.success"; } return "gitlab.pipeline"; - }else { + } else { return null; } } diff --git a/src/gitlab/WebhookTypes.ts b/src/gitlab/WebhookTypes.ts index 1332a7247..4db4c7776 100644 --- a/src/gitlab/WebhookTypes.ts +++ b/src/gitlab/WebhookTypes.ts @@ -201,7 +201,6 @@ export interface IGitLabWebhookNoteEvent { merge_request?: IGitlabMergeRequest; } - export interface IGitLabWebhookIssueStateEvent { user: IGitlabUser; event_type: string; @@ -240,4 +239,4 @@ export interface IGitLabWebhookPipelineEvent { created_at: string; finished_at: string; }; -} \ No newline at end of file +} diff --git a/tests/connections/GitlabRepoTest.ts b/tests/connections/GitlabRepoTest.ts index 1148e0d3a..03cd51ab1 100644 --- a/tests/connections/GitlabRepoTest.ts +++ b/tests/connections/GitlabRepoTest.ts @@ -423,7 +423,6 @@ describe("GitLabRepoConnection", () => { }); describe("onPipelineEvent", () => { - let baseEvent: IGitLabWebhookPipelineEvent; beforeEach(() => { From dd908ba6318dc99e41130e9dae8981c38f3afdb0 Mon Sep 17 00:00:00 2001 From: ManilDf Date: Mon, 9 Jun 2025 16:25:58 +0200 Subject: [PATCH 23/27] fix: issues and refactor code, address comments --- src/Bridge.ts | 22 +++++- src/Connections/GitlabRepo.ts | 105 ++++++++++++++++------------ src/gitlab/Router.ts | 5 +- tests/connections/GitlabRepoTest.ts | 79 +++++++++++++++++---- 4 files changed, 146 insertions(+), 65 deletions(-) diff --git a/src/Bridge.ts b/src/Bridge.ts index 4f7585da4..5fcec77ea 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -615,6 +615,15 @@ 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) => @@ -625,12 +634,21 @@ export class Bridge { ); this.bindHandlerToQueue( - "gitlab.pipeline", + "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.onPipelineEvent(data), + (c, data) => c.onPipelineCanceled(data), ); this.queue.on( diff --git a/src/Connections/GitlabRepo.ts b/src/Connections/GitlabRepo.ts index 8a2f2272e..5a3798973 100644 --- a/src/Connections/GitlabRepo.ts +++ b/src/Connections/GitlabRepo.ts @@ -100,7 +100,10 @@ type AllowedEventsNames = | "release" | "release.created" | "pipeline" - | "pipeline.success"; + | "pipeline.running" + | "pipeline.success" + | "pipeline.failed" + | "pipeline.canceled"; const AllowedEvents: AllowedEventsNames[] = [ "merge_request.open", @@ -118,7 +121,10 @@ const AllowedEvents: AllowedEventsNames[] = [ "release", "release.created", "pipeline", + "pipeline.running", "pipeline.success", + "pipeline.failed", + "pipeline.canceled", ]; const DefaultHooks = AllowedEvents; @@ -565,8 +571,6 @@ export class GitLabRepoConnection private readonly grantChecker; private readonly commentDebounceMs: number; - private notifiedPipelines = new Set(); - constructor( roomId: string, stateKey: string, @@ -978,69 +982,80 @@ ${data.description}`; }); } - public async onPipelineEvent(event: IGitLabWebhookPipelineEvent) { - /*console.log("HOOK FILTER CHECK:", { - enabledHooks: this.hookFilter.enabledHooks, - shouldSkip: this.hookFilter.shouldSkip("pipeline"), - });*/ - - if (this.hookFilter.shouldSkip("pipeline")) { - //console.log(">>> [qaqah] Skipping pipeline event due to filter."); + public async onPipelineTriggered(event: IGitLabWebhookPipelineEvent) { + if (this.hookFilter.shouldSkip("pipeline", "pipeline.running")) { return; } log.info( - `onPipelineEvent ${this.roomId} ${this.instance.url}/${this.path}`, + `onPipelineTriggered ${this.roomId} ${this.instance.url}/${this.path}`, ); - const { status, ref, duration, id: pipelineId } = event.object_attributes; + const { ref, duration } = event.object_attributes; - const statusUpper = status.toUpperCase(); - if (!this.notifiedPipelines.has(pipelineId)) { - 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", - }); + 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", + }); + } - this.notifiedPipelines.add(pipelineId); + public async onPipelineSuccess(event: IGitLabWebhookPipelineEvent) { + if (this.hookFilter.shouldSkip("pipeline", "pipeline.success")) { + return; } - if (["FAILED", "CANCELED"].includes(statusUpper)) { - const statusHtml = - statusUpper === "FAILED" - ? `${statusUpper}` - : statusUpper === "CANCELED" - ? `${statusUpper}` - : `${statusUpper}`; + log.info( + `onPipelineSuccess ${this.roomId} ${this.instance.url}/${this.path}`, + ); + const { ref, duration } = event.object_attributes; - const contentText = `Pipeline ${statusUpper} on branch \`${ref}\` for project ${event.project.name} by ${event.user.username} - Duration: ${duration ?? "?"}s`; - const contentHtml = `Pipeline ${statusHtml} on branch ${ref} for project ${event.project.name} by ${event.user.username} - Duration: ${duration ?? "?"}s`; + 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: ${duration ?? "?"}s`; - await this.intent.sendEvent(this.roomId, { - msgtype: "m.notice", - body: contentText, - formatted_body: contentHtml, - format: "org.matrix.custom.html", - }); - this.notifiedPipelines.delete(pipelineId); + 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: ${duration ?? "?"}s`; + + await this.intent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: contentText, + formatted_body: contentHtml, + format: "org.matrix.custom.html", + }); } - public async onPipelineSuccess(event: IGitLabWebhookPipelineEvent) { - if (this.hookFilter.shouldSkip("pipeline", "pipeline.success")) { + public async onPipelineCanceled(event: IGitLabWebhookPipelineEvent) { + if (this.hookFilter.shouldSkip("pipeline", "pipeline.canceled")) { return; } log.info( - `onPipelineSuccess ${this.roomId} ${this.instance.url}/${this.path}`, + `onPipelineCanceled ${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: ${duration ?? "?"}s`; + 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: ${duration ?? "?"}s`; await this.intent.sendEvent(this.roomId, { msgtype: "m.notice", diff --git a/src/gitlab/Router.ts b/src/gitlab/Router.ts index 5640374e9..1b3857ce1 100644 --- a/src/gitlab/Router.ts +++ b/src/gitlab/Router.ts @@ -71,10 +71,7 @@ export class GitLabWebhooksRouter { } else if (body.object_kind === "pipeline") { const pipelineEevent = body as unknown as IGitLabWebhookPipelineEvent; const status = pipelineEevent.object_attributes?.status?.toLowerCase(); - if (status === "success") { - return "gitlab.pipeline.success"; - } - return "gitlab.pipeline"; + return `gitlab.pipeline.${status}`; } else { return null; } diff --git a/tests/connections/GitlabRepoTest.ts b/tests/connections/GitlabRepoTest.ts index 03cd51ab1..962155232 100644 --- a/tests/connections/GitlabRepoTest.ts +++ b/tests/connections/GitlabRepoTest.ts @@ -434,9 +434,21 @@ describe("GitLabRepoConnection", () => { }; }); - it("should skip event if hook is disabled", async () => { + it("should skip onPipelineTriggered if hook is disabled", async () => { const { connection, intent } = createConnection({ enableHooks: [] }); - await connection.onPipelineEvent({ + 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, @@ -446,15 +458,39 @@ describe("GitLabRepoConnection", () => { 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.onPipelineEvent({ + await connection.onPipelineTriggered({ ...baseEvent, object_attributes: { ...baseEvent.object_attributes, - status: "pending", // pipeline just started + status: "running", // pipeline just started }, }); @@ -474,32 +510,47 @@ describe("GitLabRepoConnection", () => { }, }); - //expect(intent.sentEvents[0].content.body).to.include("triggered"); - expect(intent.sentEvents[0].content.body).to.include("SUCCESS"); expect(intent.sentEvents[0].content.formatted_body).to.include( - 'SUCCESS', + 'SUCCESS', ); }); - it("should send triggered and final failed message (red)", async () => { + it("should send canceled message (gray)", async () => { const { connection, intent } = createConnection({ enableHooks: ["pipeline"], }); - await connection.onPipelineEvent({ + await connection.onPipelineCanceled({ ...baseEvent, object_attributes: { ...baseEvent.object_attributes, - status: "failed", + status: "canceled", }, }); - expect(intent.sentEvents[0].content.body).to.include("triggered"); + expect(intent.sentEvents[0].content.body).to.include("CANCELED"); + expect(intent.sentEvents[0].content.formatted_body).to.include( + 'CANCELED', + ); + }); - expect(intent.sentEvents[1].content.body).to.include("FAILED"); - expect(intent.sentEvents[1].content.formatted_body).to.include( - 'FAILED', + 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', ); }); }); From 37cef84331f32ad393c0a7a2adeebfa2cf534aee Mon Sep 17 00:00:00 2001 From: LAKROUT Hakim <99540145+Hakim-Lakrout@users.noreply.github.com> Date: Mon, 9 Jun 2025 18:24:45 +0200 Subject: [PATCH 24/27] Update src/Connections/GitlabRepo.ts Remove unused type Co-authored-by: Will Hunt Signed-off-by: LAKROUT Hakim <99540145+Hakim-Lakrout@users.noreply.github.com> --- src/Connections/GitlabRepo.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Connections/GitlabRepo.ts b/src/Connections/GitlabRepo.ts index 5a3798973..5320e43bf 100644 --- a/src/Connections/GitlabRepo.ts +++ b/src/Connections/GitlabRepo.ts @@ -990,7 +990,7 @@ ${data.description}`; log.info( `onPipelineTriggered ${this.roomId} ${this.instance.url}/${this.path}`, ); - const { ref, duration } = event.object_attributes; + 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}`; From 8ea8e71df1e5a6e3cd6ccc8d40a8e6cb02ad517a Mon Sep 17 00:00:00 2001 From: LAKROUT Hakim <99540145+Hakim-Lakrout@users.noreply.github.com> Date: Mon, 9 Jun 2025 18:25:39 +0200 Subject: [PATCH 25/27] Update src/gitlab/Router.ts Fix typo Co-authored-by: Will Hunt Signed-off-by: LAKROUT Hakim <99540145+Hakim-Lakrout@users.noreply.github.com> --- src/gitlab/Router.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/gitlab/Router.ts b/src/gitlab/Router.ts index 1b3857ce1..a3951c895 100644 --- a/src/gitlab/Router.ts +++ b/src/gitlab/Router.ts @@ -69,8 +69,8 @@ export class GitLabWebhooksRouter { } else if (body.object_kind === "push") { return `gitlab.push`; } else if (body.object_kind === "pipeline") { - const pipelineEevent = body as unknown as IGitLabWebhookPipelineEvent; - const status = pipelineEevent.object_attributes?.status?.toLowerCase(); + const pipelineEvent = body as unknown as IGitLabWebhookPipelineEvent; + const status = pipelineEvent.object_attributes?.status?.toLowerCase(); return `gitlab.pipeline.${status}`; } else { return null; From 090b84924ec79c03677087f8b3967bccf5950168 Mon Sep 17 00:00:00 2001 From: hakim Date: Mon, 9 Jun 2025 18:47:12 +0200 Subject: [PATCH 26/27] Fix tsx format for pipeline and remove comment and duration when unused --- src/Connections/GitlabRepo.ts | 6 +-- src/HookFilter.ts | 1 - .../roomConfig/GitlabRepoConfig.tsx | 48 ++++++++++++++++--- 3 files changed, 44 insertions(+), 11 deletions(-) diff --git a/src/Connections/GitlabRepo.ts b/src/Connections/GitlabRepo.ts index 5320e43bf..00cb25944 100644 --- a/src/Connections/GitlabRepo.ts +++ b/src/Connections/GitlabRepo.ts @@ -1013,7 +1013,7 @@ ${data.description}`; 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: ${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", @@ -1034,7 +1034,7 @@ ${data.description}`; 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: ${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", @@ -1055,7 +1055,7 @@ ${data.description}`; 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: ${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", diff --git a/src/HookFilter.ts b/src/HookFilter.ts index a96868f1a..573a257d4 100644 --- a/src/HookFilter.ts +++ b/src/HookFilter.ts @@ -24,7 +24,6 @@ export class HookFilter { public shouldSkip(...hookName: T[]) { // Should skip if all of the hook names are missing - //console.log("→ shouldSkip called with", hookName, "vs", this.enabledHooks); return hookName.every((name) => !this.enabledHooks.includes(name)); } } diff --git a/web/components/roomConfig/GitlabRepoConfig.tsx b/web/components/roomConfig/GitlabRepoConfig.tsx index e76a8cccb..e33dcc828 100644 --- a/web/components/roomConfig/GitlabRepoConfig.tsx +++ b/web/components/roomConfig/GitlabRepoConfig.tsx @@ -272,13 +272,6 @@ const ConnectionConfiguration: FunctionComponent< enabledHooks={enabledHooks} hookEventName="tag_push" onChange={toggleEnabledHook} - > - Pipelines - - Tag pushes @@ -296,6 +289,47 @@ const ConnectionConfiguration: FunctionComponent< > Releases + + Pipelines + +
    + + Success + + + Failed + + + Running + + + Canceled + +
From 3874c7fbccd182d6cf7ef8bb383c5f95f0fcbc33 Mon Sep 17 00:00:00 2001 From: ManilDf Date: Mon, 9 Jun 2025 18:58:46 +0200 Subject: [PATCH 27/27] Add contributor --- changelog.d/1061.feature | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/1061.feature b/changelog.d/1061.feature index 352822b9c..959069a29 100644 --- a/changelog.d/1061.feature +++ b/changelog.d/1061.feature @@ -1 +1 @@ -Add support for GitLab 'pipeline' event notifications. Thanks to @madjidDer, @ManilDF, and @leguye! \ No newline at end of file +Add support for GitLab 'pipeline' event notifications. Thanks to @madjidDer, @ManilDF, @Hakim-Lakrout and @leguye! \ No newline at end of file