From a932a4cd45a2a72400b437c5de2ba5e1304eda1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Commaille?= Date: Fri, 28 Jun 2024 17:50:10 +0200 Subject: [PATCH 1/4] Add support for GitLab issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Kévin Commaille --- .../room_configuration/gitlab_project.md | 5 + src/Bridge.ts | 24 ++++ src/Connections/GitlabRepo.ts | 119 +++++++++++++++++- src/Gitlab/WebhookTypes.ts | 36 ++++-- .../roomConfig/GitlabRepoConfig.tsx | 7 ++ 5 files changed, 178 insertions(+), 13 deletions(-) diff --git a/docs/usage/room_configuration/gitlab_project.md b/docs/usage/room_configuration/gitlab_project.md index 92d678ce..f3eb5227 100644 --- a/docs/usage/room_configuration/gitlab_project.md +++ b/docs/usage/room_configuration/gitlab_project.md @@ -50,6 +50,11 @@ the events marked as default below will be enabled. Otherwise, this is ignored. - merge_request.review.comments * - merge_request.review * - merge_request.review.individual +- issue * + - issue.close * + - issue.open * + - issue.reopen * + - issue.update * - push * - release * - release.created * diff --git a/src/Bridge.ts b/src/Bridge.ts index f968c804..8993f257 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -416,6 +416,30 @@ export class Bridge { (c, data) => c.onWikiPageEvent(data), ); + this.bindHandlerToQueue( + "gitlab.issue.open", + (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), + (c, data) => c.onIssueOpened(data), + ); + + this.bindHandlerToQueue( + "gitlab.issue.reopen", + (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), + (c, data) => c.onIssueReopened(data), + ); + + this.bindHandlerToQueue( + "gitlab.issue.close", + (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), + (c, data) => c.onIssueClosed(data), + ); + + this.bindHandlerToQueue( + "gitlab.issue.update", + (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), + (c, data) => c.onIssueUpdated(data), + ); + this.queue.on("notifications.user.events", async (msg) => { const adminRoom = this.adminRooms.get(msg.data.roomId); if (!adminRoom) { diff --git a/src/Connections/GitlabRepo.ts b/src/Connections/GitlabRepo.ts index 05e3eb9b..9ba7da56 100644 --- a/src/Connections/GitlabRepo.ts +++ b/src/Connections/GitlabRepo.ts @@ -5,7 +5,7 @@ import { MatrixEvent, MatrixMessageContent } from "../MatrixEvent"; import markdown from "markdown-it"; import { Logger } from "matrix-appservice-bridge"; import { BridgeConfigGitLab, GitLabInstance } from "../config/Config"; -import { IGitlabMergeRequest, IGitlabProject, IGitlabUser, IGitLabWebhookMREvent, IGitLabWebhookNoteEvent, IGitLabWebhookPushEvent, IGitLabWebhookReleaseEvent, IGitLabWebhookTagPushEvent, IGitLabWebhookWikiPageEvent } from "../Gitlab/WebhookTypes"; +import { IGitLabLabel, IGitlabMergeRequest, IGitlabProject, IGitlabUser, IGitLabWebhookIssueStateEvent, IGitLabWebhookMREvent, IGitLabWebhookNoteEvent, IGitLabWebhookPushEvent, IGitLabWebhookReleaseEvent, IGitLabWebhookTagPushEvent, IGitLabWebhookWikiPageEvent } from "../Gitlab/WebhookTypes"; import { CommandConnection } from "./CommandConnection"; import { Connection, IConnection, IConnectionState, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection"; import { ConnectionWarning, GetConnectionsResponseItem } from "../provisioning/api"; @@ -19,6 +19,8 @@ import { GitLabClient } from "../Gitlab/Client"; import { IBridgeStorageProvider } from "../Stores/StorageProvider"; import axios from "axios"; import { GitLabGrantChecker } from "../Gitlab/GrantChecker"; +import { FormatUtil } from "../FormatUtil"; +import { emojify } from "node-emoji"; export interface GitLabRepoConnectionState extends IConnectionState { instance: string; @@ -77,7 +79,12 @@ type AllowedEventsNames = "wiki" | `wiki.${string}` | "release" | - "release.created"; + "release.created" | + "issue" | + "issue.open" | + "issue.reopen" | + "issue.close" | + "issue.update"; const AllowedEvents: AllowedEventsNames[] = [ "merge_request.open", @@ -94,6 +101,11 @@ const AllowedEvents: AllowedEventsNames[] = [ "wiki", "release", "release.created", + "issue", + "issue.open", + "issue.reopen", + "issue.close", + "issue.update", ]; const DefaultHooks = AllowedEvents; @@ -745,6 +757,109 @@ ${data.description}`; }); } + private validateIssueEvent(event: IGitLabWebhookIssueStateEvent) { + if (!event.object_attributes) { + throw Error('No issue content!'); + } + if (!event.project) { + throw Error('No repository content!'); + } + } + + public async onIssueOpened(event: IGitLabWebhookIssueStateEvent) { + if (this.hookFilter.shouldSkip('issue', 'issue.open') || !this.matchesLabelFilter(event)) { + return; + } + log.info(`onIssueOpened ${this.roomId} ${this.path} #${event.object_attributes.iid}`); + this.validateIssueEvent(event); + const orgRepoName = event.project.path_with_namespace; + let content = emojify(`📥 **${event.user.username}** created new issue [${orgRepoName}#${event.object_attributes.iid}](${event.object_attributes.url}): "${event.object_attributes.title}"`); + content += (event.assignees?.length ? ` assigned to ${event.assignees.map(a => a.username).join(', ')}` : ''); + const labels = FormatUtil.formatLabels(event.labels?.map(l => ({ name: l.title, description: l.description || undefined, color: l.color || undefined }))); + await this.intent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: content + (labels.plain.length > 0 ? ` with labels ${labels.plain}`: ""), + formatted_body: md.renderInline(content) + (labels.html.length > 0 ? ` with labels ${labels.html}`: ""), + format: "org.matrix.custom.html", + }); + } + + public async onIssueReopened(event: IGitLabWebhookIssueStateEvent) { + if (this.hookFilter.shouldSkip('issue', 'issue.reopen') || !this.matchesLabelFilter(event)) { + return; + } + log.info(`onIssueReopened ${this.roomId} ${this.path} #${event.object_attributes.iid}`); + this.validateIssueEvent(event); + const orgRepoName = event.project.path_with_namespace; + const content = emojify(`🔷 **${event.user.username}** reopened issue [${orgRepoName}#${event.object_attributes.iid}](${event.object_attributes.url}): "${event.object_attributes.title}"`); + await this.intent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: content, + formatted_body: md.renderInline(content), + format: "org.matrix.custom.html", + }); + } + + public async onIssueClosed(event: IGitLabWebhookIssueStateEvent) { + if (this.hookFilter.shouldSkip('issue', 'issue.close') || !this.matchesLabelFilter(event)) { + return; + } + log.info(`onIssueClosed ${this.roomId} ${this.path} #${event.object_attributes.iid}`); + this.validateIssueEvent(event); + const orgRepoName = event.project.path_with_namespace; + const content = emojify(`⬛ **${event.user.username}** closed issue [${orgRepoName}#${event.object_attributes.iid}](${event.object_attributes.url}): "${event.object_attributes.title}"`); + await this.intent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: content, + formatted_body: md.renderInline(content), + format: "org.matrix.custom.html", + }); + } + + public async onIssueUpdated(event: IGitLabWebhookIssueStateEvent) { + if (this.hookFilter.shouldSkip('issue', 'issue.update') || !this.matchesLabelFilter(event)) { + return; + } + this.validateIssueEvent(event); + log.info(`onIssueUpdated ${this.roomId} ${this.path} #${event.object_attributes.iid}`); + const orgRepoName = event.project.path_with_namespace; + + let action = ''; + let icon = ''; + let labels = { plain: '', html: '' }; + if (event.changes.title || event.changes.description) { + action = 'edited'; + icon = '✏'; + } else if (event.changes.labels) { + let added: IGitLabLabel[] = []; + for (const label of event.changes.labels.current) { + if (!event.changes.labels.previous.includes(label)) { + added.push(label); + } + } + + if (added.length == 0) { + // We only support added labels. + return; + } + + action = 'labeled'; + icon = '🗃'; + labels = FormatUtil.formatLabels(added.map(l => ({ name: l.title, description: l.description || undefined, color: l.color || undefined }))); + } else { + // We don't support this change. + return; + } + + const content = emojify(`${icon} **${event.user.username}** ${action} issue [${orgRepoName}#${event.object_attributes.iid}](${event.object_attributes.url}): "${event.object_attributes.title}"`); + await this.intent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: content + (labels.plain.length > 0 ? ` with labels ${labels.plain}`: ""), + formatted_body: md.renderInline(content) + (labels.html.length > 0 ? ` with labels ${labels.html}`: ""), + format: "org.matrix.custom.html", + }); + } + private async renderDebouncedMergeRequest(uniqueId: string, mergeRequest: IGitlabMergeRequest, project: IGitlabProject) { const result = this.debounceMRComments.get(uniqueId); if (!result) { diff --git a/src/Gitlab/WebhookTypes.ts b/src/Gitlab/WebhookTypes.ts index ac302e9f..1611c70e 100644 --- a/src/Gitlab/WebhookTypes.ts +++ b/src/Gitlab/WebhookTypes.ts @@ -26,7 +26,14 @@ export interface IGitlabProject { export interface IGitlabIssue { iid: number; + title: string; description: string; + url: string; + state: 'opened'|'closed'; +} + +export interface IGitLabIssueObjectAttributes extends IGitlabIssue { + action: 'open'|'close'|'reopen'|'update'; } export interface IGitlabMergeRequest { @@ -196,19 +203,26 @@ export interface IGitLabWebhookNoteEvent { merge_request?: IGitlabMergeRequest; } export interface IGitLabWebhookIssueStateEvent { + object_kind: "issue"; user: IGitlabUser; event_type: string; project: IGitlabProject; - repository: { - name: string; - url: string; - description: string; - homepage: string; - }; - object_attributes: { - id: number; - iid: number; - action: string; - description: string; + repository: IGitlabRepository; + object_attributes: IGitLabIssueObjectAttributes; + labels: IGitLabLabel[]; + assignees?: IGitlabUser[]; + changes: { + title?: { + previous: string; + current: string; + }; + description?: { + previous: string; + current: string; + }; + labels?: { + previous: IGitLabLabel[]; + current: IGitLabLabel[]; + }; } } diff --git a/web/components/roomConfig/GitlabRepoConfig.tsx b/web/components/roomConfig/GitlabRepoConfig.tsx index 7b692225..d248c726 100644 --- a/web/components/roomConfig/GitlabRepoConfig.tsx +++ b/web/components/roomConfig/GitlabRepoConfig.tsx @@ -104,6 +104,13 @@ const ConnectionConfiguration: FunctionComponentSingle review Ready for review + Issues +
    + Opened + Reopened + Closed + Updated +
Pushes Tag pushes Wiki page updates From 0e20b9b8fd7f0cdd3a4a3674d7257ebb6205ae97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Commaille?= Date: Fri, 28 Jun 2024 17:53:21 +0200 Subject: [PATCH 2/4] Add changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Kévin Commaille --- changelog.d/956.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/956.feature diff --git a/changelog.d/956.feature b/changelog.d/956.feature new file mode 100644 index 00000000..98207be6 --- /dev/null +++ b/changelog.d/956.feature @@ -0,0 +1 @@ +Add support for GitLab open, close, reopen and update issue webhooks. From c7ea06600f18191af1904c86e49b0704773fe92f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Commaille?= Date: Fri, 28 Jun 2024 22:24:59 +0200 Subject: [PATCH 3/4] Add support for issue comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Kévin Commaille --- changelog.d/956.feature | 2 +- src/Connections/GitlabRepo.ts | 85 ++++++++++++++++--- src/Gitlab/WebhookTypes.ts | 2 +- .../roomConfig/GitlabRepoConfig.tsx | 1 + 4 files changed, 74 insertions(+), 16 deletions(-) diff --git a/changelog.d/956.feature b/changelog.d/956.feature index 98207be6..bc8a586d 100644 --- a/changelog.d/956.feature +++ b/changelog.d/956.feature @@ -1 +1 @@ -Add support for GitLab open, close, reopen and update issue webhooks. +Add support for GitLab open, close, reopen, update and comment issue webhooks. diff --git a/src/Connections/GitlabRepo.ts b/src/Connections/GitlabRepo.ts index 9ba7da56..76b1145a 100644 --- a/src/Connections/GitlabRepo.ts +++ b/src/Connections/GitlabRepo.ts @@ -5,7 +5,7 @@ import { MatrixEvent, MatrixMessageContent } from "../MatrixEvent"; import markdown from "markdown-it"; import { Logger } from "matrix-appservice-bridge"; import { BridgeConfigGitLab, GitLabInstance } from "../config/Config"; -import { IGitLabLabel, IGitlabMergeRequest, IGitlabProject, IGitlabUser, IGitLabWebhookIssueStateEvent, IGitLabWebhookMREvent, IGitLabWebhookNoteEvent, IGitLabWebhookPushEvent, IGitLabWebhookReleaseEvent, IGitLabWebhookTagPushEvent, IGitLabWebhookWikiPageEvent } from "../Gitlab/WebhookTypes"; +import { IGitlabIssue, IGitLabLabel, IGitlabMergeRequest, IGitlabProject, IGitlabUser, IGitLabWebhookIssueStateEvent, IGitLabWebhookMREvent, IGitLabWebhookNoteEvent, IGitLabWebhookPushEvent, IGitLabWebhookReleaseEvent, IGitLabWebhookTagPushEvent, IGitLabWebhookWikiPageEvent } from "../Gitlab/WebhookTypes"; import { CommandConnection } from "./CommandConnection"; import { Connection, IConnection, IConnectionState, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection"; import { ConnectionWarning, GetConnectionsResponseItem } from "../provisioning/api"; @@ -84,7 +84,8 @@ type AllowedEventsNames = "issue.open" | "issue.reopen" | "issue.close" | - "issue.update"; + "issue.update" | + "issue.comment"; const AllowedEvents: AllowedEventsNames[] = [ "merge_request.open", @@ -106,6 +107,7 @@ const AllowedEvents: AllowedEventsNames[] = [ "issue.reopen", "issue.close", "issue.update", + "issue.comment", ]; const DefaultHooks = AllowedEvents; @@ -1012,24 +1014,79 @@ ${data.description}`; ); } - public async onCommentCreated(event: IGitLabWebhookNoteEvent) { - if (this.hookFilter.shouldSkip('merge_request', 'merge_request.review')) { - return; + private async renderIssueComment(event: IGitLabWebhookNoteEvent) { + const orgRepoName = event.project.path_with_namespace; + const discussionId = event.object_attributes.discussion_id; + + let relation; + const threadEventId = discussionId ? await this.discussionThreads.get(discussionId)?.catch(() => { /* already logged */ }) : undefined; + if (threadEventId) { + relation = { + "m.relates_to": { + "event_id": threadEventId, + "rel_type": "m.thread" + }, + }; } - 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") { - // Not a MR comment - return; + + let action = relation ? 'replied' : `commented on issue [${orgRepoName}#${event.issue?.iid}](${event.issue?.url}): "${event.issue?.title}"`; + let content = emojify(`🗣 **${event.user.username}** ${action}`); + + let formatted = ''; + if (this.state.includeCommentBody) { + content += "\n\n> " + emojify(event.object_attributes.note); + formatted = md.render(content); + } else { + formatted = md.renderInline(content); } - this.debounceMergeRequestReview(event.user, event.merge_request, event.project, { - commentCount: 1, - commentNotes: this.state.includeCommentBody ? [event.object_attributes.note] : undefined, - discussionId: event.object_attributes.discussion_id, - skip: this.hookFilter.shouldSkip('merge_request.review.comments'), + const eventPromise = this.intent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: content, + formatted_body: formatted, + format: "org.matrix.custom.html", + ...relation, + }).catch(ex => { + log.error('Failed to send issue comment message', ex); + return undefined; + }); + + if (discussionId) { + if (!this.discussionThreads.has(discussionId)) { + this.discussionThreads.set(discussionId, eventPromise); + } + } + void this.persistDiscussionThreads().catch(ex => { + log.error(`Failed to persistently store Gitlab discussion threads for connection ${this.connectionId}:`, ex); }); } + public async onCommentCreated(event: IGitLabWebhookNoteEvent) { + if (event.merge_request && event.object_attributes.noteable_type === "MergeRequest") { + 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}`); + + this.debounceMergeRequestReview(event.user, event.merge_request, event.project, { + commentCount: 1, + commentNotes: this.state.includeCommentBody ? [event.object_attributes.note] : undefined, + discussionId: event.object_attributes.discussion_id, + skip: this.hookFilter.shouldSkip('merge_request.review.comments'), + }); + } else if (event.issue && event.object_attributes.noteable_type === "Issue") { + if (this.hookFilter.shouldSkip('issue', 'issue.comment')) { + return; + } + log.info(`onCommentCreated ${this.roomId} ${this.toString()} #${event.issue?.iid} ${event.object_attributes.id}`); + + this.renderIssueComment(event); + } else { + // Not a MR or issue comment. + return; + } + } + public toString() { return `GitLabRepo ${this.instance.url}/${this.path}`; } diff --git a/src/Gitlab/WebhookTypes.ts b/src/Gitlab/WebhookTypes.ts index 1611c70e..e49b103a 100644 --- a/src/Gitlab/WebhookTypes.ts +++ b/src/Gitlab/WebhookTypes.ts @@ -186,7 +186,7 @@ export interface IGitLabWebhookReleaseEvent { export interface IGitLabNote { id: number; note: string; - noteable_type: 'MergeRequest'; + noteable_type: 'MergeRequest'|'Issue'; author_id: number; noteable_id: number; description: string; diff --git a/web/components/roomConfig/GitlabRepoConfig.tsx b/web/components/roomConfig/GitlabRepoConfig.tsx index d248c726..7529d079 100644 --- a/web/components/roomConfig/GitlabRepoConfig.tsx +++ b/web/components/roomConfig/GitlabRepoConfig.tsx @@ -110,6 +110,7 @@ const ConnectionConfiguration: FunctionComponentReopened Closed Updated + Comment Pushes Tag pushes From 754096ecc7e0b223e30b65334fb55ebabac22eb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Commaille?= Date: Fri, 28 Jun 2024 22:59:05 +0200 Subject: [PATCH 4/4] Use comments, plural MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Kévin Commaille --- docs/usage/room_configuration/gitlab_project.md | 1 + src/Connections/GitlabRepo.ts | 6 +++--- web/components/roomConfig/GitlabRepoConfig.tsx | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/usage/room_configuration/gitlab_project.md b/docs/usage/room_configuration/gitlab_project.md index f3eb5227..affca2f2 100644 --- a/docs/usage/room_configuration/gitlab_project.md +++ b/docs/usage/room_configuration/gitlab_project.md @@ -55,6 +55,7 @@ the events marked as default below will be enabled. Otherwise, this is ignored. - issue.open * - issue.reopen * - issue.update * + - issue.comments * - push * - release * - release.created * diff --git a/src/Connections/GitlabRepo.ts b/src/Connections/GitlabRepo.ts index 76b1145a..f8d43d42 100644 --- a/src/Connections/GitlabRepo.ts +++ b/src/Connections/GitlabRepo.ts @@ -85,7 +85,7 @@ type AllowedEventsNames = "issue.reopen" | "issue.close" | "issue.update" | - "issue.comment"; + "issue.comments"; const AllowedEvents: AllowedEventsNames[] = [ "merge_request.open", @@ -107,7 +107,7 @@ const AllowedEvents: AllowedEventsNames[] = [ "issue.reopen", "issue.close", "issue.update", - "issue.comment", + "issue.comments", ]; const DefaultHooks = AllowedEvents; @@ -1075,7 +1075,7 @@ ${data.description}`; skip: this.hookFilter.shouldSkip('merge_request.review.comments'), }); } else if (event.issue && event.object_attributes.noteable_type === "Issue") { - if (this.hookFilter.shouldSkip('issue', 'issue.comment')) { + if (this.hookFilter.shouldSkip('issue', 'issue.comments')) { return; } log.info(`onCommentCreated ${this.roomId} ${this.toString()} #${event.issue?.iid} ${event.object_attributes.id}`); diff --git a/web/components/roomConfig/GitlabRepoConfig.tsx b/web/components/roomConfig/GitlabRepoConfig.tsx index 7529d079..db33cbbd 100644 --- a/web/components/roomConfig/GitlabRepoConfig.tsx +++ b/web/components/roomConfig/GitlabRepoConfig.tsx @@ -110,7 +110,7 @@ const ConnectionConfiguration: FunctionComponentReopened Closed Updated - Comment + Comments Pushes Tag pushes