Merge pull request #88 from Half-Shot/hs/meta-create-command

Add `!setup` command to create new connections in rooms via bot commands
This commit is contained in:
Will Hunt 2021-12-01 10:55:00 +00:00 committed by GitHub
commit b1c68d759f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 341 additions and 92 deletions

View File

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

View File

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

View File

@ -173,6 +173,9 @@ export class AdminRoom extends AdminRoomCommandHandler {
if (!this.config.github) {
throw new CommandError("no-github-support", "The bridge is not configured with GitHub support");
}
if (!this.config.github.oauth) {
throw new CommandError("no-github-support", "The bridge is not configured with GitHub OAuth support");
}
// If this is already set, calling this command will invalidate the previous session.
this.pendingOAuthState = uuid();
const q = qs.stringify({

View File

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

View File

@ -30,6 +30,7 @@ import * as GitHubWebhookTypes from "@octokit/webhooks-types";
import LogWrapper from "./LogWrapper";
import { OAuthRequest } from "./WebhookTypes";
import { promises as fs } from "fs";
import { SetupConnection } from "./Connections/SetupConnection";
const log = new LogWrapper("Bridge");
export class Bridge {
@ -264,15 +265,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),
@ -561,7 +556,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>) {
@ -581,55 +575,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>) {

View File

@ -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;
}
@ -139,7 +175,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"];
}

View 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,
}
}, {});
@ -140,6 +144,7 @@ async function renderRegistrationFile(configPath?: string) {
rooms: [],
},
};
// eslint-disable-next-line no-console
console.log(YAML.stringify(obj));
}
@ -147,9 +152,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);
});

View File

@ -58,10 +58,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)) {
@ -130,7 +130,6 @@ export class ConnectionManager {
}
if (JiraProjectConnection.EventTypes.includes(state.type)) {
console.log("WOOF", state);
if (!this.config.jira) {
throw Error('JIRA is not configured');
}

View File

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

View File

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

View File

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

View File

@ -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) {
@ -322,9 +326,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() {

View File

@ -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();
}
@ -311,7 +321,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, {

View File

@ -179,8 +179,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() {

View File

@ -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`');
@ -66,7 +66,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) {
@ -89,7 +89,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) {

View File

@ -13,9 +13,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>;

View File

@ -7,13 +7,13 @@ import { JiraIssueEvent, JiraIssueUpdatedEvent } from "../Jira/WebhookTypes";
import { FormatUtil } from "../FormatUtil";
import markdownit from "markdown-it";
import { generateJiraWebLinkFromIssue } from "../Jira";
import { JiraIssue, JiraProject } from "../Jira/Types";
import { JiraProject } from "../Jira/Types";
import { botCommand, BotCommands, compileBotCommands } from "../BotCommands";
import { MatrixMessageContent } from "../MatrixEvent";
import { CommandConnection } from "./CommandConnection";
import { start } from "repl";
import { UserTokenStore } from "../UserTokenStore";
import { CommandError, NotLoggedInError } from "../errors";
import JiraApi from "jira-client";
type JiraAllowedEventsNames = "issue.created";
const JiraAllowedEvents: JiraAllowedEventsNames[] = ["issue.created"];
@ -159,11 +159,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)
@ -183,7 +186,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: {},

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

View File

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

View File

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

View File

@ -69,7 +69,7 @@ export interface JiraIssue {
}
export interface JiraOAuthResult {
state: string;
state?: string;
access_token: string;
refresh_token: string;
expires_in: number;

View File

@ -32,6 +32,5 @@ export class GitLabWatcher extends EventEmitter implements NotificationWatcherTa
const events = await this.client.getEvents({
after: new Date(this.since)
});
console.log(events);
}
}

View File

@ -3,7 +3,6 @@ import { GitLabClient } from "./Gitlab/Client";
import { Intent } from "matrix-bot-sdk";
import { promises as fs } from "fs";
import { publicEncrypt, privateDecrypt } from "crypto";
import JiraApi from 'jira-client';
import LogWrapper from "./LogWrapper";
import { JiraClient } from "./Jira/Client";
import { JiraOAuthResult } from "./Jira/Types";

View File

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