mirror of
https://github.com/matrix-org/matrix-hookshot.git
synced 2025-03-10 13:17:08 +00:00
Merge remote-tracking branch 'origin/main' into hs/provisioning
This commit is contained in:
commit
d649d872bc
@ -12,6 +12,7 @@ module.exports = {
|
||||
rules: {
|
||||
"@typescript-eslint/explicit-module-boundary-types": "off",
|
||||
"camelcase": ["error", { "properties": "never", "ignoreDestructuring": true }],
|
||||
"no-console": "error"
|
||||
},
|
||||
"env": {
|
||||
"node": true,
|
||||
|
@ -12,14 +12,24 @@ github:
|
||||
# (Optional) Configure this to enable GitHub support
|
||||
#
|
||||
auth:
|
||||
# Authentication for the GitHub App.
|
||||
#
|
||||
id: 123
|
||||
privateKeyFile: github-key.pem
|
||||
webhook:
|
||||
# Webhook settings for the GitHub app.
|
||||
#
|
||||
secret: secrettoken
|
||||
oauth:
|
||||
# (Optional) Settings for allowing users to sign in via OAuth.
|
||||
#
|
||||
client_id: foo
|
||||
client_secret: bar
|
||||
redirect_uri: https://example.com/bridge_oauth/
|
||||
webhook:
|
||||
secret: secrettoken
|
||||
defaultOptions:
|
||||
# (Optional) Default options for GitHub connections.
|
||||
#
|
||||
showIssueRoomLink: false
|
||||
gitlab:
|
||||
# (Optional) Configure this to enable GitLab support
|
||||
#
|
||||
@ -41,6 +51,7 @@ generic:
|
||||
# (Optional) Support for generic webhook events. `allowJsTransformationFunctions` will allow users to write short transformation snippets in code, and thus is unsafe in untrusted environments
|
||||
#
|
||||
enabled: false
|
||||
urlPrefix: https://example.com/mywebhookspath/
|
||||
allowJsTransformationFunctions: false
|
||||
webhook:
|
||||
# HTTP webhook listener options
|
||||
|
@ -5,7 +5,7 @@ import { BridgeConfig, parseRegistrationFile } from "../Config/Config";
|
||||
import { Webhooks } from "../Webhooks";
|
||||
import { MatrixSender } from "../MatrixSender";
|
||||
import { UserNotificationWatcher } from "../Notifications/UserNotificationWatcher";
|
||||
import { LogLevel, LogService } from "matrix-bot-sdk";
|
||||
|
||||
LogWrapper.configureLogging("debug");
|
||||
const log = new LogWrapper("App");
|
||||
|
||||
|
@ -33,6 +33,7 @@ import { JiraProvisionerRouter } from "./Jira/Router";
|
||||
import { GitHubProvisionerRouter } from "./Github/Router";
|
||||
import { OAuthRequest } from "./WebhookTypes";
|
||||
import { promises as fs } from "fs";
|
||||
import { SetupConnection } from "./Connections/SetupConnection";
|
||||
const log = new LogWrapper("Bridge");
|
||||
|
||||
export class Bridge {
|
||||
@ -288,15 +289,9 @@ export class Bridge {
|
||||
this.bindHandlerToQueue<IGitLabWebhookMREvent, GitLabRepoConnection>(
|
||||
"gitlab.merge_request.merge",
|
||||
(data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
|
||||
(c, data) => c.onMergeRequestReviewed(data),
|
||||
(c, data) => c.onMergeRequestMerged(data),
|
||||
);
|
||||
|
||||
this.bindHandlerToQueue<IGitLabWebhookMREvent, GitLabRepoConnection>(
|
||||
"gitlab.merge_request.merge",
|
||||
(data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
|
||||
(c, data) => c.onMergeRequestReviewed(data),
|
||||
);
|
||||
|
||||
|
||||
this.bindHandlerToQueue<IGitLabWebhookMREvent, GitLabRepoConnection>(
|
||||
"gitlab.merge_request.approved",
|
||||
(data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace),
|
||||
@ -592,7 +587,6 @@ export class Bridge {
|
||||
BRIDGE_ROOM_TYPE, roomId, room.accountData,
|
||||
);
|
||||
}
|
||||
// This is a group room, don't add the admin settings and just sit in the room.
|
||||
}
|
||||
|
||||
private async onRoomMessage(roomId: string, event: MatrixEvent<MatrixMessageContent>) {
|
||||
@ -612,55 +606,68 @@ export class Bridge {
|
||||
log.debug("Content:", JSON.stringify(event));
|
||||
const adminRoom = this.adminRooms.get(roomId);
|
||||
|
||||
if (adminRoom) {
|
||||
if (adminRoom.userId !== event.sender) {
|
||||
return;
|
||||
}
|
||||
|
||||
const replyProcessor = new RichRepliesPreprocessor(true);
|
||||
const processedReply = await replyProcessor.processEvent(event, this.as.botClient);
|
||||
|
||||
if (processedReply) {
|
||||
const metadata: IRichReplyMetadata = processedReply.mx_richreply;
|
||||
log.info(`Handling reply to ${metadata.parentEventId} for ${adminRoom.userId}`);
|
||||
// This might be a reply to a notification
|
||||
if (!adminRoom) {
|
||||
let handled = false;
|
||||
for (const connection of this.connectionManager.getAllConnectionsForRoom(roomId)) {
|
||||
try {
|
||||
const ev = metadata.realEvent;
|
||||
const splitParts: string[] = ev.content["uk.half-shot.matrix-hookshot.github.repo"]?.name.split("/");
|
||||
const issueNumber = ev.content["uk.half-shot.matrix-hookshot.github.issue"]?.number;
|
||||
if (splitParts && issueNumber) {
|
||||
log.info(`Handling reply for ${splitParts}${issueNumber}`);
|
||||
const connections = this.connectionManager.getConnectionsForGithubIssue(splitParts[0], splitParts[1], issueNumber);
|
||||
await Promise.all(connections.map(async c => {
|
||||
if (c instanceof GitHubIssueConnection) {
|
||||
return c.onMatrixIssueComment(processedReply);
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
log.info("Missing parts!:", splitParts, issueNumber);
|
||||
if (connection.onMessageEvent) {
|
||||
handled = await connection.onMessageEvent(event);
|
||||
}
|
||||
} catch (ex) {
|
||||
await adminRoom.sendNotice("Failed to handle repy. You may not be authenticated to do that.");
|
||||
log.error("Reply event could not be handled:", ex);
|
||||
log.warn(`Connection ${connection.toString()} failed to handle message:`, ex);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const command = event.content.body;
|
||||
if (command) {
|
||||
await adminRoom.handleCommand(event.event_id, command);
|
||||
if (!handled) {
|
||||
// Divert to the setup room code if we didn't match any of these
|
||||
try {
|
||||
await (
|
||||
new SetupConnection(roomId, this.as, this.tokenStore, this.github, !!this.config.jira, this.config.generic)
|
||||
).onMessageEvent(event);
|
||||
} catch (ex) {
|
||||
log.warn(`Setup connection failed to handle:`, ex);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const connection of this.connectionManager.getAllConnectionsForRoom(roomId)) {
|
||||
if (adminRoom.userId !== event.sender) {
|
||||
return;
|
||||
}
|
||||
|
||||
const replyProcessor = new RichRepliesPreprocessor(true);
|
||||
const processedReply = await replyProcessor.processEvent(event, this.as.botClient);
|
||||
|
||||
if (processedReply) {
|
||||
const metadata: IRichReplyMetadata = processedReply.mx_richreply;
|
||||
log.info(`Handling reply to ${metadata.parentEventId} for ${adminRoom.userId}`);
|
||||
// This might be a reply to a notification
|
||||
try {
|
||||
if (connection.onMessageEvent) {
|
||||
await connection.onMessageEvent(event);
|
||||
const ev = metadata.realEvent;
|
||||
const splitParts: string[] = ev.content["uk.half-shot.matrix-hookshot.github.repo"]?.name.split("/");
|
||||
const issueNumber = ev.content["uk.half-shot.matrix-hookshot.github.issue"]?.number;
|
||||
if (splitParts && issueNumber) {
|
||||
log.info(`Handling reply for ${splitParts}${issueNumber}`);
|
||||
const connections = this.connectionManager.getConnectionsForGithubIssue(splitParts[0], splitParts[1], issueNumber);
|
||||
await Promise.all(connections.map(async c => {
|
||||
if (c instanceof GitHubIssueConnection) {
|
||||
return c.onMatrixIssueComment(processedReply);
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
log.info("Missing parts!:", splitParts, issueNumber);
|
||||
}
|
||||
} catch (ex) {
|
||||
log.warn(`Connection ${connection.toString()} failed to handle message:`, ex);
|
||||
await adminRoom.sendNotice("Failed to handle repy. You may not be authenticated to do that.");
|
||||
log.error("Reply event could not be handled:", ex);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const command = event.content.body;
|
||||
if (command) {
|
||||
await adminRoom.handleCommand(event.event_id, command);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private async onRoomJoin(roomId: string, matrixEvent: MatrixEvent<MatrixMemberContent>) {
|
||||
|
@ -4,15 +4,15 @@ import { IAppserviceRegistration } from "matrix-bot-sdk";
|
||||
import * as assert from "assert";
|
||||
import { configKey } from "./Decorators";
|
||||
|
||||
export interface BridgeConfigGitHub {
|
||||
interface BridgeConfigGitHubYAML {
|
||||
auth: {
|
||||
id: number|string;
|
||||
privateKeyFile: string;
|
||||
};
|
||||
webhook: {
|
||||
secret: string;
|
||||
},
|
||||
oauth: {
|
||||
};
|
||||
oauth?: {
|
||||
// eslint-disable-next-line camelcase
|
||||
client_id: string;
|
||||
// eslint-disable-next-line camelcase
|
||||
@ -20,6 +20,41 @@ export interface BridgeConfigGitHub {
|
||||
// eslint-disable-next-line camelcase
|
||||
redirect_uri: string;
|
||||
};
|
||||
defaultOptions?: {
|
||||
showIssueRoomLink: false;
|
||||
}
|
||||
}
|
||||
|
||||
export class BridgeConfigGitHub {
|
||||
@configKey("Authentication for the GitHub App.", false)
|
||||
auth: {
|
||||
id: number|string;
|
||||
privateKeyFile: string;
|
||||
};
|
||||
@configKey("Webhook settings for the GitHub app.", false)
|
||||
webhook: {
|
||||
secret: string;
|
||||
};
|
||||
@configKey("Settings for allowing users to sign in via OAuth.", true)
|
||||
oauth?: {
|
||||
// eslint-disable-next-line camelcase
|
||||
client_id: string;
|
||||
// eslint-disable-next-line camelcase
|
||||
client_secret: string;
|
||||
// eslint-disable-next-line camelcase
|
||||
redirect_uri: string;
|
||||
};
|
||||
@configKey("Default options for GitHub connections.", true)
|
||||
defaultOptions?: {
|
||||
showIssueRoomLink: false;
|
||||
};
|
||||
|
||||
constructor(yaml: BridgeConfigGitHubYAML) {
|
||||
this.auth = yaml.auth;
|
||||
this.webhook = yaml.webhook;
|
||||
this.oauth = yaml.oauth;
|
||||
this.defaultOptions = yaml.defaultOptions;
|
||||
}
|
||||
}
|
||||
|
||||
export interface GitLabInstance {
|
||||
@ -52,8 +87,9 @@ export interface BridgeConfigJira {
|
||||
};
|
||||
}
|
||||
|
||||
interface BridgeGenericWebhooksConfig {
|
||||
export interface BridgeGenericWebhooksConfig {
|
||||
enabled: boolean;
|
||||
urlPrefix: string;
|
||||
allowJsTransformationFunctions?: boolean;
|
||||
}
|
||||
|
||||
@ -147,7 +183,7 @@ export class BridgeConfig {
|
||||
constructor(configData: BridgeConfigRoot, env: {[key: string]: string|undefined}) {
|
||||
this.bridge = configData.bridge;
|
||||
assert.ok(this.bridge);
|
||||
this.github = configData.github;
|
||||
this.github = configData.github && new BridgeConfigGitHub(configData.github);
|
||||
if (this.github?.auth && env["GITHUB_PRIVATE_KEY_FILE"]) {
|
||||
this.github.auth.privateKeyFile = env["GITHUB_PRIVATE_KEY_FILE"];
|
||||
}
|
||||
|
@ -47,6 +47,9 @@ export const DefaultConfig = new BridgeConfig({
|
||||
webhook: {
|
||||
secret: "secrettoken",
|
||||
},
|
||||
defaultOptions: {
|
||||
showIssueRoomLink: false,
|
||||
}
|
||||
},
|
||||
gitlab: {
|
||||
instances: {
|
||||
@ -70,6 +73,7 @@ export const DefaultConfig = new BridgeConfig({
|
||||
},
|
||||
generic: {
|
||||
enabled: false,
|
||||
urlPrefix: "https://example.com/mywebhookspath/",
|
||||
allowJsTransformationFunctions: false,
|
||||
},
|
||||
provisioning: {
|
||||
@ -145,6 +149,7 @@ async function renderRegistrationFile(configPath?: string) {
|
||||
rooms: [],
|
||||
},
|
||||
};
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(YAML.stringify(obj));
|
||||
}
|
||||
|
||||
@ -152,9 +157,11 @@ async function renderRegistrationFile(configPath?: string) {
|
||||
// Can be called directly
|
||||
if (require.main === module) {
|
||||
if (process.argv[2] === '--config') {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(renderDefaultConfig());
|
||||
} else if (process.argv[2] === '--registration') {
|
||||
renderRegistrationFile(process.argv[3]).catch(ex => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(ex);
|
||||
process.exit(1);
|
||||
});
|
||||
|
@ -8,6 +8,7 @@ import { Appservice, StateEvent } from "matrix-bot-sdk";
|
||||
import { CommentProcessor } from "./CommentProcessor";
|
||||
import { BridgeConfig, GitLabInstance } from "./Config/Config";
|
||||
import { GenericHookConnection, GitHubDiscussionConnection, GitHubDiscussionSpace, GitHubIssueConnection, GitHubProjectConnection, GitHubRepoConnection, GitHubUserSpace, GitLabIssueConnection, GitLabRepoConnection, IConnection, JiraProjectConnection } from "./Connections";
|
||||
import { GenericHookAccountData } from "./Connections/GenericHook";
|
||||
import { GithubInstance } from "./Github/GithubInstance";
|
||||
import { GitLabClient } from "./Gitlab/Client";
|
||||
import { JiraProject } from "./Jira/Types";
|
||||
@ -15,6 +16,7 @@ import LogWrapper from "./LogWrapper";
|
||||
import { MessageSenderClient } from "./MatrixSender";
|
||||
import { ApiError, GetConnectionTypeResponseItem } from "./provisioning/api";
|
||||
import { UserTokenStore } from "./UserTokenStore";
|
||||
import {v4 as uuid} from "uuid";
|
||||
|
||||
const log = new LogWrapper("ConnectionManager");
|
||||
|
||||
@ -74,10 +76,10 @@ export class ConnectionManager {
|
||||
}
|
||||
|
||||
if (GitHubRepoConnection.EventTypes.includes(state.type)) {
|
||||
if (!this.github) {
|
||||
if (!this.github || !this.config.github) {
|
||||
throw Error('GitHub is not configured');
|
||||
}
|
||||
return new GitHubRepoConnection(roomId, this.as, state.content, this.tokenStore, state.stateKey);
|
||||
return new GitHubRepoConnection(roomId, this.as, state.content, this.tokenStore, state.stateKey, this.github, this.config.github);
|
||||
}
|
||||
|
||||
if (GitHubDiscussionConnection.EventTypes.includes(state.type)) {
|
||||
@ -153,9 +155,18 @@ export class ConnectionManager {
|
||||
}
|
||||
|
||||
if (GenericHookConnection.EventTypes.includes(state.type) && this.config.generic?.enabled) {
|
||||
// Generic hooks store the hookId in the account data
|
||||
let acctData = await this.as.botClient.getSafeRoomAccountData<GenericHookAccountData|null>(GenericHookConnection.CanonicalEventType, roomId);
|
||||
if (!acctData) {
|
||||
log.info(`hookId for ${roomId} not set, setting`);
|
||||
acctData = { hookId: uuid() };
|
||||
await this.as.botClient.setRoomAccountData(GenericHookConnection.CanonicalEventType, roomId, acctData);
|
||||
await this.as.botClient.sendStateEvent(roomId, GenericHookConnection.CanonicalEventType, state.stateKey, {...state.content, hookId: acctData.hookId });
|
||||
}
|
||||
return new GenericHookConnection(
|
||||
roomId,
|
||||
state.content,
|
||||
acctData,
|
||||
state.stateKey,
|
||||
this.messageClient,
|
||||
this.config.generic.allowJsTransformationFunctions
|
||||
|
@ -21,7 +21,7 @@ export abstract class CommandConnection {
|
||||
const { error, handled, humanError } = await handleCommand(ev.sender, ev.content.body, this.botCommands, this, this.commandPrefix);
|
||||
if (!handled) {
|
||||
// Not for us.
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (error) {
|
||||
await this.botClient.sendEvent(this.roomId, "m.reaction", {
|
||||
@ -36,7 +36,7 @@ export abstract class CommandConnection {
|
||||
body: humanError ? `Failed to handle command: ${humanError}` : "Failed to handle command",
|
||||
});
|
||||
log.warn(`Failed to handle command:`, error);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
await this.botClient.sendEvent(this.roomId, "m.reaction", {
|
||||
"m.relates_to": {
|
||||
@ -45,6 +45,7 @@ export abstract class CommandConnection {
|
||||
key: "✅",
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@botCommand("help", "This help text")
|
||||
|
@ -6,10 +6,20 @@ import { Script, createContext } from "vm";
|
||||
import { MatrixEvent } from "../MatrixEvent";
|
||||
|
||||
export interface GenericHookConnectionState {
|
||||
/**
|
||||
* This is ONLY used for display purposes.
|
||||
*/
|
||||
hookId: string;
|
||||
transformationFunction?: string;
|
||||
}
|
||||
|
||||
export interface GenericHookAccountData {
|
||||
/**
|
||||
* This is where the true hook ID is kept.
|
||||
*/
|
||||
hookId: string;
|
||||
}
|
||||
|
||||
const log = new LogWrapper("GenericHookConnection");
|
||||
const md = new markdownit();
|
||||
|
||||
@ -28,13 +38,14 @@ export class GenericHookConnection implements IConnection {
|
||||
];
|
||||
|
||||
public get hookId() {
|
||||
return this.state.hookId;
|
||||
return this.accountData.hookId;
|
||||
}
|
||||
|
||||
private transformationFunction?: Script;
|
||||
|
||||
constructor(public readonly roomId: string,
|
||||
private state: GenericHookConnectionState,
|
||||
state: GenericHookConnectionState,
|
||||
private readonly accountData: GenericHookAccountData,
|
||||
private readonly stateKey: string,
|
||||
private messageClient: MessageSenderClient,
|
||||
private readonly allowJSTransformation: boolean = false) {
|
||||
@ -63,11 +74,28 @@ export class GenericHookConnection implements IConnection {
|
||||
}
|
||||
}
|
||||
|
||||
public transformHookData(data: Record<string, unknown>): string {
|
||||
// Supported parameters https://developers.mattermost.com/integrate/incoming-webhooks/#parameters
|
||||
let msg = "";
|
||||
if (typeof data.username === "string") {
|
||||
// Create a matrix user for this person
|
||||
msg = `**${data.username}**: `
|
||||
}
|
||||
if (typeof data.text === "string") {
|
||||
msg = data.text;
|
||||
} else {
|
||||
msg = `Recieved webhook data:\n\n\`\`\`${JSON.stringify(data, undefined, 2)}\`\`\``;
|
||||
}
|
||||
|
||||
// TODO: Transform Slackdown into markdown.
|
||||
return msg;
|
||||
}
|
||||
|
||||
public async onGenericHook(data: Record<string, unknown>) {
|
||||
log.info(`onGenericHook ${this.roomId} ${this.hookId}`);
|
||||
let content: string;
|
||||
if (!this.transformationFunction) {
|
||||
content = `Recieved webhook data:\n\n\`\`\`${JSON.stringify(data)}\`\`\``;
|
||||
content = this.transformHookData(data);
|
||||
} else {
|
||||
try {
|
||||
const context = createContext({data});
|
||||
|
@ -100,12 +100,13 @@ export class GitHubDiscussionConnection implements IConnection {
|
||||
if (octokit === null) {
|
||||
// TODO: Use Reply - Also mention user.
|
||||
await this.as.botClient.sendNotice(this.roomId, `${ev.sender}: Cannot send comment, you are not logged into GitHub`);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
const qlClient = new GithubGraphQLClient(octokit);
|
||||
const commentId = await qlClient.addDiscussionComment(this.state.internalId, ev.content.body);
|
||||
log.info(`Sent ${commentId} for ${ev.event_id} (${ev.sender})`);
|
||||
this.sentEvents.add(commentId);
|
||||
return true;
|
||||
}
|
||||
|
||||
public get discussionNumber() {
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { IConnection } from "./IConnection";
|
||||
import { Appservice, Space } from "matrix-bot-sdk";
|
||||
import LogWrapper from "../LogWrapper";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import { ReposGetResponseData } from "../Github/Types";
|
||||
import axios from "axios";
|
||||
import { GitHubDiscussionConnection } from "./GithubDiscussion";
|
||||
|
@ -47,6 +47,10 @@ export class GitHubIssueConnection implements IConnection {
|
||||
|
||||
static readonly QueryRoomRegex = /#github_(.+)_(.+)_(\d+):.*/;
|
||||
|
||||
static generateAliasLocalpart(org: string, repo: string, issueNo: string|number) {
|
||||
return `github_${org}_${repo}_${issueNo}`;
|
||||
}
|
||||
|
||||
static async onQueryRoom(result: RegExpExecArray, opts: IQueryRoomOpts): Promise<unknown> {
|
||||
const parts = result?.slice(1);
|
||||
if (!parts) {
|
||||
@ -327,9 +331,11 @@ export class GitHubIssueConnection implements IConnection {
|
||||
public async onMessageEvent(ev: MatrixEvent<MatrixMessageContent>) {
|
||||
if (ev.content.body === '!sync') {
|
||||
// Sync data.
|
||||
return this.syncIssueState();
|
||||
await this.syncIssueState();
|
||||
return true;
|
||||
}
|
||||
await this.onMatrixIssueComment(ev);
|
||||
return true;
|
||||
}
|
||||
|
||||
public toString() {
|
||||
|
@ -16,6 +16,8 @@ import LogWrapper from "../LogWrapper";
|
||||
import markdown from "markdown-it";
|
||||
import { CommandConnection } from "./CommandConnection";
|
||||
import { GithubInstance } from "../Github/GithubInstance";
|
||||
import { GitHubIssueConnection } from ".";
|
||||
import { BridgeConfigGitHub } from "../Config/Config";
|
||||
const log = new LogWrapper("GitHubRepoConnection");
|
||||
const md = new markdown();
|
||||
|
||||
@ -32,6 +34,7 @@ export interface GitHubRepoConnectionState {
|
||||
repo: string;
|
||||
ignoreHooks?: string[],
|
||||
commandPrefix?: string;
|
||||
showIssueRoomLink?: boolean;
|
||||
}
|
||||
|
||||
const GITHUB_REACTION_CONTENT: {[emoji: string]: string} = {
|
||||
@ -151,7 +154,10 @@ export class GitHubRepoConnection extends CommandConnection implements IConnecti
|
||||
private readonly as: Appservice,
|
||||
private state: GitHubRepoConnectionState,
|
||||
private readonly tokenStore: UserTokenStore,
|
||||
private readonly stateKey: string) {
|
||||
private readonly stateKey: string,
|
||||
private readonly githubInstance: GithubInstance,
|
||||
private readonly config: BridgeConfigGitHub,
|
||||
) {
|
||||
super(
|
||||
roomId,
|
||||
as.botClient,
|
||||
@ -165,6 +171,10 @@ export class GitHubRepoConnection extends CommandConnection implements IConnecti
|
||||
return this.state.org.toLowerCase();
|
||||
}
|
||||
|
||||
private get showIssueRoomLink() {
|
||||
return this.state.showIssueRoomLink === undefined ? (this.config.defaultOptions?.showIssueRoomLink || false) : this.state.showIssueRoomLink;
|
||||
}
|
||||
|
||||
public get repo() {
|
||||
return this.state.repo.toLowerCase();
|
||||
}
|
||||
@ -315,7 +325,15 @@ export class GitHubRepoConnection extends CommandConnection implements IConnecti
|
||||
const orgRepoName = event.repository.full_name;
|
||||
|
||||
let message = `${event.issue.user?.login} created new issue [${orgRepoName}#${event.issue.number}](${event.issue.html_url}): "${event.issue.title}"`;
|
||||
message = message + (event.issue.assignee ? ` assigned to ${event.issue.assignee.login}` : '');
|
||||
message += (event.issue.assignee ? ` assigned to ${event.issue.assignee.login}` : '');
|
||||
if (this.showIssueRoomLink) {
|
||||
const appInstance = await this.githubInstance.getSafeOctokitForRepo(this.org, this.repo);
|
||||
if (appInstance) {
|
||||
message += ` [Issue Room](https://matrix.to/#/${this.as.getAlias(GitHubIssueConnection.generateAliasLocalpart(this.org, this.repo, event.issue.number))})`;
|
||||
} else {
|
||||
log.warn(`Cannot show issue room link, no app install for ${orgRepoName}`);
|
||||
}
|
||||
}
|
||||
const content = emoji.emojify(message);
|
||||
const labels = FormatUtil.formatLabels(event.issue.labels?.map(l => ({ name: l.name, description: l.description || undefined, color: l.color || undefined })));
|
||||
await this.as.botIntent.sendEvent(this.roomId, {
|
||||
|
@ -183,8 +183,10 @@ export class GitLabIssueConnection implements IConnection {
|
||||
if (ev.content.body === '!sync') {
|
||||
// Sync data.
|
||||
// return this.syncIssueState();
|
||||
return true;
|
||||
}
|
||||
await this.onMatrixIssueComment(ev);
|
||||
return true;
|
||||
}
|
||||
|
||||
public toString() {
|
||||
|
@ -46,7 +46,7 @@ export class GitLabRepoConnection extends CommandConnection {
|
||||
as.botClient,
|
||||
GitLabRepoConnection.botCommands,
|
||||
GitLabRepoConnection.helpMessage,
|
||||
"!gl"
|
||||
state.commandPrefix || "!gl"
|
||||
)
|
||||
if (!state.path || !state.instance) {
|
||||
throw Error('Invalid state, missing `path` or `instance`');
|
||||
@ -70,7 +70,7 @@ export class GitLabRepoConnection extends CommandConnection {
|
||||
return GitLabRepoConnection.EventTypes.includes(eventType) && this.stateKey === stateKey;
|
||||
}
|
||||
|
||||
@botCommand("gl create", "Create an issue for this repo", ["title"], ["description", "labels"], true)
|
||||
@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.tokenStore.getGitLabForUser(userId, this.instance.url);
|
||||
if (!client) {
|
||||
@ -93,7 +93,7 @@ export class GitLabRepoConnection extends CommandConnection {
|
||||
});
|
||||
}
|
||||
|
||||
@botCommand("gl close", "Close an issue", ["number"], ["comment"], true)
|
||||
@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) {
|
||||
|
@ -16,9 +16,10 @@ export interface IConnection {
|
||||
onEvent?: (ev: MatrixEvent<unknown>) => Promise<void>;
|
||||
|
||||
/**
|
||||
* When a room gets a message event
|
||||
* When a room gets a message event.
|
||||
* @returns Was the message handled
|
||||
*/
|
||||
onMessageEvent?: (ev: MatrixEvent<MatrixMessageContent>) => Promise<void>;
|
||||
onMessageEvent?: (ev: MatrixEvent<MatrixMessageContent>) => Promise<boolean>;
|
||||
|
||||
onIssueCreated?: (ev: IssuesOpenedEvent) => Promise<void>;
|
||||
|
||||
|
@ -14,6 +14,7 @@ import { CommandConnection } from "./CommandConnection";
|
||||
import { UserTokenStore } from "../UserTokenStore";
|
||||
import { CommandError, NotLoggedInError } from "../errors";
|
||||
import { ApiError, ErrCode } from "../provisioning/api";
|
||||
import JiraApi from "jira-client";
|
||||
|
||||
type JiraAllowedEventsNames = "issue.created";
|
||||
const JiraAllowedEvents: JiraAllowedEventsNames[] = ["issue.created"];
|
||||
@ -214,11 +215,14 @@ export class JiraProjectConnection extends CommandConnection implements IConnect
|
||||
if (!jiraClient) {
|
||||
throw new NotLoggedInError();
|
||||
}
|
||||
const resource = (await jiraClient.getAccessibleResources()).find((r) => new URL(r.url).origin === this.instanceOrigin);
|
||||
if (!resource) {
|
||||
throw new CommandError("No-resource", "You do not have permission to create issues for this JIRA org");
|
||||
if (!this.projectUrl) {
|
||||
throw new CommandError("No-resource-origin", "Room is configured with an ID and not a URL, cannot determine correct JIRA client");
|
||||
}
|
||||
return jiraClient.getClientForResource(resource);
|
||||
const jiraProjectClient = await jiraClient.getClientForUrl(this.projectUrl);
|
||||
if (!jiraProjectClient) {
|
||||
throw new CommandError("No-resource", "You do not have permission to manage issues for this JIRA org");
|
||||
}
|
||||
return jiraProjectClient;
|
||||
}
|
||||
|
||||
@botCommand("create", "Create an issue for this project", ["type", "title"], ["description", "labels"], true)
|
||||
@ -238,7 +242,7 @@ export class JiraProjectConnection extends CommandConnection implements IConnect
|
||||
throw new CommandError("invalid-issuetype", `You must specify a valid issue type (one of ${content}). E.g. ${this.commandPrefix} create ${project.issueTypes[0].name}`);
|
||||
}
|
||||
log.info(`Creating new issue on behalf of ${userId}`);
|
||||
let result: any;
|
||||
let result: JiraApi.JsonResponse;
|
||||
try {
|
||||
result = await api.addNewIssue({
|
||||
//update: {},
|
||||
|
144
src/Connections/SetupConnection.ts
Normal file
144
src/Connections/SetupConnection.ts
Normal file
@ -0,0 +1,144 @@
|
||||
// We need to instantiate some functions which are not directly called, which confuses typescript.
|
||||
import { Appservice } from "matrix-bot-sdk";
|
||||
import { BotCommands, botCommand, compileBotCommands } from "../BotCommands";
|
||||
import { MatrixEvent, MatrixMessageContent } from "../MatrixEvent";
|
||||
import LogWrapper from "../LogWrapper";
|
||||
import { CommandConnection } from "./CommandConnection";
|
||||
import { GenericHookConnection, GitHubRepoConnection, GitHubRepoConnectionState, JiraProjectConnection, JiraProjectConnectionState } from ".";
|
||||
import { CommandError } from "../errors";
|
||||
import { UserTokenStore } from "../UserTokenStore";
|
||||
import { GithubInstance } from "../Github/GithubInstance";
|
||||
import { JiraProject } from "../Jira/Types";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import { BridgeGenericWebhooksConfig } from "../Config/Config";
|
||||
import markdown from "markdown-it";
|
||||
const md = new markdown();
|
||||
|
||||
const log = new LogWrapper("SetupConnection");
|
||||
|
||||
/**
|
||||
* Handles setting up a room with connections. This connection is "virtual" in that it has
|
||||
* no state, and is only invoked when messages from other clients fall through.
|
||||
*/
|
||||
export class SetupConnection extends CommandConnection {
|
||||
|
||||
static botCommands: BotCommands;
|
||||
static helpMessage: (cmdPrefix?: string | undefined) => MatrixMessageContent;
|
||||
|
||||
constructor(public readonly roomId: string,
|
||||
private readonly as: Appservice,
|
||||
private readonly tokenStore: UserTokenStore,
|
||||
private readonly githubInstance?: GithubInstance,
|
||||
private readonly jiraEnabled?: boolean,
|
||||
private readonly webhooksConfig?: BridgeGenericWebhooksConfig) {
|
||||
super(
|
||||
roomId,
|
||||
as.botClient,
|
||||
SetupConnection.botCommands,
|
||||
SetupConnection.helpMessage,
|
||||
"!hookshot",
|
||||
)
|
||||
}
|
||||
|
||||
public async onMessageEvent(ev: MatrixEvent<MatrixMessageContent>) {
|
||||
// Just check if the user has enough PL to change state
|
||||
if (!await this.as.botClient.userHasPowerLevelFor(ev.sender, this.roomId, "", true)) {
|
||||
throw new CommandError("not-configured", "You must be able to set state in a room ('Change settings') in order to setup new integrations.");
|
||||
}
|
||||
return super.onMessageEvent(ev);
|
||||
}
|
||||
|
||||
@botCommand("github repo", "Create a connection for a GitHub repository. (You must be logged in with GitHub to do this)", ["url"], [], true)
|
||||
public async onGitHubRepo(userId: string, url: string) {
|
||||
if (!this.githubInstance) {
|
||||
throw new CommandError("not-configured", "The bridge is not configured to support GitHub");
|
||||
}
|
||||
if (!await this.as.botClient.userHasPowerLevelFor(this.as.botUserId, this.roomId, GitHubRepoConnection.CanonicalEventType, true)) {
|
||||
throw new CommandError("Bot lacks power level to set room state", "I do not have permission to setup a bridge in this room. Please promote me to an Admin/Moderator");
|
||||
}
|
||||
const octokit = await this.tokenStore.getOctokitForUser(userId);
|
||||
if (!octokit) {
|
||||
throw new CommandError("User not logged in", "You are not logged into GitHub. Start a DM with this bot and use the command `github login`.");
|
||||
}
|
||||
const res = /^https:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(url.trim().toLowerCase());
|
||||
if (!res) {
|
||||
throw new CommandError("Invalid GitHub url", "The GitHub url you entered was not valid");
|
||||
}
|
||||
const [org, repo] = res;
|
||||
let resultRepo
|
||||
try {
|
||||
resultRepo = await octokit.repos.get({owner: org, repo});
|
||||
} catch (ex) {
|
||||
throw new CommandError("Invalid GitHub repo", "Could not find the requested GitHub repo. Do you have permission to view it?");
|
||||
}
|
||||
// Check if we have a webhook for this repo
|
||||
try {
|
||||
await this.githubInstance.getOctokitForRepo(org, repo);
|
||||
} catch (ex) {
|
||||
log.warn(`No app instance for new git connection:`, ex);
|
||||
// We might be able to do it via a personal access token
|
||||
await this.as.botClient.sendNotice(this.roomId, `Note: There doesn't appear to be a GitHub App install that covers this repository so webhooks won't work.`)
|
||||
}
|
||||
await this.as.botClient.sendStateEvent(this.roomId, GitHubRepoConnection.CanonicalEventType, url, {
|
||||
org,
|
||||
repo,
|
||||
} as GitHubRepoConnectionState);
|
||||
await this.as.botClient.sendNotice(this.roomId, `Room configured to bridge ${resultRepo.data.full_name}`);
|
||||
}
|
||||
|
||||
@botCommand("jira project", "Create a connection for a JIRA project. (You must be logged in with JIRA to do this)", ["url"], [], true)
|
||||
public async onJiraProject(userId: string, url: string) {
|
||||
if (!this.jiraEnabled) {
|
||||
throw new CommandError("not-configured", "The bridge is not configured to support Jira");
|
||||
}
|
||||
if (!await this.as.botClient.userHasPowerLevelFor(this.as.botUserId, this.roomId, GitHubRepoConnection.CanonicalEventType, true)) {
|
||||
throw new CommandError("Bot lacks power level to set room state", "I do not have permission to setup a bridge in this room. Please promote me to an Admin/Moderator");
|
||||
}
|
||||
const jiraClient = await this.tokenStore.getJiraForUser(userId);
|
||||
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 res = /^https:\/\/([A-z.\-_]+)\/.+\/projects\/(\w+)\/?(\w+\/?)*$/.exec(url.trim().toLowerCase());
|
||||
if (!res) {
|
||||
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/...`");
|
||||
}
|
||||
const [, origin, projectKey] = res;
|
||||
const safeUrl = `https://${origin}/projects/${projectKey}`;
|
||||
const jiraOriginClient = await jiraClient.getClientForUrl(new URL(safeUrl));
|
||||
if (!jiraOriginClient) {
|
||||
throw new CommandError("User does not have permission to access this JIRA instance", "You do not have access to this JIRA instance. You may need to log into Jira again to provide access");
|
||||
}
|
||||
let jiraProject: JiraProject;
|
||||
try {
|
||||
jiraProject = await jiraOriginClient.getProject(projectKey.toUpperCase());
|
||||
} catch (ex) {
|
||||
log.warn(`Failed to get jira project:`, ex);
|
||||
throw new CommandError("Missing or invalid JIRA project", "Could not find the requested JIRA project. Do you have permission to view it?");
|
||||
}
|
||||
await this.as.botClient.sendStateEvent(this.roomId, JiraProjectConnection.CanonicalEventType, safeUrl, {
|
||||
url: safeUrl,
|
||||
} as JiraProjectConnectionState);
|
||||
await this.as.botClient.sendNotice(this.roomId, `Room configured to bridge Jira project '${jiraProject.name}' (${jiraProject.key})`);
|
||||
}
|
||||
|
||||
@botCommand("webhook", "Create a inbound webhook")
|
||||
public async onWebhook() {
|
||||
if (!this.webhooksConfig?.enabled) {
|
||||
throw new CommandError("not-configured", "The bridge is not configured to support webhooks");
|
||||
}
|
||||
if (!await this.as.botClient.userHasPowerLevelFor(this.as.botUserId, this.roomId, GitHubRepoConnection.CanonicalEventType, true)) {
|
||||
throw new CommandError("Bot lacks power level to set room state", "I do not have permission to setup a bridge in this room. Please promote me to an Admin/Moderator");
|
||||
}
|
||||
const hookId = uuid();
|
||||
const url = `${this.webhooksConfig.urlPrefix}${this.webhooksConfig.urlPrefix.endsWith('/') ? '' : '/'}${hookId}`;
|
||||
await this.as.botClient.setRoomAccountData(this.roomId, GenericHookConnection.CanonicalEventType, {hookId});
|
||||
await this.as.botClient.sendStateEvent(this.roomId, GenericHookConnection.CanonicalEventType, hookId, {hookId});
|
||||
return this.as.botClient.sendHtmlNotice(this.roomId, md.renderInline(`Room configured to bridge webhooks. Please configure your webhook source to use \`${url}\``));
|
||||
}
|
||||
}
|
||||
|
||||
// Typescript doesn't understand Prototypes very well yet.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const res = compileBotCommands(SetupConnection.prototype as any, CommandConnection.prototype as any);
|
||||
SetupConnection.helpMessage = res.helpMessage;
|
||||
SetupConnection.botCommands = res.botCommands;
|
@ -5,7 +5,6 @@ import LogWrapper from "../LogWrapper";
|
||||
import { DiscussionQLResponse, DiscussionQL } from "./Discussion";
|
||||
import * as GitHubWebhookTypes from "@octokit/webhooks-types";
|
||||
import { InstallationDataType } from "./Types";
|
||||
import e from "express";
|
||||
|
||||
const log = new LogWrapper("GithubInstance");
|
||||
|
||||
@ -39,15 +38,23 @@ export class GithubInstance {
|
||||
});
|
||||
}
|
||||
|
||||
public getOctokitForRepo(orgName: string, repoName?: string) {
|
||||
public getSafeOctokitForRepo(orgName: string, repoName?: string) {
|
||||
const targetName = (repoName ? `${orgName}/${repoName}` : orgName).toLowerCase();
|
||||
for (const install of this.installationsCache.values()) {
|
||||
if (install.matchesRepository.includes(targetName) || install.matchesRepository.includes(`${targetName.split('/')[0]}/*`)) {
|
||||
return this.createOctokitForInstallation(install.id);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public getOctokitForRepo(orgName: string, repoName?: string) {
|
||||
const res = this.getSafeOctokitForRepo(orgName, repoName);
|
||||
if (res) {
|
||||
return res;
|
||||
}
|
||||
// TODO: Refresh cache?
|
||||
throw Error(`No installation found to handle ${targetName}`);
|
||||
throw Error(`No installation found to handle ${orgName}/${repoName}`);
|
||||
}
|
||||
|
||||
private createOctokitForInstallation(installationId: number) {
|
||||
|
@ -4,7 +4,9 @@ import JiraApi, { SearchUserOptions } from 'jira-client';
|
||||
import QuickLRU from "@alloc/quick-lru";
|
||||
import { JiraAccount, JiraAPIAccessibleResource, JiraIssue, JiraOAuthResult, JiraProject } from './Types';
|
||||
import { BridgeConfigJira } from '../Config/Config';
|
||||
import LogWrapper from '../LogWrapper';
|
||||
|
||||
const log = new LogWrapper("JiraClient");
|
||||
const ACCESSIBLE_RESOURCE_CACHE_LIMIT = 100;
|
||||
const ACCESSIBLE_RESOURCE_CACHE_TTL_MS = 60000;
|
||||
|
||||
@ -13,11 +15,11 @@ export class HookshotJiraApi extends JiraApi {
|
||||
super(options);
|
||||
}
|
||||
|
||||
async getProject(projectIdOrKey: string) {
|
||||
async getProject(projectIdOrKey: string): Promise<JiraProject> {
|
||||
return await super.getProject(projectIdOrKey) as JiraProject;
|
||||
}
|
||||
|
||||
async getIssue(issueIdOrKey: string) {
|
||||
async getIssue(issueIdOrKey: string): Promise<JiraIssue> {
|
||||
const res = await axios.get<JiraIssue>(`https://api.atlassian.com/${this.options.base}/rest/api/3/issue/${issueIdOrKey}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.options.bearer}`
|
||||
@ -60,6 +62,7 @@ export class JiraClient {
|
||||
// Existing failed promise, break out and try again.
|
||||
JiraClient.resourceCache.delete(this.bearer);
|
||||
}
|
||||
await this.checkTokenAge();
|
||||
const promise = (async () => {
|
||||
const res = await axios.get(`https://api.atlassian.com/oauth/token/accessible-resources`, {
|
||||
headers: {
|
||||
@ -77,23 +80,24 @@ export class JiraClient {
|
||||
if (this.oauth2State.expires_in + 60000 > Date.now()) {
|
||||
return;
|
||||
}
|
||||
log.info(`Refreshing oauth token`);
|
||||
// Refresh the token
|
||||
const res = await axios.post<unknown, JiraOAuthResult>(`https://api.atlassian.com/oauth/token`, {
|
||||
const res = await axios.post(`https://api.atlassian.com/oauth/token`, {
|
||||
grant_type: "refresh_token",
|
||||
client_id: this.config.oauth.client_id,
|
||||
client_secret: this.config.oauth.client_secret,
|
||||
refresh_token: this.oauth2State.refresh_token,
|
||||
});
|
||||
res.expires_in += Date.now() + (res.expires_in * 1000);
|
||||
this.oauth2State = res;
|
||||
const data = res.data as JiraOAuthResult;
|
||||
data.expires_in += Date.now() + (data.expires_in * 1000);
|
||||
this.oauth2State = data;
|
||||
this.onTokenRefreshed(this.oauth2State);
|
||||
}
|
||||
|
||||
async getClientForName(name: string) {
|
||||
const resources = await this.getAccessibleResources();
|
||||
const resource = resources.find((res) => res.name === name);
|
||||
await this.checkTokenAge();
|
||||
async getClientForUrl(url: URL) {
|
||||
const resource = (await this.getAccessibleResources()).find((r) => new URL(r.url).origin === url.origin);
|
||||
if (!resource) {
|
||||
throw Error('User does not have access to this resource');
|
||||
return null;
|
||||
}
|
||||
return this.getClientForResource(resource);
|
||||
}
|
||||
|
@ -69,7 +69,7 @@ export interface JiraIssue {
|
||||
}
|
||||
|
||||
export interface JiraOAuthResult {
|
||||
state: string;
|
||||
state?: string;
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
|
@ -205,7 +205,8 @@ export class Webhooks extends EventEmitter {
|
||||
public async onGitHubGetOauth(req: Request, res: Response) {
|
||||
log.info("Got new oauth request");
|
||||
try {
|
||||
if (!this.config.github) {
|
||||
if (!this.config.github || !this.config.github.oauth) {
|
||||
res.status(500).send(`<p>Bridge is not configured with OAuth support</p>`);
|
||||
throw Error("Got GitHub oauth request but github was not configured!");
|
||||
}
|
||||
const exists = await this.queue.pushWait<OAuthRequest, boolean>({
|
||||
|
Loading…
x
Reference in New Issue
Block a user