Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for GitLab issues #956

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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/956.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add support for GitLab open, close, reopen, update and comment issue webhooks.
6 changes: 6 additions & 0 deletions docs/usage/room_configuration/gitlab_project.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ 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 *
- issue.comments *
- push *
- release *
- release.created *
Expand Down
24 changes: 24 additions & 0 deletions src/Bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,30 @@ export class Bridge {
(c, data) => c.onWikiPageEvent(data),
);

this.bindHandlerToQueue<IGitLabWebhookIssueStateEvent, GitLabRepoConnection>(
"gitlab.issue.open",
(data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
(c, data) => c.onIssueOpened(data),
);

this.bindHandlerToQueue<IGitLabWebhookIssueStateEvent, GitLabRepoConnection>(
"gitlab.issue.reopen",
(data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
(c, data) => c.onIssueReopened(data),
);

this.bindHandlerToQueue<IGitLabWebhookIssueStateEvent, GitLabRepoConnection>(
"gitlab.issue.close",
(data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
(c, data) => c.onIssueClosed(data),
);

this.bindHandlerToQueue<IGitLabWebhookIssueStateEvent, GitLabRepoConnection>(
"gitlab.issue.update",
(data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
(c, data) => c.onIssueUpdated(data),
);

this.queue.on<UserNotificationsEvent>("notifications.user.events", async (msg) => {
const adminRoom = this.adminRooms.get(msg.data.roomId);
if (!adminRoom) {
Expand Down
200 changes: 186 additions & 14 deletions src/Connections/GitlabRepo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { 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";
Expand All @@ -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;
Expand Down Expand Up @@ -77,7 +79,13 @@ type AllowedEventsNames =
"wiki" |
`wiki.${string}` |
"release" |
"release.created";
"release.created" |
"issue" |
"issue.open" |
"issue.reopen" |
"issue.close" |
"issue.update" |
"issue.comments";

const AllowedEvents: AllowedEventsNames[] = [
"merge_request.open",
Expand All @@ -94,6 +102,12 @@ const AllowedEvents: AllowedEventsNames[] = [
"wiki",
"release",
"release.created",
"issue",
"issue.open",
"issue.reopen",
"issue.close",
"issue.update",
"issue.comments",
];

const DefaultHooks = AllowedEvents;
Expand Down Expand Up @@ -745,6 +759,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) {
Expand Down Expand Up @@ -897,22 +1014,77 @@ ${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.comments')) {
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() {
Expand Down
38 changes: 26 additions & 12 deletions src/Gitlab/WebhookTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -179,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;
Expand All @@ -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[];
};
}
}
8 changes: 8 additions & 0 deletions web/components/roomConfig/GitlabRepoConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,14 @@ const ConnectionConfiguration: FunctionComponent<ConnectionConfigurationProps<ne
<EventHookCheckbox enabledHooks={enabledHooks} parentEvent="merge_request" hookEventName="merge_request.review.individual" onChange={toggleEnabledHook}>Single review</EventHookCheckbox>
<EventHookCheckbox enabledHooks={enabledHooks} parentEvent="merge_request" hookEventName="merge_request.ready_for_review" onChange={toggleEnabledHook}>Ready for review</EventHookCheckbox>
</ul>
<EventHookCheckbox enabledHooks={enabledHooks} hookEventName="issue" onChange={toggleEnabledHook}>Issues</EventHookCheckbox>
<ul>
<EventHookCheckbox enabledHooks={enabledHooks} parentEvent="issue" hookEventName="issue.open" onChange={toggleEnabledHook}>Opened</EventHookCheckbox>
<EventHookCheckbox enabledHooks={enabledHooks} parentEvent="issue" hookEventName="issue.reopen" onChange={toggleEnabledHook}>Reopened</EventHookCheckbox>
<EventHookCheckbox enabledHooks={enabledHooks} parentEvent="issue" hookEventName="issue.close" onChange={toggleEnabledHook}>Closed</EventHookCheckbox>
<EventHookCheckbox enabledHooks={enabledHooks} parentEvent="issue" hookEventName="issue.update" onChange={toggleEnabledHook}>Updated</EventHookCheckbox>
<EventHookCheckbox enabledHooks={enabledHooks} parentEvent="issue" hookEventName="issue.comments" onChange={toggleEnabledHook}>Comments</EventHookCheckbox>
</ul>
<EventHookCheckbox enabledHooks={enabledHooks} hookEventName="push" onChange={toggleEnabledHook}>Pushes</EventHookCheckbox>
<EventHookCheckbox enabledHooks={enabledHooks} hookEventName="tag_push" onChange={toggleEnabledHook}>Tag pushes</EventHookCheckbox>
<EventHookCheckbox enabledHooks={enabledHooks} hookEventName="wiki" onChange={toggleEnabledHook}>Wiki page updates</EventHookCheckbox>
Expand Down