diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c30b8b9..d2f3cf6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,77 @@ +2.2.0 (2022-09-16) +================== + +Features +-------- + +- Ready/draft state changes for GitLab merge requests are now reported. ([\#480](https://github.com/matrix-org/matrix-hookshot/issues/480)) +- Merge GitLab MR approvals and comments into one message. ([\#484](https://github.com/matrix-org/matrix-hookshot/issues/484)) + + +Bugfixes +-------- + +- Log noisy "Got GitHub webhook event" log line at debug level. ([\#473](https://github.com/matrix-org/matrix-hookshot/issues/473)) +- Fix Figma service not being able to create new webhooks on startup, causing a crash. ([\#481](https://github.com/matrix-org/matrix-hookshot/issues/481)) +- Fix a bug where the bridge can crash when JSON logging is enabled. ([\#478](https://github.com/matrix-org/matrix-hookshot/issues/478)) + + +Internal Changes +---------------- + +- Update codemirror and remove unused font. ([\#489](https://github.com/matrix-org/matrix-hookshot/issues/489)) + + +2.1.2 (2022-09-03) +================== + +Bugfixes +-------- + +- Fix a bug where reading RSS feeds could crash the process. ([\#469](https://github.com/matrix-org/matrix-hookshot/issues/469)) + + +2.1.1 (2022-09-02) +================== + +Bugfixes +-------- + +- Fixed issue where log lines would only be outputted when the `logging.level` is `debug`. ([\#467](https://github.com/matrix-org/matrix-hookshot/issues/467)) + + +2.1.0 (2022-09-02) +================== + +Features +-------- + +- Add support for ARM64 docker images. ([\#458](https://github.com/matrix-org/matrix-hookshot/issues/458)) +- Added new config option `feeds.pollTimeoutSeconds` to explictly set how long to wait for a feed response. ([\#459](https://github.com/matrix-org/matrix-hookshot/issues/459)) +- JSON logging output now includes new keys such as `error` and `args`. ([\#463](https://github.com/matrix-org/matrix-hookshot/issues/463)) + + +Bugfixes +-------- + +- Fix error when responding to a provisioning request for a room that the Hookshot bot isn't yet a member of. ([\#457](https://github.com/matrix-org/matrix-hookshot/issues/457)) +- Fix a bug users without "login" permissions could run login commands for GitHub/GitLab/JIRA, but get an error when attempting to store the token. Users now have their permissions checked earlier. ([\#461](https://github.com/matrix-org/matrix-hookshot/issues/461)) +- Hookshot now waits for Redis to be ready before handling traffic. ([\#462](https://github.com/matrix-org/matrix-hookshot/issues/462)) +- Fix room membership going stale for rooms used in the permissions config. ([\#464](https://github.com/matrix-org/matrix-hookshot/issues/464)) + + +Improved Documentation +---------------------- + +- Be explicit that identifiers in the permissions yaml config need to be wrapped in quotes, because they start with the characters @ and !. ([\#453](https://github.com/matrix-org/matrix-hookshot/issues/453)) + + +Internal Changes +---------------- + +- Track coverage of tests. ([\#351](https://github.com/matrix-org/matrix-hookshot/issues/351)) + + 2.0.1 (2022-08-22) ================== diff --git a/changelog.d/351.misc b/changelog.d/351.misc deleted file mode 100644 index cc6d21c0..00000000 --- a/changelog.d/351.misc +++ /dev/null @@ -1 +0,0 @@ -Track coverage of tests. \ No newline at end of file diff --git a/changelog.d/453.doc b/changelog.d/453.doc deleted file mode 100644 index 531370bc..00000000 --- a/changelog.d/453.doc +++ /dev/null @@ -1 +0,0 @@ -Be explicit that identifiers in the permissions yaml config need to be wrapped in quotes, because they start with the characters @ and !. diff --git a/changelog.d/457.bugfix b/changelog.d/457.bugfix deleted file mode 100644 index 7c18f54d..00000000 --- a/changelog.d/457.bugfix +++ /dev/null @@ -1 +0,0 @@ -Fix error when responding to a provisioning request for a room that the Hookshot bot isn't yet a member of. diff --git a/changelog.d/458.feature b/changelog.d/458.feature deleted file mode 100644 index 052d3ea6..00000000 --- a/changelog.d/458.feature +++ /dev/null @@ -1 +0,0 @@ -Add support for ARM64 docker images. diff --git a/changelog.d/459.feature b/changelog.d/459.feature deleted file mode 100644 index e1268b6a..00000000 --- a/changelog.d/459.feature +++ /dev/null @@ -1 +0,0 @@ -Added new config option `feeds.pollTimeoutSeconds` to explictly set how long to wait for a feed response. diff --git a/changelog.d/461.bugfix b/changelog.d/461.bugfix deleted file mode 100644 index 32518f0d..00000000 --- a/changelog.d/461.bugfix +++ /dev/null @@ -1 +0,0 @@ -Fix a bug users without "login" permissions could run login commands for GitHub/GitLab/JIRA, but get an error when attempting to store the token. Users now have their permissions checked earlier. \ No newline at end of file diff --git a/changelog.d/462.bugfix b/changelog.d/462.bugfix deleted file mode 100644 index a147e1a8..00000000 --- a/changelog.d/462.bugfix +++ /dev/null @@ -1 +0,0 @@ -Hookshot now waits for Redis to be ready before handling traffic. \ No newline at end of file diff --git a/changelog.d/463.feature b/changelog.d/463.feature deleted file mode 100644 index 9c7c1548..00000000 --- a/changelog.d/463.feature +++ /dev/null @@ -1 +0,0 @@ -JSON logging output now includes new keys such as `error` and `args`. \ No newline at end of file diff --git a/changelog.d/464.bugfix b/changelog.d/464.bugfix deleted file mode 100644 index d68598e9..00000000 --- a/changelog.d/464.bugfix +++ /dev/null @@ -1 +0,0 @@ -Fix room membership going stale for rooms used in the permissions config. \ No newline at end of file diff --git a/changelog.d/488.misc b/changelog.d/488.misc new file mode 100644 index 00000000..5764f3e2 --- /dev/null +++ b/changelog.d/488.misc @@ -0,0 +1 @@ +Use the `matrix-appservice-bridge` logging implementation. \ No newline at end of file diff --git a/changelog.d/491.bugfix b/changelog.d/491.bugfix new file mode 100644 index 00000000..9ee644a3 --- /dev/null +++ b/changelog.d/491.bugfix @@ -0,0 +1 @@ +Give a warning if the user attempts to add a configuration widget to the room without giving the bot permissions. \ No newline at end of file diff --git a/changelog.d/496.feature b/changelog.d/496.feature new file mode 100644 index 00000000..a35fcb12 --- /dev/null +++ b/changelog.d/496.feature @@ -0,0 +1 @@ +Added `create-confidential` GitLab connection command. diff --git a/changelog.d/500.feature b/changelog.d/500.feature new file mode 100644 index 00000000..3d551b27 --- /dev/null +++ b/changelog.d/500.feature @@ -0,0 +1 @@ +Add new GitLab connection flag `includeCommentBody`, to enable including the body of comments on MR notifications. \ No newline at end of file diff --git a/changelog.d/502.feature b/changelog.d/502.feature new file mode 100644 index 00000000..13cfda1b --- /dev/null +++ b/changelog.d/502.feature @@ -0,0 +1 @@ +Add room configuration widget for Jira. diff --git a/changelog.d/503.feature b/changelog.d/503.feature new file mode 100644 index 00000000..63737b2a --- /dev/null +++ b/changelog.d/503.feature @@ -0,0 +1 @@ +Add bot commands to list and remove Jira connections. diff --git a/changelog.d/504.bugfix b/changelog.d/504.bugfix new file mode 100644 index 00000000..24ce6e2f --- /dev/null +++ b/changelog.d/504.bugfix @@ -0,0 +1 @@ +Improve formatting of help commands and Jira's `whoami` command. diff --git a/changelog.d/505.misc b/changelog.d/505.misc new file mode 100644 index 00000000..5cab6c6c --- /dev/null +++ b/changelog.d/505.misc @@ -0,0 +1 @@ +Improve some type-checking in the codebase. diff --git a/changelog.d/506.misc b/changelog.d/506.misc new file mode 100644 index 00000000..c370fd3b --- /dev/null +++ b/changelog.d/506.misc @@ -0,0 +1 @@ +Refactor the Vite component's `tsconfig.json` file to make it compatible with the TypeScript project settings & the TypeScript language server. diff --git a/changelog.d/507.bugfix b/changelog.d/507.bugfix new file mode 100644 index 00000000..c2eb4214 --- /dev/null +++ b/changelog.d/507.bugfix @@ -0,0 +1 @@ +Add a configuration widget for Jira. diff --git a/changelog.d/508.feature b/changelog.d/508.feature new file mode 100644 index 00000000..4cd5eea9 --- /dev/null +++ b/changelog.d/508.feature @@ -0,0 +1 @@ +Reorganize the GitHub widget to allow searching for repositories by organization. diff --git a/changelog.d/512.feature b/changelog.d/512.feature new file mode 100644 index 00000000..6dc4c438 --- /dev/null +++ b/changelog.d/512.feature @@ -0,0 +1 @@ +Print a notice message after successfully logging in to GitHub when conversing with the bot in a DM. diff --git a/changelog.d/515.bugfix b/changelog.d/515.bugfix new file mode 100644 index 00000000..2972cb96 --- /dev/null +++ b/changelog.d/515.bugfix @@ -0,0 +1 @@ +Fix inactive "Command Prefix" field in configuration widgets. diff --git a/changelog.d/517.feature b/changelog.d/517.feature new file mode 100644 index 00000000..1a7d31b3 --- /dev/null +++ b/changelog.d/517.feature @@ -0,0 +1 @@ +Add new GitLab connection flag `includeCommentBody`, to enable including the body of comments on MR notifications. diff --git a/changelog.d/518.misc b/changelog.d/518.misc new file mode 100644 index 00000000..046023e8 --- /dev/null +++ b/changelog.d/518.misc @@ -0,0 +1 @@ +Don't send empty query string in some widget API requests. diff --git a/changelog.d/519.bugfix b/changelog.d/519.bugfix new file mode 100644 index 00000000..a96e0f78 --- /dev/null +++ b/changelog.d/519.bugfix @@ -0,0 +1 @@ +Fix support for the "Labeled" event in the GitHub widget. diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 3dc890c0..94cbcde8 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -15,6 +15,7 @@ - [Room Configuration](./usage/room_configuration.md) - [GitHub Repo](./usage/room_configuration/github_repo.md) - [GitLab Project](./usage/room_configuration/gitlab_project.md) + - [JIRA Project](./usage/room_configuration/jira_project.md) - [📊 Metrics](./metrics.md) # 🧑‍💻 Development diff --git a/docs/setup/jira.md b/docs/setup/jira.md index 9e8c7c59..ceb91f1c 100644 --- a/docs/setup/jira.md +++ b/docs/setup/jira.md @@ -1,21 +1,22 @@ # JIRA -## Adding a webhook to a JIRA Organisation +## Adding a webhook to a JIRA Instance -This should be done for all JIRA organisations you wish to bridge. The setup steps are the same for both On-Prem and Cloud. +This should be done for the JIRA instance you wish to bridge. The setup steps are the same for both On-Prem and Cloud. You need to go to the `WebHooks` configuration page under Settings > System. +Note that this may require administrative access to the JIRA instance. Next, add a webhook that points to `/` on the public webhooks address for hookshot. You should also include a secret value by appending `?secret=your-webhook-secret`. The secret value can be anything, but should be reasonably secure and should also be stored in the `config.yml` file. -Ensure that you enable all the events that you wish to be bridge. +Ensure that you enable all the events that you wish to be bridged. ## Configuration -You can now set some configuration in the bridge `config.yml` +You can now set some configuration in the bridge `config.yml`: ```yaml jira: diff --git a/docs/usage/room_configuration/gitlab_project.md b/docs/usage/room_configuration/gitlab_project.md index 0e4dadfe..9a31bc9f 100644 --- a/docs/usage/room_configuration/gitlab_project.md +++ b/docs/usage/room_configuration/gitlab_project.md @@ -26,9 +26,9 @@ This connection supports a few options which can be defined in the room state: |ignoreHooks|Choose to exclude notifications for some event types|Array of: [Supported event types](#supported-event-types) |*empty*| |commandPrefix|Choose the prefix to use when sending commands to the bot|A string, ideally starts with "!"|`!gh`| |pushTagsRegex|Only mention pushed tags which match this regex|Regex string|*empty*| -|prDiff|Show a diff in the room when a PR is created, subject to limits|`{enabled: boolean, maxLines: number}`|`{enabled: false}`| |includingLabels|Only notify on issues matching these label names|Array of: String matching a label name|*empty*| |excludingLabels|Never notify on issues matching these label names|Array of: String matching a label name|*empty*| +|includeCommentBody|Include the body of a comment when notifying on merge requests|Boolean|false| ### Supported event types diff --git a/docs/usage/room_configuration/jira_project.md b/docs/usage/room_configuration/jira_project.md new file mode 100644 index 00000000..386378ea --- /dev/null +++ b/docs/usage/room_configuration/jira_project.md @@ -0,0 +1,38 @@ +JIRA Project +================= + +This connection type connects a JIRA project to a room. + +You can run commands to create and assign issues, and receive notifications when issues are created. + +## Setting up + +To set up a connection to a JIRA project in a new room: + +(NB you must have permission to bridge JIRA projects before you can use this command, see [auth](../auth.html#jira).) + +1. Create a new, unencrypted room. It can be public or private. +1. Invite the bridge bot (e.g. `@hookshot:example.com`). +1. Give the bridge bot moderator permissions or higher (power level 50) (or otherwise configure the room so the bot can edit room state). +1. Send the command `!hookshot jira project https://jira-instance/.../projects/PROJECTKEY/...`. +1. If you have permission to bridge this repo, the bridge will respond with a confirmation message. + +## Managing connections + +Send the command `!hookshot jira list project` to list all of a room's connections to JIRA projects. + +Send the command `!hookshot jira remove project ` to remove a room's connection to a JIRA project at a given URL. + +## Configuration + +This connection supports a few options which can be defined in the room state: + +| Option | Description | Allowed values | Default | +|--------|-------------|----------------|---------| +|events|Choose to include notifications for some event types|Array of: [Supported event types](#supported-event-types) |*empty*| +|commandPrefix|Choose the prefix to use when sending commands to the bot|A string, ideally starts with "!"|`!jira`| + + +### Supported event types + +This connection currently supports sending messages only when a `issue.created` action happens on the project. diff --git a/package.json b/package.json index 16e29857..ef283ff9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-hookshot", - "version": "2.0.1", + "version": "2.2.0", "description": "A bridge between Matrix and multiple project management services, such as GitHub, GitLab and JIRA.", "main": "lib/app.js", "repository": "https://github.com/matrix-org/matrix-hookshot", @@ -54,8 +54,8 @@ "ioredis": "^5.2.3", "jira-client": "^8.0.0", "markdown-it": "^12.3.2", - "matrix-appservice-bridge": "^5.0.0", - "matrix-bot-sdk": "^0.6.1", + "matrix-appservice-bridge": "^6.0.0", + "matrix-bot-sdk": "^0.6.2", "matrix-widget-api": "^1.0.0", "micromatch": "^4.0.4", "mime": "^3.0.0", @@ -69,20 +69,19 @@ "string-argv": "^0.3.1", "tiny-typed-emitter": "^2.1.0", "uuid": "^8.3.2", - "vm2": "^3.9.6", + "vm2": "^3.9.11", "winston": "^3.3.3", "xml2js": "^0.4.23", "yaml": "^1.10.2" }, "devDependencies": { - "@codemirror/lang-javascript": "^0.19.7", - "@fontsource/open-sans": "^4.2.2", + "@codemirror/lang-javascript": "^6.0.2", "@napi-rs/cli": "^2.2.0", "@preact/preset-vite": "^2.2.0", "@types/ajv": "^1.0.0", "@types/chai": "^4.2.22", "@types/cors": "^2.8.12", - "@types/express": "^4.17.13", + "@types/express": "^4.17.14", "@types/jira-client": "^7.1.0", "@types/markdown-it": "^12.2.3", "@types/micromatch": "^4.0.1", @@ -95,7 +94,7 @@ "@types/xml2js": "^0.4.11", "@typescript-eslint/eslint-plugin": "^5.4.0", "@typescript-eslint/parser": "^5.4.0", - "@uiw/react-codemirror": "^4.5.3", + "@uiw/react-codemirror": "^4.12.3", "chai": "^4.3.4", "eslint": "^8.3.0", "eslint-config-preact": "^1.3.0", diff --git a/src/AdminRoom.ts b/src/AdminRoom.ts index 86e7dc6a..73784486 100644 --- a/src/AdminRoom.ts +++ b/src/AdminRoom.ts @@ -17,13 +17,13 @@ import { JiraBotCommands } from "./Jira/AdminCommands"; import { NotifFilter, NotificationFilterStateContent } from "./NotificationFilters"; import { ProjectsListResponseData } from "./Github/Types"; import { UserTokenStore } from "./UserTokenStore"; -import LogWrapper from "./LogWrapper"; +import { Logger } from "matrix-appservice-bridge"; import markdown from "markdown-it"; type ProjectsListForRepoResponseData = Endpoints["GET /repos/{owner}/{repo}/projects"]["response"]; type ProjectsListForUserResponseData = Endpoints["GET /users/{username}/projects"]["response"]; const md = new markdown(); -const log = new LogWrapper('AdminRoom'); +const log = new Logger('AdminRoom'); export const LEGACY_BRIDGE_ROOM_TYPE = "uk.half-shot.matrix-github.room"; export const LEGACY_BRIDGE_NOTIF_TYPE = "uk.half-shot.matrix-github.notif_state"; diff --git a/src/App/BridgeApp.ts b/src/App/BridgeApp.ts index c4cd38a0..909bb151 100644 --- a/src/App/BridgeApp.ts +++ b/src/App/BridgeApp.ts @@ -1,15 +1,15 @@ import { Bridge } from "../Bridge"; -import LogWrapper from "../LogWrapper"; import { BridgeConfig, parseRegistrationFile } from "../Config/Config"; import { Webhooks } from "../Webhooks"; import { MatrixSender } from "../MatrixSender"; import { UserNotificationWatcher } from "../Notifications/UserNotificationWatcher"; import { ListenerService } from "../ListenerService"; -import { Logging } from "matrix-appservice-bridge"; +import { Logger } from "matrix-appservice-bridge"; +import { LogService } from "matrix-bot-sdk"; -LogWrapper.configureLogging({level: "info"}); -const log = new LogWrapper("App"); +Logger.configure({console: "info"}); +const log = new Logger("App"); async function start() { const configFile = process.argv[2] || "./config.yml"; @@ -17,10 +17,13 @@ async function start() { const config = await BridgeConfig.parseConfig(configFile, process.env); const registration = await parseRegistrationFile(registrationFile); const listener = new ListenerService(config.listeners); - LogWrapper.configureLogging(config.logging); - // Bridge SDK doesn't support trace, use "debug" instead. - const bridgeSdkLevel = config.logging.level === "trace" ? "debug" : config.logging.level; - Logging.configure({console: bridgeSdkLevel }); + Logger.configure({ + console: config.logging.level, + colorize: config.logging.colorize, + json: config.logging.json, + timestampFormat: config.logging.timestampFormat + }); + LogService.setLogger(Logger.botSdkLogger); if (config.queue.monolithic) { const matrixSender = new MatrixSender(config, registration); @@ -49,6 +52,11 @@ async function start() { } start().catch((ex) => { - log.error("BridgeApp encountered an error and has stopped:", ex); + if (Logger.root.configured) { + log.error("BridgeApp encountered an error and has stopped:", ex); + } else { + // eslint-disable-next-line no-console + console.error("BridgeApp encountered an error and has stopped", ex); + } process.exit(1); }); diff --git a/src/App/GithubWebhookApp.ts b/src/App/GithubWebhookApp.ts index 90c521f3..4bf65db4 100644 --- a/src/App/GithubWebhookApp.ts +++ b/src/App/GithubWebhookApp.ts @@ -1,17 +1,24 @@ import { BridgeConfig } from "../Config/Config"; import { Webhooks } from "../Webhooks"; -import LogWrapper from "../LogWrapper"; +import { Logger } from "matrix-appservice-bridge"; import { UserNotificationWatcher } from "../Notifications/UserNotificationWatcher"; import Metrics from "../Metrics"; import { ListenerService } from "../ListenerService"; +import { LogService } from "matrix-bot-sdk"; -const log = new LogWrapper("App"); +const log = new Logger("App"); async function start() { const configFile = process.argv[2] || "./config.yml"; const config = await BridgeConfig.parseConfig(configFile, process.env); - LogWrapper.configureLogging(config.logging); + Logger.configure({ + console: config.logging.level, + colorize: config.logging.colorize, + json: config.logging.json, + timestampFormat: config.logging.timestampFormat + }); + LogService.setLogger(Logger.botSdkLogger); const listener = new ListenerService(config.listeners); if (config.metrics) { if (!config.metrics.port) { diff --git a/src/App/MatrixSenderApp.ts b/src/App/MatrixSenderApp.ts index 4321adad..1198c98b 100644 --- a/src/App/MatrixSenderApp.ts +++ b/src/App/MatrixSenderApp.ts @@ -1,18 +1,25 @@ import { BridgeConfig, parseRegistrationFile } from "../Config/Config"; import { MatrixSender } from "../MatrixSender"; -import LogWrapper from "../LogWrapper"; +import { Logger } from "matrix-appservice-bridge"; import Metrics from "../Metrics"; import { ListenerService } from "../ListenerService"; +import { LogService } from "matrix-bot-sdk"; -const log = new LogWrapper("App"); +const log = new Logger("App"); async function start() { const configFile = process.argv[2] || "./config.yml"; const registrationFile = process.argv[3] || "./registration.yml"; const config = await BridgeConfig.parseConfig(configFile, process.env); const registration = await parseRegistrationFile(registrationFile); - LogWrapper.configureLogging(config.logging); + Logger.configure({ + console: config.logging.level, + colorize: config.logging.colorize, + json: config.logging.json, + timestampFormat: config.logging.timestampFormat + }); + LogService.setLogger(Logger.botSdkLogger); const listener = new ListenerService(config.listeners); const sender = new MatrixSender(config, registration); if (config.metrics) { diff --git a/src/BotCommands.ts b/src/BotCommands.ts index e92b5bc9..d4a7b1d0 100644 --- a/src/BotCommands.ts +++ b/src/BotCommands.ts @@ -49,10 +49,15 @@ export function compileBotCommands(...prototypes: Record `<${arg}>`).join(" ") || ""; const optionalArgs = b.optionalArgs?.map((arg: string) => `[${arg}]`).join(" ") || ""; + const cmdStr = + ` - \`££PREFIX££${b.prefix}` + + (requiredArgs ? ` ${requiredArgs}` : "") + + (optionalArgs ? ` ${optionalArgs}` : "") + + `\` - ${b.help}`; cmdStrs[category] = cmdStrs[category] || [] - cmdStrs[category].push(` - \`££PREFIX££${b.prefix}\` ${requiredArgs} ${optionalArgs} - ${b.help}`); + cmdStrs[category].push(cmdStr); // We know that these types are safe. botCommands[b.prefix as string] = { fn: prototype[propertyKey], diff --git a/src/Bridge.ts b/src/Bridge.ts index 739e60e8..91a516ee 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -26,7 +26,7 @@ import { retry } from "./PromiseUtil"; import { UserNotificationsEvent } from "./Notifications/UserNotificationWatcher"; import { UserTokenStore } from "./UserTokenStore"; import * as GitHubWebhookTypes from "@octokit/webhooks-types"; -import LogWrapper from "./LogWrapper"; +import { Logger } from "matrix-appservice-bridge"; import { Provisioner } from "./provisioning/provisioner"; import { JiraProvisionerRouter } from "./Jira/Router"; import { GitHubProvisionerRouter } from "./Github/Router"; @@ -40,8 +40,8 @@ import { getAppservice } from "./appservice"; import { JiraOAuthRequestCloud, JiraOAuthRequestOnPrem, JiraOAuthRequestResult } from "./Jira/OAuth"; import { GenericWebhookEvent, GenericWebhookEventResult } from "./generic/types"; import { SetupWidget } from "./Widgets/SetupWidget"; -import { FeedEntry, FeedError, FeedReader } from "./feeds/FeedReader"; -const log = new LogWrapper("Bridge"); +import { FeedEntry, FeedError, FeedReader, FeedSuccess } from "./feeds/FeedReader"; +const log = new Logger("Bridge"); export class Bridge { private readonly as: Appservice; @@ -88,6 +88,7 @@ export class Bridge { public async start() { log.info('Starting up'); + await this.tokenStore.load(); await this.storage.connect?.(); await this.queue.connect?.(); @@ -140,7 +141,6 @@ export class Bridge { } - await this.tokenStore.load(); const connManager = this.connectionManager = new ConnectionManager(this.as, this.config, this.tokenStore, this.commentProcessor, this.messageClient, this.storage, this.github); @@ -358,6 +358,12 @@ export class Bridge { (c, data) => c.onMergeRequestReviewed(data), ); + this.bindHandlerToQueue( + "gitlab.merge_request.update", + (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), + (c, data) => c.onMergeRequestUpdate(data), + ); + this.bindHandlerToQueue( "gitlab.release.create", (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), @@ -414,6 +420,12 @@ export class Bridge { refresh_token: msg.data.refresh_token, refresh_token_expires_in: msg.data.refresh_token_expires_in && ((parseInt(msg.data.refresh_token_expires_in) * 1000) + Date.now()), } as GitHubOAuthToken)); + + // Some users won't have an admin room and would have gone through provisioning. + const adminRoom = [...this.adminRooms.values()].find(r => r.userId === userId); + if (adminRoom) { + await adminRoom.sendNotice("Logged into GitHub"); + } }); this.bindHandlerToQueue( @@ -529,7 +541,7 @@ export class Bridge { // Some users won't have an admin room and would have gone through provisioning. const adminRoom = [...this.adminRooms.values()].find(r => r.userId === userId); if (adminRoom) { - await adminRoom.sendNotice(`Logged into Jira`); + await adminRoom.sendNotice("Logged into Jira"); } result = JiraOAuthRequestResult.Success; } catch (ex) { @@ -615,9 +627,9 @@ export class Bridge { (data) => connManager.getConnectionsForFeedUrl(data.feed.url), (c, data) => c.handleFeedEntry(data), ); - this.bindHandlerToQueue( + this.bindHandlerToQueue( "feed.success", - (data) => connManager.getConnectionsForFeedUrl(data.feed.url), + (data) => connManager.getConnectionsForFeedUrl(data.url), c => c.handleFeedSuccess(), ); this.bindHandlerToQueue( diff --git a/src/CommentProcessor.ts b/src/CommentProcessor.ts index c8003e08..2ffd442e 100644 --- a/src/CommentProcessor.ts +++ b/src/CommentProcessor.ts @@ -3,7 +3,7 @@ import markdown from "markdown-it"; import mime from "mime"; import emoji from "node-emoji"; import { MatrixMessageContent, MatrixEvent } from "./MatrixEvent"; -import LogWrapper from "./LogWrapper"; +import { Logger } from "matrix-appservice-bridge"; import axios from "axios"; import { FormatUtil } from "./FormatUtil"; import { IssuesGetCommentResponseData, ReposGetResponseData, IssuesGetResponseData } from "./Github/Types" @@ -13,7 +13,7 @@ const REGEX_MENTION = /(^|\s)(@[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38})(\s|$)/ig; const REGEX_MATRIX_MENTION = /(.*)<\/a>/gmi; const REGEX_IMAGES = /!\[.*]\((.*\.(\w+))\)/gm; const md = new markdown(); -const log = new LogWrapper("CommentProcessor"); +const log = new Logger("CommentProcessor"); interface IMatrixCommentEvent extends MatrixMessageContent { // eslint-disable-next-line camelcase diff --git a/src/Config/Config.ts b/src/Config/Config.ts index 34556dfb..1f415297 100644 --- a/src/Config/Config.ts +++ b/src/Config/Config.ts @@ -6,12 +6,12 @@ import { configKey, hideKey } from "./Decorators"; import { BridgeConfigListener, ResourceTypeArray } from "../ListenerService"; import { GitHubRepoConnectionOptions } from "../Connections/GithubRepo"; import { BridgeConfigActorPermission, BridgePermissions } from "../libRs"; -import LogWrapper from "../LogWrapper"; import { ConfigError } from "../errors"; import { ApiError, ErrCode } from "../api"; import { GITHUB_CLOUD_URL } from "../Github/GithubInstance"; +import { Logger } from "matrix-appservice-bridge"; -const log = new LogWrapper("Config"); +const log = new Logger("Config"); function makePrefixedUrl(urlString: string): URL { return new URL(urlString.endsWith("/") ? urlString : urlString + "/"); @@ -637,6 +637,9 @@ export class BridgeConfig { case "gitlab": config = this.gitlab?.publicConfig; break; + case "jira": + config = {}; + break; default: throw new ApiError("Not a known service, or service doesn't expose a config", ErrCode.NotFound); } @@ -661,7 +664,7 @@ export async function parseRegistrationFile(filename: string) { // Can be called directly if (require.main === module) { - LogWrapper.configureLogging({level: "info"}); + Logger.configure({console: "info"}); BridgeConfig.parseConfig(process.argv[2] || "config.yml", process.env).then(() => { // eslint-disable-next-line no-console console.log('Config successfully validated.'); diff --git a/src/ConnectionManager.ts b/src/ConnectionManager.ts index 4f203fff..07c6327b 100644 --- a/src/ConnectionManager.ts +++ b/src/ConnectionManager.ts @@ -11,7 +11,7 @@ import { ConnectionDeclarations, GenericHookConnection, GitHubDiscussionConnecti import { GithubInstance } from "./Github/GithubInstance"; import { GitLabClient } from "./Gitlab/Client"; import { JiraProject } from "./Jira/Types"; -import LogWrapper from "./LogWrapper"; +import { Logger } from "matrix-appservice-bridge"; import { MessageSenderClient } from "./MatrixSender"; import { GetConnectionTypeResponseItem } from "./provisioning/api"; import { ApiError, ErrCode } from "./api"; @@ -21,7 +21,7 @@ import { IBridgeStorageProvider } from "./Stores/StorageProvider"; import Metrics from "./Metrics"; import EventEmitter from "events"; -const log = new LogWrapper("ConnectionManager"); +const log = new Logger("ConnectionManager"); export class ConnectionManager extends EventEmitter { private connections: IConnection[] = []; @@ -299,8 +299,15 @@ export class ConnectionManager extends EventEmitter { return await GitLabRepoConnection.getConnectionTargets(userId, this.tokenStore, configObject, filters); } case GitHubRepoConnection.CanonicalEventType: { - const configObject = this.validateConnectionTarget(userId, this.config.github, "GitHub", "github"); - return await GitHubRepoConnection.getConnectionTargets(userId, this.tokenStore, configObject); + this.validateConnectionTarget(userId, this.config.github, "GitHub", "github"); + if (!this.github) { + throw Error("GitHub instance was never initialized"); + } + return await GitHubRepoConnection.getConnectionTargets(userId, this.tokenStore, this.github, filters); + } + case JiraProjectConnection.CanonicalEventType: { + const configObject = this.validateConnectionTarget(userId, this.config.jira, "JIRA", "jira"); + return await JiraProjectConnection.getConnectionTargets(userId, this.tokenStore, configObject, filters); } default: throw new ApiError(`Connection type doesn't support getting targets or is not known`, ErrCode.NotFound); diff --git a/src/Connections/CommandConnection.ts b/src/Connections/CommandConnection.ts index cc6c8d69..8469c547 100644 --- a/src/Connections/CommandConnection.ts +++ b/src/Connections/CommandConnection.ts @@ -1,10 +1,10 @@ import { botCommand, BotCommands, handleCommand, HelpFunction } from "../BotCommands"; -import LogWrapper from "../LogWrapper"; +import { Logger } from "matrix-appservice-bridge"; import { MatrixClient } from "matrix-bot-sdk"; import { MatrixMessageContent, MatrixEvent } from "../MatrixEvent"; import { BaseConnection } from "./BaseConnection"; import { IConnectionState, PermissionCheckFn } from "."; -const log = new LogWrapper("CommandConnection"); +const log = new Logger("CommandConnection"); /** * Connection class that handles commands for a given connection. Should be used @@ -59,7 +59,7 @@ export abstract class CommandConnection; @@ -85,6 +90,7 @@ const AllowedEvents: AllowedEventsNames[] = [ "issue.changed" , "issue.created" , "issue.edited" , + "issue.labeled" , "issue" , "pull_request.closed" , "pull_request.merged" , @@ -217,6 +223,12 @@ function compareEmojiStrings(e0: string, e1: string, e0Index = 0) { return e0.codePointAt(e0Index) === e1.codePointAt(0); } +export interface GitHubTargetFilter { + orgName?: string; + page?: number; + perPage?: number; +} + /** * Handles rooms connected to a GitHub repo. */ @@ -1042,7 +1054,7 @@ ${event.release.body}`; } } - public getProvisionerDetails() { + public getProvisionerDetails(): GitHubRepoResponseItem { return { ...GitHubRepoConnection.getProvisionerDetails(this.as.botUserId), id: this.connectionId, @@ -1052,30 +1064,77 @@ ${event.release.body}`; } } - public static async getConnectionTargets(userId: string, tokenStore: UserTokenStore, config: BridgeConfigGitHub): Promise { + public static async getConnectionTargets(userId: string, tokenStore: UserTokenStore, githubInstance: GithubInstance, filters: GitHubTargetFilter = {}): Promise { // Search for all repos under the user's control. const octokit = await tokenStore.getOctokitForUser(userId); if (!octokit) { throw new ApiError("User is not authenticated with GitHub", ErrCode.ForbiddenUser); } - const allRepos = await octokit.repos.listForAuthenticatedUser({ - baseUrl: config.baseUrl.href.replace(/\/$/, ""), - }); - return allRepos.data.filter(r => r.permissions?.admin).map(r => { - const splitRepoName = r.full_name.split("/"); - return { - state: { - org: splitRepoName[0], - repo: splitRepoName[1], - }, - name: r.full_name, - }; - }) as GitHubRepoConnectionTarget[]; + + if (!filters.orgName) { + const results: GitHubRepoConnectionOrgTarget[] = []; + try { + const installs = await octokit.apps.listInstallationsForAuthenticatedUser(); + for (const install of installs.data.installations) { + if (install.account) { + results.push({ + name: install.account.login || NAMELESS_ORG_PLACEHOLDER, // org or user name + }); + } else { + log.debug(`Skipping install ${install.id}, has no attached account`); + } + } + } catch (ex) { + log.warn(`Failed to fetch orgs for GitHub user ${userId}`, ex); + throw new ApiError("Could not fetch orgs for GitHub user", ErrCode.AdditionalActionRequired); + } + return results; + } + // If we have an instance, search under it. + const ownSelf = await octokit.users.getAuthenticated(); + + const page = filters.page ?? 1; + const perPage = filters.perPage ?? 10; + try { + let reposPromise; + + if (ownSelf.data.login === filters.orgName) { + const userInstallation = await githubInstance.appOctokit.apps.getUserInstallation({username: ownSelf.data.login}); + reposPromise = octokit.apps.listInstallationReposForAuthenticatedUser({ + page, + installation_id: userInstallation.data.id, + per_page: perPage, + }); + } else { + const orgInstallation = await githubInstance.appOctokit.apps.getOrgInstallation({org: filters.orgName}); + + // Github will error if the authed user tries to list repos of a disallowed installation, even + // if we got the installation ID from the app's instance. + reposPromise = octokit.apps.listInstallationReposForAuthenticatedUser({ + page, + installation_id: orgInstallation.data.id, + per_page: perPage, + }); + } + const reposRes = await reposPromise; + return reposRes.data.repositories + .map(r => ({ + state: { + org: filters.orgName, + repo: r.name, + }, + name: r.name, + })) as GitHubRepoConnectionRepoTarget[]; + } catch (ex) { + log.warn(`Failed to fetch accessible repos for ${filters.orgName} / ${userId}`, ex); + throw new ApiError("Could not fetch accessible repos for GitHub org", ErrCode.AdditionalActionRequired); + } } public async provisionerUpdateConfig(userId: string, config: Record) { const validatedConfig = GitHubRepoConnection.validateState(config); await this.as.botClient.sendStateEvent(this.roomId, GitHubRepoConnection.CanonicalEventType, this.stateKey, validatedConfig); + this.state = validatedConfig; } public async onRemove() { diff --git a/src/Connections/GithubUserSpace.ts b/src/Connections/GithubUserSpace.ts index 93de4beb..ba3d230e 100644 --- a/src/Connections/GithubUserSpace.ts +++ b/src/Connections/GithubUserSpace.ts @@ -1,12 +1,12 @@ import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection"; import { Appservice, Space, StateEvent } from "matrix-bot-sdk"; -import LogWrapper from "../LogWrapper"; +import { Logger } from "matrix-appservice-bridge"; import axios from "axios"; import { GitHubDiscussionSpace } from "."; import { GithubInstance } from "../Github/GithubInstance"; import { BaseConnection } from "./BaseConnection"; -const log = new LogWrapper("GitHubOwnerSpace"); +const log = new Logger("GitHubOwnerSpace"); export interface GitHubUserSpaceConnectionState { username: string; diff --git a/src/Connections/GitlabIssue.ts b/src/Connections/GitlabIssue.ts index 9263ad53..f8623493 100644 --- a/src/Connections/GitlabIssue.ts +++ b/src/Connections/GitlabIssue.ts @@ -2,7 +2,7 @@ import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnectio import { Appservice, StateEvent } from "matrix-bot-sdk"; import { MatrixMessageContent, MatrixEvent } from "../MatrixEvent"; import { UserTokenStore } from "../UserTokenStore"; -import LogWrapper from "../LogWrapper"; +import { Logger } from "matrix-appservice-bridge"; import { CommentProcessor } from "../CommentProcessor"; import { MessageSenderClient } from "../MatrixSender"; import { BridgeConfigGitLab, GitLabInstance } from "../Config/Config"; @@ -20,7 +20,7 @@ export interface GitLabIssueConnectionState { authorName: string; } -const log = new LogWrapper("GitLabIssueConnection"); +const log = new Logger("GitLabIssueConnection"); // interface IQueryRoomOpts { // as: Appservice; diff --git a/src/Connections/GitlabRepo.ts b/src/Connections/GitlabRepo.ts index 5296e91d..714e5325 100644 --- a/src/Connections/GitlabRepo.ts +++ b/src/Connections/GitlabRepo.ts @@ -5,20 +5,22 @@ import { Appservice, StateEvent } from "matrix-bot-sdk"; import { BotCommands, botCommand, compileBotCommands } from "../BotCommands"; import { MatrixMessageContent } from "../MatrixEvent"; import markdown from "markdown-it"; -import LogWrapper from "../LogWrapper"; +import { Logger } from "matrix-appservice-bridge"; import { BridgeConfigGitLab, GitLabInstance } from "../Config/Config"; -import { IGitLabWebhookMREvent, IGitLabWebhookNoteEvent, IGitLabWebhookPushEvent, IGitLabWebhookReleaseEvent, IGitLabWebhookTagPushEvent, IGitLabWebhookWikiPageEvent } from "../Gitlab/WebhookTypes"; +import { IGitlabMergeRequest, IGitlabProject, IGitlabUser, IGitLabWebhookMREvent, IGitLabWebhookNoteEvent, IGitLabWebhookPushEvent, IGitLabWebhookReleaseEvent, IGitLabWebhookTagPushEvent, IGitLabWebhookWikiPageEvent } from "../Gitlab/WebhookTypes"; import { CommandConnection } from "./CommandConnection"; import { Connection, IConnection, IConnectionState, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection"; import { GetConnectionsResponseItem } from "../provisioning/api"; import { ErrCode, ApiError, ValidatorApiError } from "../api" import { AccessLevel } from "../Gitlab/Types"; import Ajv, { JSONSchemaType } from "ajv"; +import { CommandError } from "../errors"; export interface GitLabRepoConnectionState extends IConnectionState { instance: string; path: string; ignoreHooks?: AllowedEventsNames[], + includeCommentBody?: boolean; pushTagsRegex?: string, includingLabels?: string[]; excludingLabels?: string[]; @@ -35,7 +37,7 @@ export interface GitLabRepoConnectionProjectTarget { export type GitLabRepoConnectionTarget = GitLabRepoConnectionInstanceTarget|GitLabRepoConnectionProjectTarget; -const log = new LogWrapper("GitLabRepoConnection"); +const log = new Logger("GitLabRepoConnection"); const md = new markdown(); const PUSH_MAX_COMMITS = 5; @@ -50,6 +52,7 @@ type AllowedEventsNames = "merge_request.close" | "merge_request.merge" | "merge_request.review" | + "merge_request.ready_for_review" | "merge_request.review.comments" | `merge_request.${string}` | "merge_request" | @@ -65,6 +68,7 @@ const AllowedEvents: AllowedEventsNames[] = [ "merge_request.close", "merge_request.merge", "merge_request.review", + "merge_request.ready_for_review", "merge_request.review.comments", "merge_request", "tag_push", @@ -109,7 +113,11 @@ const ConnectionStateSchema = { type: "array", nullable: true, items: {type: "string"}, - } + }, + includeCommentBody: { + type: "boolean", + nullable: true, + }, }, required: [ "instance", @@ -249,7 +257,7 @@ export class GitLabRepoConnection extends CommandConnection(); + private readonly debounceMRComments = new Map(); constructor(roomId: string, stateKey: string, @@ -311,7 +325,7 @@ export class GitLabRepoConnection extends CommandConnection 1) { + comments = ` with ${result.commentCount} comments`; + } + + let approvalState = 'reviewed'; + if (result.approved === true) { + approvalState = '✅ approved' + } else if (result.approved === false) { + approvalState = '🔴 unapproved'; + } + + let content = `**${result.author}** ${approvalState} MR [${orgRepoName}#${mergeRequest.iid}](${mergeRequest.url}): "${mergeRequest.title}"${comments}`; + + if (result.commentNotes) { + content += "\n\n> " + result.commentNotes.join("\n\n> "); + } + + this.as.botIntent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: content, + formatted_body: md.renderInline(content), + format: "org.matrix.custom.html", + }).catch(ex => { + log.error('Failed to send MR review message', ex); + }); + } + + private debounceMergeRequestReview( + user: IGitlabUser, + mergeRequest: IGitlabMergeRequest, + project: IGitlabProject, + opts: { + commentCount: number, + commentNotes?: string[], + approved?: boolean, + } + ) { + const { commentCount, commentNotes, approved } = opts; + const uniqueId = `${mergeRequest?.iid}/${user.username}`; + const existing = this.debounceMRComments.get(uniqueId); + if (existing) { + clearTimeout(existing.timeout); + existing.approved = approved; + if (commentNotes) { + existing.commentNotes = [...(existing.commentNotes ?? []), ...commentNotes]; + } + existing.commentCount = opts.commentCount; + existing.timeout = setTimeout(() => this.renderDebouncedMergeRequest(uniqueId, mergeRequest, project), MRRCOMMENT_DEBOUNCE_MS); + return; + } + this.debounceMRComments.set(uniqueId, { + commentCount: commentCount, + commentNotes: commentNotes, + approved, + author: user.name, + timeout: setTimeout(() => this.renderDebouncedMergeRequest(uniqueId, mergeRequest, project), MRRCOMMENT_DEBOUNCE_MS), + }); + } + + public async onMergeRequestReviewed(event: IGitLabWebhookMREvent) { + if (this.shouldSkipHook('merge_request', 'merge_request.review', `merge_request.${event.object_attributes.action}`) || !this.matchesLabelFilter(event)) { + return; + } + log.info(`onMergeRequestReviewed ${this.roomId} ${this.instance}/${this.path} ${event.object_attributes.iid}`); + this.validateMREvent(event); + if (event.object_attributes.action !== "approved" && event.object_attributes.action !== "unapproved") { + // Not interested. + return; + } + this.debounceMergeRequestReview( + event.user, + event.object_attributes, + event.project, + { + commentCount: 0, + approved: "approved" === event.object_attributes.action + } + ); + } + public async onCommentCreated(event: IGitLabWebhookNoteEvent) { if (this.shouldSkipHook('merge_request', 'merge_request.review', 'merge_request.review.comments')) { return; } log.info(`onCommentCreated ${this.roomId} ${this.toString()} ${event.merge_request?.iid} ${event.object_attributes.id}`); - const uniqueId = `${event.merge_request?.iid}/${event.object_attributes.author_id}`; - if (!event.merge_request || event.object_attributes.noteable_type !== "MergeRequest") { // Not a MR comment return; } - - if (event.object_attributes.author_id === event.merge_request.author_id) { - // If it's the same author, ignore - return; - } - - const mergeRequest = event.merge_request; - - const renderFn = () => { - const result = this.debounceMRComments.get(uniqueId); - if (!result) { - // Always defined, but for type checking purposes. - return; - } - const orgRepoName = event.project.path_with_namespace; - const comments = result.comments !== 1 ? `${result.comments} comments` : '1 comment'; - const content = `**${result.author}** reviewed MR [${orgRepoName}#${mergeRequest.iid}](${mergeRequest.url}): "${mergeRequest.title}" with ${comments}`; - this.as.botIntent.sendEvent(this.roomId, { - msgtype: "m.notice", - body: content, - formatted_body: md.renderInline(content), - format: "org.matrix.custom.html", - }).catch(ex => { - log.error('Failed to send onCommentCreated message', ex); - }); - }; - - const existing = this.debounceMRComments.get(uniqueId); - if (existing) { - clearTimeout(existing.timeout); - existing.comments = existing.comments + 1; - existing.timeout = setTimeout(renderFn, MRRCOMMENT_DEBOUNCE_MS); - } else { - this.debounceMRComments.set(uniqueId, { - comments: 1, - author: event.user.name, - timeout: setTimeout(renderFn, MRRCOMMENT_DEBOUNCE_MS), - }) - } - + this.debounceMergeRequestReview(event.user, event.merge_request, event.project, { + commentCount: 1, + commentNotes: this.state.includeCommentBody ? [event.object_attributes.note] : undefined, + }); } - public toString() { return `GitLabRepo ${this.instance.url}/${this.path}`; } @@ -627,6 +722,7 @@ ${data.description}`; public async provisionerUpdateConfig(userId: string, config: Record) { const validatedConfig = GitLabRepoConnection.validateState(config); await this.as.botClient.sendStateEvent(this.roomId, GitLabRepoConnection.CanonicalEventType, this.stateKey, validatedConfig); + this.state = validatedConfig; } diff --git a/src/Connections/IConnection.ts b/src/Connections/IConnection.ts index 8a73d1b4..528ee45c 100644 --- a/src/Connections/IConnection.ts +++ b/src/Connections/IConnection.ts @@ -65,7 +65,7 @@ export interface IConnection { /** * If supported, this is sent when a user attempts to update the configuration of a connection. */ - provisionerUpdateConfig?: >(userId: string, config: T) => void; + provisionerUpdateConfig?: >(userId: string, config: T) => Promise; /** * If supported, this is sent when a user attempts to remove the connection from a room. The connection diff --git a/src/Connections/JiraProject.ts b/src/Connections/JiraProject.ts index 64867a1b..4f4bc644 100644 --- a/src/Connections/JiraProject.ts +++ b/src/Connections/JiraProject.ts @@ -1,6 +1,6 @@ import { Connection, IConnection, IConnectionState, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection"; import { Appservice, StateEvent } from "matrix-bot-sdk"; -import LogWrapper from "../LogWrapper"; +import { Logger } from "matrix-appservice-bridge"; import { JiraIssueEvent, JiraIssueUpdatedEvent } from "../Jira/WebhookTypes"; import { FormatUtil } from "../FormatUtil"; import markdownit from "markdown-it"; @@ -13,6 +13,9 @@ import { UserTokenStore } from "../UserTokenStore"; import { CommandError, NotLoggedInError } from "../errors"; import { ApiError, ErrCode } from "../api"; import JiraApi from "jira-client"; +import { GetConnectionsResponseItem } from "../provisioning/api"; +import { BridgeConfigJira } from "../Config/Config"; +import { HookshotJiraApi } from "../Jira/Client"; type JiraAllowedEventsNames = "issue.created"; const JiraAllowedEvents: JiraAllowedEventsNames[] = ["issue.created"]; @@ -23,6 +26,27 @@ export interface JiraProjectConnectionState extends IConnectionState { events?: JiraAllowedEventsNames[], } + +export interface JiraProjectConnectionInstanceTarget { + url: string; + name: string; +} +export interface JiraProjectConnectionProjectTarget { + state: JiraProjectConnectionState; + key: string; + name: string; +} + +export type JiraProjectConnectionTarget = JiraProjectConnectionInstanceTarget|JiraProjectConnectionProjectTarget; + +export interface JiraTargetFilter { + instanceName?: string; +} + + +export type JiraProjectResponseItem = GetConnectionsResponseItem; + + function validateJiraConnectionState(state: unknown): JiraProjectConnectionState { const {url, commandPrefix, events, priority} = state as Partial; if (url === undefined) { @@ -42,7 +66,7 @@ function validateJiraConnectionState(state: unknown): JiraProjectConnectionState return {url, commandPrefix, events, priority}; } -const log = new LogWrapper("JiraProjectConnection"); +const log = new Logger("JiraProjectConnection"); const md = new markdownit(); /** @@ -210,7 +234,7 @@ export class JiraProjectConnection extends CommandConnection { + // Search for all projects under the user's control. + const jiraUser = await tokenStore.getJiraForUser(userId, config.url); + if (!jiraUser) { + throw new ApiError("User is not authenticated with JIRA", ErrCode.ForbiddenUser); + } + + if (!filters.instanceName) { + const results: JiraProjectConnectionInstanceTarget[] = []; + try { + for (const resource of await jiraUser.getAccessibleResources()) { + results.push({ + url: resource.url, + name: resource.name, + }); + } + } catch (ex) { + log.warn(`Failed to fetch accessible resources for ${userId}`, ex); + throw new ApiError("Could not fetch accessible resources for JIRA user.", ErrCode.Unknown); + } + return results; + } + // If we have an instance, search under it. + let resClient: HookshotJiraApi|null; + try { + resClient = await jiraUser.getClientForName(filters.instanceName); + } catch (ex) { + log.warn(`Failed to fetch client for ${filters.instanceName} for ${userId}`, ex); + throw new ApiError("Could not fetch accessible resources for JIRA user.", ErrCode.Unknown); + } + if (!resClient) { + throw new ApiError("Instance not known or not accessible to this user.", ErrCode.ForbiddenUser); + } + + const allProjects: JiraProjectConnectionProjectTarget[] = []; + try { + for await (const project of resClient.getAllProjects()) { + allProjects.push({ + state: { + // Technically not the real URL, but good enough for hookshot! + url: `${resClient.resource.url}/projects/${project.key}`, + }, + key: project.key, + name: project.name, + }); + } + } catch (ex) { + log.warn(`Failed to fetch accessible projects for ${config.instanceName} / ${userId}`, ex); + throw new ApiError("Could not fetch accessible projects for JIRA user.", ErrCode.Unknown); + } + return allProjects; + } + public async onJiraIssueUpdated(data: JiraIssueUpdatedEvent) { log.info(`onJiraIssueUpdated ${this.roomId} ${this.projectId} ${data.issue.id}`); const url = generateJiraWebLinkFromIssue(data.issue); @@ -372,6 +449,7 @@ export class JiraProjectConnection extends CommandConnection) { const validatedConfig = validateJiraConnectionState(config); await this.as.botClient.sendStateEvent(this.roomId, JiraProjectConnection.CanonicalEventType, this.stateKey, validatedConfig); + this.state = validatedConfig; } } diff --git a/src/Connections/SetupConnection.ts b/src/Connections/SetupConnection.ts index 61bf25a2..d5ac77ee 100644 --- a/src/Connections/SetupConnection.ts +++ b/src/Connections/SetupConnection.ts @@ -1,7 +1,7 @@ // We need to instantiate some functions which are not directly called, which confuses typescript. import { BotCommands, botCommand, compileBotCommands, HelpFunction } from "../BotCommands"; import { CommandConnection } from "./CommandConnection"; -import { GenericHookConnection, GitHubRepoConnection, JiraProjectConnection } from "."; +import { GenericHookConnection, GitHubRepoConnection, JiraProjectConnection, JiraProjectConnectionState } from "."; import { CommandError } from "../errors"; import { v4 as uuid } from "uuid"; import { BridgePermissionLevel } from "../Config/Config"; @@ -13,10 +13,9 @@ import { SetupWidget } from "../Widgets/SetupWidget"; import { AdminRoom } from "../AdminRoom"; import { GitLabRepoConnection } from "./GitlabRepo"; import { IConnectionState, ProvisionConnectionOpts } from "./IConnection"; -import LogWrapper from "../LogWrapper"; -import { ApiError } from "matrix-appservice-bridge"; +import { ApiError, Logger } from "matrix-appservice-bridge"; const md = new markdown(); -const log = new LogWrapper("SetupConnection"); +const log = new Logger("SetupConnection"); /** * Handles setting up a room with connections. This connection is "virtual" in that it has @@ -112,29 +111,93 @@ export class SetupConnection extends CommandConnection { await this.as.botClient.sendNotice(this.roomId, `Room configured to bridge ${connection.path}`); } + private async checkJiraLogin(userId: string, urlStr: string) { + const jiraClient = await this.provisionOpts.tokenStore.getJiraForUser(userId, urlStr); + if (!jiraClient) { + throw new CommandError("User not logged in", "You are not logged into Jira. Start a DM with this bot and use the command `jira login`."); + } + } + + private async getJiraProjectSafeUrl(userId: string, urlStr: string) { + const url = new URL(urlStr); + const urlParts = /\/projects\/(\w+)\/?(\w+\/?)*$/.exec(url.pathname); + const projectKey = urlParts?.[1] || url.searchParams.get('projectKey'); + if (!projectKey) { + throw new CommandError("Invalid Jira url", "The JIRA project url you entered was not valid. It should be in the format of `https://jira-instance/.../projects/PROJECTKEY/...` or `.../RapidBoard.jspa?projectKey=TEST`."); + } + return `https://${url.host}/projects/${projectKey}`; + } + @botCommand("jira project", { help: "Create a connection for a JIRA project. (You must be logged in with JIRA to do this.)", requiredArgs: ["url"], includeUserId: true, category: "jira"}) public async onJiraProject(userId: string, urlStr: string) { - const url = new URL(urlStr); if (!this.config.jira) { throw new CommandError("not-configured", "The bridge is not configured to support Jira."); } await this.checkUserPermissions(userId, "jira", JiraProjectConnection.CanonicalEventType); + await this.checkJiraLogin(userId, urlStr); + const safeUrl = await this.getJiraProjectSafeUrl(userId, urlStr); - const jiraClient = await this.provisionOpts.tokenStore.getJiraForUser(userId, urlStr); - if (!jiraClient) { - throw new CommandError("User not logged in", "You are not logged into Jira. Start a DM with this bot and use the command `jira login`."); - } - const urlParts = /.+\/projects\/(\w+)\/?(\w+\/?)*$/.exec(url.pathname.toLowerCase()); - const projectKey = urlParts?.[1] || url.searchParams.get('projectKey'); - if (!projectKey) { - throw new CommandError("Invalid Jira url", "The JIRA project url you entered was not valid. It should be in the format of `https://jira-instance/.../projects/PROJECTKEY/...` or `.../RapidBoard.jspa?projectKey=TEST`."); - } - const safeUrl = `https://${url.host}/projects/${projectKey}`; const res = await JiraProjectConnection.provisionConnection(this.roomId, userId, { url: safeUrl }, this.provisionOpts); await this.as.botClient.sendNotice(this.roomId, `Room configured to bridge Jira project ${res.connection.projectKey}.`); } + @botCommand("jira list project", { help: "Show JIRA projects currently connected to.", category: "jira"}) + public async onJiraListProject() { + const projects: JiraProjectConnectionState[] = await this.as.botClient.getRoomState(this.roomId).catch((err: any) => { + if (err.body.errcode === 'M_NOT_FOUND') { + return []; // not an error to us + } + throw err; + }).then(events => + events.filter( + (ev: any) => ( + ev.type === JiraProjectConnection.CanonicalEventType || + ev.type === JiraProjectConnection.LegacyCanonicalEventType + ) && ev.content.url + ).map(ev => ev.content) + ); + + if (projects.length === 0) { + return this.as.botClient.sendHtmlNotice(this.roomId, md.renderInline('Not connected to any JIRA projects')); + } else { + return this.as.botClient.sendHtmlNotice(this.roomId, md.render( + 'Currently connected to these JIRA projects:\n\n' + + projects.map(project => ` - ${project.url}`).join('\n') + )); + } + } + + @botCommand("jira remove project", { help: "Remove a connection for a JIRA project.", requiredArgs: ["url"], includeUserId: true, category: "jira"}) + public async onJiraRemoveProject(userId: string, urlStr: string) { + await this.checkUserPermissions(userId, "jira", JiraProjectConnection.CanonicalEventType); + await this.checkJiraLogin(userId, urlStr); + const safeUrl = await this.getJiraProjectSafeUrl(userId, urlStr); + + const eventTypes = [ + JiraProjectConnection.CanonicalEventType, + JiraProjectConnection.LegacyCanonicalEventType, + ]; + let event = null; + let eventType = ""; + for (eventType of eventTypes) { + try { + event = await this.as.botClient.getRoomStateEvent(this.roomId, eventType, safeUrl); + break; + } catch (err: any) { + if (err.body.errcode !== 'M_NOT_FOUND') { + throw err; + } + } + } + if (!event || Object.keys(event).length === 0) { + throw new CommandError("Invalid Jira project URL", `Feed "${urlStr}" is not currently bridged to this room`); + } + + await this.as.botClient.sendStateEvent(this.roomId, eventType, safeUrl, {}); + return this.as.botClient.sendHtmlNotice(this.roomId, md.renderInline(`Room no longer bridged to Jira project \`${safeUrl}\`.`)); + } + @botCommand("webhook", { help: "Create an inbound webhook.", requiredArgs: ["name"], includeUserId: true, category: "webhook"}) public async onWebhook(userId: string, name: string) { if (!this.config.generic?.enabled) { @@ -224,7 +287,7 @@ export class SetupConnection extends CommandConnection { } } - @botCommand("feed remove", { help: "Unsubscribe from an RSS/Atom.", requiredArgs: ["url"], includeUserId: true, category: "feed"}) + @botCommand("feed remove", { help: "Unsubscribe from an RSS/Atom feed.", requiredArgs: ["url"], includeUserId: true, category: "feed"}) public async onFeedRemove(userId: string, url: string) { await this.checkUserPermissions(userId, "feed", FeedConnection.CanonicalEventType); diff --git a/src/Github/AdminCommands.ts b/src/Github/AdminCommands.ts index 95c6f716..c1ab7553 100644 --- a/src/Github/AdminCommands.ts +++ b/src/Github/AdminCommands.ts @@ -1,13 +1,12 @@ -import qs from "querystring"; import { AdminRoomCommandHandler, Category } from "../AdminRoomCommandHandler" import { botCommand } from "../BotCommands"; import { CommandError, TokenError, TokenErrorCode } from "../errors"; import { GithubInstance } from "./GithubInstance"; import { GitHubOAuthToken } from "./Types"; -import LogWrapper from "../LogWrapper"; +import { Logger } from "matrix-appservice-bridge"; import { BridgePermissionLevel } from "../Config/Config"; -const log = new LogWrapper('GitHubBotCommands'); +const log = new Logger('GitHubBotCommands'); export class GitHubBotCommands extends AdminRoomCommandHandler { @botCommand("github login", {help: "Log in to GitHub", category: Category.Github, permissionLevel: BridgePermissionLevel.login}) public async loginCommand() { diff --git a/src/Github/GithubInstance.ts b/src/Github/GithubInstance.ts index 61503a94..bb93c2f4 100644 --- a/src/Github/GithubInstance.ts +++ b/src/Github/GithubInstance.ts @@ -1,6 +1,6 @@ import { createAppAuth } from "@octokit/auth-app"; import { Octokit } from "@octokit/rest"; -import LogWrapper from "../LogWrapper"; +import { Logger } from "matrix-appservice-bridge"; import { DiscussionQLResponse, DiscussionQL } from "./Discussion"; import * as GitHubWebhookTypes from "@octokit/webhooks-types"; import { GitHubOAuthTokenResponse, InstallationDataType } from "./Types"; @@ -8,7 +8,7 @@ import axios from "axios"; import qs from "querystring"; import UserAgent from "../UserAgent"; -const log = new LogWrapper("GithubInstance"); +const log = new Logger("GithubInstance"); export const GITHUB_CLOUD_URL = new URL("https://api.github.com"); diff --git a/src/Github/Router.ts b/src/Github/Router.ts index e50bcf60..5ca36b95 100644 --- a/src/Github/Router.ts +++ b/src/Github/Router.ts @@ -2,10 +2,11 @@ import { Router, Request, Response, NextFunction } from "express"; import { BridgeConfigGitHub } from "../Config/Config"; import { ApiError, ErrCode } from "../api"; import { UserTokenStore } from "../UserTokenStore"; -import LogWrapper from "../LogWrapper"; +import { Logger } from "matrix-appservice-bridge"; import { GithubInstance } from "./GithubInstance"; +import { NAMELESS_ORG_PLACEHOLDER } from "./Types"; -const log = new LogWrapper("GitHubProvisionerRouter"); +const log = new Logger("GitHubProvisionerRouter"); interface GitHubAccountStatus { loggedIn: boolean; username?: string; @@ -74,7 +75,7 @@ export class GitHubProvisionerRouter { for (const install of installs.data.installations) { if (install.account) { organisations.push({ - name: install.account.login || "No name", // org or user name + name: install.account.login || NAMELESS_ORG_PLACEHOLDER, // org or user name avatarUrl: install.account.avatar_url, }); } else { @@ -110,7 +111,7 @@ export class GitHubProvisionerRouter { if (ownSelf.data.login === req.params.orgName) { const userInstallation = await this.githubInstance.appOctokit.apps.getUserInstallation({username: ownSelf.data.login}); - reposPromise = await octokit.apps.listInstallationReposForAuthenticatedUser({ + reposPromise = octokit.apps.listInstallationReposForAuthenticatedUser({ page, installation_id: userInstallation.data.id, per_page: perPage, @@ -123,7 +124,7 @@ export class GitHubProvisionerRouter { // Github will error if the authed user tries to list repos of a disallowed installation, even // if we got the installation ID from the app's instance. - reposPromise = await octokit.apps.listInstallationReposForAuthenticatedUser({ + reposPromise = octokit.apps.listInstallationReposForAuthenticatedUser({ page, installation_id: orgInstallation.data.id, per_page: perPage, diff --git a/src/Github/Types.ts b/src/Github/Types.ts index ad67df1c..3ffd6ce2 100644 --- a/src/Github/Types.ts +++ b/src/Github/Types.ts @@ -17,6 +17,8 @@ export type DiscussionDataType = Endpoints["GET /repos/{owner}/{repo}/pulls/{pul export type InstallationDataType = Endpoints["GET /app/installations/{installation_id}"]["response"]["data"]; export type CreateInstallationAccessTokenDataType = Endpoints["POST /app/installations/{installation_id}/access_tokens"]["response"]["data"]; +export const NAMELESS_ORG_PLACEHOLDER = "No name"; + /* eslint-disable camelcase */ export interface GitHubUserNotification { id: string; diff --git a/src/Gitlab/Client.ts b/src/Gitlab/Client.ts index 08b467bc..ca781bfc 100644 --- a/src/Gitlab/Client.ts +++ b/src/Gitlab/Client.ts @@ -1,11 +1,11 @@ import axios from "axios"; import { GitLabInstance } from "../Config/Config"; import { GetIssueResponse, GetUserResponse, CreateIssueOpts, CreateIssueResponse, GetIssueOpts, EditIssueOpts, GetTodosResponse, EventsOpts, CreateIssueNoteOpts, CreateIssueNoteResponse, GetProjectResponse, ProjectHook, ProjectHookOpts, AccessLevel, SimpleProject } from "./Types"; -import LogWrapper from "../LogWrapper"; +import { Logger } from "matrix-appservice-bridge"; import { URLSearchParams } from "url"; import UserAgent from "../UserAgent"; -const log = new LogWrapper("GitLabClient"); +const log = new Logger("GitLabClient"); /** * A GitLab project used inside a URL may either be the ID of the project, or the encoded path of the project. diff --git a/src/Gitlab/WebhookTypes.ts b/src/Gitlab/WebhookTypes.ts index 0499c11d..561b1c58 100644 --- a/src/Gitlab/WebhookTypes.ts +++ b/src/Gitlab/WebhookTypes.ts @@ -63,6 +63,12 @@ export interface IGitLabWebhookMREvent { repository: IGitlabRepository; object_attributes: IGitLabMergeRequestObjectAttributes; labels: IGitLabLabel[]; + changes: { + [key: string]: { + before: string; + after: string; + } + } } export interface IGitLabWebhookTagPushEvent { diff --git a/src/IntentUtils.ts b/src/IntentUtils.ts index 61eca953..415ed633 100644 --- a/src/IntentUtils.ts +++ b/src/IntentUtils.ts @@ -1,8 +1,8 @@ -import LogWrapper from "./LogWrapper"; +import { Logger } from "matrix-appservice-bridge"; import { Appservice } from "matrix-bot-sdk"; import axios from "axios"; -const log = new LogWrapper("IntentUtils"); +const log = new Logger("IntentUtils"); export async function getIntentForUser(user: {avatarUrl?: string, login: string}, as: Appservice, prefix: string) { const domain = as.botUserId.split(":")[1]; diff --git a/src/Jira/AdminCommands.ts b/src/Jira/AdminCommands.ts index f1373e78..fbde8df7 100644 --- a/src/Jira/AdminCommands.ts +++ b/src/Jira/AdminCommands.ts @@ -1,10 +1,10 @@ import { AdminRoomCommandHandler, Category } from "../AdminRoomCommandHandler"; import { botCommand } from "../BotCommands"; import { JiraAPIAccessibleResource } from "./Types"; -import LogWrapper from "../LogWrapper"; +import { Logger } from "matrix-appservice-bridge"; import { BridgePermissionLevel } from "../Config/Config"; -const log = new LogWrapper('JiraBotCommands'); +const log = new Logger('JiraBotCommands'); export class JiraBotCommands extends AdminRoomCommandHandler { @botCommand("jira login", {help: "Log in to JIRA", category: Category.Jira, permissionLevel: BridgePermissionLevel.login}) @@ -59,7 +59,10 @@ export class JiraBotCommands extends AdminRoomCommandHandler { continue; } const user = await clientForResource.getCurrentUser(); - response += `\n - ${resource.name} ${user.name} (${user.displayName || ""})`; + response += + `\n - ${resource.name}` + + (user.name ? ` ${user.name}` : "") + + (user.displayName ? ` (${user.displayName})` : ""); } await this.sendNotice(response); } diff --git a/src/Jira/Router.ts b/src/Jira/Router.ts index 98af1ab2..a40fe39f 100644 --- a/src/Jira/Router.ts +++ b/src/Jira/Router.ts @@ -2,12 +2,12 @@ import { BridgeConfigJira } from "../Config/Config"; import { MessageQueue } from "../MessageQueue"; import { Router, Request, Response, NextFunction, json } from "express"; import { UserTokenStore } from "../UserTokenStore"; -import LogWrapper from "../LogWrapper"; +import { Logger } from "matrix-appservice-bridge"; import { ApiError, ErrCode } from "../api"; import { JiraOAuthRequestCloud, JiraOAuthRequestOnPrem, JiraOAuthRequestResult } from "./OAuth"; import { HookshotJiraApi } from "./Client"; -const log = new LogWrapper("JiraRouter"); +const log = new Logger("JiraRouter"); interface OAuthQueryCloud { state: string; diff --git a/src/Jira/client/CloudClient.ts b/src/Jira/client/CloudClient.ts index 1b620fd7..d83a4602 100644 --- a/src/Jira/client/CloudClient.ts +++ b/src/Jira/client/CloudClient.ts @@ -3,11 +3,11 @@ import axios from 'axios'; import QuickLRU from "@alloc/quick-lru"; import { JiraAPIAccessibleResource, JiraIssue, JiraOAuthResult, JiraProject, JiraCloudProjectSearchResponse, JiraStoredToken } from '../Types'; import { BridgeConfigJira, BridgeConfigJiraCloudOAuth } from '../../Config/Config'; -import LogWrapper from '../../LogWrapper'; +import { Logger } from "matrix-appservice-bridge"; import { HookshotJiraApi, JiraClient } from '../Client'; import JiraApi from 'jira-client'; -const log = new LogWrapper("JiraCloudClient"); +const log = new Logger("JiraCloudClient"); const ACCESSIBLE_RESOURCE_CACHE_LIMIT = 100; const ACCESSIBLE_RESOURCE_CACHE_TTL_MS = 60000; diff --git a/src/Jira/oauth/OnPremOAuth.ts b/src/Jira/oauth/OnPremOAuth.ts index ff086c8e..551e7e79 100644 --- a/src/Jira/oauth/OnPremOAuth.ts +++ b/src/Jira/oauth/OnPremOAuth.ts @@ -3,7 +3,7 @@ import Axios, { Method } from "axios" import qs from "querystring"; import { createPrivateKey, createSign, KeyObject } from "crypto"; import fs from "fs"; -import Logger from "../../LogWrapper"; +import { Logger } from "matrix-appservice-bridge"; import { encodeJiraToken, JiraOAuth } from "../OAuth"; import { JiraOAuthResult } from "../Types"; diff --git a/src/ListenerService.ts b/src/ListenerService.ts index 4d132012..cf5bc29b 100644 --- a/src/ListenerService.ts +++ b/src/ListenerService.ts @@ -1,6 +1,6 @@ import { Server } from "http"; +import { Logger } from "matrix-appservice-bridge"; import { Application, default as expressApp, NextFunction, Request, Response, Router } from "express"; -import LogWrapper from "./LogWrapper"; import { errorMiddleware } from "./api"; // Appserices can't be handled yet because the bot-sdk maintains control of it. @@ -14,7 +14,7 @@ export interface BridgeConfigListener { resources: Array; } -const log = new LogWrapper("ListenerService"); +const log = new Logger("ListenerService"); export class ListenerService { private readonly listeners: { diff --git a/src/LogWrapper.ts b/src/LogWrapper.ts deleted file mode 100644 index 74cc3697..00000000 --- a/src/LogWrapper.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { LogLevel, LogService } from "matrix-bot-sdk"; -import util from "util"; -import winston, { format } from "winston"; -import { BridgeConfigLogging } from "./Config/Config"; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type MsgType = string|Error|any|{error?: string}; - -function isMessageNoise(messageOrObject: MsgType[]) { - const error = messageOrObject[0]?.error || messageOrObject[1]?.error || messageOrObject[1]?.body?.error; - const errcode = messageOrObject[0]?.errcode || messageOrObject[1]?.errcode; - if (errcode === "M_NOT_FOUND" && error === "Room account data not found") { - return true; - } - if (errcode === "M_NOT_FOUND" && error === "Account data not found") { - return true; - } - if (errcode === "M_NOT_FOUND" && error === "Event not found.") { - return true; - } - if (errcode === "M_USER_IN_USE") { - return true; - } - return false; -} - -interface HookshotLogInfo extends winston.Logform.TransformableInfo { - data: MsgType[]; -} -export default class LogWrapper { - - static formatMsgTypeArray(...data: MsgType[]): string { - data = data.flat(); - return data.map(obj => { - if (typeof obj === "string") { - return obj; - } - return util.inspect(obj); - }).join(" "); - } - - static messageFormatter(info: HookshotLogInfo): string { - const logPrefix = `${info.level} ${info.timestamp} [${info.module}] `; - return logPrefix + this.formatMsgTypeArray(info.data); - } - - static winstonLog: winston.Logger; - - public static configureLogging(cfg: BridgeConfigLogging) { - if (typeof cfg === "string") { - cfg = { level: cfg }; - } - - const formatters = [ - winston.format.timestamp({ - format: cfg.timestampFormat || "HH:mm:ss:SSS", - }), - (format((info) => { - info.level = info.level.toUpperCase(); - return info; - }))(), - ] - - if (!cfg.json && cfg.colorize) { - formatters.push( - winston.format.colorize({ - level: true, - }) - ); - } - - if (cfg.json) { - formatters.push((format((info) => { - const hsData = {...info as HookshotLogInfo}.data; - const firstArg = hsData.shift(); - const result: winston.Logform.TransformableInfo = { - level: info.level, - module: info.module, - timestamp: info.timestamp, - // Find the first instance of an error, subsequent errors are treated as args. - error: hsData.find(d => d instanceof Error)?.message, - message: "", // Always filled out - args: hsData.length ? hsData : undefined, - }; - - if (typeof firstArg === "string") { - result.message = firstArg; - } else if (firstArg instanceof Error) { - result.message = firstArg.message; - } else { - result.message = util.inspect(firstArg); - } - - return result; - }))()), - formatters.push(winston.format.json()); - } else { - formatters.push(winston.format.printf(i => LogWrapper.messageFormatter(i as HookshotLogInfo))); - } - - const log = this.winstonLog = winston.createLogger({ - level: cfg.level, - transports: [ - new winston.transports.Console({ - format: winston.format.combine(...formatters), - }), - ], - }); - - function formatBotSdkMessage(module: string, ...messageOrObject: MsgType[]) { - return { module, data: [LogWrapper.formatMsgTypeArray(messageOrObject)] }; - } - - LogService.setLogger({ - info: (module: string, ...messageOrObject: MsgType[]) => { - // These are noisy, redirect to debug. - if (module.startsWith("MatrixLiteClient") || module.startsWith("MatrixHttpClient")) { - log.log("debug", formatBotSdkMessage(module, ...messageOrObject)); - return; - } - log.log("info", formatBotSdkMessage(module, ...messageOrObject)); - }, - warn: (module: string, ...messageOrObject: MsgType[]) => { - if (isMessageNoise(messageOrObject)) { - log.debug(formatBotSdkMessage(module, ...messageOrObject)); - return; - } - log.log("warn", formatBotSdkMessage(module, ...messageOrObject)); - }, - error: (module: string, ...messageOrObject: MsgType[]) => { - if (isMessageNoise(messageOrObject)) { - log.log("debug", formatBotSdkMessage(module, ...messageOrObject)); - return; - } - log.log("error", formatBotSdkMessage(module, ...messageOrObject)); - }, - debug: (module: string, ...messageOrObject: MsgType[]) => { - log.log("debug", formatBotSdkMessage(module, ...messageOrObject)); - }, - trace: (module: string, ...messageOrObject: MsgType[]) => { - log.log("verbose", formatBotSdkMessage(module, ...messageOrObject)); - }, - }); - - LogService.setLevel(LogLevel.fromString(cfg.level)); - LogService.debug("LogWrapper", "Reconfigured logging"); - } - - constructor(private module: string) { } - - /** - * Logs to the DEBUG channel - * @param {string} module The module being logged - * @param {*[]} messageOrObject The data to log - */ - public debug(...messageOrObject: MsgType[]) { - LogWrapper.winstonLog.debug("debug", { module: this.module, data: messageOrObject }); - } - - /** - * Logs to the ERROR channel - * @param {*[]} messageOrObject The data to log - */ - public error(...messageOrObject: MsgType[]) { - LogWrapper.winstonLog.debug("error", { module: this.module, data: messageOrObject }); - } - - /** - * Logs to the INFO channel - * @param {*[]} messageOrObject The data to log - */ - public info(...messageOrObject: MsgType[]) { - LogWrapper.winstonLog.debug("info", { module: this.module, data: messageOrObject }); - } - - /** - * Logs to the WARN channel - * @param {*[]} messageOrObject The data to log - */ - public warn(...messageOrObject: MsgType[]) { - LogWrapper.winstonLog.debug("warn", { module: this.module, data: messageOrObject }); - } -} diff --git a/src/MatrixSender.ts b/src/MatrixSender.ts index eec41b25..f1f37523 100644 --- a/src/MatrixSender.ts +++ b/src/MatrixSender.ts @@ -1,7 +1,7 @@ import { BridgeConfig } from "./Config/Config"; import { MessageQueue, createMessageQueue } from "./MessageQueue"; import { Appservice, IAppserviceRegistration, MemoryStorageProvider } from "matrix-bot-sdk"; -import LogWrapper from "./LogWrapper"; +import { Logger } from "matrix-appservice-bridge"; import { v4 as uuid } from "uuid"; import { getAppservice } from "./appservice"; import Metrics from "./Metrics"; @@ -22,7 +22,7 @@ export interface IMatrixSendMessageFailedResponse { } -const log = new LogWrapper("MatrixSender"); +const log = new Logger("MatrixSender"); export class MatrixSender { private mq: MessageQueue; diff --git a/src/MessageQueue/RedisQueue.ts b/src/MessageQueue/RedisQueue.ts index c32d917e..e328de66 100644 --- a/src/MessageQueue/RedisQueue.ts +++ b/src/MessageQueue/RedisQueue.ts @@ -3,11 +3,11 @@ import { MessageQueue, MessageQueueMessage, DEFAULT_RES_TIMEOUT, MessageQueueMes import { Redis, default as redis } from "ioredis"; import { BridgeConfig, BridgeConfigQueue } from "../Config/Config"; import { EventEmitter } from "events"; -import LogWrapper from "../LogWrapper"; +import { Logger } from "matrix-appservice-bridge"; import {v4 as uuid} from "uuid"; -const log = new LogWrapper("RedisMq"); +const log = new Logger("RedisMq"); const CONSUMER_TRACK_PREFIX = "consumers."; diff --git a/src/Metrics.ts b/src/Metrics.ts index a3158693..ff4ec7c8 100644 --- a/src/Metrics.ts +++ b/src/Metrics.ts @@ -1,8 +1,8 @@ import { Appservice, FunctionCallContext, METRIC_MATRIX_CLIENT_FAILED_FUNCTION_CALL, METRIC_MATRIX_CLIENT_SUCCESSFUL_FUNCTION_CALL } from "matrix-bot-sdk"; import { collectDefaultMetrics, Counter, Gauge, register, Registry } from "prom-client"; import { Response, Router } from "express"; -import LogWrapper from "./LogWrapper"; -const log = new LogWrapper("Metrics"); +import { Logger } from "matrix-appservice-bridge"; +const log = new Logger("Metrics"); export class Metrics { public readonly expressRouter = Router(); diff --git a/src/Notifications/GitHubWatcher.ts b/src/Notifications/GitHubWatcher.ts index 40f700a6..bc3a595d 100644 --- a/src/Notifications/GitHubWatcher.ts +++ b/src/Notifications/GitHubWatcher.ts @@ -2,11 +2,11 @@ import { Octokit, RestEndpointMethodTypes } from "@octokit/rest"; import { EventEmitter } from "events"; import { GithubInstance } from "../Github/GithubInstance"; import { GitHubUserNotification as HSGitHubUserNotification } from "../Github/Types"; -import LogWrapper from "../LogWrapper"; +import { Logger } from "matrix-appservice-bridge"; import { NotificationWatcherTask } from "./NotificationWatcherTask"; import { RequestError } from "@octokit/request-error"; import Metrics from "../Metrics"; -const log = new LogWrapper("GitHubWatcher"); +const log = new Logger("GitHubWatcher"); const GH_API_THRESHOLD = 50; const GH_API_RETRY_IN = 1000 * 60; diff --git a/src/Notifications/GitLabWatcher.ts b/src/Notifications/GitLabWatcher.ts index e14aa7f5..264294ec 100644 --- a/src/Notifications/GitLabWatcher.ts +++ b/src/Notifications/GitLabWatcher.ts @@ -1,9 +1,9 @@ import { EventEmitter } from "events"; import { GitLabClient } from "../Gitlab/Client"; -import LogWrapper from "../LogWrapper"; +import { Logger } from "matrix-appservice-bridge"; import { NotificationWatcherTask } from "./NotificationWatcherTask"; -const log = new LogWrapper("GitLabWatcher"); +const log = new Logger("GitLabWatcher"); export class GitLabWatcher extends EventEmitter implements NotificationWatcherTask { private client: GitLabClient; diff --git a/src/Notifications/UserNotificationWatcher.ts b/src/Notifications/UserNotificationWatcher.ts index 661d25bb..c7d881f3 100644 --- a/src/Notifications/UserNotificationWatcher.ts +++ b/src/Notifications/UserNotificationWatcher.ts @@ -1,5 +1,5 @@ import { NotificationsDisableEvent, NotificationsEnableEvent } from "../Webhooks"; -import LogWrapper from "../LogWrapper"; +import { Logger } from "matrix-appservice-bridge"; import { createMessageQueue, MessageQueue, MessageQueueMessage } from "../MessageQueue"; import { MessageSenderClient } from "../MatrixSender"; import { NotificationWatcherTask } from "./NotificationWatcherTask"; @@ -17,7 +17,7 @@ export interface UserNotificationsEvent { const MIN_INTERVAL_MS = 15000; const FAILURE_THRESHOLD = 50; -const log = new LogWrapper("UserNotificationWatcher"); +const log = new Logger("UserNotificationWatcher"); export class UserNotificationWatcher { /* Key: userId:type:instanceUrl */ diff --git a/src/NotificationsProcessor.ts b/src/NotificationsProcessor.ts index e9ff5920..2bf9b74d 100644 --- a/src/NotificationsProcessor.ts +++ b/src/NotificationsProcessor.ts @@ -1,7 +1,7 @@ import { MessageSenderClient } from "./MatrixSender"; import { IBridgeStorageProvider } from "./Stores/StorageProvider"; import { UserNotificationsEvent } from "./Notifications/UserNotificationWatcher"; -import LogWrapper from "./LogWrapper"; +import { Logger } from "matrix-appservice-bridge"; import { AdminRoom } from "./AdminRoom"; import markdown from "markdown-it"; import { FormatUtil } from "./FormatUtil"; @@ -11,7 +11,7 @@ import { components } from "@octokit/openapi-types/types"; import { NotifFilter } from "./NotificationFilters"; -const log = new LogWrapper("NotificationProcessor"); +const log = new Logger("NotificationProcessor"); const md = new markdown(); export interface IssueDiff { diff --git a/src/PromiseUtil.ts b/src/PromiseUtil.ts index 88d3d04e..49ac8ccd 100644 --- a/src/PromiseUtil.ts +++ b/src/PromiseUtil.ts @@ -1,8 +1,8 @@ -import LogWrapper from "./LogWrapper"; +import { Logger } from "matrix-appservice-bridge"; const SLEEP_TIME_MS = 250; const DEFAULT_RETRY = () => true; -const log = new LogWrapper("PromiseUtil"); +const log = new Logger("PromiseUtil"); export async function retry(actionFn: () => Promise, maxAttempts: number, diff --git a/src/Stores/RedisStorageProvider.ts b/src/Stores/RedisStorageProvider.ts index 52fa486e..ff47a3d8 100644 --- a/src/Stores/RedisStorageProvider.ts +++ b/src/Stores/RedisStorageProvider.ts @@ -1,6 +1,6 @@ import { IssuesGetResponseData } from "../Github/Types"; import { Redis, default as redis } from "ioredis"; -import LogWrapper from "../LogWrapper"; +import { Logger } from "matrix-appservice-bridge"; import { IBridgeStorageProvider } from "./StorageProvider"; import { IFilterInfo } from "matrix-bot-sdk"; @@ -23,7 +23,7 @@ const ISSUES_LAST_COMMENT_EXPIRE_AFTER = 14 * 24 * 60 * 60; // 7 days const WIDGET_TOKENS = "widgets.tokens."; const WIDGET_USER_TOKENS = "widgets.user-tokens."; -const log = new LogWrapper("RedisASProvider"); +const log = new Logger("RedisASProvider"); export class RedisStorageProvider implements IBridgeStorageProvider { private redis: Redis; diff --git a/src/UserTokenStore.ts b/src/UserTokenStore.ts index f1e28131..459b0ce1 100644 --- a/src/UserTokenStore.ts +++ b/src/UserTokenStore.ts @@ -3,7 +3,7 @@ import { GitLabClient } from "./Gitlab/Client"; import { Intent } from "matrix-bot-sdk"; import { promises as fs } from "fs"; import { publicEncrypt, privateDecrypt } from "crypto"; -import LogWrapper from "./LogWrapper"; +import { Logger } from "matrix-appservice-bridge"; import { isJiraCloudInstance, JiraClient } from "./Jira/Client"; import { JiraStoredToken } from "./Jira/Types"; import { BridgeConfig, BridgeConfigJira, BridgeConfigJiraOnPremOAuth, BridgePermissionLevel } from "./Config/Config"; @@ -25,7 +25,7 @@ const ACCOUNT_DATA_JIRA_TYPE = "uk.half-shot.matrix-hookshot.jira.password-store const LEGACY_ACCOUNT_DATA_TYPE = "uk.half-shot.matrix-github.password-store:"; const LEGACY_ACCOUNT_DATA_GITLAB_TYPE = "uk.half-shot.matrix-github.gitlab.password-store:"; -const log = new LogWrapper("UserTokenStore"); +const log = new Logger("UserTokenStore"); type TokenType = "github"|"gitlab"|"jira"; const AllowedTokenTypes = ["github", "gitlab", "jira"]; diff --git a/src/Webhooks.ts b/src/Webhooks.ts index 1556900b..ad0cce92 100644 --- a/src/Webhooks.ts +++ b/src/Webhooks.ts @@ -2,7 +2,7 @@ import { BridgeConfig } from "./Config/Config"; import { Router, default as express, Request, Response } from "express"; import { EventEmitter } from "events"; import { MessageQueue, createMessageQueue } from "./MessageQueue"; -import LogWrapper from "./LogWrapper"; +import { Logger } from "matrix-appservice-bridge"; import qs from "querystring"; import axios from "axios"; import { IGitLabWebhookEvent, IGitLabWebhookIssueStateEvent, IGitLabWebhookMREvent, IGitLabWebhookReleaseEvent } from "./Gitlab/WebhookTypes"; @@ -17,7 +17,7 @@ import { GenericWebhooksRouter } from "./generic/Router"; import { GithubInstance } from "./Github/GithubInstance"; import QuickLRU from "@alloc/quick-lru"; -const log = new LogWrapper("Webhooks"); +const log = new Logger("Webhooks"); export interface NotificationsEnableEvent { userId: string; @@ -124,7 +124,7 @@ export class Webhooks extends EventEmitter { private async onGitHubPayload({id, name, payload}: EmitterWebhookEvent) { const action = (payload as unknown as {action: string|undefined}).action; const eventName = `github.${name}${action ? `.${action}` : ""}`; - log.info(`Got GitHub webhook event ${id} ${eventName}`, payload); + log.debug(`Got GitHub webhook event ${id} ${eventName}`, payload); try { await this.queue.push({ eventName, diff --git a/src/Widgets/BridgeWidgetApi.ts b/src/Widgets/BridgeWidgetApi.ts index d6d22345..d1bdd3f7 100644 --- a/src/Widgets/BridgeWidgetApi.ts +++ b/src/Widgets/BridgeWidgetApi.ts @@ -1,6 +1,6 @@ import { Application, NextFunction, Response } from "express"; import { AdminRoom } from "../AdminRoom"; -import LogWrapper from "../LogWrapper"; +import { Logger } from "matrix-appservice-bridge"; import { ApiError, ErrCode } from "../api"; import { BridgeConfig } from "../Config/Config"; import { GetConnectionsForServiceResponse } from "./BridgeWidgetInterface"; @@ -10,7 +10,7 @@ import { ConnectionManager } from "../ConnectionManager"; import { assertUserPermissionsInRoom, GetConnectionsResponseItem } from "../provisioning/api"; import { Intent, PowerLevelsEvent } from "matrix-bot-sdk"; -const log = new LogWrapper("BridgeWidgetApi"); +const log = new Logger("BridgeWidgetApi"); export class BridgeWidgetApi { private readonly api: ProvisioningApi; diff --git a/src/Widgets/SetupWidget.ts b/src/Widgets/SetupWidget.ts index a9d83eb0..0a9c0385 100644 --- a/src/Widgets/SetupWidget.ts +++ b/src/Widgets/SetupWidget.ts @@ -1,9 +1,10 @@ import { Intent } from "matrix-bot-sdk"; import { BridgeWidgetConfig } from "../Config/Config"; -import LogWrapper from "../LogWrapper"; +import { Logger } from "matrix-appservice-bridge"; +import { CommandError } from "../errors"; import { HookshotWidgetKind } from "./WidgetKind"; -const log = new LogWrapper("SetupWidget"); +const log = new Logger("SetupWidget"); export class SetupWidget { @@ -25,6 +26,9 @@ export class SetupWidget { private static async createWidgetInRoom(roomId: string, botIntent: Intent, config: BridgeWidgetConfig, kind: HookshotWidgetKind, stateKey: string): Promise { log.info(`Running SetupRoomConfigWidget for ${roomId}`); + if (!await botIntent.underlyingClient.userHasPowerLevelFor(botIntent.userId, roomId, "im.vector.modular.widgets", true)) { + throw new CommandError("Bot lacks power level to set room state", "I do not have permission to create a widget in this room. Please promote me to an Admin/Moderator."); + } try { const res = await botIntent.underlyingClient.getRoomStateEvent( roomId, diff --git a/src/api/error.ts b/src/api/error.ts index 93105362..004f6d28 100644 --- a/src/api/error.ts +++ b/src/api/error.ts @@ -2,7 +2,7 @@ import { ErrorObject } from "ajv"; import { NextFunction, Response, Request } from "express"; import { StatusCodes } from "http-status-codes"; import { IApiError } from "matrix-appservice-bridge"; -import LogWrapper from "../LogWrapper"; +import { Logger } from "matrix-appservice-bridge"; export enum ErrCode { // Errors are prefixed with HS_ @@ -108,7 +108,7 @@ export class ValidatorApiError extends ApiError { } -export function errorMiddleware(log: LogWrapper) { +export function errorMiddleware(log: Logger) { return (err: unknown, _req: Request, res: Response, next: NextFunction) => { if (!err) { next(); diff --git a/src/feeds/FeedReader.ts b/src/feeds/FeedReader.ts index fd4bdab2..692a5aba 100644 --- a/src/feeds/FeedReader.ts +++ b/src/feeds/FeedReader.ts @@ -2,7 +2,7 @@ import { MatrixClient } from "matrix-bot-sdk"; import { BridgeConfigFeeds } from "../Config/Config"; import { ConnectionManager } from "../ConnectionManager"; import { FeedConnection } from "../Connections"; -import LogWrapper from "../LogWrapper"; +import { Logger } from "matrix-appservice-bridge"; import { MessageQueue } from "../MessageQueue"; import Ajv from "ajv"; @@ -12,7 +12,7 @@ import Metrics from "../Metrics"; import UserAgent from "../UserAgent"; import { randomUUID } from "crypto"; -const log = new LogWrapper("FeedReader"); +const log = new Logger("FeedReader"); export class FeedError extends Error { constructor( @@ -55,6 +55,10 @@ export interface FeedEntry { fetchKey: string, } +export interface FeedSuccess { + url: string, +} + interface AccountData { [url: string]: string[], } @@ -210,7 +214,7 @@ export class FeedReader { const entry = { feed: { title: feed.title ? stripHtml(feed.title) : null, - url: url.toString() + url: url, }, title: item.title ? stripHtml(item.title) : null, link: item.link || null, @@ -233,7 +237,7 @@ export class FeedReader { const newSeenItems = Array.from(new Set([ ...newGuids, ...seenGuids ]).values()).slice(0, maxGuids); this.seenEntries.set(url, newSeenItems); } - this.queue.push({ eventName: 'feed.success', sender: 'FeedReader', data: undefined}); + this.queue.push({ eventName: 'feed.success', sender: 'FeedReader', data: { url: url } }); } catch (err: unknown) { const error = err instanceof Error ? err : new Error(`Unknown error ${err}`); const feedError = new FeedError(url.toString(), error, fetchKey); diff --git a/src/figma/index.ts b/src/figma/index.ts index a9752fe9..02de42df 100644 --- a/src/figma/index.ts +++ b/src/figma/index.ts @@ -1,8 +1,10 @@ import { BridgeConfigFigma } from "../Config/Config"; import * as Figma from 'figma-js'; import { MatrixClient } from "matrix-bot-sdk"; +export * from "./router"; +export * from "./types"; +import { Logger } from "matrix-appservice-bridge"; import { AxiosError } from "axios"; -import LogWrapper from "../LogWrapper"; export * from "./router"; export * from "./types"; @@ -15,8 +17,8 @@ interface FigmaWebhookDefinition { description: string; } -const log = new LogWrapper('FigmaWebhooks'); - +const log = new Logger('FigmaWebhooks'); + export async function ensureFigmaWebhooks(figmaConfig: BridgeConfigFigma, matrixClient: MatrixClient) { const publicUrl = figmaConfig.publicUrl; const axiosConfig = { baseURL: 'https://api.figma.com/v2'}; @@ -41,21 +43,25 @@ export async function ensureFigmaWebhooks(figmaConfig: BridgeConfigFigma, matrix let webhookDefinition: FigmaWebhookDefinition|undefined; if (webhookId) { try { - webhookDefinition = (await client.client.get(`v2/webhooks/${webhookId}`, axiosConfig)).data; + webhookDefinition = (await client.client.get(`webhooks/${webhookId}`, axiosConfig)).data; log.info(`Found existing hook for Figma instance ${instanceName} ${webhookId}`); } catch (ex) { const axiosErr = ex as AxiosError; - if (axiosErr.isAxiosError) { - log.error(`Failed to update webhook: ${axiosErr.code} ${axiosErr.response?.data?.message ?? ""}`) + if (axiosErr.response?.status !== 404) { + // Missing webhook, probably not found. + if (axiosErr.isAxiosError) { + log.error(`Failed to update webhook: ${axiosErr.response?.status} ${axiosErr.response?.data?.message ?? ""}`) + } + throw Error(`Failed to verify Figma webhooks for ${instanceName}: ${ex.message}`); } - throw Error(`Failed to verify Figma webhooks for ${instanceName}: ${ex.message}`); + log.warn(`Previous webhook ID ${webhookId} stored but API returned not found, creating new one.`); } } if (webhookDefinition) { if (webhookDefinition.endpoint !== publicUrl || webhookDefinition.passcode !== passcode) { log.info(`Existing hook ${webhookId} for ${instanceName} has stale endpoint or passcode, updating`); try { - await client.client.put(`v2/webhooks/${webhookId}`, { + await client.client.put(`webhooks/${webhookId}`, { passcode, endpoint: publicUrl, }, axiosConfig); @@ -70,12 +76,12 @@ export async function ensureFigmaWebhooks(figmaConfig: BridgeConfigFigma, matrix } else { log.info(`No webhook defined for instance ${instanceName}, creating`); try { - const res = await client.client.post(`v2/webhooks`, { + const res = await client.client.post(`webhooks`, { passcode, endpoint: publicUrl, description: 'matrix-hookshot', event_type: 'FILE_COMMENT', - team_id: teamId, + team_id: teamId.toString(), }, axiosConfig); webhookDefinition = res.data as FigmaWebhookDefinition; await matrixClient.setAccountData(accountDataKey, {webhookId: webhookDefinition.id}); diff --git a/src/figma/router.ts b/src/figma/router.ts index 7b42fb10..12ec8497 100644 --- a/src/figma/router.ts +++ b/src/figma/router.ts @@ -2,9 +2,10 @@ import { BridgeConfigFigma } from "../Config/Config"; import { MessageQueue } from "../MessageQueue"; import { Request, Response, Router, json } from "express"; import { FigmaPayload } from "./types"; -import LogWrapper from "../LogWrapper"; +import { Logger } from "matrix-appservice-bridge"; + +const log = new Logger('FigmaWebhooksRouter'); -const log = new LogWrapper('FigmaWebhooksRouter'); export class FigmaWebhooksRouter { constructor(private readonly config: BridgeConfigFigma, private readonly queue: MessageQueue) { } diff --git a/src/generic/Router.ts b/src/generic/Router.ts index adec70df..2f84addb 100644 --- a/src/generic/Router.ts +++ b/src/generic/Router.ts @@ -1,6 +1,6 @@ import { MessageQueue } from "../MessageQueue"; import express, { NextFunction, Request, Response, Router } from "express"; -import LogWrapper from "../LogWrapper"; +import { Logger } from "matrix-appservice-bridge"; import { ApiError, ErrCode } from "../api"; import { GenericWebhookEvent, GenericWebhookEventResult } from "./types"; import * as xml from "xml2js"; @@ -9,7 +9,7 @@ import { StatusCodes } from "http-status-codes"; const WEBHOOK_RESPONSE_TIMEOUT = 5000; -const log = new LogWrapper('GenericWebhooksRouter'); +const log = new Logger('GenericWebhooksRouter'); export class GenericWebhooksRouter { constructor(private readonly queue: MessageQueue, private readonly deprecatedPath = false, private readonly allowGet: boolean) { } diff --git a/src/provisioning/api.ts b/src/provisioning/api.ts index 9e95a3b1..0cb44869 100644 --- a/src/provisioning/api.ts +++ b/src/provisioning/api.ts @@ -1,6 +1,6 @@ import { Intent, MembershipEventContent, PowerLevelsEventContent } from "matrix-bot-sdk"; import { ApiError, ErrCode } from "../api"; -import LogWrapper from "../LogWrapper"; +import { Logger } from "matrix-appservice-bridge"; export interface GetConnectionTypeResponseItem { eventType: string; @@ -16,7 +16,7 @@ export interface GetConnectionsResponseItem e canEdit?: boolean; } -const log = new LogWrapper("Provisioner.api"); +const log = new Logger("Provisioner.api"); export async function assertUserPermissionsInRoom(userId: string, roomId: string, requiredPermission: "read"|"write", intent: Intent) { try { diff --git a/src/provisioning/provisioner.ts b/src/provisioning/provisioner.ts index 3ac786f0..1f0e7146 100644 --- a/src/provisioning/provisioner.ts +++ b/src/provisioning/provisioner.ts @@ -1,13 +1,13 @@ import { BridgeConfigProvisioning } from "../Config/Config"; import { Router, default as express, NextFunction, Request, Response } from "express"; import { ConnectionManager } from "../ConnectionManager"; -import LogWrapper from "../LogWrapper"; +import { Logger } from "matrix-appservice-bridge"; import { assertUserPermissionsInRoom, GetConnectionsResponseItem, GetConnectionTypeResponseItem } from "./api"; import { ApiError, ErrCode } from "../api"; import { Intent } from "matrix-bot-sdk"; import Metrics from "../Metrics"; -const log = new LogWrapper("Provisioner"); +const log = new Logger("Provisioner"); // Simple validator const ROOM_ID_VALIDATOR = /!.+:.+/; diff --git a/tests/init.ts b/tests/init.ts index e03684c3..b08b3350 100644 --- a/tests/init.ts +++ b/tests/init.ts @@ -1,2 +1,2 @@ -import LogWrapper from "../src/LogWrapper"; -LogWrapper.configureLogging({level: "info"}); +import { Logger } from "matrix-appservice-bridge"; +Logger.configure({console: "info"}); diff --git a/tsconfig.web.json b/tsconfig.web.json deleted file mode 100644 index e9bfaa91..00000000 --- a/tsconfig.web.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "include": ["web"], - "compilerOptions": { - "module": "commonjs", - "target": "es2019", - "moduleResolution": "node", - "jsx": "preserve", - "jsxFactory": "h", - "baseUrl": "./", - /* noEmit - Snowpack builds (emits) files, not tsc. */ - "noEmit": true, - /* Additional Options */ - "strict": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "allowSyntheticDefaultImports": true, - "importsNotUsedAsValues": "error", - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - "esModuleInterop": true, - "typeRoots": [ - "web/typings" - ] - }, - } - \ No newline at end of file diff --git a/web/BridgeAPI.ts b/web/BridgeAPI.ts index 8194f117..22b4defa 100644 --- a/web/BridgeAPI.ts +++ b/web/BridgeAPI.ts @@ -4,6 +4,7 @@ import { ExchangeOpenAPIRequestBody, ExchangeOpenAPIResponseBody } from "matrix- import { WidgetApi } from 'matrix-widget-api'; import { ApiError } from '../src/api'; import { FunctionComponent } from 'preact'; +import { IConnectionState } from '../src/Connections'; export class BridgeAPIError extends Error { constructor(msg: string, public readonly body: ApiError) { super(msg); @@ -116,11 +117,11 @@ export class BridgeAPI { return this.request('GET', `/widgetapi/v1/${encodeURIComponent(roomId)}/connections/${encodeURIComponent(service)}`); } - async createConnection(roomId: string, type: string, config: unknown) { + async createConnection(roomId: string, type: string, config: IConnectionState) { return this.request('POST', `/widgetapi/v1/${encodeURIComponent(roomId)}/connections/${encodeURIComponent(type)}`, config); } - async updateConnection(roomId: string, connectionId: string, config: unknown) { + async updateConnection(roomId: string, connectionId: string, config: IConnectionState) { return this.request('PUT', `/widgetapi/v1/${encodeURIComponent(roomId)}/connections/${encodeURIComponent(connectionId)}`, config); } @@ -128,8 +129,8 @@ export class BridgeAPI { return this.request('DELETE', `/widgetapi/v1/${encodeURIComponent(roomId)}/connections/${encodeURIComponent(connectionId)}`); } - getConnectionTargets(type: string, filters?: unknown): Promise { - const searchParams = filters && new URLSearchParams(filters as Record); + getConnectionTargets(type: string, filters?: Record|Record): Promise { + const searchParams = filters && !!Object.keys(filters).length && new URLSearchParams(filters); return this.request('GET', `/widgetapi/v1/targets/${encodeURIComponent(type)}${searchParams ? `?${searchParams}` : ''}`); } } diff --git a/web/components/RoomConfigView.tsx b/web/components/RoomConfigView.tsx index 0f96250b..116e6e51 100644 --- a/web/components/RoomConfigView.tsx +++ b/web/components/RoomConfigView.tsx @@ -7,10 +7,12 @@ import { FeedsConfig } from "./roomConfig/FeedsConfig"; import { GenericWebhookConfig } from "./roomConfig/GenericWebhookConfig"; import { GithubRepoConfig } from "./roomConfig/GithubRepoConfig"; import { GitlabRepoConfig } from "./roomConfig/GitlabRepoConfig"; +import { JiraProjectConfig } from "./roomConfig/JiraProjectConfig"; import FeedsIcon from "../icons/feeds.png"; import GitHubIcon from "../icons/github.png"; import GitLabIcon from "../icons/gitlab.png"; +import JiraIcon from "../icons/jira.png"; import WebhookIcon from "../icons/webhook.png"; @@ -27,6 +29,7 @@ enum ConnectionType { Generic = "generic", Github = "github", Gitlab = "gitlab", + Jira = "jira", } interface IConnectionProps { @@ -55,6 +58,12 @@ const connections: Record = { icon: GitLabIcon, component: GitlabRepoConfig, }, + [ConnectionType.Jira]: { + displayName: 'JIRA', + description: "Connect the room to a JIRA project", + icon: JiraIcon, + component: JiraProjectConfig, + }, [ConnectionType.Generic]: { displayName: 'Generic Webhook', description: "Create a webhook which can be used to connect any service to Matrix", diff --git a/web/components/elements/InputField.tsx b/web/components/elements/InputField.tsx index f00952e8..bec0c830 100644 --- a/web/components/elements/InputField.tsx +++ b/web/components/elements/InputField.tsx @@ -5,11 +5,12 @@ interface Props { visible?: boolean; label?: string; noPadding: boolean; + innerChild?: boolean; } -export const InputField: FunctionComponent = ({ children, visible = true, label, noPadding }) => { - return visible &&
- {label && } - {children} -
; +export const InputField: FunctionComponent = ({ children, visible = true, label, noPadding, innerChild = false }) => { + return visible ?
+ {label && } + {(!label || !innerChild) && children} +
: <> }; \ No newline at end of file diff --git a/web/components/roomConfig/GithubRepoConfig.tsx b/web/components/roomConfig/GithubRepoConfig.tsx index fa9415de..f39f6a44 100644 --- a/web/components/roomConfig/GithubRepoConfig.tsx +++ b/web/components/roomConfig/GithubRepoConfig.tsx @@ -3,43 +3,69 @@ import { useState, useCallback, useEffect, useMemo } from "preact/hooks"; import { BridgeAPI, BridgeConfig } from "../../BridgeAPI"; import { ConnectionConfigurationProps, RoomConfig } from "./RoomConfig"; import { ErrCode } from "../../../src/api"; -import { GitHubRepoConnectionState, GitHubRepoResponseItem, GitHubRepoConnectionTarget } from "../../../src/Connections/GithubRepo"; +import { GitHubRepoConnectionState, GitHubRepoResponseItem, GitHubRepoConnectionRepoTarget, GitHubTargetFilter, GitHubRepoConnectionOrgTarget } from "../../../src/Connections/GithubRepo"; import { InputField, ButtonSet, Button, ErrorPane } from "../elements"; import GitHubIcon from "../../icons/github.png"; const EventType = "uk.half-shot.matrix-hookshot.github.repository"; +const NUM_REPOS_PER_PAGE = 10; function getRepoFullName(state: GitHubRepoConnectionState) { return `${state.org}/${state.repo}`; } const ConnectionSearch: FunctionComponent<{api: BridgeAPI, onPicked: (state: GitHubRepoConnectionState) => void}> = ({api, onPicked}) => { - const [filter, setFilter] = useState(""); - const [results, setResults] = useState(null); + const [filter, setFilter] = useState({}); + const [results, setResults] = useState(null); + const [orgs, setOrgs] = useState(null); const [isConnected, setIsConnected] = useState(null); const [debounceTimer, setDebounceTimer] = useState(undefined); - const [currentRepo, setCurrentRepo] = useState(""); + const [currentRepo, setCurrentRepo] = useState(null); const [searchError, setSearchError] = useState(null); const searchFn = useCallback(async() => { try { - const res = await api.getConnectionTargets(EventType); + const res = await api.getConnectionTargets(EventType, filter); setIsConnected(true); - setResults(res); - } catch (ex) { + if (!filter.orgName) { + setOrgs(res as GitHubRepoConnectionOrgTarget[]); + if (res[0]) { + setFilter({ + orgName: res[0].name, + perPage: NUM_REPOS_PER_PAGE, + }); + } + } else { + setResults((prevResults: GitHubRepoConnectionRepoTarget[]|null) => + !prevResults ? res : prevResults.concat(...res) + ); + if (res.length == NUM_REPOS_PER_PAGE) { + setFilter({ + ...filter, + page: filter.page ? filter.page + 1 : 2, + }); + } + } + } catch (ex: any) { if (ex?.errcode === ErrCode.ForbiddenUser) { setIsConnected(false); + setOrgs([]); } else { setSearchError("There was an error fetching search results."); // Rather than raising an error, let's just log and let the user retry a query. console.warn(`Failed to get connection targets from query:`, ex); } } - }, [api]); + }, [api, filter]); const updateSearchFn = useCallback((evt: InputEvent) => { - setFilter((evt.target as HTMLInputElement).value); - }, [setFilter]); + const repo = (evt.target as HTMLOptionElement).value; + const hasResult = results?.find(n => n.name == repo); + if (hasResult) { + onPicked(hasResult.state); + setCurrentRepo(hasResult.state.repo); + } + }, [onPicked, results]); useEffect(() => { if (debounceTimer) { @@ -52,18 +78,22 @@ const ConnectionSearch: FunctionComponent<{api: BridgeAPI, onPicked: (state: Git } // Things break if we depend on the thing we are clearing. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchFn, filter, isConnected]); + }, [searchFn, filter]); - useEffect(() => { - const hasResult = results?.find(n => n.name === filter); - if (hasResult) { - onPicked(hasResult.state); - setCurrentRepo(hasResult.name); - } - }, [onPicked, results, filter]); + const onOrgPicked = useCallback((evt: InputEvent) => { + // Reset the search string. + setFilter({ + orgName: (evt.target as HTMLSelectElement).selectedOptions[0].value, + }); + }, []); + + const orgListResults = useMemo( + () => orgs?.map(i => ), + [orgs] + ); const repoListResults = useMemo( - () => results?.map(i =>