From fefe9da5fe34b2640aceb1e1fcd8e61e16fc2dea Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Sun, 29 Nov 2020 19:55:08 +0000 Subject: [PATCH] More fiddling --- package.json | 1 + src/AdminRoom.ts | 176 +++++++++++++------ src/CommentProcessor.ts | 18 +- src/Connections/GithubIssue.ts | 12 +- src/Connections/GithubProject.ts | 1 - src/Connections/GitlabIssue.ts | 104 +++++++---- src/Connections/IConnection.ts | 5 - src/FormatUtil.ts | 4 +- src/Github/GithubInstance.ts | 2 +- src/GithubBridge.ts | 131 ++++++++++---- src/Gitlab/Client.ts | 80 +++++++-- src/Gitlab/Types.ts | 43 ++++- src/Gitlab/WebhookTypes.ts | 16 +- src/IntentUtils.ts | 15 +- src/LogWrapper.ts | 5 + src/Notifications/GitLabWatcher.ts | 37 ++++ src/Notifications/UserNotificationWatcher.ts | 11 +- src/NotificationsProcessor.ts | 2 +- src/UserTokenStore.ts | 32 ++-- tests/FormatUtilTest.ts | 16 +- 20 files changed, 521 insertions(+), 190 deletions(-) create mode 100644 src/Notifications/GitLabWatcher.ts diff --git a/package.json b/package.json index fc6c6cbe..9cc7a21b 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "scripts": { "build": "tsc --project tsconfig.json", "prepare": "yarn build", + "start": "node lib/App/BridgeApp.js", "start:app": "node lib/App/BridgeApp.js", "start:webhooks": "node lib/App/GithubWebhookApp.js", "start:matrixsender": "node lib/App/MatrixSenderApp.js", diff --git a/src/AdminRoom.ts b/src/AdminRoom.ts index ce18dc43..5dad5609 100644 --- a/src/AdminRoom.ts +++ b/src/AdminRoom.ts @@ -27,10 +27,20 @@ export const BRIDGE_GITLAB_NOTIF_TYPE = "uk.half-shot.matrix-github.gitlab.notif export interface AdminAccountData { // eslint-disable-next-line camelcase admin_user: string; - notifications?: { - enabled: boolean; - participating?: boolean; + github?: { + notifications?: { + enabled: boolean; + participating?: boolean; + }; }; + gitlab?: { + [instanceUrl: string]: { + notifications: { + enabled: boolean; + } + } + } + } export class AdminRoom extends EventEmitter { public static helpMessage: MatrixMessageContent; @@ -54,19 +64,40 @@ export class AdminRoom extends EventEmitter { return this.pendingOAuthState; } - public get notificationsEnabled() { - return !!this.data.notifications?.enabled; + public notificationsEnabled(type: "github"|"gitlab", instanceName?: string) { + if (type === "github") { + return this.data.github?.notifications?.enabled; + } + return (type === "gitlab" && + !!instanceName && + this.data.gitlab && + this.data.gitlab[instanceName].notifications.enabled + ); } - public get notificationsParticipating() { - return !!this.data.notifications?.participating; + public notificationsParticipating(type: string) { + if (type !== "github") { + return false; + } + return this.data.github?.notifications?.participating || false; } public clearOauthState() { this.pendingOAuthState = null; } - public async getNotifSince() { + public async getNotifSince(type: "github"|"gitlab", instanceName?: string) { + if (type === "gitlab") { + try { + const { since } = await this.botIntent.underlyingClient.getRoomAccountData( + `${BRIDGE_GITLAB_NOTIF_TYPE}:${instanceName}`, this.roomId + ); + return since; + } catch { + // TODO: We should look at this error. + return 0; + } + } try { const { since } = await this.botIntent.underlyingClient.getRoomAccountData(BRIDGE_NOTIF_TYPE, this.roomId); return since; @@ -76,7 +107,14 @@ export class AdminRoom extends EventEmitter { } } - public async setNotifSince(since: number) { + public async setNotifSince(type: "github"|"gitlab", since: number, instanceName?: string) { + if (type === "gitlab") { + return this.botIntent.underlyingClient.setRoomAccountData( + `${BRIDGE_GITLAB_NOTIF_TYPE}:${instanceName}`, + this.roomId, { + since, + }); + } return this.botIntent.underlyingClient.setRoomAccountData(BRIDGE_NOTIF_TYPE, this.roomId, { since, }); @@ -143,33 +181,38 @@ export class AdminRoom extends EventEmitter { @botCommand("github notifications toggle", "Toggle enabling/disabling GitHub notifications in this room") // @ts-ignore - property is used private async setGitHubNotificationsStateToggle() { - const data: AdminAccountData = await this.botIntent.underlyingClient.getRoomAccountData( - BRIDGE_ROOM_TYPE, this.roomId, - ); - const oldState = data.notifications || { - enabled: false, - participating: true, - }; - data.notifications = { enabled: !oldState?.enabled, participating: oldState?.participating }; - await this.botIntent.underlyingClient.setRoomAccountData(BRIDGE_ROOM_TYPE, this.roomId, data); - this.emit("settings.changed", this, data); - await this.sendNotice(`${data.notifications.enabled ? "En" : "Dis"}abled GitHub notifcations`); + const data = await this.saveAccountData((data) => { + return { + ...data, + github: { + notifications: { + enabled: !(data.github?.notifications?.enabled ?? false), + participating: data.github?.notifications?.participating, + }, + }, + }; + }); + await this.sendNotice(`${data.github?.notifications?.enabled ? "En" : "Dis"}abled GitHub notifcations`); } @botCommand("github notifications filter participating", "Toggle enabling/disabling GitHub notifications in this room") // @ts-ignore - property is used private async setGitHubNotificationsStateParticipating() { - const data: AdminAccountData = await this.botIntent.underlyingClient.getRoomAccountData( - BRIDGE_ROOM_TYPE, this.roomId, - ); - const oldState = data.notifications || { - enabled: false, - participating: true, - }; - data.notifications = { enabled: oldState?.enabled, participating: !oldState?.participating }; - await this.botIntent.underlyingClient.setRoomAccountData(BRIDGE_ROOM_TYPE, this.roomId, data); - this.emit("settings.changed", this, data); - await this.sendNotice(`${data.notifications.participating ? "En" : "Dis"}abled filtering for participating notifications`); + const data = await this.saveAccountData((data) => { + if (!data.github?.notifications?.enabled) { + throw Error('Notifications are not enabled') + } + return { + ...data, + github: { + notifications: { + participating: !(data.github?.notifications?.participating ?? false), + enabled: true, + }, + }, + }; + }); + await this.sendNotice(`${data.github?.notifications?.enabled ? "" : "Not"} filtering for events you are participating in`); } @botCommand("github project list-for-user", "List GitHub projects for a user", [], ['user', 'repo']) @@ -273,27 +316,30 @@ export class AdminRoom extends EventEmitter { /* GitLab commands */ - @botCommand("gitlab open issue", "Open or join a issue room for GitLab", ['instanceName', 'projectParts', 'issueNumber']) + @botCommand("gitlab open issue", "Open or join a issue room for GitLab", ['url']) // @ts-ignore - property is used - private async gitLabOpenIssue(instanceName: string, projectParts: string, issueNumber: string) { + private async gitLabOpenIssue(url: string) { if (!this.config.gitlab) { return this.sendNotice("The bridge is not configured with GitLab support"); } - const instance = this.config.gitlab.instances[instanceName]; - if (!instance) { - return this.sendNotice("The bridge is not configured for this GitLab instance"); + + const urlResult = GitLabClient.splitUrlIntoParts(this.config.gitlab.instances, url); + if (!urlResult) { + return this.sendNotice("The URL was not understood. The URL must be an issue and the bridge must know of the GitLab instance."); } + const [instanceName, parts] = urlResult; + const instance = this.config.gitlab.instances[instanceName]; const client = await this.tokenStore.getGitLabForUser(this.userId, instance.url); if (!client) { return this.sendNotice("You have not added a personal access token for GitLab"); } const getIssueOpts = { - issue: parseInt(issueNumber), - projects: projectParts.split("/"), + issue: parseInt(parts[parts.length-1]), + projects: parts.slice(0, parts.length-3), // Remove - and /issues }; + log.info(`Looking up issue ${instanceName} ${getIssueOpts.projects.join("/")}#${getIssueOpts.issue}`); const issue = await client.issues.get(getIssueOpts); - this.emit('open.gitlab-issue', getIssueOpts, issue, instance); - + this.emit('open.gitlab-issue', getIssueOpts, issue, instanceName, instance); } @botCommand("gitlab personaltoken", "Set your personal access token for GitLab", ['instanceName', 'accessToken']) @@ -338,19 +384,47 @@ export class AdminRoom extends EventEmitter { await this.sendNotice("A token is stored for your GitLab account."); } - @botCommand("gitlab notifications toggle", "Toggle enabling/disabling GitHub notifications in this room") + @botCommand("gitlab notifications toggle", "Toggle enabling/disabling GitHub notifications in this room", ["instanceName"]) // @ts-ignore - property is used - private async setGitLabNotificationsStateToggle() { - const data: AdminAccountData = await this.botIntent.underlyingClient.getRoomAccountData( - BRIDGE_GITLAB_NOTIF_TYPE, this.roomId, + private async setGitLabNotificationsStateToggle(instanceName: string) { + if (!this.config.gitlab) { + return this.sendNotice("The bridge is not configured with GitLab support"); + } + const instance = this.config.gitlab.instances[instanceName]; + if (!instance) { + return this.sendNotice("The bridge is not configured for this GitLab instance"); + } + const hasClient = await this.tokenStore.getGitLabForUser(this.userId, instance.url); + if (!hasClient) { + return this.sendNotice("You do not have a GitLab token configured for this instance"); + } + let newValue = false; + await this.saveAccountData((data) => { + const currentNotifs = (data.gitlab || {})[instanceName].notifications; + console.log("current:", currentNotifs.enabled); + newValue = !currentNotifs.enabled; + return { + ...data, + gitlab: { + [instanceName]: { + notifications: { + enabled: newValue, + }, + } + }, + }; + }); + await this.sendNotice(`${newValue ? "En" : "Dis"}abled GitLab notifications for ${instanceName}`); + } + + private async saveAccountData(updateFn: (record: AdminAccountData) => AdminAccountData) { + const oldData: AdminAccountData = await this.botIntent.underlyingClient.getRoomAccountData( + BRIDGE_ROOM_TYPE, this.roomId, ); - const oldState = data.notifications || { - enabled: false, - }; - data.notifications = { enabled: !oldState?.enabled }; - await this.botIntent.underlyingClient.setRoomAccountData(BRIDGE_GITLAB_NOTIF_TYPE, this.roomId, data); - this.emit("settings.changed", this, data); - await this.sendNotice(`${data.notifications.enabled ? "En" : "Dis"}abled GitLab notifcations`); + const newData = updateFn(oldData); + await this.botIntent.underlyingClient.setRoomAccountData(BRIDGE_ROOM_TYPE, this.roomId, newData); + this.emit("settings.changed", this, oldData, newData); + return newData; } public async handleCommand(eventId: string, command: string) { diff --git a/src/CommentProcessor.ts b/src/CommentProcessor.ts index 2ab3ae63..596ac9f2 100644 --- a/src/CommentProcessor.ts +++ b/src/CommentProcessor.ts @@ -7,6 +7,7 @@ import LogWrapper from "./LogWrapper"; import axios from "axios"; import { FormatUtil } from "./FormatUtil"; import { IssuesGetCommentResponseData, ReposGetResponseData, IssuesGetResponseData } from "@octokit/types"; +import { IGitLabWebhookNoteEvent } from "./Gitlab/WebhookTypes"; const REGEX_MENTION = /(^|\s)(@[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38})(\s|$)/ig; const REGEX_MATRIX_MENTION = /(.*)<\/a>/gmi; @@ -56,7 +57,7 @@ export class CommentProcessor { return body; } - public async getEventBodyForComment(comment: IssuesGetCommentResponseData, + public async getEventBodyForGitHubComment(comment: IssuesGetCommentResponseData, repo?: ReposGetResponseData, issue?: IssuesGetResponseData): Promise { let body = comment.body; @@ -73,6 +74,21 @@ export class CommentProcessor { }; } + public async getEventBodyForGitLabNote(comment: IGitLabWebhookNoteEvent): Promise { + let body = comment.object_attributes.description; + body = this.replaceMentions(body); + body = await this.replaceImages(body, true); + body = emoji.emojify(body); + const htmlBody = md.render(body); + return { + body, + formatted_body: htmlBody, + msgtype: "m.text", + format: "org.matrix.custom.html", + // ...FormatUtil.getPartialBodyForComment(comment, repo, issue) + }; + } + private replaceMentions(body: string): string { return body.replace(REGEX_MENTION, (match: string, part1: string, githubId: string) => { const userId = this.as.getUserIdForSuffix(githubId.substr(1)); diff --git a/src/Connections/GithubIssue.ts b/src/Connections/GithubIssue.ts index 5e1516b3..762c964f 100644 --- a/src/Connections/GithubIssue.ts +++ b/src/Connections/GithubIssue.ts @@ -163,8 +163,11 @@ export class GitHubIssueConnection implements IConnection { return; } } - const commentIntent = await getIntentForUser(comment.user, this.as, this.github.octokit); - const matrixEvent = await this.commentProcessor.getEventBodyForComment(comment, event.repository, event.issue); + const commentIntent = await getIntentForUser({ + login: comment.user.login, + avatarUrl: comment.user.avatar_url, + }, this.as); + const matrixEvent = await this.commentProcessor.getEventBodyForGitHubComment(comment, event.repository, event.issue); await this.messageClient.sendMatrixMessage(this.roomId, matrixEvent, "m.room.message", commentIntent.userId); if (!updateState) { @@ -189,7 +192,10 @@ export class GitHubIssueConnection implements IConnection { if (this.state.comments_processed === -1) { // This has a side effect of creating a profile for the user. - const creator = await getIntentForUser(issue.data.user, this.as, this.github.octokit); + const creator = await getIntentForUser({ + login: issue.data.user.login, + avatarUrl: issue.data.user.avatar_url + }, this.as); // We've not sent any messages into the room yet, let's do it! if (issue.data.body) { await this.messageClient.sendMatrixMessage(this.roomId, { diff --git a/src/Connections/GithubProject.ts b/src/Connections/GithubProject.ts index 368fe1d2..b6f8ad50 100644 --- a/src/Connections/GithubProject.ts +++ b/src/Connections/GithubProject.ts @@ -8,7 +8,6 @@ export interface GitHubProjectConnectionState { project_id: number; state: "open"|"closed"; } - const log = new LogWrapper("GitHubProjectConnection"); /** diff --git a/src/Connections/GitlabIssue.ts b/src/Connections/GitlabIssue.ts index 92eb86c5..2719a316 100644 --- a/src/Connections/GitlabIssue.ts +++ b/src/Connections/GitlabIssue.ts @@ -9,14 +9,16 @@ import { MessageSenderClient } from "../MatrixSender"; import { FormatUtil } from "../FormatUtil"; import { IGitHubWebhookEvent } from "../GithubWebhooks"; import { GitLabInstance } from "../Config"; +import { GetIssueResponse } from "../Gitlab/Types"; +import { IGitLabWebhookNoteEvent } from "../Gitlab/WebhookTypes"; +import { getIntentForUser } from "../IntentUtils"; export interface GitLabIssueConnectionState { instance: string; projects: string[]; state: string; - issue: number; - // eslint-disable-next-line camelcase - comments_processed: number; + iid: number; + id: number; } const log = new LogWrapper("GitLabIssueConnection"); @@ -44,8 +46,34 @@ export class GitLabIssueConnection implements IConnection { static readonly QueryRoomRegex = /#gitlab_(.+)_(.+)_(\d+):.*/; - public static createRoomForIssue() { - // Fill me in + public static async createRoomForIssue(instanceName: string, instance: GitLabInstance, + issue: GetIssueResponse, projects: string[], as: Appservice, + tokenStore: UserTokenStore, commentProcessor: CommentProcessor, + messageSender: MessageSenderClient) { + const state: GitLabIssueConnectionState = { + projects, + state: issue.state, + iid: issue.iid, + id: issue.id, + instance: instanceName, + }; + + const roomId = await as.botClient.createRoom({ + visibility: "private", + name: `${issue.references.full}`, + topic: `Author: ${issue.author.name} | State: ${issue.state}`, + preset: "private_chat", + invite: [], + initial_state: [ + { + type: this.CanonicalEventType, + content: state, + state_key: issue.web_url, + }, + ], + }); + + return new GitLabIssueConnection(roomId, as, state, issue.web_url, tokenStore, commentProcessor, messageSender, instance); } public get projectPath() { @@ -63,7 +91,7 @@ export class GitLabIssueConnection implements IConnection { private tokenStore: UserTokenStore, private commentProcessor: CommentProcessor, private messageClient: MessageSenderClient, - private instance: GitLabInstance) { + private instance: GitLabInstance,) { } public isInterestedInStateEvent(eventType: string, stateKey: string) { @@ -71,33 +99,29 @@ export class GitLabIssueConnection implements IConnection { } public get issueNumber() { - return this.state.issue; + return this.state.iid; } - // public async onCommentCreated(event: IGitHubWebhookEvent, updateState = true) { - // const comment = event.comment!; - // if (event.repository) { - // // Delay to stop comments racing sends - // await new Promise((resolve) => setTimeout(resolve, 500)); - // if (this.commentProcessor.hasCommentBeenProcessed(this.state.org, this.state.repo, this.state.issues[0], comment.id)) { - // return; - // } - // } - // const commentIntent = await getIntentForUser(comment.user, this.as, this.octokit); - // const matrixEvent = await this.commentProcessor.getEventBodyForComment(comment, event.repository, event.issue); + public async onCommentCreated(event: IGitLabWebhookNoteEvent) { + if (event.repository) { + // Delay to stop comments racing sends + await new Promise((resolve) => setTimeout(resolve, 500)); + if (this.commentProcessor.hasCommentBeenProcessed( + this.state.instance, + this.state.projects.join("/"), + this.state.iid.toString(), + event.object_attributes.noteable_id)) { + return; + } + } + const commentIntent = await getIntentForUser({ + login: event.user.name, + avatarUrl: event.user.avatar_url, + }, this.as); + const matrixEvent = await this.commentProcessor.getEventBodyForGitLabNote(event); - // await this.messageClient.sendMatrixMessage(this.roomId, matrixEvent, "m.room.message", commentIntent.userId); - // if (!updateState) { - // return; - // } - // this.state.comments_processed++; - // await this.as.botIntent.underlyingClient.sendStateEvent( - // this.roomId, - // GitLabIssueConnection.CanonicalEventType, - // this.stateKey, - // this.state, - // ); - // } + await this.messageClient.sendMatrixMessage(this.roomId, matrixEvent, "m.room.message", commentIntent.userId); + } // private async syncIssueState() { // log.debug("Syncing issue state for", this.roomId); @@ -171,7 +195,6 @@ export class GitLabIssueConnection implements IConnection { public async onMatrixIssueComment(event: MatrixEvent, allowEcho = false) { - console.log(this.messageClient, this.commentProcessor); const clientKit = await this.tokenStore.getGitLabForUser(event.sender, this.instanceUrl); if (clientKit === null) { @@ -186,15 +209,20 @@ export class GitLabIssueConnection implements IConnection { return; } - // const result = await clientKit.issues.createComment({ - // repo: this.state.repo, - // owner: this.state.org, - // body: await this.commentProcessor.getCommentBodyForEvent(event, false), - // issue_number: parseInt(this.state.issues[0], 10), - // }); + const result = await clientKit.notes.createForIssue( + this.state.projects, + this.state.iid, { + body: await this.commentProcessor.getCommentBodyForEvent(event, false), + } + ); if (!allowEcho) { - //this.commentProcessor.markCommentAsProcessed(this.state.org, this.state.repo, this.state.issues[0], result.data.id); + this.commentProcessor.markCommentAsProcessed( + this.state.instance, + this.state.projects.join("/"), + this.state.iid.toString(), + result.noteable_id, + ); } } diff --git a/src/Connections/IConnection.ts b/src/Connections/IConnection.ts index c1c66bf3..ddf22391 100644 --- a/src/Connections/IConnection.ts +++ b/src/Connections/IConnection.ts @@ -17,11 +17,6 @@ export interface IConnection { */ onMessageEvent?: (ev: MatrixEvent) => Promise; - /** - * When a comment is created on a repo - */ - onCommentCreated?: (ev: IGitHubWebhookEvent) => Promise; - onIssueCreated?: (ev: IGitHubWebhookEvent) => Promise; onIssueStateChange?: (ev: IGitHubWebhookEvent) => Promise; diff --git a/src/FormatUtil.ts b/src/FormatUtil.ts index 4404403f..7d25ecac 100644 --- a/src/FormatUtil.ts +++ b/src/FormatUtil.ts @@ -12,8 +12,8 @@ export class FormatUtil { return `${orgRepoName}#${issue.number}: ${issue.title}`; } - public static formatRepoRoomName(repo: {full_name: string, url: string, title: string, number: number}) { - return `${repo.full_name}#${repo.number}: ${repo.title}`; + public static formatRepoRoomName(repo: {full_name: string, description: string}) { + return `${repo.full_name}: ${repo.description}`; } public static formatRoomTopic(repo: {state: string, html_url: string}) { diff --git a/src/Github/GithubInstance.ts b/src/Github/GithubInstance.ts index 2ddcfc9d..d58454bb 100644 --- a/src/Github/GithubInstance.ts +++ b/src/Github/GithubInstance.ts @@ -31,7 +31,7 @@ export class GithubInstance { public async start() { // TODO: Make this generic. const auth = { - id: parseInt(this.config.auth.id as string, 10), + appId: parseInt(this.config.auth.id as string, 10), privateKey: await fs.readFile(this.config.auth.privateKeyFile, "utf-8"), installationId: parseInt(this.config.installationId as string, 10), }; diff --git a/src/GithubBridge.ts b/src/GithubBridge.ts index 86a0cdd2..ac06e9aa 100644 --- a/src/GithubBridge.ts +++ b/src/GithubBridge.ts @@ -25,6 +25,7 @@ import { GithubInstance } from "./Github/GithubInstance"; import { IGitLabWebhookMREvent, IGitLabWebhookNoteEvent } from "./Gitlab/WebhookTypes"; import { GitLabIssueConnection } from "./Connections/GitlabIssue"; import { GetIssueResponse, GetIssueOpts } from "./Gitlab/Types" +import { GitLabClient } from "./Gitlab/Client"; // import { IGitLabWebhookMREvent } from "./Gitlab/WebhookTypes"; const log = new LogWrapper("GithubBridge"); @@ -97,18 +98,22 @@ export class GithubBridge { return state.map((event) => this.createConnectionForState(roomId, event)).filter((connection) => !!connection) as unknown as IConnection[]; } - private getConnectionsForGithubIssue(org: string, repo: string, issueNumber: number) { + private getConnectionsForGithubIssue(org: string, repo: string, issueNumber: number): (GitHubIssueConnection|GitLabRepoConnection)[] { return this.connections.filter((c) => (c instanceof GitHubIssueConnection && c.org === org && c.repo === repo && c.issueNumber === issueNumber) || - (c instanceof GitHubRepoConnection && c.org === org && c.repo === repo)); + (c instanceof GitHubRepoConnection && c.org === org && c.repo === repo)) as (GitHubIssueConnection|GitLabRepoConnection)[]; } - private getConnectionsForGitLabIssue(instance: GitLabInstance, projects: string[], issueNumber: number) { + private getConnectionsForGithubRepo(org: string, repo: string): GitHubRepoConnection[] { + return this.connections.filter((c) => (c instanceof GitHubRepoConnection && c.org === org && c.repo === repo)) as GitHubRepoConnection[]; + } + + private getConnectionsForGitLabIssue(instance: GitLabInstance, projects: string[], issueNumber: number): GitLabIssueConnection[] { return this.connections.filter((c) => ( c instanceof GitLabIssueConnection && c.issueNumber == issueNumber && c.instanceUrl == instance.url && c.projectPath == projects.join("/") - )); + )) as GitLabIssueConnection[]; } public stop() { @@ -204,7 +209,7 @@ export class GithubBridge { const connections = this.getConnectionsForGithubIssue(repository.owner.login, repository.name, issue.number); connections.map(async (c) => { try { - if (c.onCommentCreated) + if (c instanceof GitHubIssueConnection) await c.onCommentCreated(data); } catch (ex) { log.warn(`Connection ${c.toString()} failed to handle comment.created:`, ex); @@ -213,12 +218,11 @@ export class GithubBridge { }); this.queue.on("issue.opened", async ({ data }) => { - const { repository, issue } = validateRepoIssue(data); - const connections = this.getConnectionsForGithubIssue(repository.owner.login, repository.name, issue.number); + const { repository } = validateRepoIssue(data); + const connections = this.getConnectionsForGithubRepo(repository.owner.login, repository.name); connections.map(async (c) => { try { - if (c.onIssueCreated) - await c.onIssueCreated(data); + await c.onIssueCreated(data); } catch (ex) { log.warn(`Connection ${c.toString()} failed to handle comment.created:`, ex); } @@ -230,7 +234,7 @@ export class GithubBridge { const connections = this.getConnectionsForGithubIssue(repository.owner.login, repository.name, issue.number); connections.map(async (c) => { try { - if (c.onIssueEdited) + if (c instanceof GitHubIssueConnection) await c.onIssueEdited(data); } catch (ex) { log.warn(`Connection ${c.toString()} failed to handle comment.created:`, ex); @@ -243,8 +247,8 @@ export class GithubBridge { const connections = this.getConnectionsForGithubIssue(repository.owner.login, repository.name, issue.number); connections.map(async (c) => { try { - if (c.onIssueStateChange) - await c.onIssueStateChange(data); + if (c instanceof GitHubIssueConnection) + await c.onIssueStateChange(); } catch (ex) { log.warn(`Connection ${c.toString()} failed to handle comment.created:`, ex); } @@ -256,8 +260,8 @@ export class GithubBridge { const connections = this.getConnectionsForGithubIssue(repository.owner.login, repository.name, issue.number); connections.map(async (c) => { try { - if (c.onIssueStateChange) - await c.onIssueStateChange(data); + if (c instanceof GitHubIssueConnection) + await c.onIssueStateChange(); } catch (ex) { log.warn(`Connection ${c.toString()} failed to handle comment.created:`, ex); } @@ -306,17 +310,24 @@ export class GithubBridge { await this.tokenStore.storeUserToken("github", adminRoom.userId, msg.data.access_token); }); - this.queue.on("gitlab.note.created", async (msg) => { - console.log(msg); - // const connections = this.getConnectionsForGitLabIssue(msg.data.repository!.owner.login, msg.data.repository!.name, msg.data.issue!.number); - // connections.map(async (c) => { - // try { - // if (c.onCommentCreated) - // await c.onCommentCreated(msg.data); - // } catch (ex) { - // log.warn(`Connection ${c.toString()} failed to handle comment.created:`, ex); - // } - // }) + this.queue.on("gitlab.note.created", async ({data}) => { + if (!this.config.gitlab) { + throw Error('GitLab configuration missing, cannot handle note'); + } + const res = GitLabClient.splitUrlIntoParts(this.config.gitlab.instances, data.repository.homepage); + if (!res) { + throw Error('No instance found for note'); + } + const instance = this.config.gitlab.instances[res[0]]; + const connections = this.getConnectionsForGitLabIssue(instance, res[1], data.issue.iid); + connections.map(async (c) => { + try { + if (c.onCommentCreated) + await c.onCommentCreated(data); + } catch (ex) { + log.warn(`Connection ${c.toString()} failed to handle comment.created:`, ex); + } + }) }); // Fetch all room state @@ -354,7 +365,7 @@ export class GithubBridge { } for (const roomId of joinedRooms) { - log.info("Fetching state for " + roomId); + log.debug("Fetching state for " + roomId); const connections = await this.createConnectionsForRoomId(roomId); this.connections.push(...connections); if (connections.length === 0) { @@ -365,7 +376,7 @@ export class GithubBridge { ); const adminRoom = this.setupAdminRoom(roomId, accountData); // Call this on startup to set the state - await this.onAdminRoomSettingsChanged(adminRoom, accountData); + await this.onAdminRoomSettingsChanged(adminRoom, accountData, { admin_user: accountData.admin_user }); } catch (ex) { log.warn(`Room ${roomId} has no connections and is not an admin room`); } @@ -390,7 +401,7 @@ export class GithubBridge { } await retry(() => this.as.botIntent.joinRoom(roomId), 5); if (event.content.is_direct) { - const room = this.setupAdminRoom(roomId, {admin_user: event.sender, notifications: { enabled: false, participating: false}}); + const room = this.setupAdminRoom(roomId, {admin_user: event.sender}); await this.as.botIntent.underlyingClient.setRoomAccountData( BRIDGE_ROOM_TYPE, roomId, room.data, ); @@ -541,9 +552,10 @@ export class GithubBridge { throw Error('No regex matching query pattern'); } - private async onAdminRoomSettingsChanged(adminRoom: AdminRoom, settings: AdminAccountData) { - log.info(`Settings changed for ${adminRoom.userId} ${settings}`); - if (adminRoom.notificationsEnabled) { + private async onAdminRoomSettingsChanged(adminRoom: AdminRoom, settings: AdminAccountData, oldSettings: AdminAccountData) { + log.info(`Settings changed for ${adminRoom.userId}`, settings); + // Make this more efficent. + if (!oldSettings.github?.notifications?.enabled && settings.github?.notifications?.enabled) { log.info(`Notifications enabled for ${adminRoom.userId}`); const token = await this.tokenStore.getUserToken("github", adminRoom.userId); if (token) { @@ -555,8 +567,8 @@ export class GithubBridge { userId: adminRoom.userId, roomId: adminRoom.roomId, token, - since: await adminRoom.getNotifSince(), - filterParticipating: adminRoom.notificationsParticipating, + since: await adminRoom.getNotifSince("github"), + filterParticipating: adminRoom.notificationsParticipating("github"), type: "github", instanceUrl: undefined, }, @@ -564,7 +576,7 @@ export class GithubBridge { } else { log.warn(`Notifications enabled for ${adminRoom.userId} but no token stored!`); } - } else { + } else if (oldSettings.github?.notifications?.enabled && !settings.github?.notifications?.enabled) { await this.queue.push({ eventName: "notifications.user.disable", sender: "GithubBridge", @@ -575,6 +587,39 @@ export class GithubBridge { }, }); } + + for (const [instanceName, instanceSettings] of Object.entries(settings.gitlab || {})) { + const instanceUrl = this.config.gitlab?.instances[instanceName].url; + const token = await this.tokenStore.getUserToken("gitlab", adminRoom.userId, instanceUrl); + if (token && instanceSettings.notifications.enabled) { + log.info(`GitLab ${instanceName} notifications enabled for ${adminRoom.userId}`); + await this.queue.push({ + eventName: "notifications.user.enable", + sender: "GithubBridge", + data: { + userId: adminRoom.userId, + roomId: adminRoom.roomId, + token, + since: await adminRoom.getNotifSince("gitlab", instanceName), + filterParticipating: adminRoom.notificationsParticipating("gitlab"), + type: "gitlab", + instanceUrl, + }, + }); + } else if (!instanceSettings.notifications.enabled) { + log.info(`GitLab ${instanceName} notifications disabled for ${adminRoom.userId}`); + await this.queue.push({ + eventName: "notifications.user.disable", + sender: "GithubBridge", + data: { + userId: adminRoom.userId, + type: "gitlab", + instanceUrl, + }, + }); + } + } + } private setupAdminRoom(roomId: string, accountData: AdminAccountData) { @@ -586,13 +631,23 @@ export class GithubBridge { const connection = await GitHubProjectConnection.onOpenProject(project, this.as, adminRoom.userId); this.connections.push(connection); }); - adminRoom.on("open.gitlab-issue", async (issueInfo: GetIssueOpts, res: GetIssueResponse, instance: GitLabInstance) => { + adminRoom.on("open.gitlab-issue", async (issueInfo: GetIssueOpts, res: GetIssueResponse, instanceName: string, instance: GitLabInstance) => { const [ connection ] = this.getConnectionsForGitLabIssue(instance, issueInfo.projects, issueInfo.issue); if (connection) { return this.as.botClient.inviteUser(adminRoom.userId, connection.roomId); - } - // connection = await GitLabIssueConnection.createRoomForIssue(instance, res, this.as); - // this.connections.push(connection); + } + const newConnection = await GitLabIssueConnection.createRoomForIssue( + instanceName, + instance, + res, + issueInfo.projects, + this.as, + this.tokenStore, + this.commentProcessor, + this.messageClient + ); + this.connections.push(newConnection); + return this.as.botClient.inviteUser(adminRoom.userId, newConnection.roomId); }); this.adminRooms.set(roomId, adminRoom); log.info(`Setup ${roomId} as an admin room for ${adminRoom.userId}`); diff --git a/src/Gitlab/Client.ts b/src/Gitlab/Client.ts index a9070583..56f2584d 100644 --- a/src/Gitlab/Client.ts +++ b/src/Gitlab/Client.ts @@ -1,44 +1,89 @@ import axios from "axios"; -import { GetIssueResponse, GetUserResponse, CreateIssueOpts, CreateIssueResponse, GetIssueOpts, EditIssueOpts, GetTodosResponse } from "./Types"; +import { GitLabInstance } from "../Config"; +import { GetIssueResponse, GetUserResponse, CreateIssueOpts, CreateIssueResponse, GetIssueOpts, EditIssueOpts, GetTodosResponse, EventsOpts, CreateIssueNoteOpts, CreateIssueNoteResponse } from "./Types"; +import LogWrapper from "../LogWrapper"; +import { URLSearchParams } from "url"; + +const log = new LogWrapper("GitLabClient"); export class GitLabClient { constructor(private instanceUrl: string, private token: string) { } + public static splitUrlIntoParts(instances: {[name: string]: GitLabInstance}, url: string): [string, string[]]|null { + for (const [instanceKey, instanceConfig] of Object.entries(instances)) { + if (url.startsWith(instanceConfig.url)) { + return [instanceKey, url.substr(instanceConfig.url.length).split("/").filter(part => part.length > 0)]; + } + } + return null; + } + get defaultConfig() { return { headers: { - Authorization: `Bearer ${this.token}`, - UserAgent: "matrix-github v0.0.1", + "Authorization": `Bearer ${this.token}`, + "User-Agent": "matrix-github v0.0.1", }, baseURL: this.instanceUrl }; } async version() { - return (await axios.get(`${this.instanceUrl}/api/v4/versions`, this.defaultConfig)).data; + return (await axios.get("api/v4/versions", this.defaultConfig)).data; } async user(): Promise { - return (await axios.get(`${this.instanceUrl}/api/v4/user`, this.defaultConfig)).data; + return (await axios.get("api/v4/user", this.defaultConfig)).data; } private async createIssue(opts: CreateIssueOpts): Promise { - return (await axios.post(`${this.instanceUrl}/api/v4/projects/${opts.id}/issues`, opts, this.defaultConfig)).data; + return (await axios.post(`api/v4/projects/${opts.id}/issues`, opts, this.defaultConfig)).data; } private async getIssue(opts: GetIssueOpts): Promise { - const projectBit = opts.projects.join("%2F"); - const url = `${this.instanceUrl}/api/v4/projects/${projectBit}/issues/${opts.issue}`; - return (await axios.get(url, this.defaultConfig)).data; + try { + return (await axios.get(`api/v4/projects/${opts.projects.join("%2F")}/issues/${opts.issue}`, this.defaultConfig)).data; + } catch (ex) { + log.warn(`Failed to get issue:`, ex); + throw ex; + } } private async editIssue(opts: EditIssueOpts): Promise { - return (await axios.put(`${this.instanceUrl}/api/v4/projects/${opts.id}/issues/${opts.issue_iid}`, opts, this.defaultConfig)).data; + return (await axios.put(`api/v4/projects/${opts.id}/issues/${opts.issue_iid}`, opts, this.defaultConfig)).data; } - public async getTodos() { - return (await axios.get(`${this.instanceUrl}/api/v4/todos`, this.defaultConfig)).data as GetTodosResponse[]; + private async getProject(projectParts: string[]): Promise { + try { + return (await axios.get(`api/v4/projects/${projectParts.join("%2F")}`, this.defaultConfig)).data; + } catch (ex) { + log.warn(`Failed to get issue:`, ex); + throw ex; + } + } + + public async getEvents(opts: EventsOpts) { + const after = `${opts.after.getFullYear()}-` + + `${(opts.after.getMonth()+1).toString().padStart(2, "0")}`+ + `-${opts.after.getDay().toString().padStart(2, "0")}`; + return (await axios.get( + `api/v4/events?after=${after}`, + this.defaultConfig) + ).data as GetTodosResponse[]; + } + + public async createIssueNote(projectParts: string[], issueId: number, opts: CreateIssueNoteOpts): Promise { + try { + const qp = new URLSearchParams({ + body: opts.body, + confidential: (opts.confidential || false).toString(), + }).toString(); + return (await axios.post(`api/v4/projects/${projectParts.join("%2F")}/issues/${issueId}/notes?${qp}`, undefined, this.defaultConfig)).data as CreateIssueNoteResponse; + } catch (ex) { + log.warn(`Failed to create issue note:`, ex); + throw ex; + } } get issues() { @@ -48,4 +93,15 @@ export class GitLabClient { get: this.getIssue.bind(this), } } + get projects() { + return { + get: this.getProject.bind(this), + } + } + + get notes() { + return { + createForIssue: this.createIssueNote.bind(this), + } + } } \ No newline at end of file diff --git a/src/Gitlab/Types.ts b/src/Gitlab/Types.ts index 861b54cb..a4c09838 100644 --- a/src/Gitlab/Types.ts +++ b/src/Gitlab/Types.ts @@ -1,13 +1,11 @@ /* eslint-disable camelcase */ export interface GitLabAuthor { - author: { - id: number; - name: string; - username: string; - state: 'active'; - avatar_url: string; - web_url: string; - }; + id: number; + name: string; + username: string; + state: 'active'; + avatar_url: string; + web_url: string; } export interface GetUserResponse { @@ -127,4 +125,31 @@ export interface GetTodosResponse { body: string; created_at: string; updated_at: string; -} \ No newline at end of file +} + +export interface EventsOpts { + after: Date; +} + +export interface CreateIssueNoteOpts { + body: string; + confidential?: boolean; +} + +export interface CreateIssueNoteResponse { + id: number; + type: string|null; + body: string; + attachment: null; + author: GitLabAuthor; + created_at: string; + updated_at: string; + system: boolean; + noteable_id: number; + noteable_type: 'Issue'; + resolvable: boolean; + confidential: boolean; + noteable_iid: string; + commands_changes: unknown; +} + \ No newline at end of file diff --git a/src/Gitlab/WebhookTypes.ts b/src/Gitlab/WebhookTypes.ts index 3eeb5f86..18d7cdf1 100644 --- a/src/Gitlab/WebhookTypes.ts +++ b/src/Gitlab/WebhookTypes.ts @@ -9,19 +9,19 @@ export interface IGitLabWebhookEvent { } } -interface IGitlabUser { +export interface IGitlabUser { name: string; username: string; avatar_url: string; email: string; } -interface IGitlabProject { +export interface IGitlabProject { path_with_namespace: string; web_url: string; } -interface IGitlabIssue { +export interface IGitlabIssue { iid: number; description: string; } @@ -37,4 +37,14 @@ export interface IGitLabWebhookNoteEvent { user: IGitlabUser; project: IGitlabProject; issue: IGitlabIssue; + repository: { + name: string; + url: string; + description: string; + homepage: string; + }; + object_attributes: { + noteable_id: number; + description: string; + } } \ No newline at end of file diff --git a/src/IntentUtils.ts b/src/IntentUtils.ts index 228fa565..9d096ce7 100644 --- a/src/IntentUtils.ts +++ b/src/IntentUtils.ts @@ -1,10 +1,10 @@ import LogWrapper from "./LogWrapper"; -import { Octokit } from "@octokit/rest"; import { Appservice } from "matrix-bot-sdk"; +import axios from "axios"; const log = new LogWrapper("IntentUtils"); -export async function getIntentForUser(user: {avatar_url?: string, login: string}, as: Appservice, octokit: Octokit) { +export async function getIntentForUser(user: {avatarUrl?: string, login: string}, as: Appservice) { const intent = as.getIntentForSuffix(user.login); const displayName = `${user.login}`; // Verify up-to-date profile @@ -22,19 +22,20 @@ export async function getIntentForUser(user: {avatar_url?: string, login: string await intent.underlyingClient.setDisplayName(displayName); } - if (!profile.avatar_url && user.avatar_url) { + if (!profile.avatar_url && user.avatarUrl) { log.debug(`Updating ${intent.userId}'s avatar`); - const buffer = await octokit.request(user.avatar_url); - log.info(`uploading ${user.avatar_url}`); + const buffer = await axios.get(user.avatarUrl, { + responseType: "arraybuffer", + }); + log.info(`Uploading ${user.avatarUrl}`); // This does exist, but headers is silly and doesn't have content-type. // tslint:disable-next-line: no-any - const contentType = (buffer.headers as any)["content-type"]; + const contentType = buffer.headers["content-type"]; const mxc = await intent.underlyingClient.uploadContent( Buffer.from(buffer.data as ArrayBuffer), contentType, ); await intent.underlyingClient.setAvatarUrl(mxc); - } return intent; diff --git a/src/LogWrapper.ts b/src/LogWrapper.ts index a875b0b5..0bb3f6a9 100644 --- a/src/LogWrapper.ts +++ b/src/LogWrapper.ts @@ -38,6 +38,11 @@ export default class LogWrapper { }; LogService.setLogger({ info: (module: string, ...messageOrObject: any[]) => { + // These are noisy, redirect to debug. + if (module.startsWith("MatrixLiteClient")) { + log.debug(getMessageString(messageOrObject), { module }); + return; + } log.info(getMessageString(messageOrObject), { module }); }, warn: (module: string, ...messageOrObject: any[]) => { diff --git a/src/Notifications/GitLabWatcher.ts b/src/Notifications/GitLabWatcher.ts new file mode 100644 index 00000000..49254b12 --- /dev/null +++ b/src/Notifications/GitLabWatcher.ts @@ -0,0 +1,37 @@ +import { EventEmitter } from "events"; +import { GitLabClient } from "../Gitlab/Client"; +import LogWrapper from "../LogWrapper"; +import { NotificationWatcherTask } from "./NotificationWatcherTask"; + +const log = new LogWrapper("GitLabWatcher"); + +export class GitLabWatcher extends EventEmitter implements NotificationWatcherTask { + private client: GitLabClient; + private interval?: NodeJS.Timeout; + public readonly type = "gitlab"; + public failureCount = 0; + constructor(token: string, url: string, public userId: string, public roomId: string, public since: number) { + super(); + this.client = new GitLabClient(url, token); + } + + public start(intervalMs: number) { + this.interval = setTimeout(() => { + this.getNotifications(); + }, intervalMs); + } + + public stop() { + if (this.interval) { + clearInterval(this.interval); + } + } + + private async getNotifications() { + log.info(`Fetching events from GitLab for ${this.userId}`); + const events = await this.client.getEvents({ + after: new Date(this.since) + }); + console.log(events); + } +} \ No newline at end of file diff --git a/src/Notifications/UserNotificationWatcher.ts b/src/Notifications/UserNotificationWatcher.ts index df556c70..2a2aeaec 100644 --- a/src/Notifications/UserNotificationWatcher.ts +++ b/src/Notifications/UserNotificationWatcher.ts @@ -5,6 +5,7 @@ import { MessageSenderClient } from "../MatrixSender"; import { NotificationWatcherTask } from "./NotificationWatcherTask"; import { GitHubWatcher } from "./GitHubWatcher"; import { GitHubUserNotification } from "../Github/Types"; +import { GitLabWatcher } from "./GitLabWatcher"; export interface UserNotificationsEvent { roomId: string; @@ -59,14 +60,14 @@ Check your token is still valid, and then turn notifications back on.`, "m.notic let task: NotificationWatcherTask; const key = UserNotificationWatcher.constructMapKey(data.userId, data.type, data.instanceUrl); if (data.type === "github") { - this.userIntervals.get(key)?.stop(); task = new GitHubWatcher(data.token, data.userId, data.roomId, data.since, data.filterParticipating); - task.start(MIN_INTERVAL_MS); - }/* else if (data.type === "gitlab") { - - }*/ else { + } else if (data.type === "gitlab" && data.instanceUrl) { + task = new GitLabWatcher(data.token, data.instanceUrl, data.userId, data.roomId, data.since); + } else { throw Error('Notification type not known'); } + this.userIntervals.get(key)?.stop(); + task.start(MIN_INTERVAL_MS); task.on("fetch_failure", this.onFetchFailure.bind(this)); task.on("new_events", (payload) => { this.queue.push(payload); diff --git a/src/NotificationsProcessor.ts b/src/NotificationsProcessor.ts index 38230735..b8e2ecf6 100644 --- a/src/NotificationsProcessor.ts +++ b/src/NotificationsProcessor.ts @@ -140,7 +140,7 @@ export class NotificationProcessor { } } try { - await adminRoom.setNotifSince(msg.lastReadTs); + await adminRoom.setNotifSince("github", msg.lastReadTs); } catch (ex) { log.error("Failed to update stream position for notifications:", ex); } diff --git a/src/UserTokenStore.ts b/src/UserTokenStore.ts index e4e0d3bd..eefb110d 100644 --- a/src/UserTokenStore.ts +++ b/src/UserTokenStore.ts @@ -9,6 +9,13 @@ const ACCOUNT_DATA_TYPE = "uk.half-shot.matrix-github.password-store:"; const ACCOUNT_DATA_GITLAB_TYPE = "uk.half-shot.matrix-github.gitlab.password-store:"; const log = new LogWrapper("UserTokenStore"); +function tokenKey(type: "github"|"gitlab", userId: string, instanceUrl?: string) { + if (type === "github") { + return `${ACCOUNT_DATA_TYPE}${userId}`; + } + return `${ACCOUNT_DATA_GITLAB_TYPE}${instanceUrl}${userId}`; +} + export class UserTokenStore { private key!: Buffer; private userTokens: Map; @@ -21,32 +28,35 @@ export class UserTokenStore { this.key = await fs.readFile(this.keyPath); } - public async storeUserToken(type: "github"|"gitlab", userId: string, token: string, instance?: string): Promise { - const prefix = type === "github" ? ACCOUNT_DATA_TYPE : ACCOUNT_DATA_GITLAB_TYPE; - await this.intent.underlyingClient.setAccountData(`${prefix}${userId}`, { + public async storeUserToken(type: "github"|"gitlab", userId: string, token: string, instanceUrl?: string): Promise { + const key = tokenKey(type, userId, instanceUrl); + const data = { encrypted: publicEncrypt(this.key, Buffer.from(token)).toString("base64"), - instance: instance, - }); - this.userTokens.set(userId, token); + instance: instanceUrl, + }; + await this.intent.underlyingClient.setAccountData(key, data); + this.userTokens.set(key, token); log.info(`Stored new ${type} token for ${userId}`); + log.debug(`Stored`, data); } - public async getUserToken(type: "github"|"gitlab", userId: string, instance?: string): Promise { - const existingToken = this.userTokens.get(userId); + public async getUserToken(type: "github"|"gitlab", userId: string, instanceUrl?: string): Promise { + const key = tokenKey(type, userId, instanceUrl); + const existingToken = this.userTokens.get(key); if (existingToken) { return existingToken; } let obj; try { if (type === "github") { - obj = await this.intent.underlyingClient.getAccountData(`${ACCOUNT_DATA_TYPE}${userId}`); + obj = await this.intent.underlyingClient.getAccountData(key); } else if (type === "gitlab") { - obj = await this.intent.underlyingClient.getAccountData(`${ACCOUNT_DATA_GITLAB_TYPE}${instance}${userId}`); + obj = await this.intent.underlyingClient.getAccountData(key); } const encryptedTextB64 = obj.encrypted; const encryptedText = Buffer.from(encryptedTextB64, "base64"); const token = privateDecrypt(this.key, encryptedText).toString("utf-8"); - this.userTokens.set(userId, token); + this.userTokens.set(key, token); return token; } catch (ex) { log.error(`Failed to get token for user ${userId}`); diff --git a/tests/FormatUtilTest.ts b/tests/FormatUtilTest.ts index 0ff3a8a1..7baab0ce 100644 --- a/tests/FormatUtilTest.ts +++ b/tests/FormatUtilTest.ts @@ -11,9 +11,21 @@ const SIMPLE_ISSUE = { repository_url: "https://api.github.com/repos/evilcorp/lab", }; +const SIMPLE_REPO = { + description: "A simple description", + full_name: "evilcorp/lab", + html_url: "https://github.com/evilcorp/lab/issues/123", +}; + + describe("FormatUtilTest", () => { - it("correctly formats a room name", () => { - expect(FormatUtil.formatRepoRoomName(SIMPLE_ISSUE)).to.equal( + it("correctly formats a repo room name", () => { + expect(FormatUtil.formatRepoRoomName(SIMPLE_REPO)).to.equal( + "evilcorp/lab: A simple description", + ); + }); + it("correctly formats a issue room name", () => { + expect(FormatUtil.formatIssueRoomName(SIMPLE_ISSUE)).to.equal( "evilcorp/lab#123: A simple title", ); });