Merge branch 'main' into hs/testing-generic-webhooks

This commit is contained in:
Will Hunt 2022-10-05 15:55:15 +01:00 committed by GitHub
commit 5cce6611a5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
102 changed files with 1261 additions and 789 deletions

View File

@ -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)
==================

View File

@ -1 +0,0 @@
Track coverage of tests.

View File

@ -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 !.

View File

@ -1 +0,0 @@
Fix error when responding to a provisioning request for a room that the Hookshot bot isn't yet a member of.

View File

@ -1 +0,0 @@
Add support for ARM64 docker images.

View File

@ -1 +0,0 @@
Added new config option `feeds.pollTimeoutSeconds` to explictly set how long to wait for a feed response.

View File

@ -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.

View File

@ -1 +0,0 @@
Hookshot now waits for Redis to be ready before handling traffic.

View File

@ -1 +0,0 @@
JSON logging output now includes new keys such as `error` and `args`.

View File

@ -1 +0,0 @@
Fix room membership going stale for rooms used in the permissions config.

1
changelog.d/488.misc Normal file
View File

@ -0,0 +1 @@
Use the `matrix-appservice-bridge` logging implementation.

1
changelog.d/491.bugfix Normal file
View 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
View File

@ -0,0 +1 @@
Added `create-confidential` GitLab connection command.

1
changelog.d/500.feature Normal file
View 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
View File

@ -0,0 +1 @@
Add room configuration widget for Jira.

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

@ -0,0 +1 @@
Add bot commands to list and remove Jira connections.

1
changelog.d/504.bugfix Normal file
View File

@ -0,0 +1 @@
Improve formatting of help commands and Jira's `whoami` command.

1
changelog.d/505.misc Normal file
View File

@ -0,0 +1 @@
Improve some type-checking in the codebase.

1
changelog.d/506.misc Normal file
View 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
View File

@ -0,0 +1 @@
Add a configuration widget for Jira.

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

@ -0,0 +1 @@
Reorganize the GitHub widget to allow searching for repositories by organization.

1
changelog.d/512.feature Normal file
View 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
View File

@ -0,0 +1 @@
Fix inactive "Command Prefix" field in configuration widgets.

1
changelog.d/517.feature Normal file
View 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
View File

@ -0,0 +1 @@
Don't send empty query string in some widget API requests.

1
changelog.d/519.bugfix Normal file
View File

@ -0,0 +1 @@
Fix support for the "Labeled" event in the GitHub widget.

View File

@ -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

View File

@ -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:

View File

@ -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

View 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.

View File

@ -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",

View File

@ -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";

View File

@ -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);
});

View File

@ -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) {

View File

@ -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) {

View File

@ -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],

View File

@ -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>(

View File

@ -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

View File

@ -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.');

View File

@ -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);

View File

@ -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 || '✅';

View File

@ -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 {

View File

@ -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;

View File

@ -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() {

View File

@ -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();
/**

View File

@ -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;

View File

@ -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 {

View File

@ -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.

View File

@ -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() {

View File

@ -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;

View File

@ -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;

View File

@ -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;
}

View File

@ -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

View File

@ -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,
@ -219,7 +243,60 @@ 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;
}
}

View File

@ -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);

View File

@ -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() {

View File

@ -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");

View File

@ -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,

View File

@ -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;

View File

@ -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.

View File

@ -63,6 +63,12 @@ export interface IGitLabWebhookMREvent {
repository: IGitlabRepository;
object_attributes: IGitLabMergeRequestObjectAttributes;
labels: IGitLabLabel[];
changes: {
[key: string]: {
before: string;
after: string;
}
}
}
export interface IGitLabWebhookTagPushEvent {

View File

@ -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];

View File

@ -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);
}

View File

@ -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;

View File

@ -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;

View File

@ -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";

View File

@ -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: {

View File

@ -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 });
}
}

View File

@ -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;

View File

@ -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.";

View File

@ -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();

View File

@ -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;

View File

@ -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;

View File

@ -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 */

View File

@ -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 {

View File

@ -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,

View File

@ -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;

View File

@ -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"];

View File

@ -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,

View File

@ -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;

View File

@ -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,

View File

@ -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();

View File

@ -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);

View File

@ -1,8 +1,10 @@
import { BridgeConfigFigma } from "../Config/Config";
import * as Figma from 'figma-js';
import { MatrixClient } from "matrix-bot-sdk";
export * from "./router";
export * from "./types";
import { Logger } from "matrix-appservice-bridge";
import { AxiosError } from "axios";
import LogWrapper from "../LogWrapper";
export * from "./router";
export * from "./types";
@ -15,8 +17,8 @@ interface FigmaWebhookDefinition {
description: string;
}
const log = new LogWrapper('FigmaWebhooks');
const log = new Logger('FigmaWebhooks');
export async function ensureFigmaWebhooks(figmaConfig: BridgeConfigFigma, matrixClient: MatrixClient) {
const publicUrl = figmaConfig.publicUrl;
const axiosConfig = { baseURL: 'https://api.figma.com/v2'};
@ -41,21 +43,25 @@ export async function ensureFigmaWebhooks(figmaConfig: BridgeConfigFigma, matrix
let webhookDefinition: FigmaWebhookDefinition|undefined;
if (webhookId) {
try {
webhookDefinition = (await client.client.get(`v2/webhooks/${webhookId}`, axiosConfig)).data;
webhookDefinition = (await client.client.get(`webhooks/${webhookId}`, axiosConfig)).data;
log.info(`Found existing hook for Figma instance ${instanceName} ${webhookId}`);
} catch (ex) {
const axiosErr = ex as AxiosError;
if (axiosErr.isAxiosError) {
log.error(`Failed to update webhook: ${axiosErr.code} ${axiosErr.response?.data?.message ?? ""}`)
if (axiosErr.response?.status !== 404) {
// Missing webhook, probably not found.
if (axiosErr.isAxiosError) {
log.error(`Failed to update webhook: ${axiosErr.response?.status} ${axiosErr.response?.data?.message ?? ""}`)
}
throw Error(`Failed to verify Figma webhooks for ${instanceName}: ${ex.message}`);
}
throw Error(`Failed to verify Figma webhooks for ${instanceName}: ${ex.message}`);
log.warn(`Previous webhook ID ${webhookId} stored but API returned not found, creating new one.`);
}
}
if (webhookDefinition) {
if (webhookDefinition.endpoint !== publicUrl || webhookDefinition.passcode !== passcode) {
log.info(`Existing hook ${webhookId} for ${instanceName} has stale endpoint or passcode, updating`);
try {
await client.client.put(`v2/webhooks/${webhookId}`, {
await client.client.put(`webhooks/${webhookId}`, {
passcode,
endpoint: publicUrl,
}, axiosConfig);
@ -70,12 +76,12 @@ export async function ensureFigmaWebhooks(figmaConfig: BridgeConfigFigma, matrix
} else {
log.info(`No webhook defined for instance ${instanceName}, creating`);
try {
const res = await client.client.post(`v2/webhooks`, {
const res = await client.client.post(`webhooks`, {
passcode,
endpoint: publicUrl,
description: 'matrix-hookshot',
event_type: 'FILE_COMMENT',
team_id: teamId,
team_id: teamId.toString(),
}, axiosConfig);
webhookDefinition = res.data as FigmaWebhookDefinition;
await matrixClient.setAccountData(accountDataKey, {webhookId: webhookDefinition.id});

View File

@ -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) { }

View File

@ -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) { }

View File

@ -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 {

View File

@ -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 = /!.+:.+/;

View File

@ -1,2 +1,2 @@
import LogWrapper from "../src/LogWrapper";
LogWrapper.configureLogging({level: "info"});
import { Logger } from "matrix-appservice-bridge";
Logger.configure({console: "info"});

View File

@ -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"
]
},
}

View File

@ -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}` : ''}`);
}
}

View File

@ -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",

View File

@ -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> : <></>
};

View File

@ -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>

View File

@ -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>

View 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}
/>;
};

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -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
View 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