mirror of
https://github.com/matrix-org/matrix-hookshot.git
synced 2025-03-10 21:19:13 +00:00
Merge branch 'main' into hs/testing-generic-webhooks
This commit is contained in:
commit
5cce6611a5
74
CHANGELOG.md
74
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)
|
||||
==================
|
||||
|
||||
|
@ -1 +0,0 @@
|
||||
Track coverage of tests.
|
@ -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 !.
|
@ -1 +0,0 @@
|
||||
Fix error when responding to a provisioning request for a room that the Hookshot bot isn't yet a member of.
|
@ -1 +0,0 @@
|
||||
Add support for ARM64 docker images.
|
@ -1 +0,0 @@
|
||||
Added new config option `feeds.pollTimeoutSeconds` to explictly set how long to wait for a feed response.
|
@ -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.
|
@ -1 +0,0 @@
|
||||
Hookshot now waits for Redis to be ready before handling traffic.
|
@ -1 +0,0 @@
|
||||
JSON logging output now includes new keys such as `error` and `args`.
|
@ -1 +0,0 @@
|
||||
Fix room membership going stale for rooms used in the permissions config.
|
1
changelog.d/488.misc
Normal file
1
changelog.d/488.misc
Normal file
@ -0,0 +1 @@
|
||||
Use the `matrix-appservice-bridge` logging implementation.
|
1
changelog.d/491.bugfix
Normal file
1
changelog.d/491.bugfix
Normal file
@ -0,0 +1 @@
|
||||
Give a warning if the user attempts to add a configuration widget to the room without giving the bot permissions.
|
1
changelog.d/496.feature
Normal file
1
changelog.d/496.feature
Normal file
@ -0,0 +1 @@
|
||||
Added `create-confidential` GitLab connection command.
|
1
changelog.d/500.feature
Normal file
1
changelog.d/500.feature
Normal file
@ -0,0 +1 @@
|
||||
Add new GitLab connection flag `includeCommentBody`, to enable including the body of comments on MR notifications.
|
1
changelog.d/502.feature
Normal file
1
changelog.d/502.feature
Normal file
@ -0,0 +1 @@
|
||||
Add room configuration widget for Jira.
|
1
changelog.d/503.feature
Normal file
1
changelog.d/503.feature
Normal file
@ -0,0 +1 @@
|
||||
Add bot commands to list and remove Jira connections.
|
1
changelog.d/504.bugfix
Normal file
1
changelog.d/504.bugfix
Normal file
@ -0,0 +1 @@
|
||||
Improve formatting of help commands and Jira's `whoami` command.
|
1
changelog.d/505.misc
Normal file
1
changelog.d/505.misc
Normal file
@ -0,0 +1 @@
|
||||
Improve some type-checking in the codebase.
|
1
changelog.d/506.misc
Normal file
1
changelog.d/506.misc
Normal file
@ -0,0 +1 @@
|
||||
Refactor the Vite component's `tsconfig.json` file to make it compatible with the TypeScript project settings & the TypeScript language server.
|
1
changelog.d/507.bugfix
Normal file
1
changelog.d/507.bugfix
Normal file
@ -0,0 +1 @@
|
||||
Add a configuration widget for Jira.
|
1
changelog.d/508.feature
Normal file
1
changelog.d/508.feature
Normal file
@ -0,0 +1 @@
|
||||
Reorganize the GitHub widget to allow searching for repositories by organization.
|
1
changelog.d/512.feature
Normal file
1
changelog.d/512.feature
Normal file
@ -0,0 +1 @@
|
||||
Print a notice message after successfully logging in to GitHub when conversing with the bot in a DM.
|
1
changelog.d/515.bugfix
Normal file
1
changelog.d/515.bugfix
Normal file
@ -0,0 +1 @@
|
||||
Fix inactive "Command Prefix" field in configuration widgets.
|
1
changelog.d/517.feature
Normal file
1
changelog.d/517.feature
Normal file
@ -0,0 +1 @@
|
||||
Add new GitLab connection flag `includeCommentBody`, to enable including the body of comments on MR notifications.
|
1
changelog.d/518.misc
Normal file
1
changelog.d/518.misc
Normal file
@ -0,0 +1 @@
|
||||
Don't send empty query string in some widget API requests.
|
1
changelog.d/519.bugfix
Normal file
1
changelog.d/519.bugfix
Normal file
@ -0,0 +1 @@
|
||||
Fix support for the "Labeled" event in the GitHub widget.
|
@ -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
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
38
docs/usage/room_configuration/jira_project.md
Normal file
38
docs/usage/room_configuration/jira_project.md
Normal file
@ -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 <url>` 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.
|
15
package.json
15
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",
|
||||
|
@ -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";
|
||||
|
@ -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);
|
||||
});
|
||||
|
@ -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) {
|
||||
|
@ -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) {
|
||||
|
@ -49,10 +49,15 @@ export function compileBotCommands(...prototypes: Record<string, BotCommandFunct
|
||||
const b = Reflect.getMetadata(botCommandSymbol, prototype, propertyKey);
|
||||
if (b) {
|
||||
const category = b.category || "default";
|
||||
const requiredArgs = b.requiredArgs?.join(" ") || "";
|
||||
const requiredArgs = b.requiredArgs?.map((arg: string) => `<${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],
|
||||
|
@ -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<IGitLabWebhookMREvent, GitLabRepoConnection>(
|
||||
"gitlab.merge_request.update",
|
||||
(data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
|
||||
(c, data) => c.onMergeRequestUpdate(data),
|
||||
);
|
||||
|
||||
this.bindHandlerToQueue<IGitLabWebhookReleaseEvent, GitLabRepoConnection>(
|
||||
"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<IGitLabWebhookNoteEvent, GitLabIssueConnection|GitLabRepoConnection>(
|
||||
@ -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<FeedEntry, FeedConnection>(
|
||||
this.bindHandlerToQueue<FeedSuccess, FeedConnection>(
|
||||
"feed.success",
|
||||
(data) => connManager.getConnectionsForFeedUrl(data.feed.url),
|
||||
(data) => connManager.getConnectionsForFeedUrl(data.url),
|
||||
c => c.handleFeedSuccess(),
|
||||
);
|
||||
this.bindHandlerToQueue<FeedError, FeedConnection>(
|
||||
|
@ -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 href="https:\/\/matrix\.to\/#\/(.+)">(.*)<\/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
|
||||
|
@ -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.');
|
||||
|
@ -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);
|
||||
|
@ -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<StateType extends IConnectionState = ICo
|
||||
msgtype: "m.notice",
|
||||
body: humanError ? `Failed to handle command: ${humanError}` : "Failed to handle command.",
|
||||
});
|
||||
log.warn(`Failed to handle command:`, error);
|
||||
log.warn(`Failed to handle command:`, error ?? 'Unknown error');
|
||||
return true;
|
||||
} else {
|
||||
const reaction = commandResult.result?.reaction || '✅';
|
||||
|
@ -3,7 +3,7 @@ import { IConnection, IConnectionState, InstantiateConnectionOpts } from ".";
|
||||
import { ApiError, ErrCode } from "../api";
|
||||
import { BridgeConfigFeeds } from "../Config/Config";
|
||||
import { FeedEntry, FeedError} from "../feeds/FeedReader";
|
||||
import LogWrapper from "../LogWrapper";
|
||||
import { Logger } from "matrix-appservice-bridge";
|
||||
import { IBridgeStorageProvider } from "../Stores/StorageProvider";
|
||||
import { BaseConnection } from "./BaseConnection";
|
||||
import axios from "axios";
|
||||
@ -11,7 +11,7 @@ import markdown from "markdown-it";
|
||||
import { Connection, ProvisionConnectionOpts } from "./IConnection";
|
||||
import { GetConnectionsResponseItem } from "../provisioning/api";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
const log = new LogWrapper("FeedConnection");
|
||||
const log = new Logger("FeedConnection");
|
||||
const md = new markdown();
|
||||
|
||||
export interface LastResultOk {
|
||||
|
@ -3,11 +3,12 @@ import markdownit from "markdown-it";
|
||||
import { FigmaPayload } from "../figma/types";
|
||||
import { BaseConnection } from "./BaseConnection";
|
||||
import { IConnection, IConnectionState } from ".";
|
||||
import LogWrapper from "../LogWrapper";
|
||||
import { Logger } from "matrix-appservice-bridge";
|
||||
import { IBridgeStorageProvider } from "../Stores/StorageProvider";
|
||||
import { BridgeConfigFigma } from "../Config/Config";
|
||||
import { Connection, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection";
|
||||
const log = new LogWrapper("FigmaFileConnection");
|
||||
|
||||
const log = new Logger("FigmaFileConnection");
|
||||
|
||||
export interface FigmaFileConnectionState extends IConnectionState {
|
||||
fileId: string;
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { Connection, IConnection, IConnectionState, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection";
|
||||
import LogWrapper from "../LogWrapper";
|
||||
import { Logger } from "matrix-appservice-bridge";
|
||||
import { MessageSenderClient } from "../MatrixSender"
|
||||
import markdownit from "markdown-it";
|
||||
import { VMScript as Script, NodeVM } from "vm2";
|
||||
@ -52,7 +52,7 @@ interface WebhookTransformationResult {
|
||||
empty?: boolean;
|
||||
}
|
||||
|
||||
const log = new LogWrapper("GenericHookConnection");
|
||||
const log = new Logger("GenericHookConnection");
|
||||
const md = new markdownit();
|
||||
|
||||
const TRANSFORMATION_TIMEOUT_MS = 500;
|
||||
@ -432,6 +432,7 @@ export class GenericHookConnection extends BaseConnection implements IConnection
|
||||
hookId: this.hookId
|
||||
}
|
||||
);
|
||||
this.state = validatedConfig;
|
||||
}
|
||||
|
||||
public toString() {
|
||||
|
@ -10,7 +10,7 @@ import emoji from "node-emoji";
|
||||
import markdown from "markdown-it";
|
||||
import { DiscussionCommentCreatedEvent } from "@octokit/webhooks-types";
|
||||
import { GithubGraphQLClient } from "../Github/GithubInstance";
|
||||
import LogWrapper from "../LogWrapper";
|
||||
import { Logger } from "matrix-appservice-bridge";
|
||||
import { BaseConnection } from "./BaseConnection";
|
||||
import { BridgeConfigGitHub } from "../Config/Config";
|
||||
export interface GitHubDiscussionConnectionState {
|
||||
@ -22,7 +22,7 @@ export interface GitHubDiscussionConnectionState {
|
||||
category: number;
|
||||
}
|
||||
|
||||
const log = new LogWrapper("GitHubDiscussion");
|
||||
const log = new Logger("GitHubDiscussion");
|
||||
const md = new markdown();
|
||||
|
||||
/**
|
||||
|
@ -1,13 +1,13 @@
|
||||
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 { ReposGetResponseData } from "../Github/Types";
|
||||
import axios from "axios";
|
||||
import { GitHubDiscussionConnection } from "./GithubDiscussion";
|
||||
import { GithubInstance } from "../Github/GithubInstance";
|
||||
import { BaseConnection } from "./BaseConnection";
|
||||
|
||||
const log = new LogWrapper("GitHubDiscussionSpace");
|
||||
const log = new Logger("GitHubDiscussionSpace");
|
||||
|
||||
export interface GitHubDiscussionSpaceConnectionState {
|
||||
owner: string;
|
||||
|
@ -3,7 +3,7 @@ import { Appservice, StateEvent } from "matrix-bot-sdk";
|
||||
import { MatrixMessageContent, MatrixEvent } from "../MatrixEvent";
|
||||
import markdown from "markdown-it";
|
||||
import { UserTokenStore } from "../UserTokenStore";
|
||||
import LogWrapper from "../LogWrapper";
|
||||
import { Logger } from "matrix-appservice-bridge";
|
||||
import { CommentProcessor } from "../CommentProcessor";
|
||||
import { MessageSenderClient } from "../MatrixSender";
|
||||
import { getIntentForUser } from "../IntentUtils";
|
||||
@ -24,7 +24,7 @@ export interface GitHubIssueConnectionState {
|
||||
comments_processed: number;
|
||||
}
|
||||
|
||||
const log = new LogWrapper("GitHubIssueConnection");
|
||||
const log = new Logger("GitHubIssueConnection");
|
||||
const md = new markdown();
|
||||
|
||||
interface IQueryRoomOpts {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection";
|
||||
import { Appservice, StateEvent } from "matrix-bot-sdk";
|
||||
import LogWrapper from "../LogWrapper";
|
||||
import { Logger } from "matrix-appservice-bridge";
|
||||
import { ProjectsGetResponseData } from "../Github/Types";
|
||||
import { BaseConnection } from "./BaseConnection";
|
||||
|
||||
@ -9,7 +9,7 @@ export interface GitHubProjectConnectionState {
|
||||
project_id: number;
|
||||
state: "open"|"closed";
|
||||
}
|
||||
const log = new LogWrapper("GitHubProjectConnection");
|
||||
const log = new Logger("GitHubProjectConnection");
|
||||
|
||||
/**
|
||||
* Handles rooms connected to a GitHub project.
|
||||
|
@ -9,11 +9,11 @@ import { IssuesOpenedEvent, IssuesReopenedEvent, IssuesEditedEvent, PullRequestO
|
||||
import { MatrixMessageContent, MatrixEvent, MatrixReactionContent } from "../MatrixEvent";
|
||||
import { MessageSenderClient } from "../MatrixSender";
|
||||
import { CommandError, NotLoggedInError } from "../errors";
|
||||
import { ReposGetResponseData } from "../Github/Types";
|
||||
import { NAMELESS_ORG_PLACEHOLDER, ReposGetResponseData } from "../Github/Types";
|
||||
import { UserTokenStore } from "../UserTokenStore";
|
||||
import axios, { AxiosError } from "axios";
|
||||
import emoji from "node-emoji";
|
||||
import LogWrapper from "../LogWrapper";
|
||||
import { Logger } from "matrix-appservice-bridge";
|
||||
import markdown from "markdown-it";
|
||||
import { CommandConnection } from "./CommandConnection";
|
||||
import { GithubInstance } from "../Github/GithubInstance";
|
||||
@ -24,7 +24,7 @@ import { PermissionCheckFn } from ".";
|
||||
import { MinimalGitHubIssue, MinimalGitHubRepo } from "../libRs";
|
||||
import Ajv, { JSONSchemaType } from "ajv";
|
||||
|
||||
const log = new LogWrapper("GitHubRepoConnection");
|
||||
const log = new Logger("GitHubRepoConnection");
|
||||
const md = new markdown();
|
||||
|
||||
interface IQueryRoomOpts {
|
||||
@ -57,11 +57,16 @@ export interface GitHubRepoConnectionState extends GitHubRepoConnectionOptions {
|
||||
}
|
||||
|
||||
|
||||
export interface GitHubRepoConnectionTarget {
|
||||
export interface GitHubRepoConnectionOrgTarget {
|
||||
name: string;
|
||||
}
|
||||
export interface GitHubRepoConnectionRepoTarget {
|
||||
state: GitHubRepoConnectionState;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export type GitHubRepoConnectionTarget = GitHubRepoConnectionOrgTarget|GitHubRepoConnectionRepoTarget;
|
||||
|
||||
|
||||
export type GitHubRepoResponseItem = GetConnectionsResponseItem<GitHubRepoConnectionState>;
|
||||
|
||||
@ -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<GitHubRepoConnectionTarget[]> {
|
||||
public static async getConnectionTargets(userId: string, tokenStore: UserTokenStore, githubInstance: GithubInstance, filters: GitHubTargetFilter = {}): Promise<GitHubRepoConnectionTarget[]> {
|
||||
// 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<string, unknown>) {
|
||||
const validatedConfig = GitHubRepoConnection.validateState(config);
|
||||
await this.as.botClient.sendStateEvent(this.roomId, GitHubRepoConnection.CanonicalEventType, this.stateKey, validatedConfig);
|
||||
this.state = validatedConfig;
|
||||
}
|
||||
|
||||
public async onRemove() {
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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<GitLabRepoConnection
|
||||
if (client) {
|
||||
results.push({
|
||||
name,
|
||||
} as GitLabRepoConnectionInstanceTarget);
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
@ -271,7 +279,13 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
|
||||
})) as GitLabRepoConnectionProjectTarget[];
|
||||
}
|
||||
|
||||
private readonly debounceMRComments = new Map<string, {comments: number, author: string, timeout: NodeJS.Timeout}>();
|
||||
private readonly debounceMRComments = new Map<string, {
|
||||
commentCount: number,
|
||||
commentNotes?: string[],
|
||||
author: string,
|
||||
timeout: NodeJS.Timeout,
|
||||
approved?: boolean,
|
||||
}>();
|
||||
|
||||
constructor(roomId: string,
|
||||
stateKey: string,
|
||||
@ -311,7 +325,7 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
|
||||
return GitLabRepoConnection.EventTypes.includes(eventType) && this.stateKey === stateKey;
|
||||
}
|
||||
|
||||
public getProvisionerDetails() {
|
||||
public getProvisionerDetails(): GitLabRepoResponseItem {
|
||||
return {
|
||||
...GitLabRepoConnection.getProvisionerDetails(this.as.botUserId),
|
||||
id: this.connectionId,
|
||||
@ -321,13 +335,17 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
|
||||
}
|
||||
}
|
||||
|
||||
@botCommand("create", "Create an issue for this repo", ["title"], ["description", "labels"], true)
|
||||
public async onCreateIssue(userId: string, title: string, description?: string, labels?: string) {
|
||||
private async getClientForUser(userId: string) {
|
||||
const client = await this.tokenStore.getGitLabForUser(userId, this.instance.url);
|
||||
if (!client) {
|
||||
await this.as.botIntent.sendText(this.roomId, "You must be logged in to create an issue.", "m.notice");
|
||||
throw Error('Not logged in');
|
||||
throw new CommandError('User is not logged into GitLab', 'You must be logged in to create an issue.');
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
@botCommand("create", "Create an issue for this repo", ["title"], ["description", "labels"], true)
|
||||
public async onCreateIssue(userId: string, title: string, description?: string, labels?: string) {
|
||||
const client = await this.getClientForUser(userId);
|
||||
const res = await client.issues.create({
|
||||
id: this.path,
|
||||
title,
|
||||
@ -344,13 +362,29 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
|
||||
});
|
||||
}
|
||||
|
||||
@botCommand("create-confidential", "Create a confidental issue for this repo", ["title"], ["description", "labels"], true)
|
||||
public async onCreateConfidentialIssue(userId: string, title: string, description?: string, labels?: string) {
|
||||
const client = await this.getClientForUser(userId);
|
||||
const res = await client.issues.create({
|
||||
id: this.path,
|
||||
title,
|
||||
description,
|
||||
confidential: true,
|
||||
labels: labels ? labels.split(",") : undefined,
|
||||
});
|
||||
|
||||
const content = `Created confidential issue #${res.iid}: [${res.web_url}](${res.web_url})`;
|
||||
return this.as.botIntent.sendEvent(this.roomId,{
|
||||
msgtype: "m.notice",
|
||||
body: content,
|
||||
formatted_body: md.render(content),
|
||||
format: "org.matrix.custom.html"
|
||||
});
|
||||
}
|
||||
|
||||
@botCommand("close", "Close an issue", ["number"], ["comment"], true)
|
||||
public async onClose(userId: string, number: string) {
|
||||
const client = await this.tokenStore.getGitLabForUser(userId, this.instance.url);
|
||||
if (!client) {
|
||||
await this.as.botIntent.sendText(this.roomId, "You must be logged in to create an issue.", "m.notice");
|
||||
throw Error('Not logged in');
|
||||
}
|
||||
const client = await this.getClientForUser(userId);
|
||||
|
||||
await client.issues.edit({
|
||||
id: this.state.path,
|
||||
@ -369,10 +403,10 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
|
||||
}
|
||||
|
||||
public async onMergeRequestOpened(event: IGitLabWebhookMREvent) {
|
||||
log.info(`onMergeRequestOpened ${this.roomId} ${this.path} #${event.object_attributes.iid}`);
|
||||
if (this.shouldSkipHook('merge_request', 'merge_request.open') || !this.matchesLabelFilter(event)) {
|
||||
return;
|
||||
}
|
||||
log.info(`onMergeRequestOpened ${this.roomId} ${this.path} #${event.object_attributes.iid}`);
|
||||
this.validateMREvent(event);
|
||||
const orgRepoName = event.project.path_with_namespace;
|
||||
const content = `**${event.user.username}** opened a new MR [${orgRepoName}#${event.object_attributes.iid}](${event.object_attributes.url}): "${event.object_attributes.title}"`;
|
||||
@ -385,10 +419,10 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
|
||||
}
|
||||
|
||||
public async onMergeRequestClosed(event: IGitLabWebhookMREvent) {
|
||||
log.info(`onMergeRequestClosed ${this.roomId} ${this.path} #${event.object_attributes.iid}`);
|
||||
if (this.shouldSkipHook('merge_request', 'merge_request.close') || !this.matchesLabelFilter(event)) {
|
||||
return;
|
||||
}
|
||||
log.info(`onMergeRequestClosed ${this.roomId} ${this.path} #${event.object_attributes.iid}`);
|
||||
this.validateMREvent(event);
|
||||
const orgRepoName = event.project.path_with_namespace;
|
||||
const content = `**${event.user.username}** closed MR [${orgRepoName}#${event.object_attributes.iid}](${event.object_attributes.url}): "${event.object_attributes.title}"`;
|
||||
@ -401,10 +435,10 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
|
||||
}
|
||||
|
||||
public async onMergeRequestMerged(event: IGitLabWebhookMREvent) {
|
||||
log.info(`onMergeRequestMerged ${this.roomId} ${this.path} #${event.object_attributes.iid}`);
|
||||
if (this.shouldSkipHook('merge_request', 'merge_request.merge') || !this.matchesLabelFilter(event)) {
|
||||
return;
|
||||
}
|
||||
log.info(`onMergeRequestMerged ${this.roomId} ${this.path} #${event.object_attributes.iid}`);
|
||||
this.validateMREvent(event);
|
||||
const orgRepoName = event.project.path_with_namespace;
|
||||
const content = `**${event.user.username}** merged MR [${orgRepoName}#${event.object_attributes.iid}](${event.object_attributes.url}): "${event.object_attributes.title}"`;
|
||||
@ -416,22 +450,30 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
|
||||
});
|
||||
}
|
||||
|
||||
public async onMergeRequestReviewed(event: IGitLabWebhookMREvent) {
|
||||
if (this.shouldSkipHook('merge_request', 'merge_request.review', `merge_request.${event.object_attributes.action}`) || !this.matchesLabelFilter(event)) {
|
||||
public async onMergeRequestUpdate(event: IGitLabWebhookMREvent) {
|
||||
if (this.shouldSkipHook('merge_request', 'merge_request.ready_for_review')) {
|
||||
return;
|
||||
}
|
||||
log.info(`onMergeRequestReviewed ${this.roomId} ${this.instance}/${this.path} ${event.object_attributes.iid}`);
|
||||
log.info(`onMergeRequestUpdate ${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.
|
||||
// Check if the MR changed to / from a draft
|
||||
if (!event.changes.title) {
|
||||
return;
|
||||
}
|
||||
const emojiForReview = {
|
||||
'approved': '✅',
|
||||
'unapproved': '🔴'
|
||||
}[event.object_attributes.action];
|
||||
const orgRepoName = event.project.path_with_namespace;
|
||||
const content = `**${event.user.username}** ${emojiForReview} ${event.object_attributes.action} MR [${orgRepoName}#${event.object_attributes.iid}](${event.object_attributes.url}): "${event.object_attributes.title}"`;
|
||||
let content: string;
|
||||
const wasDraft = event.changes.title.before.startsWith('Draft: ');
|
||||
const isDraft = event.changes.title.after.startsWith('Draft: ');
|
||||
if (wasDraft && !isDraft) {
|
||||
// Ready for review
|
||||
content = `**${event.user.username}** marked MR [${orgRepoName}#${event.object_attributes.iid}](${event.object_attributes.url}) as ready for review "${event.object_attributes.title}" `;
|
||||
} else if (!wasDraft && isDraft) {
|
||||
// Back to draft.
|
||||
content = `**${event.user.username}** marked MR [${orgRepoName}#${event.object_attributes.iid}](${event.object_attributes.url}) as draft "${event.object_attributes.title}" `;
|
||||
} else {
|
||||
// Nothing changed, drop it.
|
||||
return;
|
||||
}
|
||||
await this.as.botIntent.sendEvent(this.roomId, {
|
||||
msgtype: "m.notice",
|
||||
body: content,
|
||||
@ -441,10 +483,10 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
|
||||
}
|
||||
|
||||
public async onGitLabTagPush(event: IGitLabWebhookTagPushEvent) {
|
||||
log.info(`onGitLabTagPush ${this.roomId} ${this.instance.url}/${this.path} ${event.ref}`);
|
||||
if (this.shouldSkipHook('tag_push')) {
|
||||
return;
|
||||
}
|
||||
log.info(`onGitLabTagPush ${this.roomId} ${this.instance.url}/${this.path} ${event.ref}`);
|
||||
const tagname = event.ref.replace("refs/tags/", "");
|
||||
if (this.state.pushTagsRegex && !tagname.match(this.state.pushTagsRegex)) {
|
||||
return;
|
||||
@ -461,10 +503,10 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
|
||||
|
||||
|
||||
public async onGitLabPush(event: IGitLabWebhookPushEvent) {
|
||||
log.info(`onGitLabPush ${this.roomId} ${this.instance.url}/${this.path} ${event.after}`);
|
||||
if (this.shouldSkipHook('push')) {
|
||||
return;
|
||||
}
|
||||
log.info(`onGitLabPush ${this.roomId} ${this.instance.url}/${this.path} ${event.after}`);
|
||||
const branchname = event.ref.replace("refs/heads/", "");
|
||||
const commitsurl = `${event.project.homepage}/-/commits/${branchname}`;
|
||||
const branchurl = `${event.project.homepage}/-/tree/${branchname}`;
|
||||
@ -500,10 +542,10 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
|
||||
|
||||
public async onWikiPageEvent(data: IGitLabWebhookWikiPageEvent) {
|
||||
const attributes = data.object_attributes;
|
||||
log.info(`onWikiPageEvent ${this.roomId} ${this.instance}/${this.path}`);
|
||||
if (this.shouldSkipHook('wiki', `wiki.${attributes.action}`)) {
|
||||
return;
|
||||
}
|
||||
log.info(`onWikiPageEvent ${this.roomId} ${this.instance}/${this.path}`);
|
||||
|
||||
let statement: string;
|
||||
if (attributes.action === "create") {
|
||||
@ -542,60 +584,113 @@ ${data.description}`;
|
||||
});
|
||||
}
|
||||
|
||||
private renderDebouncedMergeRequest(uniqueId: string, mergeRequest: IGitlabMergeRequest, project: IGitlabProject) {
|
||||
const result = this.debounceMRComments.get(uniqueId);
|
||||
if (!result) {
|
||||
// Always defined, but for type checking purposes.
|
||||
return;
|
||||
}
|
||||
// Delete after use.
|
||||
this.debounceMRComments.delete(uniqueId);
|
||||
const orgRepoName = project.path_with_namespace;
|
||||
let comments = '';
|
||||
if (result.commentCount === 1) {
|
||||
comments = ' with one comment';
|
||||
} else if (result.commentCount > 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<string, unknown>) {
|
||||
const validatedConfig = GitLabRepoConnection.validateState(config);
|
||||
await this.as.botClient.sendStateEvent(this.roomId, GitLabRepoConnection.CanonicalEventType, this.stateKey, validatedConfig);
|
||||
this.state = validatedConfig;
|
||||
}
|
||||
|
||||
|
||||
|
@ -65,7 +65,7 @@ export interface IConnection {
|
||||
/**
|
||||
* If supported, this is sent when a user attempts to update the configuration of a connection.
|
||||
*/
|
||||
provisionerUpdateConfig?: <T extends Record<string, unknown>>(userId: string, config: T) => void;
|
||||
provisionerUpdateConfig?: <T extends Record<string, unknown>>(userId: string, config: T) => Promise<void>;
|
||||
|
||||
/**
|
||||
* If supported, this is sent when a user attempts to remove the connection from a room. The connection
|
||||
|
@ -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<JiraProjectConnectionState>;
|
||||
|
||||
|
||||
function validateJiraConnectionState(state: unknown): JiraProjectConnectionState {
|
||||
const {url, commandPrefix, events, priority} = state as Partial<JiraProjectConnectionState>;
|
||||
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<JiraProjectConnecti
|
||||
}
|
||||
}
|
||||
|
||||
public getProvisionerDetails() {
|
||||
public getProvisionerDetails(): JiraProjectResponseItem {
|
||||
return {
|
||||
...JiraProjectConnection.getProvisionerDetails(this.as.botUserId),
|
||||
id: this.connectionId,
|
||||
@ -220,6 +244,59 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
|
||||
}
|
||||
}
|
||||
|
||||
public static async getConnectionTargets(userId: string, tokenStore: UserTokenStore, config: BridgeConfigJira, filters: JiraTargetFilter = {}): Promise<JiraProjectConnectionTarget[]> {
|
||||
// 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<JiraProjectConnecti
|
||||
public async provisionerUpdateConfig(userId: string, config: Record<string, unknown>) {
|
||||
const validatedConfig = validateJiraConnectionState(config);
|
||||
await this.as.botClient.sendStateEvent(this.roomId, JiraProjectConnection.CanonicalEventType, this.stateKey, validatedConfig);
|
||||
this.state = validatedConfig;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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() {
|
||||
|
@ -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");
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
|
@ -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.
|
||||
|
@ -63,6 +63,12 @@ export interface IGitLabWebhookMREvent {
|
||||
repository: IGitlabRepository;
|
||||
object_attributes: IGitLabMergeRequestObjectAttributes;
|
||||
labels: IGitLabLabel[];
|
||||
changes: {
|
||||
[key: string]: {
|
||||
before: string;
|
||||
after: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface IGitLabWebhookTagPushEvent {
|
||||
|
@ -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];
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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";
|
||||
|
||||
|
@ -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<ResourceName>;
|
||||
}
|
||||
|
||||
const log = new LogWrapper("ListenerService");
|
||||
const log = new Logger("ListenerService");
|
||||
|
||||
export class ListenerService {
|
||||
private readonly listeners: {
|
||||
|
@ -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 });
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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.";
|
||||
|
||||
|
@ -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();
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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 */
|
||||
|
@ -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 {
|
||||
|
@ -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<T>(actionFn: () => Promise<T>,
|
||||
maxAttempts: number,
|
||||
|
@ -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;
|
||||
|
@ -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"];
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
|
@ -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<boolean> {
|
||||
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,
|
||||
|
@ -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();
|
||||
|
@ -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<undefined>({ eventName: 'feed.success', sender: 'FeedReader', data: undefined});
|
||||
this.queue.push<FeedSuccess>({ 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);
|
||||
|
@ -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,7 +17,7 @@ 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;
|
||||
@ -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});
|
||||
|
@ -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) { }
|
||||
|
||||
|
@ -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) { }
|
||||
|
||||
|
@ -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<Config = object, Secrets = object> 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 {
|
||||
|
@ -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 = /!.+:.+/;
|
||||
|
@ -1,2 +1,2 @@
|
||||
import LogWrapper from "../src/LogWrapper";
|
||||
LogWrapper.configureLogging({level: "info"});
|
||||
import { Logger } from "matrix-appservice-bridge";
|
||||
Logger.configure({console: "info"});
|
||||
|
@ -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"
|
||||
]
|
||||
},
|
||||
}
|
||||
|
@ -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<R>(type: string, filters?: unknown): Promise<R[]> {
|
||||
const searchParams = filters && new URLSearchParams(filters as Record<string, string>);
|
||||
getConnectionTargets<R>(type: string, filters?: Record<never, never>|Record<string, string>): Promise<R[]> {
|
||||
const searchParams = filters && !!Object.keys(filters).length && new URLSearchParams(filters);
|
||||
return this.request('GET', `/widgetapi/v1/targets/${encodeURIComponent(type)}${searchParams ? `?${searchParams}` : ''}`);
|
||||
}
|
||||
}
|
||||
|
@ -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<ConnectionType, IConnectionProps> = {
|
||||
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",
|
||||
|
@ -5,11 +5,12 @@ interface Props {
|
||||
visible?: boolean;
|
||||
label?: string;
|
||||
noPadding: boolean;
|
||||
innerChild?: boolean;
|
||||
}
|
||||
|
||||
export const InputField: FunctionComponent<Props> = ({ children, visible = true, label, noPadding }) => {
|
||||
return visible && <div className={style.inputField}>
|
||||
{label && <label className={noPadding ? style.nopad : ""}>{label}</label>}
|
||||
{children}
|
||||
</div>;
|
||||
export const InputField: FunctionComponent<Props> = ({ children, visible = true, label, noPadding, innerChild = false }) => {
|
||||
return visible ? <div className={style.inputField}>
|
||||
{label && <label className={noPadding ? style.nopad : ""}>{innerChild && children}{label}</label>}
|
||||
{(!label || !innerChild) && children}
|
||||
</div> : <></>
|
||||
};
|
@ -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<string>("");
|
||||
const [results, setResults] = useState<GitHubRepoConnectionTarget[]|null>(null);
|
||||
const [filter, setFilter] = useState<GitHubTargetFilter>({});
|
||||
const [results, setResults] = useState<GitHubRepoConnectionRepoTarget[]|null>(null);
|
||||
const [orgs, setOrgs] = useState<GitHubRepoConnectionOrgTarget[]|null>(null);
|
||||
const [isConnected, setIsConnected] = useState<boolean|null>(null);
|
||||
const [debounceTimer, setDebounceTimer] = useState<number|undefined>(undefined);
|
||||
const [currentRepo, setCurrentRepo] = useState<string>("");
|
||||
const [currentRepo, setCurrentRepo] = useState<string|null>(null);
|
||||
const [searchError, setSearchError] = useState<string|null>(null);
|
||||
|
||||
const searchFn = useCallback(async() => {
|
||||
try {
|
||||
const res = await api.getConnectionTargets<GitHubRepoConnectionTarget>(EventType);
|
||||
const res = await api.getConnectionTargets<GitHubRepoConnectionRepoTarget>(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 => <option key={i.name}>{i.name}</option>),
|
||||
[orgs]
|
||||
);
|
||||
|
||||
const repoListResults = useMemo(
|
||||
() => results?.map(i => <option repo={i.state.repo} org={i.state.org} value={i.name} key={i.name} />),
|
||||
() => results?.map(i => <option key={i.name} value={i.name} />),
|
||||
[results]
|
||||
);
|
||||
|
||||
@ -71,10 +101,15 @@ const ConnectionSearch: FunctionComponent<{api: BridgeAPI, onPicked: (state: Git
|
||||
return <div>
|
||||
{isConnected === null && <p> Loading GitHub connection. </p>}
|
||||
{isConnected === false && <p> You are not logged into GitHub. </p>}
|
||||
{isConnected === true && orgs?.length === 0 && <p> You do not have access to any GitHub organizations. </p>}
|
||||
{searchError && <ErrorPane> {searchError} </ErrorPane> }
|
||||
<InputField visible={!!orgs?.length} label="Organization" noPadding={true}>
|
||||
<select onChange={onOrgPicked}>
|
||||
{orgListResults}
|
||||
</select>
|
||||
</InputField>
|
||||
<InputField visible={!!isConnected} label="Repository" noPadding={true}>
|
||||
<small>{currentRepo}</small>
|
||||
<input onChange={updateSearchFn} value={filter} list="github-repos" type="text" />
|
||||
<input onChange={updateSearchFn} value={currentRepo ?? undefined} list="github-repos" type="text" />
|
||||
<datalist id="github-repos">
|
||||
{repoListResults}
|
||||
</datalist>
|
||||
@ -126,18 +161,15 @@ const ConnectionConfiguration: FunctionComponent<ConnectionConfigurationProps<ne
|
||||
onSave({
|
||||
...(state),
|
||||
ignoreHooks: ignoredHooks as any[],
|
||||
commandPrefix: commandPrefixRef.current?.value,
|
||||
commandPrefix: commandPrefixRef.current?.value || commandPrefixRef.current?.placeholder,
|
||||
});
|
||||
}
|
||||
}, [canEdit, existingConnection, connectionState, ignoredHooks, commandPrefixRef, onSave]);
|
||||
|
||||
return <form onSubmit={handleSave}>
|
||||
{!existingConnection && <ConnectionSearch api={api} onPicked={setConnectionState} />}
|
||||
<InputField visible={!!existingConnection} label="Repository" noPadding={true}>
|
||||
<input disabled={true} type="text" value={existingConnection ? getRepoFullName(existingConnection.config) : undefined} />
|
||||
</InputField>
|
||||
<InputField visible={!!existingConnection || !!connectionState} ref={commandPrefixRef} label="Command Prefix" noPadding={true}>
|
||||
<input type="text" value={existingConnection?.config.commandPrefix} placeholder="!gh" />
|
||||
<InputField visible={!!existingConnection || !!connectionState} label="Command Prefix" noPadding={true}>
|
||||
<input ref={commandPrefixRef} type="text" value={existingConnection?.config.commandPrefix} placeholder="!gh" />
|
||||
</InputField>
|
||||
<InputField visible={!!existingConnection || !!connectionState} label="Events" noPadding={true}>
|
||||
<p>Choose which event should send a notification to the room</p>
|
||||
|
@ -20,7 +20,7 @@ const ConnectionSearch: FunctionComponent<{api: BridgeAPI, onPicked: (state: Git
|
||||
try {
|
||||
const res = await api.getConnectionTargets<GitLabRepoConnectionTarget>(EventType, filter);
|
||||
if (!filter.instance) {
|
||||
setInstances(res);
|
||||
setInstances(res as GitLabRepoConnectionInstanceTarget[]);
|
||||
if (res[0]) {
|
||||
setFilter({instance: res[0].name, search: ""});
|
||||
}
|
||||
@ -82,12 +82,12 @@ const ConnectionSearch: FunctionComponent<{api: BridgeAPI, onPicked: (state: Git
|
||||
{instances === null && <p> Loading GitLab instances. </p>}
|
||||
{instances?.length === 0 && <p> You are not logged into any GitLab instances. </p>}
|
||||
{searchError && <ErrorPane> {searchError} </ErrorPane> }
|
||||
<InputField visible={instances ? instances.length > 0 : false} label="GitLab Instance" noPadding={true}>
|
||||
<InputField visible={!!instances?.length} label="GitLab Instance" noPadding={true}>
|
||||
<select onChange={onInstancePicked}>
|
||||
{instanceListResults}
|
||||
</select>
|
||||
</InputField>
|
||||
<InputField visible={instances ? instances.length > 0 : false} label="Project" noPadding={true}>
|
||||
<InputField visible={!!instances?.length} label="Project" noPadding={true}>
|
||||
<small>{currentProjectPath ?? ""}</small>
|
||||
<input onChange={updateSearchFn} value={filter.search} list="gitlab-projects" type="text" />
|
||||
<datalist id="gitlab-projects">
|
||||
@ -131,6 +131,7 @@ const ConnectionConfiguration: FunctionComponent<ConnectionConfigurationProps<ne
|
||||
|
||||
const canEdit = !existingConnection || (existingConnection?.canEdit ?? false);
|
||||
const commandPrefixRef = createRef<HTMLInputElement>();
|
||||
const includeBodyRef = createRef<HTMLInputElement>();
|
||||
const handleSave = useCallback((evt: Event) => {
|
||||
evt.preventDefault();
|
||||
if (!canEdit || !existingConnection && !newInstanceState) {
|
||||
@ -141,7 +142,8 @@ const ConnectionConfiguration: FunctionComponent<ConnectionConfigurationProps<ne
|
||||
onSave({
|
||||
...(state),
|
||||
ignoreHooks: ignoredHooks as any[],
|
||||
commandPrefix: commandPrefixRef.current?.value,
|
||||
includeCommentBody: includeBodyRef.current?.checked,
|
||||
commandPrefix: commandPrefixRef.current?.value || commandPrefixRef.current?.placeholder,
|
||||
});
|
||||
}
|
||||
}, [canEdit, existingConnection, newInstanceState, ignoredHooks, commandPrefixRef, onSave]);
|
||||
@ -154,8 +156,11 @@ const ConnectionConfiguration: FunctionComponent<ConnectionConfigurationProps<ne
|
||||
<InputField visible={!!existingConnection} label="Project" noPadding={true}>
|
||||
<input disabled={true} type="text" value={existingConnection?.config.path} />
|
||||
</InputField>
|
||||
<InputField visible={!!existingConnection || !!newInstanceState} ref={commandPrefixRef} label="Command Prefix" noPadding={true}>
|
||||
<input type="text" value={existingConnection?.config.commandPrefix} placeholder="!gl" />
|
||||
<InputField visible={!!existingConnection || !!newInstanceState} label="Command Prefix" noPadding={true}>
|
||||
<input ref={commandPrefixRef} type="text" value={existingConnection?.config.commandPrefix} placeholder="!gl" />
|
||||
</InputField>
|
||||
<InputField visible={!!existingConnection || !!newInstanceState} label="Include comment bodies" noPadding={true} innerChild={true}>
|
||||
<input ref={includeBodyRef} disabled={!canEdit} type="checkbox" checked={!!existingConnection?.config.includeCommentBody} />
|
||||
</InputField>
|
||||
<InputField visible={!!existingConnection || !!newInstanceState} label="Events" noPadding={true}>
|
||||
<p>Choose which event should send a notification to the room</p>
|
||||
@ -166,6 +171,7 @@ const ConnectionConfiguration: FunctionComponent<ConnectionConfigurationProps<ne
|
||||
<EventCheckbox ignoredHooks={ignoredHooks} parentEvent="merge_request" eventName="merge_request.close" onChange={toggleIgnoredHook}>Closed</EventCheckbox>
|
||||
<EventCheckbox ignoredHooks={ignoredHooks} parentEvent="merge_request" eventName="merge_request.merge" onChange={toggleIgnoredHook}>Merged</EventCheckbox>
|
||||
<EventCheckbox ignoredHooks={ignoredHooks} parentEvent="merge_request" eventName="merge_request.review" onChange={toggleIgnoredHook}>Reviewed</EventCheckbox>
|
||||
<EventCheckbox ignoredHooks={ignoredHooks} parentEvent="merge_request" eventName="merge_request.ready_for_review" onChange={toggleIgnoredHook}>Ready for review</EventCheckbox>
|
||||
</ul>
|
||||
<EventCheckbox ignoredHooks={ignoredHooks} eventName="push" onChange={toggleIgnoredHook}>Pushes</EventCheckbox>
|
||||
<EventCheckbox ignoredHooks={ignoredHooks} eventName="tag_push" onChange={toggleIgnoredHook}>Tag pushes</EventCheckbox>
|
||||
|
204
web/components/roomConfig/JiraProjectConfig.tsx
Normal file
204
web/components/roomConfig/JiraProjectConfig.tsx
Normal file
@ -0,0 +1,204 @@
|
||||
import { h, FunctionComponent, createRef } from "preact";
|
||||
import { useState, useCallback, useEffect, useMemo } from "preact/hooks";
|
||||
import { BridgeAPI, BridgeConfig } from "../../BridgeAPI";
|
||||
import { ConnectionConfigurationProps, RoomConfig } from "./RoomConfig";
|
||||
import { ErrCode } from "../../../src/api";
|
||||
import { JiraProjectConnectionState, JiraProjectResponseItem, JiraProjectConnectionProjectTarget, JiraTargetFilter, JiraProjectConnectionInstanceTarget, JiraProjectConnectionTarget } from "../../../src/Connections/JiraProject";
|
||||
import { InputField, ButtonSet, Button, ErrorPane } from "../elements";
|
||||
import JiraIcon from "../../icons/jira.png";
|
||||
|
||||
const EventType = "uk.half-shot.matrix-hookshot.jira.project";
|
||||
|
||||
function getInstancePrettyName(instance: JiraProjectConnectionInstanceTarget) {
|
||||
return `${instance.name} (${instance.url})`;
|
||||
}
|
||||
|
||||
function getProjectPrettyName(project: JiraProjectConnectionProjectTarget) {
|
||||
return `${project.key} (${project.name})`;
|
||||
}
|
||||
|
||||
const ConnectionSearch: FunctionComponent<{api: BridgeAPI, onPicked: (state: JiraProjectConnectionState) => void}> = ({api, onPicked}) => {
|
||||
const [filter, setFilter] = useState<JiraTargetFilter>({});
|
||||
const [results, setResults] = useState<JiraProjectConnectionProjectTarget[]|null>(null);
|
||||
const [instances, setInstances] = useState<JiraProjectConnectionInstanceTarget[]|null>(null);
|
||||
const [isConnected, setIsConnected] = useState<boolean|null>(null);
|
||||
const [debounceTimer, setDebounceTimer] = useState<number|undefined>(undefined);
|
||||
const [currentProject, setCurrentProject] = useState<{url: string, name: string}|null>(null);
|
||||
const [searchError, setSearchError] = useState<string|null>(null);
|
||||
|
||||
const searchFn = useCallback(async() => {
|
||||
try {
|
||||
const res = await api.getConnectionTargets<JiraProjectConnectionTarget>(EventType, filter);
|
||||
setIsConnected(true);
|
||||
if (!filter.instanceName) {
|
||||
setInstances(res as JiraProjectConnectionInstanceTarget[]);
|
||||
if (res[0]) {
|
||||
setFilter({instanceName: res[0].name});
|
||||
}
|
||||
} else {
|
||||
setResults(res as JiraProjectConnectionProjectTarget[]);
|
||||
}
|
||||
} catch (ex: any) {
|
||||
if (ex?.errcode === ErrCode.ForbiddenUser) {
|
||||
setIsConnected(false);
|
||||
setInstances([]);
|
||||
} 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, filter]);
|
||||
|
||||
const updateSearchFn = useCallback((evt: InputEvent) => {
|
||||
const project = (evt.target as HTMLOptionElement).value;
|
||||
const hasResult = results?.find(n =>
|
||||
project === n.state.url ||
|
||||
project === getProjectPrettyName(n)
|
||||
);
|
||||
if (hasResult) {
|
||||
onPicked(hasResult.state);
|
||||
setCurrentProject({
|
||||
url: hasResult.state.url,
|
||||
name: getProjectPrettyName(hasResult),
|
||||
});
|
||||
}
|
||||
}, [onPicked, results]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
}
|
||||
// Browser types
|
||||
setDebounceTimer(setTimeout(searchFn, 500) as unknown as number);
|
||||
return () => {
|
||||
clearTimeout(debounceTimer);
|
||||
}
|
||||
// Things break if we depend on the thing we are clearing.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchFn, filter]);
|
||||
|
||||
const onInstancePicked = useCallback((evt: InputEvent) => {
|
||||
// Reset the search string.
|
||||
setFilter({
|
||||
instanceName: (evt.target as HTMLSelectElement).selectedOptions[0].value,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const instanceListResults = useMemo(
|
||||
() => instances?.map(i => <option key={i.url} value={i.url}>{getInstancePrettyName(i)}</option>),
|
||||
[instances]
|
||||
);
|
||||
|
||||
const projectListResults = useMemo(
|
||||
() => results?.map(i => <option key={i.key} value={i.state.url}>{getProjectPrettyName(i)}</option>),
|
||||
[results]
|
||||
);
|
||||
|
||||
|
||||
return <div>
|
||||
{isConnected === null && <p> Loading JIRA connection. </p>}
|
||||
{isConnected === false && <p> You are not logged into JIRA. </p>}
|
||||
{isConnected === true && instances?.length === 0 && <p> You are not connected to any JIRA instances. </p>}
|
||||
{searchError && <ErrorPane> {searchError} </ErrorPane> }
|
||||
<InputField visible={!!instances?.length} label="JIRA Instance" noPadding={true}>
|
||||
<select onChange={onInstancePicked}>
|
||||
{instanceListResults}
|
||||
</select>
|
||||
</InputField>
|
||||
<InputField visible={!!instances?.length} label="Project" noPadding={true}>
|
||||
<small>{currentProject?.url}</small>
|
||||
<input onChange={updateSearchFn} value={currentProject?.name} list="jira-projects" type="text" />
|
||||
<datalist id="jira-projects">
|
||||
{projectListResults}
|
||||
</datalist>
|
||||
</InputField>
|
||||
</div>;
|
||||
}
|
||||
|
||||
const EventCheckbox: FunctionComponent<{
|
||||
allowedEvents: string[],
|
||||
onChange: (evt: HTMLInputElement) => void,
|
||||
eventName: string,
|
||||
}> = ({allowedEvents, onChange, eventName, children}) => {
|
||||
return <li>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
x-event-name={eventName}
|
||||
checked={allowedEvents.includes(eventName)}
|
||||
onChange={onChange} />
|
||||
{ children }
|
||||
</label>
|
||||
</li>;
|
||||
};
|
||||
|
||||
const ConnectionConfiguration: FunctionComponent<ConnectionConfigurationProps<never, JiraProjectResponseItem, JiraProjectConnectionState>> = ({api, existingConnection, onSave, onRemove }) => {
|
||||
const [allowedEvents, setAllowedEvents] = useState<string[]>(existingConnection?.config.events || []);
|
||||
|
||||
const toggleEvent = useCallback((evt: Event) => {
|
||||
const key = (evt.target as HTMLElement).getAttribute('x-event-name');
|
||||
if (key) {
|
||||
setAllowedEvents(allowedEvents => (
|
||||
allowedEvents.includes(key) ? allowedEvents.filter(k => k !== key) : [...allowedEvents, key]
|
||||
));
|
||||
}
|
||||
}, []);
|
||||
const [newConnectionState, setNewConnectionState] = useState<JiraProjectConnectionState|null>(null);
|
||||
|
||||
const canEdit = !existingConnection || (existingConnection?.canEdit ?? false);
|
||||
const commandPrefixRef = createRef<HTMLInputElement>();
|
||||
const handleSave = useCallback((evt: Event) => {
|
||||
evt.preventDefault();
|
||||
if (!canEdit || !existingConnection && !newConnectionState) {
|
||||
return;
|
||||
}
|
||||
const state = existingConnection?.config || newConnectionState;
|
||||
if (state) {
|
||||
onSave({
|
||||
...(state),
|
||||
events: allowedEvents as any[],
|
||||
commandPrefix: commandPrefixRef.current?.value || commandPrefixRef.current?.placeholder,
|
||||
});
|
||||
}
|
||||
}, [canEdit, existingConnection, newConnectionState, allowedEvents, commandPrefixRef, onSave]);
|
||||
|
||||
return <form onSubmit={handleSave}>
|
||||
{!existingConnection && <ConnectionSearch api={api} onPicked={setNewConnectionState} />}
|
||||
<InputField visible={!!existingConnection || !!newConnectionState} label="Command Prefix" noPadding={true}>
|
||||
<input ref={commandPrefixRef} type="text" value={existingConnection?.config.commandPrefix} placeholder="!jira" />
|
||||
</InputField>
|
||||
<InputField visible={!!existingConnection || !!newConnectionState} label="Events" noPadding={true}>
|
||||
<p>Choose which event should send a notification to the room</p>
|
||||
<ul>
|
||||
<EventCheckbox allowedEvents={allowedEvents} eventName="issue.created" onChange={toggleEvent}>Issue created</EventCheckbox>
|
||||
</ul>
|
||||
</InputField>
|
||||
<ButtonSet>
|
||||
{ canEdit && <Button type="submit" disabled={!existingConnection && !newConnectionState}>{ existingConnection ? "Save" : "Add project" }</Button>}
|
||||
{ canEdit && existingConnection && <Button intent="remove" onClick={onRemove}>Remove project</Button>}
|
||||
</ButtonSet>
|
||||
</form>;
|
||||
};
|
||||
|
||||
const RoomConfigText = {
|
||||
header: 'JIRA Projects',
|
||||
createNew: 'Add new JIRA Project',
|
||||
listCanEdit: 'Your connected projects',
|
||||
listCantEdit: 'Connected projects',
|
||||
};
|
||||
|
||||
const RoomConfigListItemFunc = (c: JiraProjectResponseItem) => c.config.url;
|
||||
|
||||
export const JiraProjectConfig: BridgeConfig = ({ api, roomId }) => {
|
||||
return <RoomConfig<never, JiraProjectResponseItem, JiraProjectConnectionState>
|
||||
headerImg={JiraIcon}
|
||||
api={api}
|
||||
roomId={roomId}
|
||||
type="jira"
|
||||
text={RoomConfigText}
|
||||
listItemName={RoomConfigListItemFunc}
|
||||
connectionEventType={EventType}
|
||||
connectionConfigComponent={ConnectionConfiguration}
|
||||
/>;
|
||||
};
|
@ -4,9 +4,10 @@ import { BridgeAPI, BridgeAPIError } from "../../BridgeAPI";
|
||||
import { ErrorPane, ListItem } from "../elements";
|
||||
import style from "./RoomConfig.module.scss";
|
||||
import { GetConnectionsResponseItem } from "../../../src/provisioning/api";
|
||||
import { IConnectionState } from "../../../src/Connections";
|
||||
|
||||
|
||||
export interface ConnectionConfigurationProps<SConfig, ConnectionType extends GetConnectionsResponseItem, ConnectionState> {
|
||||
export interface ConnectionConfigurationProps<SConfig, ConnectionType extends GetConnectionsResponseItem, ConnectionState extends IConnectionState> {
|
||||
serviceConfig: SConfig;
|
||||
onSave: (newConfig: ConnectionState) => void,
|
||||
existingConnection?: ConnectionType;
|
||||
@ -14,7 +15,7 @@ export interface ConnectionConfigurationProps<SConfig, ConnectionType extends Ge
|
||||
api: BridgeAPI;
|
||||
}
|
||||
|
||||
interface IRoomConfigProps<SConfig, ConnectionType extends GetConnectionsResponseItem, ConnectionState> {
|
||||
interface IRoomConfigProps<SConfig, ConnectionType extends GetConnectionsResponseItem, ConnectionState extends IConnectionState> {
|
||||
api: BridgeAPI;
|
||||
roomId: string;
|
||||
type: string;
|
||||
@ -30,7 +31,7 @@ interface IRoomConfigProps<SConfig, ConnectionType extends GetConnectionsRespons
|
||||
connectionConfigComponent: FunctionComponent<ConnectionConfigurationProps<SConfig, ConnectionType, ConnectionState>>;
|
||||
}
|
||||
|
||||
export const RoomConfig = function<SConfig, ConnectionType extends GetConnectionsResponseItem, ConnectionState>(props: IRoomConfigProps<SConfig, ConnectionType, ConnectionState>) {
|
||||
export const RoomConfig = function<SConfig, ConnectionType extends GetConnectionsResponseItem, ConnectionState extends IConnectionState>(props: IRoomConfigProps<SConfig, ConnectionType, ConnectionState>) {
|
||||
const { api, roomId, type, headerImg, text, listItemName, connectionEventType } = props;
|
||||
const ConnectionConfigComponent = props.connectionConfigComponent;
|
||||
const [ error, setError ] = useState<null|{header?: string, message: string}>(null);
|
||||
@ -69,7 +70,7 @@ export const RoomConfig = function<SConfig, ConnectionType extends GetConnection
|
||||
})
|
||||
}, [api, type]);
|
||||
|
||||
const handleSaveOnCreation = useCallback((config) => {
|
||||
const handleSaveOnCreation = useCallback((config: ConnectionState) => {
|
||||
api.createConnection(roomId, connectionEventType, config).then(() => {
|
||||
// Force reload
|
||||
incrementConnectionKey(undefined);
|
||||
@ -109,6 +110,7 @@ export const RoomConfig = function<SConfig, ConnectionType extends GetConnection
|
||||
existingConnection={c}
|
||||
onSave={(config) => {
|
||||
api.updateConnection(roomId, c.id, config).then(() => {
|
||||
c.config = config;
|
||||
// Force reload
|
||||
incrementConnectionKey(undefined);
|
||||
setError(null);
|
||||
|
BIN
web/icons/jira.png
Normal file
BIN
web/icons/jira.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 17 KiB |
@ -1,7 +1,6 @@
|
||||
import { h, render } from 'preact';
|
||||
import 'preact/devtools';
|
||||
import App from './App';
|
||||
import "@fontsource/open-sans/400.css";
|
||||
import "./fonts/fonts.scss"
|
||||
import "./styling.scss";
|
||||
|
||||
|
23
web/tsconfig.json
Normal file
23
web/tsconfig.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"target": "es2019",
|
||||
"moduleResolution": "node",
|
||||
"jsx": "preserve",
|
||||
"jsxFactory": "h",
|
||||
/* noEmit - Snowpack builds (emits) files, not tsc. */
|
||||
"noEmit": true,
|
||||
/* Additional Options */
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"esModuleInterop": true,
|
||||
"typeRoots": [
|
||||
"./typings"
|
||||
]
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user