Filter GitLab comments with include/exclude label config. (#1018)

* Filter GitLab comments with include/exclude label config.

* Start working on tests

* fix types

* fixup tests

* changelog

* lint
This commit is contained in:
Will Hunt 2025-02-25 12:12:49 +00:00 committed by GitHub
parent ff08dd1172
commit 4b0694a041
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 79 additions and 33 deletions

1
changelog.d/1756.bugfix Normal file
View File

@ -0,0 +1 @@
GitLab merge request comments are now correctly filtered based on label include / exclude configuration.

View File

@ -463,7 +463,7 @@ export class Bridge {
...( iid ? connManager.getConnectionsForGitLabIssueWebhook(data.repository.homepage, iid) : []), ...( iid ? connManager.getConnectionsForGitLabIssueWebhook(data.repository.homepage, iid) : []),
...connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), ...connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
]}, ]},
(c, data) => c.onCommentCreated(data), (c, data) => c instanceof GitLabRepoConnection ? c.onMergeRequestCommentCreated(data) : c.onCommentCreated(data),
); );
this.bindHandlerToQueue<IGitLabWebhookIssueStateEvent, GitLabIssueConnection>( this.bindHandlerToQueue<IGitLabWebhookIssueStateEvent, GitLabIssueConnection>(
@ -733,13 +733,13 @@ export class Bridge {
notifContent = await botUser.intent.underlyingClient.getRoomStateEvent( notifContent = await botUser.intent.underlyingClient.getRoomStateEvent(
roomId, NotifFilter.StateType, "", roomId, NotifFilter.StateType, "",
); );
} catch (ex) { } catch {
try { try {
notifContent = await botUser.intent.underlyingClient.getRoomStateEvent( notifContent = await botUser.intent.underlyingClient.getRoomStateEvent(
roomId, NotifFilter.LegacyStateType, "", roomId, NotifFilter.LegacyStateType, "",
); );
} }
catch (ex) { catch {
// No state yet // No state yet
} }
} }
@ -942,13 +942,13 @@ export class Bridge {
} }
log.info(`Got message roomId=${roomId} type=${event.type} from=${event.sender}`); log.info(`Got message roomId=${roomId} type=${event.type} from=${event.sender}`);
log.debug("Content:", JSON.stringify(event)); log.debug("Content:", JSON.stringify(event));
let processedReply: any; let processedReply;
let processedReplyMetadata: IRichReplyMetadata|undefined = undefined; let processedReplyMetadata: IRichReplyMetadata|undefined = undefined;
try { try {
processedReply = await this.replyProcessor.processEvent(event, this.as.botClient, EventKind.RoomEvent); processedReply = await this.replyProcessor.processEvent(event, this.as.botClient, EventKind.RoomEvent);
processedReplyMetadata = processedReply?.mx_richreply; processedReplyMetadata = processedReply?.mx_richreply;
} catch (ex) { } catch (ex) {
log.warn(`Event ${event.event_id} could not be processed by the reply processor, possibly a faulty event`); log.warn(`Event ${event.event_id} could not be processed by the reply processor, possibly a faulty event`, ex);
} }
const adminRoom = this.adminRooms.get(roomId); const adminRoom = this.adminRooms.get(roomId);
const checkPermission = (service: string, level: BridgePermissionLevel) => this.config.checkPermission(event.sender, service, level); const checkPermission = (service: string, level: BridgePermissionLevel) => this.config.checkPermission(event.sender, service, level);

View File

@ -77,7 +77,7 @@ export class CommentProcessor {
} }
public async getEventBodyForGitLabNote(comment: IGitLabWebhookNoteEvent): Promise<MatrixMessageContent> { public async getEventBodyForGitLabNote(comment: IGitLabWebhookNoteEvent): Promise<MatrixMessageContent> {
let body = comment.object_attributes.description; let body = comment.object_attributes.note;
body = this.replaceMentions(body); body = this.replaceMentions(body);
body = await this.replaceImages(body, true); body = await this.replaceImages(body, true);
body = emoji.emojify(body); body = emoji.emojify(body);

View File

@ -897,15 +897,15 @@ ${data.description}`;
); );
} }
public async onCommentCreated(event: IGitLabWebhookNoteEvent) { public async onMergeRequestCommentCreated(event: IGitLabWebhookNoteEvent) {
if (this.hookFilter.shouldSkip('merge_request', 'merge_request.review')) {
return;
}
log.info(`onCommentCreated ${this.roomId} ${this.toString()} !${event.merge_request?.iid} ${event.object_attributes.id}`);
if (!event.merge_request || event.object_attributes.noteable_type !== "MergeRequest") { if (!event.merge_request || event.object_attributes.noteable_type !== "MergeRequest") {
// Not a MR comment // Not a MR comment
return; return;
} }
if (this.hookFilter.shouldSkip('merge_request', 'merge_request.review') || !this.matchesLabelFilter(event.merge_request)) {
return;
}
log.info(`onCommentCreated ${this.roomId} ${this.toString()} !${event.merge_request?.iid} ${event.object_attributes.id}`);
this.debounceMergeRequestReview(event.user, event.merge_request, event.project, { this.debounceMergeRequestReview(event.user, event.merge_request, event.project, {
commentCount: 1, commentCount: 1,

View File

@ -33,7 +33,7 @@ export interface IGitlabMergeRequest {
iid: number; iid: number;
author_id: number; author_id: number;
state: 'opened'|'closed'|'merged'; state: 'opened'|'closed'|'merged';
labels: IGitLabLabel[];
} }
export interface IGitLabMergeRequestObjectAttributes extends IGitlabMergeRequest { export interface IGitLabMergeRequestObjectAttributes extends IGitlabMergeRequest {
@ -180,11 +180,12 @@ export interface IGitLabNote {
noteable_type: 'MergeRequest'; noteable_type: 'MergeRequest';
author_id: number; author_id: number;
noteable_id: number; noteable_id: number;
description: string;
discussion_id?: string; discussion_id?: string;
url: string;
} }
export interface IGitLabWebhookNoteEvent { export interface IGitLabWebhookNoteEvent {
object_kind: 'note',
user: IGitlabUser; user: IGitlabUser;
event_type: string; event_type: string;
project: IGitlabProject; project: IGitlabProject;

View File

@ -1,7 +1,5 @@
import { ConfigError } from "../../errors"; import { ConfigError } from "../../errors";
import { configKey } from "../Decorators"; import { configKey } from "../Decorators";
import { BridgeConfigCache } from "./cache";
import { BridgeConfigQueue } from "./queue";
interface BridgeConfigEncryptionYAML { interface BridgeConfigEncryptionYAML {
storagePath: string; storagePath: string;

View File

@ -182,7 +182,7 @@ export class FeedReader {
try { try {
observedFeedUrls.add(normalizeUrl(conn.feedUrl)); observedFeedUrls.add(normalizeUrl(conn.feedUrl));
} catch (err: unknown) { } catch (err: unknown) {
log.error(`Invalid feedUrl for connection ${conn.connectionId}: ${conn.feedUrl}. It will not be tracked`); log.error(`Invalid feedUrl for connection ${conn.connectionId}: ${conn.feedUrl}. It will not be tracked`, err);
} }
} }
this.feedQueue.populate([...observedFeedUrls]); this.feedQueue.populate([...observedFeedUrls]);

View File

@ -7,6 +7,7 @@ import { expect } from "chai";
import { BridgeConfigGitLab } from "../../src/config/Config"; import { BridgeConfigGitLab } from "../../src/config/Config";
import { IBridgeStorageProvider } from "../../src/Stores/StorageProvider"; import { IBridgeStorageProvider } from "../../src/Stores/StorageProvider";
import { IntentMock } from "../utils/IntentMock"; import { IntentMock } from "../utils/IntentMock";
import { IGitlabMergeRequest, IGitlabProject, IGitlabUser, IGitLabWebhookNoteEvent } from "../../src/Gitlab/WebhookTypes";
const ROOM_ID = "!foo:bar"; const ROOM_ID = "!foo:bar";
@ -15,21 +16,26 @@ const GITLAB_ORG_REPO = {
repo: "a-fake-repo", repo: "a-fake-repo",
}; };
const GITLAB_MR = { const GITLAB_MR: IGitlabMergeRequest = {
author_id: 0,
labels: [],
state: "opened", state: "opened",
iid: 1234, iid: 1234,
url: `https://gitlab.example.com/${GITLAB_ORG_REPO.org}/${GITLAB_ORG_REPO.repo}/issues/1234`, url: `https://gitlab.example.com/${GITLAB_ORG_REPO.org}/${GITLAB_ORG_REPO.repo}/issues/1234`,
title: "My MR", title: "My MR",
}; };
const GITLAB_USER = { const GITLAB_USER: IGitlabUser = {
name: "Alice", name: "Alice",
username: "alice", username: "alice",
avatar_url: "",
email: "alice@example.org"
}; };
const GITLAB_PROJECT = { const GITLAB_PROJECT: IGitlabProject = {
path_with_namespace: `${GITLAB_ORG_REPO.org}/${GITLAB_ORG_REPO.repo}`, path_with_namespace: `${GITLAB_ORG_REPO.org}/${GITLAB_ORG_REPO.repo}`,
web_url: `https://gitlab.example.com/${GITLAB_ORG_REPO.org}/${GITLAB_ORG_REPO.repo}`, web_url: `https://gitlab.example.com/${GITLAB_ORG_REPO.org}/${GITLAB_ORG_REPO.repo}`,
homepage: "",
}; };
const GITLAB_ISSUE_CREATED_PAYLOAD = { const GITLAB_ISSUE_CREATED_PAYLOAD = {
@ -39,7 +45,7 @@ const GITLAB_ISSUE_CREATED_PAYLOAD = {
project: GITLAB_PROJECT, project: GITLAB_PROJECT,
}; };
const GITLAB_MR_COMMENT = { const GITLAB_MR_COMMENT: IGitLabWebhookNoteEvent = {
'object_kind': 'note', 'object_kind': 'note',
'event_type': 'note', 'event_type': 'note',
'merge_request': GITLAB_MR, 'merge_request': GITLAB_MR,
@ -47,15 +53,24 @@ const GITLAB_MR_COMMENT = {
'discussion_id': '6babfc4ad3be2355db286ed50be111a5220d5751', 'discussion_id': '6babfc4ad3be2355db286ed50be111a5220d5751',
'note': 'I am starting a new thread', 'note': 'I am starting a new thread',
'noteable_type': 'MergeRequest', 'noteable_type': 'MergeRequest',
'url': 'https://gitlab.com/tadeuszs/my-awesome-project/-/merge_requests/2#note_1455087141' 'url': 'https://gitlab.com/tadeuszs/my-awesome-project/-/merge_requests/2#note_1455087141',
'id': 1455087141,
'author_id': 12345,
'noteable_id': 1,
}, },
'project': GITLAB_PROJECT, 'project': GITLAB_PROJECT,
'user': GITLAB_USER, 'user': GITLAB_USER,
repository: {
'description': 'A repo',
'homepage': 'https://gitlab.com/tadeuszs/my-awesome-project',
'name': 'a-repo',
'url': 'https://gitlab.com/tadeuszs/my-awesome-project'
},
}; };
const COMMENT_DEBOUNCE_MS = 25; const COMMENT_DEBOUNCE_MS = 25;
function createConnection(state: Record<string, unknown> = {}, isExistingState=false): { connection: GitLabRepoConnection, intent: IntentMock } { function createConnection(state: Partial<GitLabRepoConnectionState> = {}, isExistingState=false): { connection: GitLabRepoConnection, intent: IntentMock } {
const mq = createMessageQueue(); const mq = createMessageQueue();
mq.subscribe('*'); mq.subscribe('*');
const as = AppserviceMock.create(); const as = AppserviceMock.create();
@ -164,10 +179,10 @@ describe("GitLabRepoConnection", () => {
}); });
}); });
describe("onCommentCreated", () => { describe("onMergeRequestCommentCreated", () => {
it("will handle an MR comment", async () => { it("will handle an MR comment", async () => {
const { connection, intent } = createConnection(); const { connection, intent } = createConnection();
await connection.onCommentCreated(GITLAB_MR_COMMENT as never); await connection.onMergeRequestCommentCreated(GITLAB_MR_COMMENT);
await waitForDebouncing(); await waitForDebouncing();
intent.expectEventMatches( intent.expectEventMatches(
(ev: any) => ev.content.body.includes('**Alice** commented on MR'), (ev: any) => ev.content.body.includes('**Alice** commented on MR'),
@ -175,10 +190,41 @@ describe("GitLabRepoConnection", () => {
); );
}); });
it("will filter out issues not matching includingLabels.", async () => {
const { connection, intent } = createConnection({
includingLabels: ["include-me"]
});
// ..or issues with no labels
await connection.onMergeRequestCommentCreated(GITLAB_MR_COMMENT);
await waitForDebouncing();
intent.expectNoEvent();
});
it.only("will filter out issues matching excludingLabels.", async () => {
const { connection, intent } = createConnection({
excludingLabels: ["exclude-me"]
});
// ..or issues with no labels
await connection.onMergeRequestCommentCreated({
...GITLAB_MR_COMMENT,
merge_request: {
...GITLAB_MR,
labels: [{
id: 0,
title: 'exclude-me'
} as any]
}
});
await waitForDebouncing();
intent.expectNoEvent();
});
it("will debounce MR comments", async () => { it("will debounce MR comments", async () => {
const { connection, intent } = createConnection(); const { connection, intent } = createConnection();
await connection.onCommentCreated(GITLAB_MR_COMMENT as never); await connection.onMergeRequestCommentCreated(GITLAB_MR_COMMENT);
await connection.onCommentCreated({ await connection.onMergeRequestCommentCreated({
...GITLAB_MR_COMMENT, ...GITLAB_MR_COMMENT,
'object_attributes': { 'object_attributes': {
...GITLAB_MR_COMMENT.object_attributes, ...GITLAB_MR_COMMENT.object_attributes,
@ -197,9 +243,9 @@ describe("GitLabRepoConnection", () => {
it("will add new comments in a Matrix thread", async () => { it("will add new comments in a Matrix thread", async () => {
const { connection, intent } = createConnection(); const { connection, intent } = createConnection();
await connection.onCommentCreated(GITLAB_MR_COMMENT as never); await connection.onMergeRequestCommentCreated(GITLAB_MR_COMMENT);
await waitForDebouncing(); await waitForDebouncing();
await connection.onCommentCreated(GITLAB_MR_COMMENT as never); await connection.onMergeRequestCommentCreated(GITLAB_MR_COMMENT);
await waitForDebouncing(); await waitForDebouncing();
expect(intent.sentEvents.length).to.equal(2); expect(intent.sentEvents.length).to.equal(2);
intent.expectEventMatches( intent.expectEventMatches(
@ -211,14 +257,14 @@ describe("GitLabRepoConnection", () => {
it("will correctly map new comments to aggregated discussions", async () => { it("will correctly map new comments to aggregated discussions", async () => {
const { connection, intent } = createConnection(); const { connection, intent } = createConnection();
await connection.onCommentCreated({ await connection.onMergeRequestCommentCreated({
...GITLAB_MR_COMMENT, ...GITLAB_MR_COMMENT,
'object_attributes': { 'object_attributes': {
...GITLAB_MR_COMMENT.object_attributes, ...GITLAB_MR_COMMENT.object_attributes,
'discussion_id': 'disc1', 'discussion_id': 'disc1',
}, },
} as never); } as never);
await connection.onCommentCreated({ await connection.onMergeRequestCommentCreated({
...GITLAB_MR_COMMENT, ...GITLAB_MR_COMMENT,
'object_attributes': { 'object_attributes': {
...GITLAB_MR_COMMENT.object_attributes, ...GITLAB_MR_COMMENT.object_attributes,
@ -228,7 +274,7 @@ describe("GitLabRepoConnection", () => {
await waitForDebouncing(); await waitForDebouncing();
expect(intent.sentEvents.length).to.equal(1); expect(intent.sentEvents.length).to.equal(1);
await connection.onCommentCreated({ await connection.onMergeRequestCommentCreated({
...GITLAB_MR_COMMENT, ...GITLAB_MR_COMMENT,
'object_attributes': { 'object_attributes': {
...GITLAB_MR_COMMENT.object_attributes, ...GITLAB_MR_COMMENT.object_attributes,
@ -243,7 +289,7 @@ describe("GitLabRepoConnection", () => {
1 1
); );
await connection.onCommentCreated({ await connection.onMergeRequestCommentCreated({
...GITLAB_MR_COMMENT, ...GITLAB_MR_COMMENT,
'object_attributes': { 'object_attributes': {
...GITLAB_MR_COMMENT.object_attributes, ...GITLAB_MR_COMMENT.object_attributes,
@ -300,7 +346,7 @@ describe("GitLabRepoConnection", () => {
it("will include issues matching includingLabels.", async () => { it("will include issues matching includingLabels.", async () => {
const { connection, intent } = createConnection({ const { connection, intent } = createConnection({
includingIssues: ["include-me"] includingLabels: ["include-me"]
}); });
await connection.onMergeRequestOpened({ await connection.onMergeRequestOpened({
...GITLAB_ISSUE_CREATED_PAYLOAD, ...GITLAB_ISSUE_CREATED_PAYLOAD,