Bridge Gitlab comment replies as Matrix threads (#758)

* Bridge Gitlab comment replies as Matrix threads

* Persistently store Gitlab Discussion-Thread mapping

* Remove leftover debug line

* Denoise comment descriptions when they happen in Matrix threads

* Make comment debouncing time configurable

* Add some tests for Gitlab comments

* De-only Gitlab comment tests

* Linting

* Changelog

* Map multiple Gitlab discussions to a single Matrix thread

We debounce Gitlab comments, so multiple discussions can end up in one thread.
This ensures that replies to *any* of these discussions end up in the same thread.

* Add tests for the many-to-one reply case

* Move SerializedGitlabDiscussionThreads to Types

* Update changelog.d/758.feature

Co-authored-by: Will Hunt <will@half-shot.uk>

* Fix instructions for validating your config using Docker (#794)

* Fix instructions for validating your config using Docker

Fixes GH-787

* Changelog

---------

Co-authored-by: Tadeusz Sośnierz <tadeusz@sosnierz.com>

* Add more icons to GitHub messages (#795)

* Add more icons to GitHub messages

* Add merged icon

* Lint

* Add changelog

* Bump word-wrap from 1.2.3 to 1.2.4 (#799)

* Bump word-wrap from 1.2.3 to 1.2.4

Bumps [word-wrap](https://github.com/jonschlinkert/word-wrap) from 1.2.3 to 1.2.4.
- [Release notes](https://github.com/jonschlinkert/word-wrap/releases)
- [Commits](https://github.com/jonschlinkert/word-wrap/compare/1.2.3...1.2.4)

---
updated-dependencies:
- dependency-name: word-wrap
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* Add changelog

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Andrew Ferrazzutti <andrewf@element.io>

* Update matrix-appservice-bridge to 9.0.1 (#800)

* Bump semver from 5.7.1 to 5.7.2 (#797)

Bumps [semver](https://github.com/npm/node-semver) from 5.7.1 to 5.7.2.
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/v5.7.2/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v5.7.1...v5.7.2)

---
updated-dependencies:
- dependency-name: semver
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Will Hunt <will@half-shot.uk>

* 4.4.0

* 4.4.1

* Set the default commentDebouncMs for Gitlab in its Config

* Rename `approvalState` to something more fitting

* Update sample config

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Tadeusz Sośnierz <tadeusz@sosnierz.com>
Co-authored-by: Will Hunt <will@half-shot.uk>
Co-authored-by: Connor Davis <mail@connordav.is>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Andrew Ferrazzutti <andrewf@element.io>
This commit is contained in:
Tadeusz Sośnierz 2023-08-14 14:58:21 +02:00 committed by GitHub
parent 958743365f
commit a64a561698
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 277 additions and 55 deletions

1
changelog.d/758.feature Normal file
View File

@ -0,0 +1 @@
Bridge Gitlab comment replies as Matrix threads.

View File

@ -49,6 +49,10 @@ gitlab:
# (Optional) Prefix used when creating ghost users for GitLab accounts. # (Optional) Prefix used when creating ghost users for GitLab accounts.
_gitlab_ _gitlab_
commentDebounceMs:
# (Optional) Aggregate comments by waiting this many miliseconds before posting them to Matrix. Defaults to 5000 (5 seconds)
5000
figma: figma:
# (Optional) Configure this to enable Figma support # (Optional) Configure this to enable Figma support

View File

@ -12,7 +12,7 @@ import { CommandConnection } from "./CommandConnection";
import { Connection, IConnection, IConnectionState, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection"; import { Connection, IConnection, IConnectionState, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection";
import { ConnectionWarning, GetConnectionsResponseItem } from "../provisioning/api"; import { ConnectionWarning, GetConnectionsResponseItem } from "../provisioning/api";
import { ErrCode, ApiError, ValidatorApiError } from "../api" import { ErrCode, ApiError, ValidatorApiError } from "../api"
import { AccessLevel } from "../Gitlab/Types"; import { AccessLevel, SerializedGitlabDiscussionThreads } from "../Gitlab/Types";
import Ajv, { JSONSchemaType } from "ajv"; import Ajv, { JSONSchemaType } from "ajv";
import { CommandError } from "../errors"; import { CommandError } from "../errors";
import QuickLRU from "@alloc/quick-lru"; import QuickLRU from "@alloc/quick-lru";
@ -59,8 +59,6 @@ const log = new Logger("GitLabRepoConnection");
const md = new markdown(); const md = new markdown();
const PUSH_MAX_COMMITS = 5; const PUSH_MAX_COMMITS = 5;
const MRRCOMMENT_DEBOUNCE_MS = 5000;
export type GitLabRepoResponseItem = GetConnectionsResponseItem<GitLabRepoConnectionState>; export type GitLabRepoResponseItem = GetConnectionsResponseItem<GitLabRepoConnectionState>;
@ -205,7 +203,7 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
throw new ValidatorApiError(validator.errors); throw new ValidatorApiError(validator.errors);
} }
static async createConnectionForState(roomId: string, event: StateEvent<Record<string, unknown>>, {as, intent, tokenStore, config}: InstantiateConnectionOpts) { static async createConnectionForState(roomId: string, event: StateEvent<Record<string, unknown>>, {as, intent, storage, tokenStore, config}: InstantiateConnectionOpts) {
if (!config.gitlab) { if (!config.gitlab) {
throw Error('GitLab is not configured'); throw Error('GitLab is not configured');
} }
@ -214,7 +212,13 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
if (!instance) { if (!instance) {
throw Error('Instance name not recognised'); throw Error('Instance name not recognised');
} }
return new GitLabRepoConnection(roomId, event.stateKey, as, config.gitlab, intent, state, tokenStore, instance);
const connection = new GitLabRepoConnection(roomId, event.stateKey, as, config.gitlab, intent, state, tokenStore, instance, storage);
const discussionThreads = await storage.getGitlabDiscussionThreads(connection.connectionId);
connection.setDiscussionThreads(discussionThreads);
return connection;
} }
public static async assertUserHasAccessToProject( public static async assertUserHasAccessToProject(
@ -242,7 +246,12 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
return permissionLevel; return permissionLevel;
} }
public static async provisionConnection(roomId: string, requester: string, data: Record<string, unknown>, { as, config, intent, tokenStore, getAllConnectionsOfType }: ProvisionConnectionOpts) { public static async provisionConnection(
roomId: string,
requester: string,
data: Record<string, unknown>,
{ as, config, intent, storage, tokenStore, getAllConnectionsOfType }: ProvisionConnectionOpts
) {
if (!config.gitlab) { if (!config.gitlab) {
throw Error('GitLab is not configured'); throw Error('GitLab is not configured');
} }
@ -260,7 +269,8 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
const project = await client.projects.get(validData.path); const project = await client.projects.get(validData.path);
const stateEventKey = `${validData.instance}/${validData.path}`; const stateEventKey = `${validData.instance}/${validData.path}`;
const connection = new GitLabRepoConnection(roomId, stateEventKey, as, gitlabConfig, intent, validData, tokenStore, instance); const connection = new GitLabRepoConnection(roomId, stateEventKey, as, gitlabConfig, intent, validData, tokenStore, instance, storage);
const existingConnections = getAllConnectionsOfType(GitLabRepoConnection); const existingConnections = getAllConnectionsOfType(GitLabRepoConnection);
const existing = existingConnections.find(c => c.roomId === roomId && c.instance.url === connection.instance.url && c.path === connection.path); const existing = existingConnections.find(c => c.roomId === roomId && c.instance.url === connection.instance.url && c.path === connection.path);
@ -382,21 +392,19 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
private readonly debounceMRComments = new Map<string, { private readonly debounceMRComments = new Map<string, {
commentCount: number, commentCount: number,
commentNotes?: string[], commentNotes?: string[],
discussions: string[],
author: string, author: string,
timeout: NodeJS.Timeout, timeout: NodeJS.Timeout,
approved?: boolean, approved?: boolean,
skip?: boolean, skip?: boolean,
}>(); }>();
/** private readonly discussionThreads = new QuickLRU<string, Promise<string|undefined>>({ maxSize: 100});
* GitLab provides NO threading information in its webhook response objects,
* so we need to determine if we've seen a comment for a line before, and
* skip it if we have (because it's probably a reply).
*/
private readonly mergeRequestSeenDiscussionIds = new QuickLRU<string, undefined>({ maxSize: 100 });
private readonly hookFilter: HookFilter<AllowedEventsNames>; private readonly hookFilter: HookFilter<AllowedEventsNames>;
private readonly grantChecker; private readonly grantChecker;
private readonly commentDebounceMs: number;
constructor( constructor(
roomId: string, roomId: string,
@ -407,6 +415,7 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
state: ConnectionStateValidated, state: ConnectionStateValidated,
private readonly tokenStore: UserTokenStore, private readonly tokenStore: UserTokenStore,
private readonly instance: GitLabInstance, private readonly instance: GitLabInstance,
private readonly storage: IBridgeStorageProvider,
) { ) {
super( super(
roomId, roomId,
@ -427,7 +436,8 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
this.hookFilter = new HookFilter( this.hookFilter = new HookFilter(
state.enableHooks ?? DefaultHooks, state.enableHooks ?? DefaultHooks,
); );
} this.commentDebounceMs = config.commentDebounceMs;
}
public get path() { public get path() {
return this.state.path.toLowerCase(); return this.state.path.toLowerCase();
@ -723,7 +733,7 @@ ${data.description}`;
}); });
} }
private renderDebouncedMergeRequest(uniqueId: string, mergeRequest: IGitlabMergeRequest, project: IGitlabProject) { private async renderDebouncedMergeRequest(uniqueId: string, mergeRequest: IGitlabMergeRequest, project: IGitlabProject) {
const result = this.debounceMRComments.get(uniqueId); const result = this.debounceMRComments.get(uniqueId);
if (!result) { if (!result) {
// Always defined, but for type checking purposes. // Always defined, but for type checking purposes.
@ -739,26 +749,52 @@ ${data.description}`;
comments = ` with ${result.commentCount} comments`; comments = ` with ${result.commentCount} comments`;
} }
let approvalState = 'commented on'; let relation;
if (result.approved === true) { const discussionWithThread = result.discussions.find(discussionId => this.discussionThreads.has(discussionId));
approvalState = '✅ approved' if (discussionWithThread) {
} else if (result.approved === false) { const threadEventId = await this.discussionThreads.get(discussionWithThread)!.catch(_ => { /* already logged */ });
approvalState = '🔴 unapproved'; if (threadEventId) {
relation = {
"m.relates_to": {
"event_id": threadEventId,
"rel_type": "m.thread"
},
};
}
} }
let content = `**${result.author}** ${approvalState} MR [${orgRepoName}#${mergeRequest.iid}](${mergeRequest.url}): "${mergeRequest.title}"${comments}`; let action = relation ? 'replied' : 'commented on'; // this is the only place we need this, approve/unapprove don't appear in discussions
if (result.approved === true) {
action = '✅ approved'
} else if (result.approved === false) {
action = '🔴 unapproved';
}
const target = relation ? '' : ` MR [${orgRepoName}#${mergeRequest.iid}](${mergeRequest.url}): "${mergeRequest.title}"`;
let content = `**${result.author}** ${action}${target} ${comments}`;
if (result.commentNotes) { if (result.commentNotes) {
content += "\n\n> " + result.commentNotes.join("\n\n> "); content += "\n\n> " + result.commentNotes.join("\n\n> ");
} }
this.intent.sendEvent(this.roomId, { const eventPromise = this.intent.sendEvent(this.roomId, {
msgtype: "m.notice", msgtype: "m.notice",
body: content, body: content,
formatted_body: md.renderInline(content), formatted_body: md.renderInline(content),
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
...relation,
}).catch(ex => { }).catch(ex => {
log.error('Failed to send MR review message', ex); log.error('Failed to send MR review message', ex);
return undefined;
});
for (const discussionId of result.discussions) {
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);
}); });
} }
@ -770,6 +806,7 @@ ${data.description}`;
commentCount: number, commentCount: number,
commentNotes?: string[], commentNotes?: string[],
approved?: boolean, approved?: boolean,
discussionId?: string,
/** /**
* If the MR contains only comments, skip it. * If the MR contains only comments, skip it.
*/ */
@ -789,16 +826,20 @@ ${data.description}`;
if (!opts.skip) { if (!opts.skip) {
existing.skip = false; existing.skip = false;
} }
existing.timeout = setTimeout(() => this.renderDebouncedMergeRequest(uniqueId, mergeRequest, project), MRRCOMMENT_DEBOUNCE_MS); if (opts.discussionId) {
existing.discussions.push(opts.discussionId);
}
existing.timeout = setTimeout(() => this.renderDebouncedMergeRequest(uniqueId, mergeRequest, project), this.commentDebounceMs);
return; return;
} }
this.debounceMRComments.set(uniqueId, { this.debounceMRComments.set(uniqueId, {
commentCount: commentCount, commentCount: commentCount,
commentNotes: commentNotes, commentNotes: commentNotes,
discussions: opts.discussionId ? [opts.discussionId] : [],
skip: opts.skip, skip: opts.skip,
approved, approved,
author: user.name, author: user.name,
timeout: setTimeout(() => this.renderDebouncedMergeRequest(uniqueId, mergeRequest, project), MRRCOMMENT_DEBOUNCE_MS), timeout: setTimeout(() => this.renderDebouncedMergeRequest(uniqueId, mergeRequest, project), this.commentDebounceMs),
}); });
} }
@ -840,19 +881,6 @@ ${data.description}`;
); );
} }
private shouldHandleMRComment(event: IGitLabWebhookNoteEvent) {
// Check to see if this line has had a comment before
if (event.object_attributes.discussion_id) {
if (this.mergeRequestSeenDiscussionIds.has(event.object_attributes.discussion_id)) {
// If it has, this is probably a reply. Skip repeated replies.
return false;
}
// Otherwise, record that we have seen the line and continue (it's probably a genuine comment).
this.mergeRequestSeenDiscussionIds.set(event.object_attributes.discussion_id, undefined);
}
return true;
}
public async onCommentCreated(event: IGitLabWebhookNoteEvent) { public async onCommentCreated(event: IGitLabWebhookNoteEvent) {
if (this.hookFilter.shouldSkip('merge_request', 'merge_request.review')) { if (this.hookFilter.shouldSkip('merge_request', 'merge_request.review')) {
return; return;
@ -862,13 +890,11 @@ ${data.description}`;
// Not a MR comment // Not a MR comment
return; return;
} }
if (!this.shouldHandleMRComment(event)) {
// Skip it.
return;
}
this.debounceMergeRequestReview(event.user, event.merge_request, event.project, { this.debounceMergeRequestReview(event.user, event.merge_request, event.project, {
commentCount: 1, commentCount: 1,
commentNotes: this.state.includeCommentBody ? [event.object_attributes.note] : undefined, commentNotes: this.state.includeCommentBody ? [event.object_attributes.note] : undefined,
discussionId: event.object_attributes.discussion_id,
skip: this.hookFilter.shouldSkip('merge_request.review.comments'), skip: this.hookFilter.shouldSkip('merge_request.review.comments'),
}); });
} }
@ -912,6 +938,24 @@ ${data.description}`;
} }
// TODO: Clean up webhooks // TODO: Clean up webhooks
} }
private setDiscussionThreads(discussionThreads: SerializedGitlabDiscussionThreads): void {
for (const { discussionId, eventId } of discussionThreads) {
this.discussionThreads.set(discussionId, Promise.resolve(eventId));
}
}
private async persistDiscussionThreads(): Promise<void> {
const serialized: SerializedGitlabDiscussionThreads = [];
for (const [discussionId, eventIdPromise] of this.discussionThreads.entriesAscending()) {
const eventId = await eventIdPromise.catch(_ => { /* logged elsewhere */ });
if (eventId) {
serialized.push({ discussionId, eventId });
}
}
return this.storage.setGitlabDiscussionThreads(this.connectionId, serialized);
}
} }
// Typescript doesn't understand Prototypes very well yet. // Typescript doesn't understand Prototypes very well yet.

View File

@ -220,6 +220,9 @@ export interface ProjectHook extends ProjectHookOpts {
created_at?: string; created_at?: string;
} }
/** newest last, to enable feeding it straight into an LRU cache */
export type SerializedGitlabDiscussionThreads = { discussionId: string, eventId: string }[];
export interface SimpleProject { export interface SimpleProject {
avatar_url?: string; avatar_url?: string;
description?: string; description?: string;

View File

@ -3,6 +3,7 @@ import { IBridgeStorageProvider, MAX_FEED_ITEMS } from "./StorageProvider";
import { IssuesGetResponseData } from "../github/Types"; import { IssuesGetResponseData } from "../github/Types";
import { ProvisionSession } from "matrix-appservice-bridge"; import { ProvisionSession } from "matrix-appservice-bridge";
import QuickLRU from "@alloc/quick-lru"; import QuickLRU from "@alloc/quick-lru";
import { SerializedGitlabDiscussionThreads } from "../Gitlab/Types";
export class MemoryStorageProvider extends MSP implements IBridgeStorageProvider { export class MemoryStorageProvider extends MSP implements IBridgeStorageProvider {
private issues: Map<string, IssuesGetResponseData> = new Map(); private issues: Map<string, IssuesGetResponseData> = new Map();
@ -11,6 +12,7 @@ export class MemoryStorageProvider extends MSP implements IBridgeStorageProvider
private figmaCommentIds: Map<string, string> = new Map(); private figmaCommentIds: Map<string, string> = new Map();
private widgetSessions: Map<string, ProvisionSession> = new Map(); private widgetSessions: Map<string, ProvisionSession> = new Map();
private storedFiles = new QuickLRU<string, string>({ maxSize: 128 }); private storedFiles = new QuickLRU<string, string>({ maxSize: 128 });
private gitlabDiscussionThreads = new Map<string, SerializedGitlabDiscussionThreads>();
private feedGuids = new Map<string, Array<string>>(); private feedGuids = new Map<string, Array<string>>();
constructor() { constructor() {
@ -97,4 +99,12 @@ export class MemoryStorageProvider extends MSP implements IBridgeStorageProvider
public async setStoredTempFile(key: string, value: string) { public async setStoredTempFile(key: string, value: string) {
this.storedFiles.set(key, value); this.storedFiles.set(key, value);
} }
public async getGitlabDiscussionThreads(connectionId: string): Promise<SerializedGitlabDiscussionThreads> {
return this.gitlabDiscussionThreads.get(connectionId) ?? [];
}
public async setGitlabDiscussionThreads(connectionId: string, value: SerializedGitlabDiscussionThreads): Promise<void> {
this.gitlabDiscussionThreads.set(connectionId, value);
}
} }

View File

@ -5,6 +5,7 @@ import { Logger } from "matrix-appservice-bridge";
import { IBridgeStorageProvider, MAX_FEED_ITEMS } from "./StorageProvider"; import { IBridgeStorageProvider, MAX_FEED_ITEMS } from "./StorageProvider";
import { IFilterInfo, IStorageProvider } from "matrix-bot-sdk"; import { IFilterInfo, IStorageProvider } from "matrix-bot-sdk";
import { ProvisionSession } from "matrix-appservice-bridge"; import { ProvisionSession } from "matrix-appservice-bridge";
import { SerializedGitlabDiscussionThreads } from "../Gitlab/Types";
const BOT_SYNC_TOKEN_KEY = "bot.sync_token."; const BOT_SYNC_TOKEN_KEY = "bot.sync_token.";
const BOT_FILTER_KEY = "bot.filter."; const BOT_FILTER_KEY = "bot.filter.";
@ -16,6 +17,7 @@ const GH_ISSUES_LAST_COMMENT_KEY = "gh.issues.last_comment";
const GH_ISSUES_REVIEW_DATA_KEY = "gh.issues.review_data"; const GH_ISSUES_REVIEW_DATA_KEY = "gh.issues.review_data";
const FIGMA_EVENT_COMMENT_ID = "figma.comment_event_id"; const FIGMA_EVENT_COMMENT_ID = "figma.comment_event_id";
const STORED_FILES_KEY = "storedfiles."; const STORED_FILES_KEY = "storedfiles.";
const GL_DISCUSSIONTHREADS_KEY = "gl.discussion-threads";
const STORED_FILES_EXPIRE_AFTER = 24 * 60 * 60; // 24 hours const STORED_FILES_EXPIRE_AFTER = 24 * 60 * 60; // 24 hours
const COMPLETED_TRANSACTIONS_EXPIRE_AFTER = 24 * 60 * 60; // 24 hours const COMPLETED_TRANSACTIONS_EXPIRE_AFTER = 24 * 60 * 60; // 24 hours
const ISSUES_EXPIRE_AFTER = 7 * 24 * 60 * 60; // 7 days const ISSUES_EXPIRE_AFTER = 7 * 24 * 60 * 60; // 7 days
@ -204,6 +206,16 @@ export class RedisStorageProvider extends RedisStorageContextualProvider impleme
await this.redis.set(STORED_FILES_KEY + key, value); await this.redis.set(STORED_FILES_KEY + key, value);
} }
public async getGitlabDiscussionThreads(connectionId: string): Promise<SerializedGitlabDiscussionThreads> {
const key = `${GL_DISCUSSIONTHREADS_KEY}:${connectionId}`;
return JSON.parse(await this.redis.get(key) ?? '[]');
}
public async setGitlabDiscussionThreads(connectionId: string, value: SerializedGitlabDiscussionThreads): Promise<void> {
const key = `${GL_DISCUSSIONTHREADS_KEY}:${connectionId}`;
await this.redis.set(key, JSON.stringify(value));
}
public async storeFeedGuids(url: string, ...guid: string[]): Promise<void> { public async storeFeedGuids(url: string, ...guid: string[]): Promise<void> {
const feedKey = `${FEED_GUIDS}${url}`; const feedKey = `${FEED_GUIDS}${url}`;
await this.redis.lpush(feedKey, ...guid); await this.redis.lpush(feedKey, ...guid);

View File

@ -1,6 +1,7 @@
import { ProvisioningStore } from "matrix-appservice-bridge"; import { ProvisioningStore } from "matrix-appservice-bridge";
import { IAppserviceStorageProvider, IStorageProvider } from "matrix-bot-sdk"; import { IAppserviceStorageProvider, IStorageProvider } from "matrix-bot-sdk";
import { IssuesGetResponseData } from "../github/Types"; import { IssuesGetResponseData } from "../github/Types";
import { SerializedGitlabDiscussionThreads } from "../Gitlab/Types";
// Some RSS feeds can return a very small number of items then bounce // Some RSS feeds can return a very small number of items then bounce
// back to their "normal" size, so we cannot just clobber the recent GUID list per request or else we'll // back to their "normal" size, so we cannot just clobber the recent GUID list per request or else we'll
@ -22,6 +23,8 @@ export interface IBridgeStorageProvider extends IAppserviceStorageProvider, ISto
getFigmaCommentEventId(roomId: string, figmaCommentId: string): Promise<string|null>; getFigmaCommentEventId(roomId: string, figmaCommentId: string): Promise<string|null>;
getStoredTempFile(key: string): Promise<string|null>; getStoredTempFile(key: string): Promise<string|null>;
setStoredTempFile(key: string, value: string): Promise<void>; setStoredTempFile(key: string, value: string): Promise<void>;
getGitlabDiscussionThreads(connectionId: string): Promise<SerializedGitlabDiscussionThreads>;
setGitlabDiscussionThreads(connectionId: string, value: SerializedGitlabDiscussionThreads): Promise<void>;
storeFeedGuids(url: string, ...guid: string[]): Promise<void>; storeFeedGuids(url: string, ...guid: string[]): Promise<void>;
hasSeenFeed(url: string, ...guid: string[]): Promise<boolean>; hasSeenFeed(url: string, ...guid: string[]): Promise<boolean>;
hasSeenFeedGuid(url: string, guid: string): Promise<boolean>; hasSeenFeedGuid(url: string, guid: string): Promise<boolean>;

View File

@ -193,6 +193,7 @@ export interface BridgeConfigGitLabYAML {
}, },
instances: {[name: string]: GitLabInstance}; instances: {[name: string]: GitLabInstance};
userIdPrefix: string; userIdPrefix: string;
commentDebounceMs?: number;
} }
export class BridgeConfigGitLab { export class BridgeConfigGitLab {
@ -205,6 +206,9 @@ export class BridgeConfigGitLab {
@configKey("Prefix used when creating ghost users for GitLab accounts.", true) @configKey("Prefix used when creating ghost users for GitLab accounts.", true)
readonly userIdPrefix: string; readonly userIdPrefix: string;
@configKey("Aggregate comments by waiting this many miliseconds before posting them to Matrix. Defaults to 5000 (5 seconds)", true)
readonly commentDebounceMs: number;
constructor(yaml: BridgeConfigGitLabYAML) { constructor(yaml: BridgeConfigGitLabYAML) {
this.instances = yaml.instances; this.instances = yaml.instances;
this.webhook = yaml.webhook; this.webhook = yaml.webhook;
@ -216,6 +220,12 @@ export class BridgeConfigGitLab {
this.instances[name].url = url.slice(0, -1); this.instances[name].url = url.slice(0, -1);
} }
} }
if (yaml.commentDebounceMs === undefined) {
this.commentDebounceMs = 5000;
} else {
this.commentDebounceMs = yaml.commentDebounceMs;
}
} }
@hideKey() @hideKey()

View File

@ -5,6 +5,8 @@ import { ApiError, ErrCode, ValidatorApiError } from "../../src/api";
import { GitLabRepoConnection, GitLabRepoConnectionState } from "../../src/Connections"; import { GitLabRepoConnection, GitLabRepoConnectionState } from "../../src/Connections";
import { expect } from "chai"; import { expect } from "chai";
import { BridgeConfigGitLab } from "../../src/config/Config"; import { BridgeConfigGitLab } from "../../src/config/Config";
import { IBridgeStorageProvider } from "../../src/Stores/StorageProvider";
import { IntentMock } from "../utils/IntentMock";
const ROOM_ID = "!foo:bar"; const ROOM_ID = "!foo:bar";
@ -20,20 +22,40 @@ const GITLAB_MR = {
title: "My MR", title: "My MR",
}; };
const GITLAB_ISSUE_CREATED_PAYLOAD = { const GITLAB_USER = {
object_kind: "merge_request", name: "Alice",
user: { username: "alice",
name: "Alice",
username: "alice",
},
object_attributes: GITLAB_MR,
project: {
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}`,
}
}; };
function createConnection(state: Record<string, unknown> = {}, isExistingState=false) { const GITLAB_PROJECT = {
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}`,
};
const GITLAB_ISSUE_CREATED_PAYLOAD = {
object_kind: "merge_request",
user: GITLAB_USER,
object_attributes: GITLAB_MR,
project: GITLAB_PROJECT,
};
const GITLAB_MR_COMMENT = {
'object_kind': 'note',
'event_type': 'note',
'merge_request': GITLAB_MR,
'object_attributes': {
'discussion_id': '6babfc4ad3be2355db286ed50be111a5220d5751',
'note': 'I am starting a new thread',
'noteable_type': 'MergeRequest',
'url': 'https://gitlab.com/tadeuszs/my-awesome-project/-/merge_requests/2#note_1455087141'
},
'project': GITLAB_PROJECT,
'user': GITLAB_USER,
};
const COMMENT_DEBOUNCE_MS = 25;
function createConnection(state: Record<string, unknown> = {}, isExistingState=false): { connection: GitLabRepoConnection, intent: IntentMock } {
const mq = createMessageQueue({ const mq = createMessageQueue({
monolithic: true monolithic: true
}); });
@ -44,7 +66,9 @@ function createConnection(state: Record<string, unknown> = {}, isExistingState=f
ROOM_ID, ROOM_ID,
"state_key", "state_key",
as, as,
{} as BridgeConfigGitLab, {
commentDebounceMs: COMMENT_DEBOUNCE_MS,
} as BridgeConfigGitLab,
intent, intent,
GitLabRepoConnection.validateState({ GitLabRepoConnection.validateState({
instance: "bar", instance: "bar",
@ -55,10 +79,18 @@ function createConnection(state: Record<string, unknown> = {}, isExistingState=f
{ {
url: "https://gitlab.example.com" url: "https://gitlab.example.com"
}, },
{
setGitlabDiscussionThreads: () => Promise.resolve(),
getGitlabDiscussionThreads: () => Promise.resolve([]),
} as unknown as IBridgeStorageProvider,
); );
return {connection, intent}; return {connection, intent};
} }
async function waitForDebouncing(): Promise<void> {
return new Promise(resolve => setTimeout(resolve, COMMENT_DEBOUNCE_MS * 2));
}
describe("GitLabRepoConnection", () => { describe("GitLabRepoConnection", () => {
describe("validateState", () => { describe("validateState", () => {
it("can validate a completes state config", () => { it("can validate a completes state config", () => {
@ -128,6 +160,98 @@ describe("GitLabRepoConnection", () => {
}, true); }, true);
}); });
}); });
describe("onCommentCreated", () => {
it("will handle an MR comment", async () => {
const { connection, intent } = createConnection();
await connection.onCommentCreated(GITLAB_MR_COMMENT as never);
await waitForDebouncing();
intent.expectEventMatches(
(ev: any) => ev.content.body.includes('**Alice** commented on MR'),
'event body indicates MR comment'
);
});
it("will debounce MR comments", async () => {
const { connection, intent } = createConnection();
await connection.onCommentCreated(GITLAB_MR_COMMENT as never);
await connection.onCommentCreated({
...GITLAB_MR_COMMENT,
'object_attributes': {
...GITLAB_MR_COMMENT.object_attributes,
'discussion_id': 'fa5d',
'note': 'different comment',
},
} as never);
await waitForDebouncing();
expect(intent.sentEvents.length).to.equal(1);
intent.expectEventMatches(
(ev: any) => ev.content.body.includes('with 2 comments'),
'one event sent for both comments',
0,
);
});
it("will add new comments in a Matrix thread", async () => {
const { connection, intent } = createConnection();
await connection.onCommentCreated(GITLAB_MR_COMMENT as never);
await waitForDebouncing();
await connection.onCommentCreated(GITLAB_MR_COMMENT as never);
await waitForDebouncing();
expect(intent.sentEvents.length).to.equal(2);
intent.expectEventMatches(
(ev: any) => ev.content['m.relates_to'].event_id === 'event_0',
'one event sent for both comments',
1,
);
});
it("will correctly map new comments to aggregated discussions", async () => {
const { connection, intent } = createConnection();
await connection.onCommentCreated({
...GITLAB_MR_COMMENT,
'object_attributes': {
...GITLAB_MR_COMMENT.object_attributes,
'discussion_id': 'disc1',
},
} as never);
await connection.onCommentCreated({
...GITLAB_MR_COMMENT,
'object_attributes': {
...GITLAB_MR_COMMENT.object_attributes,
'discussion_id': 'disc2',
},
} as never);
await waitForDebouncing();
expect(intent.sentEvents.length).to.equal(1);
await connection.onCommentCreated({
...GITLAB_MR_COMMENT,
'object_attributes': {
...GITLAB_MR_COMMENT.object_attributes,
'discussion_id': 'disc1',
},
} as never);
await waitForDebouncing();
expect(intent.sentEvents.length).to.equal(2);
intent.expectEventMatches(
(ev: any) => ev.content['m.relates_to'].event_id === 'event_0',
'disc1 reply goes to existing thread',
1
);
await connection.onCommentCreated({
...GITLAB_MR_COMMENT,
'object_attributes': {
...GITLAB_MR_COMMENT.object_attributes,
'discussion_id': 'disc2',
},
} as never);
await waitForDebouncing();
expect(intent.sentEvents.length).to.equal(3);
intent.expectEventMatches(
(ev: any) => ev.content['m.relates_to'].event_id === 'event_0',
'disc2 reply also goes to existing thread',
2
);
});
});
describe("onIssueCreated", () => { describe("onIssueCreated", () => {
it("will handle a simple issue", async () => { it("will handle a simple issue", async () => {
const { connection, intent } = createConnection(); const { connection, intent } = createConnection();

View File

@ -68,11 +68,12 @@ export class IntentMock {
}); });
} }
sendEvent(roomId: string, content: any) { sendEvent(roomId: string, content: any): Promise<string> {
this.sentEvents.push({ this.sentEvents.push({
roomId, roomId,
content, content,
}); });
return Promise.resolve(`event_${this.sentEvents.length - 1}`);
} }
expectNoEvent() { expectNoEvent() {
@ -87,10 +88,20 @@ export class IntentMock {
body.includes(matcher), body.includes(matcher),
`Expected event body ${eventIndex} to match '${matcher}'.\nMessage was: '${body}'` `Expected event body ${eventIndex} to match '${matcher}'.\nMessage was: '${body}'`
).to.be.true; ).to.be.true;
return;
} }
expect(!!this.sentEvents.find(ev => ev.content.body.includes(matcher)), `Expected any event body to match '${matcher}'`).to.be.true; expect(!!this.sentEvents.find(ev => ev.content.body.includes(matcher)), `Expected any event body to match '${matcher}'`).to.be.true;
} }
expectEventMatches(matcher: (content: any) => boolean, description: string, eventIndex?: number) {
if (eventIndex !== undefined) {
expect(this.sentEvents[eventIndex], `Expected event ${eventIndex} to exist`).to.not.be.undefined;
expect(matcher(this.sentEvents[eventIndex]), description).to.be.true;
return;
}
expect(this.sentEvents.some(ev => matcher(ev)), description).to.be.true;
}
async ensureJoined() { async ensureJoined() {
return true; return true;
} }