Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
2d477e1
Support GitLab pipeline events
madjidDer May 20, 2025
3b6f188
Tests
ManilDf May 22, 2025
f784f73
test: refactor pipeline event tests using loop
madjidDer May 27, 2025
5595e18
ignore intermediate pipeline statuses
madjidDer May 27, 2025
45b8bfa
Update pipeline tests and tweak message formatting
ManilDf May 27, 2025
b93b0bd
Add e2e Tests
ManilDf May 29, 2025
a7b9a06
handle CANCELED status and deduplicate notifications
ManilDf May 29, 2025
34051aa
Merge branch 'main' into feat/gitlab-ci-support
ManilDf May 29, 2025
13deb0a
docs: mention GitLab pipeline event in supported hooks
madjidDer May 30, 2025
165cb88
Add changelog
madjidDer May 30, 2025
eeff431
remove comments
madjidDer May 30, 2025
0559e53
Fix lint issues
madjidDer Jun 1, 2025
f960f23
Update changelog.d/1061.feature
ManilDf Jun 6, 2025
92f24b5
Fix tests by adding missing type
ManilDf Jun 6, 2025
2fe82a5
refactor: add first handler for pipeline.success
ManilDf Jun 6, 2025
0112b90
Ping homeserver on startup and warn about configuration errors (#1062)
Half-Shot Jun 6, 2025
e708a23
Add option to omit the hook payload data from matrix events from gene…
Half-Shot Jun 6, 2025
25364a6
Migrate GitHub, GitLab, and JIRA webhook path from `/` to `/<service>…
Half-Shot Jun 6, 2025
f00e17b
jira webhook docs fix
Half-Shot Jun 6, 2025
8ad832c
Support GitLab pipeline events
madjidDer May 20, 2025
a25430c
refactor: add first handler for pipeline.success
ManilDf Jun 6, 2025
503caa3
move gitlab pipeline mapping to Route.ts
Hakim-Lakrout Jun 7, 2025
92c1419
Merge branch 'main' into feat/gitlab-ci-support
Hakim-Lakrout Jun 7, 2025
a46ae17
Fix lint issues
Hakim-Lakrout Jun 9, 2025
dd908ba
fix: issues and refactor code, address comments
ManilDf Jun 9, 2025
37cef84
Update src/Connections/GitlabRepo.ts
Hakim-Lakrout Jun 9, 2025
8ea8e71
Update src/gitlab/Router.ts
Hakim-Lakrout Jun 9, 2025
090b849
Fix tsx format for pipeline and remove comment and duration when unused
Hakim-Lakrout Jun 9, 2025
3874c7f
Add contributor
ManilDf Jun 9, 2025
ed5c542
Merge branch 'main' into feat/gitlab-ci-support
Hakim-Lakrout Jun 9, 2025
81c5bff
Merge branch 'main' into feat/gitlab-ci-support
Hakim-Lakrout Jun 20, 2025
839384c
Merge remote-tracking branch 'upstream/main' into feat/gitlab-ci-support
leguye Nov 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1061.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add support for GitLab 'pipeline' event notifications. Thanks to @madjidDer, @ManilDF, @Hakim-Lakrout and @leguye!
5 changes: 5 additions & 0 deletions docs/usage/room_configuration/gitlab_project.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 \*
297 changes: 297 additions & 0 deletions spec/gitlab-pipeline.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
import { E2ESetupTestTimeout, E2ETestEnv } from "./util/e2e-test";
import { describe, test, beforeAll, afterAll, expect } from "vitest";
import { createHmac, randomUUID } from "crypto";
import {
GitLabRepoConnection,
GitLabRepoConnectionState,
} from "../src/Connections";
import { MessageEventContent } from "matrix-bot-sdk";
import { getBridgeApi } from "./util/bridge-api";
import { waitFor } from "./util/helpers";
import { Server, createServer } from "http";

describe("GitLab - Pipeline Event", () => {
let testEnv: E2ETestEnv;
let gitlabServer: Server;
const webhooksPort = 9801 + E2ETestEnv.workerId;
const gitlabPort = 9901 + E2ETestEnv.workerId;

beforeAll(async () => {
gitlabServer = createServer((req, res) => {
if (req.method === "GET" && req.url?.includes("/projects")) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ id: 1234 }));
} else {
console.log("Unknown GitLab request", req.method, req.url);
res.writeHead(404);
res.end();
}
}).listen(gitlabPort);

testEnv = await E2ETestEnv.createTestEnv({
matrixLocalparts: ["user"],
config: {
gitlab: {
webhook: {
secret: "mysecret",
},
instances: {
test: {
url: `http://localhost:${gitlabPort}`,
},
},
},
widgets: {
publicUrl: `http://localhost:${webhooksPort}`,
},
listeners: [
{
port: webhooksPort,
bindAddress: "0.0.0.0",
resources: ["webhooks", "widgets"],
},
],
},
});
await testEnv.setUp();
}, E2ESetupTestTimeout);

afterAll(() => {
gitlabServer?.close();
return testEnv?.tearDown();
});

const waitForMessages = (
user: any,
roomId: string,
botMxid: string,
expectedCount: number,
timeoutMs: number = 10000,
): Promise<MessageEventContent[]> => {
return new Promise((resolve, reject) => {
const receivedMessages: MessageEventContent[] = [];
const timeout = setTimeout(() => {
cleanup();
reject(
new Error(
`Timeout: Expected ${expectedCount} messages, got ${receivedMessages.length}`,
),
);
}, timeoutMs);

const messageHandler = (eventRoomId: string, event: any) => {
if (
eventRoomId === roomId &&
event.sender === botMxid &&
event.content?.msgtype === "m.notice"
) {
receivedMessages.push(event.content);
if (receivedMessages.length >= expectedCount) {
cleanup();
resolve(receivedMessages);
}
}
};

const cleanup = () => {
clearTimeout(timeout);
user.off("room.message", messageHandler);
};

user.on("room.message", messageHandler);
});
};

test(
"should handle GitLab pipeline success event with one messages",
async () => {
const user = testEnv.getUser("user");
const bridgeApi = await getBridgeApi(
testEnv.opts.config?.widgets?.publicUrl!,
user,
);
const testRoomId = await user.createRoom({
name: "Pipeline Test Room",
invite: [testEnv.botMxid],
});
await user.setUserPowerLevel(testEnv.botMxid, testRoomId, 50);
await user.waitForRoomJoin({
sender: testEnv.botMxid,
roomId: testRoomId,
});

await testEnv.app.appservice.botClient.sendStateEvent(
testRoomId,
GitLabRepoConnection.CanonicalEventType,
"my-test-pipeline",
{
instance: "test",
path: "org/project",
enableHooks: ["pipeline"],
} satisfies GitLabRepoConnectionState,
);

await waitFor(
async () =>
(await bridgeApi.getConnectionsForRoom(testRoomId)).length === 1,
);

const messagesPromise = waitForMessages(
user,
testRoomId,
testEnv.botMxid,
1,
);

const webhookPayload = JSON.stringify({
object_kind: "pipeline",
object_attributes: {
id: 123456,
status: "success",
ref: "main",
url: "https://gitlab.example.com/org/project/-/pipelines/123456",
duration: 300,
finished_at: "2025-01-01T12:00:00Z",
},
project: {
id: 1234,
name: "project",
path_with_namespace: "org/project",
web_url: "https://gitlab.example.com/org/project",
},
user: {
id: 1,
name: "Alice Doe",
username: "alice",
email: "[email protected]",
},
commit: {
id: "abcd1234567890",
message: "Add new feature",
author_name: "Alice Doe",
author_email: "[email protected]",
},
});

const hmac = createHmac("sha256", "mysecret");
hmac.write(webhookPayload);
hmac.end();

const req = await fetch(`http://localhost:${webhooksPort}/`, {
method: "POST",
headers: {
"X-Gitlab-Event": "Pipeline Hook",
"X-Gitlab-Token": "mysecret",
"X-Hub-Signature-256": `sha256=${hmac.read().toString("hex")}`,
"Content-Type": "application/json",
},
body: webhookPayload,
});

expect(req.status).toBe(200);
expect(await req.text()).toBe("OK");

const receivedMessages = await messagesPromise;

expect(receivedMessages.length).toBe(1);

const triggeredMessage = receivedMessages[0];
expect(triggeredMessage.body.toLowerCase()).toContain("success");
},
E2ESetupTestTimeout,
);

test("should only send triggered message for running pipeline", async () => {
const user = testEnv.getUser("user");
const bridgeApi = await getBridgeApi(
testEnv.opts.config?.widgets?.publicUrl!,
user,
);
const testRoomId = await user.createRoom({
name: "Pipeline Running Test Room",
invite: [testEnv.botMxid],
});
await user.setUserPowerLevel(testEnv.botMxid, testRoomId, 50);
await user.waitForRoomJoin({ sender: testEnv.botMxid, roomId: testRoomId });

await testEnv.app.appservice.botClient.sendStateEvent(
testRoomId,
GitLabRepoConnection.CanonicalEventType,
"my-test-pipeline-running",
{
instance: "test",
path: "org/project",
enableHooks: ["pipeline"],
} satisfies GitLabRepoConnectionState,
);

await waitFor(
async () =>
(await bridgeApi.getConnectionsForRoom(testRoomId)).length === 1,
);

const receivedMessages: MessageEventContent[] = [];
const messageHandler = (roomId: string, event: any) => {
if (roomId === testRoomId && event.sender === testEnv.botMxid) {
receivedMessages.push(event.content);
}
};
user.on("room.message", messageHandler);

const webhookPayload = JSON.stringify({
object_kind: "pipeline",
object_attributes: {
id: 999888,
status: "running",
ref: "main",
url: "https://gitlab.example.com/org/project/-/pipelines/999888",
duration: null,
finished_at: null,
},
project: {
id: 1234,
name: "project",
path_with_namespace: "org/project",
web_url: "https://gitlab.example.com/org/project",
},
user: {
id: 4,
name: "David Wilson",
username: "david",
email: "[email protected]",
},
commit: {
id: "mnop3456789012",
message: "Start new feature",
author_name: "David Wilson",
author_email: "[email protected]",
},
});

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");
});
});
37 changes: 37 additions & 0 deletions src/Bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
IGitLabWebhookReleaseEvent,
IGitLabWebhookTagPushEvent,
IGitLabWebhookWikiPageEvent,
IGitLabWebhookPipelineEvent,
} from "./gitlab/WebhookTypes";
import {
JiraIssueEvent,
Expand Down Expand Up @@ -614,6 +615,42 @@
(c, data) => c.onWikiPageEvent(data),
);

this.bindHandlerToQueue<IGitLabWebhookPipelineEvent, GitLabRepoConnection>(
"gitlab.pipeline.running",
(data) =>
connManager.getConnectionsForGitLabRepo(
data.project.path_with_namespace,
),
(c, data) => c.onPipelineTriggered(data),
);

this.bindHandlerToQueue<IGitLabWebhookPipelineEvent, GitLabRepoConnection>(
"gitlab.pipeline.success",
(data) =>
connManager.getConnectionsForGitLabRepo(
data.project.path_with_namespace,
),
(c, data) => c.onPipelineSuccess(data),
);

this.bindHandlerToQueue<IGitLabWebhookPipelineEvent, GitLabRepoConnection>(
"gitlab.pipeline.failed",
(data) =>
connManager.getConnectionsForGitLabRepo(
data.project.path_with_namespace,
),
(c, data) => c.onPipelineFailed(data),
);

this.bindHandlerToQueue<IGitLabWebhookPipelineEvent, GitLabRepoConnection>(
"gitlab.pipeline.canceled",
(data) =>
connManager.getConnectionsForGitLabRepo(
data.project.path_with_namespace,
),
(c, data) => c.onPipelineCanceled(data),
);

this.queue.on<UserNotificationsEvent>(
"notifications.user.events",
async (msg) => {
Expand Down Expand Up @@ -1479,7 +1516,7 @@
);
// This might be a reply to a notification
try {
const evContent = replyEvent.content as any;

Check warning on line 1519 in src/Bridge.ts

View workflow job for this annotation

GitHub Actions / lint-node

Unexpected any. Specify a different type
const splitParts: string[] =
evContent["uk.half-shot.matrix-hookshot.github.repo"]?.name.split(
"/",
Expand Down
Loading
Loading