Huge changes:

- Update all packages
- Move to EsLint
- Update to @octokit/types
- Fix linting
- Fix the notifications code to not depend soley on GitHub
This commit is contained in:
Will Hunt 2020-11-22 21:10:27 +00:00
parent bd6838aff5
commit cad9320752
36 changed files with 1735 additions and 1208 deletions

View File

@ -9,46 +9,49 @@
"private": false, "private": false,
"scripts": { "scripts": {
"build": "tsc --project tsconfig.json", "build": "tsc --project tsconfig.json",
"prepare": "yarn build",
"start:app": "node lib/App/BridgeApp.js", "start:app": "node lib/App/BridgeApp.js",
"start:webhooks": "node lib/App/GithubWebhookApp.js", "start:webhooks": "node lib/App/GithubWebhookApp.js",
"start:matrixsender": "node lib/App/MatrixSenderApp.js", "start:matrixsender": "node lib/App/MatrixSenderApp.js",
"test": "mocha -r ts-node/register tests/**/*.ts", "test": "mocha -r ts-node/register tests/**/*.ts",
"lint": "tslint -p tsconfig.json" "lint": "eslint -c .eslintrc.js src/**/*.ts"
}, },
"dependencies": { "dependencies": {
"@octokit/auth-app": "^2.4.2", "@octokit/auth-app": "^2.10.2",
"@octokit/auth-token": "^2.4.0", "@octokit/auth-token": "^2.4.3",
"@octokit/rest": "^16.43.1", "@octokit/rest": "^18.0.9",
"argv-split": "^2.0.1", "axios": "^0.21.0",
"axios": "^0.19.2",
"express": "^4.17.1", "express": "^4.17.1",
"ioredis": "^4.14.0", "ioredis": "^4.19.2",
"markdown-it": "^9.0.1", "markdown-it": "^12.0.2",
"matrix-bot-sdk": "^0.5.4", "matrix-bot-sdk": "^0.5.8",
"micromatch": "^4.0.2", "micromatch": "^4.0.2",
"mime": "^2.4.4", "mime": "^2.4.6",
"mocha": "^6.2.0", "mocha": "^8.2.1",
"node-emoji": "^1.10.0", "node-emoji": "^1.10.0",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"uuid": "^3.3.2", "string-argv": "v0.3.1",
"winston": "^3.2.1", "uuid": "^8.3.1",
"yaml": "^1.6.0" "winston": "^3.3.3",
"yaml": "^1.10.0"
}, },
"devDependencies": { "devDependencies": {
"@types/chai": "^4.1.7", "@types/chai": "^4.2.14",
"@types/express": "^4.17.0", "@types/express": "^4.17.9",
"@types/ioredis": "^4.0.13", "@types/ioredis": "^4.17.8",
"@types/markdown-it": "^0.0.8", "@types/markdown-it": "^10.0.3",
"@types/micromatch": "^3.1.0", "@types/micromatch": "^4.0.1",
"@types/mime": "^2.0.1", "@types/mime": "^2.0.3",
"@types/mocha": "^5.2.7", "@types/mocha": "^8.0.4",
"@types/node": "^12.6.9", "@types/node": "^12",
"@types/node-emoji": "^1.8.1", "@types/node-emoji": "^1.8.1",
"@types/uuid": "^3.4.5", "@types/uuid": "^8.3.0",
"@types/yaml": "^1.0.2", "@typescript-eslint/eslint-plugin": "^4.8.1",
"@typescript-eslint/parser": "^4.8.1",
"chai": "^4.2.0", "chai": "^4.2.0",
"ts-node": "^8.3.0", "eslint": "^7.14.0",
"tslint": "^5.18.0", "eslint-plugin-mocha": "^8.0.0",
"typescript": "^3.9.7" "ts-node": "^9.0.0",
"typescript": "^4.1.2"
} }
} }

View File

@ -1,9 +1,8 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { Intent } from "matrix-bot-sdk"; import { Intent } from "matrix-bot-sdk";
import { Octokit } from "@octokit/rest";
import { createTokenAuth } from "@octokit/auth-token";
import { UserTokenStore } from "./UserTokenStore"; import { UserTokenStore } from "./UserTokenStore";
import { BridgeConfig } from "./Config"; import { BridgeConfig } from "./Config";
import uuid from "uuid/v4"; import {v4 as uuid} from "uuid";
import qs from "querystring"; import qs from "querystring";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import LogWrapper from "./LogWrapper"; import LogWrapper from "./LogWrapper";
@ -13,14 +12,20 @@ import { FormatUtil } from "./FormatUtil";
import { botCommand, compileBotCommands, handleCommand, BotCommands } from "./BotCommands"; import { botCommand, compileBotCommands, handleCommand, BotCommands } from "./BotCommands";
import { GitLabClient } from "./Gitlab/Client"; import { GitLabClient } from "./Gitlab/Client";
import { GetUserResponse } from "./Gitlab/Types"; import { GetUserResponse } from "./Gitlab/Types";
import { GithubInstance } from "./Github/GithubInstance";
import { MatrixMessageContent } from "./MatrixEvent";
import { ProjectsListForUserResponseData, ProjectsListForRepoResponseData } from "@octokit/types";
const md = new markdown(); const md = new markdown();
const log = new LogWrapper('AdminRoom'); const log = new LogWrapper('AdminRoom');
export const BRIDGE_ROOM_TYPE = "uk.half-shot.matrix-github.room"; export const BRIDGE_ROOM_TYPE = "uk.half-shot.matrix-github.room";
export const BRIDGE_NOTIF_TYPE = "uk.half-shot.matrix-github.notif_state"; export const BRIDGE_NOTIF_TYPE = "uk.half-shot.matrix-github.notif_state";
export const BRIDGE_GITLAB_NOTIF_TYPE = "uk.half-shot.matrix-github.gitlab.notif_state";
export interface AdminAccountData { export interface AdminAccountData {
// eslint-disable-next-line camelcase
admin_user: string; admin_user: string;
notifications?: { notifications?: {
enabled: boolean; enabled: boolean;
@ -28,7 +33,7 @@ export interface AdminAccountData {
}; };
} }
export class AdminRoom extends EventEmitter { export class AdminRoom extends EventEmitter {
static helpMessage: any; static helpMessage: MatrixMessageContent;
static botCommands: BotCommands; static botCommands: BotCommands;
private pendingOAuthState: string|null = null; private pendingOAuthState: string|null = null;
@ -94,11 +99,7 @@ export class AdminRoom extends EventEmitter {
} }
let me; let me;
try { try {
const octokit = new Octokit({ const octokit = GithubInstance.createUserOctokit(accessToken);
authStrategy: createTokenAuth,
auth: accessToken,
userAgent: "matrix-github v0.0.1",
});
me = await octokit.users.getAuthenticated(); me = await octokit.users.getAuthenticated();
} catch (ex) { } catch (ex) {
await this.sendNotice("Could not authenticate with GitHub. Is your token correct?"); await this.sendNotice("Could not authenticate with GitHub. Is your token correct?");
@ -139,9 +140,9 @@ export class AdminRoom extends EventEmitter {
await this.sendNotice(`You should follow ${url} to link your account to the bridge`); await this.sendNotice(`You should follow ${url} to link your account to the bridge`);
} }
@botCommand("notifications toggle", "Toggle enabling/disabling GitHub notifications in this room") @botCommand("github notifications toggle", "Toggle enabling/disabling GitHub notifications in this room")
// @ts-ignore - property is used // @ts-ignore - property is used
private async setNotificationsStateToggle() { private async setGitHubNotificationsStateToggle() {
const data: AdminAccountData = await this.botIntent.underlyingClient.getRoomAccountData( const data: AdminAccountData = await this.botIntent.underlyingClient.getRoomAccountData(
BRIDGE_ROOM_TYPE, this.roomId, BRIDGE_ROOM_TYPE, this.roomId,
); );
@ -155,9 +156,9 @@ export class AdminRoom extends EventEmitter {
await this.sendNotice(`${data.notifications.enabled ? "En" : "Dis"}abled GitHub notifcations`); await this.sendNotice(`${data.notifications.enabled ? "En" : "Dis"}abled GitHub notifcations`);
} }
@botCommand("notifications filter participating", "Toggle enabling/disabling GitHub notifications in this room") @botCommand("github notifications filter participating", "Toggle enabling/disabling GitHub notifications in this room")
// @ts-ignore - property is used // @ts-ignore - property is used
private async setNotificationsStateParticipating() { private async setGitHubNotificationsStateParticipating() {
const data: AdminAccountData = await this.botIntent.underlyingClient.getRoomAccountData( const data: AdminAccountData = await this.botIntent.underlyingClient.getRoomAccountData(
BRIDGE_ROOM_TYPE, this.roomId, BRIDGE_ROOM_TYPE, this.roomId,
); );
@ -187,7 +188,7 @@ export class AdminRoom extends EventEmitter {
username = me.data.name; username = me.data.name;
} }
let res: Octokit.ProjectsListForUserResponse|Octokit.ProjectsListForRepoResponse; let res: ProjectsListForUserResponseData|ProjectsListForRepoResponseData;
try { try {
if (repo) { if (repo) {
res = (await octokit.projects.listForRepo({ res = (await octokit.projects.listForRepo({
@ -203,7 +204,7 @@ export class AdminRoom extends EventEmitter {
return this.sendNotice(`Failed to fetch projects due to an error. See logs for details`); return this.sendNotice(`Failed to fetch projects due to an error. See logs for details`);
} }
const content = `Projects for ${username}:\n` + res.map(r => ` - ${FormatUtil.projectListing(r)}\n`).join("\n"); const content = `Projects for ${username}:\n` + res.map(r => ` - ${FormatUtil.projectListing([r])}\n`).join("\n");
return this.botIntent.sendEvent(this.roomId,{ return this.botIntent.sendEvent(this.roomId,{
msgtype: "m.notice", msgtype: "m.notice",
body: content, body: content,
@ -223,7 +224,7 @@ export class AdminRoom extends EventEmitter {
return this.sendNotice("You can not list projects without an account."); return this.sendNotice("You can not list projects without an account.");
} }
let res: Octokit.ProjectsListForUserResponse|Octokit.ProjectsListForRepoResponse; let res: ProjectsListForUserResponseData|ProjectsListForRepoResponseData;
try { try {
if (repo) { if (repo) {
res = (await octokit.projects.listForRepo({ res = (await octokit.projects.listForRepo({
@ -239,7 +240,7 @@ export class AdminRoom extends EventEmitter {
return this.sendNotice(`Failed to fetch projects due to an error. See logs for details`); return this.sendNotice(`Failed to fetch projects due to an error. See logs for details`);
} }
const content = `Projects for ${org}:\n` + res.map(r => ` - ${FormatUtil.projectListing(r)}\n`).join("\n"); const content = `Projects for ${org}:\n` + res.map(r => ` - ${FormatUtil.projectListing([r])}\n`).join("\n");
return this.botIntent.sendEvent(this.roomId,{ return this.botIntent.sendEvent(this.roomId,{
msgtype: "m.notice", msgtype: "m.notice",
body: content, body: content,
@ -319,7 +320,40 @@ export class AdminRoom extends EventEmitter {
await this.tokenStore.storeUserToken("gitlab", this.userId, accessToken, instance.url); await this.tokenStore.storeUserToken("gitlab", this.userId, accessToken, instance.url);
} }
public async handleCommand(event_id: string, command: string) { @botCommand("gitlab hastoken", "Check if you have a token stored for GitLab", ["instanceName"])
// @ts-ignore - property is used
private async gitlabHasPersonalToken(instanceName: string) {
if (!this.config.gitlab) {
return this.sendNotice("The bridge is not configured with GitLab support");
}
const instance = this.config.gitlab.instances[instanceName];
if (!instance) {
return this.sendNotice("The bridge is not configured for this GitLab instance");
}
const result = await this.tokenStore.getUserToken("gitlab", this.userId, instance.url);
if (result === null) {
await this.sendNotice("You do not currently have a token stored");
return;
}
await this.sendNotice("A token is stored for your GitLab account.");
}
@botCommand("gitlab notifications toggle", "Toggle enabling/disabling GitHub notifications in this room")
// @ts-ignore - property is used
private async setGitLabNotificationsStateToggle() {
const data: AdminAccountData = await this.botIntent.underlyingClient.getRoomAccountData(
BRIDGE_GITLAB_NOTIF_TYPE, this.roomId,
);
const oldState = data.notifications || {
enabled: false,
};
data.notifications = { enabled: !oldState?.enabled };
await this.botIntent.underlyingClient.setRoomAccountData(BRIDGE_GITLAB_NOTIF_TYPE, this.roomId, data);
this.emit("settings.changed", this, data);
await this.sendNotice(`${data.notifications.enabled ? "En" : "Dis"}abled GitLab notifcations`);
}
public async handleCommand(eventId: string, command: string) {
const { error, handled } = await handleCommand(this.userId, command, AdminRoom.botCommands, this); const { error, handled } = await handleCommand(this.userId, command, AdminRoom.botCommands, this);
if (!handled) { if (!handled) {
return this.sendNotice("Command not understood"); return this.sendNotice("Command not understood");
@ -338,6 +372,7 @@ export class AdminRoom extends EventEmitter {
} }
} }
const res = compileBotCommands(AdminRoom.prototype); // eslint-disable-next-line @typescript-eslint/no-explicit-any
const res = compileBotCommands(AdminRoom.prototype as any);
AdminRoom.helpMessage = res.helpMessage; AdminRoom.helpMessage = res.helpMessage;
AdminRoom.botCommands = res.botCommands; AdminRoom.botCommands = res.botCommands;

View File

@ -1,11 +1,11 @@
import markdown from "markdown-it"; import markdown from "markdown-it";
// @ts-ignore import stringArgv from "string-argv";
import argvSplit from "argv-split"; import { MatrixMessageContent } from "./MatrixEvent";
const md = new markdown(); const md = new markdown();
export const botCommandSymbol = Symbol("botCommandMetadata"); export const botCommandSymbol = Symbol("botCommandMetadata");
export function botCommand(prefix: string, help: string, requiredArgs: string[] = [], optionalArgs: string[] = [], includeUserId: boolean = false) { export function botCommand(prefix: string, help: string, requiredArgs: string[] = [], optionalArgs: string[] = [], includeUserId = false) {
return Reflect.metadata(botCommandSymbol, { return Reflect.metadata(botCommandSymbol, {
prefix, prefix,
help, help,
@ -15,21 +15,23 @@ export function botCommand(prefix: string, help: string, requiredArgs: string[]
}); });
} }
type BotCommandFunction = (...args: string[]) => Promise<{status: boolean}>;
export type BotCommands = {[prefix: string]: { export type BotCommands = {[prefix: string]: {
fn: (...args: string[]) => Promise<{status: boolean}>, fn: BotCommandFunction,
requiredArgs: string[], requiredArgs: string[],
optionalArgs: string[], optionalArgs: string[],
includeUserId: boolean, includeUserId: boolean,
}}; }};
export function compileBotCommands(prototype: any): {helpMessage: any, botCommands: BotCommands} { export function compileBotCommands(prototype: Record<string, BotCommandFunction>): {helpMessage: MatrixMessageContent, botCommands: BotCommands} {
let content = "Commands:\n"; let content = "Commands:\n";
let botCommands: BotCommands = {}; const botCommands: BotCommands = {};
Object.getOwnPropertyNames(prototype).forEach(propetyKey => { Object.getOwnPropertyNames(prototype).forEach(propetyKey => {
const b = Reflect.getMetadata(botCommandSymbol, prototype, propetyKey); const b = Reflect.getMetadata(botCommandSymbol, prototype, propetyKey);
if (b) { if (b) {
const requiredArgs = b.requiredArgs.join((arg: string) => `__${arg}__`); const requiredArgs = b.requiredArgs.join(" ");
const optionalArgs = b.optionalArgs.join((arg: string) => `\[${arg}\]`); const optionalArgs = b.optionalArgs.map((arg: string) => `[${arg}]`).join(" ");
content += ` - \`${b.prefix}\` ${requiredArgs} ${optionalArgs} - ${b.help}\n`; content += ` - \`${b.prefix}\` ${requiredArgs} ${optionalArgs} - ${b.help}\n`;
// We know that this is safe. // We know that this is safe.
botCommands[b.prefix as string] = { botCommands[b.prefix as string] = {
@ -51,8 +53,8 @@ export function compileBotCommands(prototype: any): {helpMessage: any, botComman
} }
} }
export async function handleCommand(userId: string, command: string, botCommands: BotCommands, obj: any): Promise<{error?: string, handled?: boolean}> { export async function handleCommand(userId: string, command: string, botCommands: BotCommands, obj: unknown): Promise<{error?: string, handled?: boolean}> {
const parts = argvSplit(command); const parts = stringArgv(command);
for (let i = parts.length; i > 0; i--) { for (let i = parts.length; i > 0; i--) {
const prefix = parts.slice(0, i).join(" ").toLowerCase(); const prefix = parts.slice(0, i).join(" ").toLowerCase();
// We have a match! // We have a match!

View File

@ -1,4 +1,3 @@
import { Octokit } from "@octokit/rest";
import { Appservice } from "matrix-bot-sdk"; import { Appservice } from "matrix-bot-sdk";
import markdown from "markdown-it"; import markdown from "markdown-it";
import mime from "mime"; import mime from "mime";
@ -7,6 +6,7 @@ import { MatrixMessageContent, MatrixEvent } from "./MatrixEvent";
import LogWrapper from "./LogWrapper"; import LogWrapper from "./LogWrapper";
import axios from "axios"; import axios from "axios";
import { FormatUtil } from "./FormatUtil"; import { FormatUtil } from "./FormatUtil";
import { IssuesGetCommentResponseData, ReposGetResponseData, IssuesGetResponseData } from "@octokit/types";
const REGEX_MENTION = /(^|\s)(@[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38})(\s|$)/ig; 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_MATRIX_MENTION = /<a href="https:\/\/matrix\.to\/#\/(.+)">(.*)<\/a>/gmi;
@ -14,11 +14,8 @@ const REGEX_IMAGES = /!\[.*]\((.*\.(\w+))\)/gm;
const md = new markdown(); const md = new markdown();
const log = new LogWrapper("CommentProcessor"); const log = new LogWrapper("CommentProcessor");
interface IMatrixCommentEvent { interface IMatrixCommentEvent extends MatrixMessageContent {
msgtype: string; // eslint-disable-next-line camelcase
body: string;
formatted_body: string;
format: string;
external_url: string; external_url: string;
"uk.half-shot.matrix-github.comment": { "uk.half-shot.matrix-github.comment": {
id: number; id: number;
@ -59,9 +56,9 @@ export class CommentProcessor {
return body; return body;
} }
public async getEventBodyForComment(comment: Octokit.IssuesGetCommentResponse, public async getEventBodyForComment(comment: IssuesGetCommentResponseData,
repo?: Octokit.ReposGetResponse, repo?: ReposGetResponseData,
issue?: Octokit.IssuesGetResponse): Promise<IMatrixCommentEvent> { issue?: IssuesGetResponseData): Promise<IMatrixCommentEvent> {
let body = comment.body; let body = comment.body;
body = this.replaceMentions(body); body = this.replaceMentions(body);
body = await this.replaceImages(body, true); body = await this.replaceImages(body, true);

View File

@ -10,12 +10,12 @@ export interface BridgeConfigGitHub {
webhook: { webhook: {
secret: string; secret: string;
}, },
userTokens: {
[userId: string]: string;
}
oauth: { oauth: {
// eslint-disable-next-line camelcase
client_id: string; client_id: string;
// eslint-disable-next-line camelcase
client_secret: string; client_secret: string;
// eslint-disable-next-line camelcase
redirect_uri: string; redirect_uri: string;
}; };
installationId: number|string; installationId: number|string;
@ -23,9 +23,6 @@ export interface BridgeConfigGitHub {
export interface GitLabInstance { export interface GitLabInstance {
url: string; url: string;
userTokens: {
[userId: string]: string;
}
// oauth: { // oauth: {
// client_id: string; // client_id: string;
// client_secret: string; // client_secret: string;

View File

@ -12,12 +12,14 @@ import { FormatUtil } from "../FormatUtil";
import { IGitHubWebhookEvent } from "../GithubWebhooks"; import { IGitHubWebhookEvent } from "../GithubWebhooks";
import axios from "axios"; import axios from "axios";
import { GithubInstance } from "../Github/GithubInstance"; import { GithubInstance } from "../Github/GithubInstance";
import { IssuesGetResponseData } from "@octokit/types";
export interface GitHubIssueConnectionState { export interface GitHubIssueConnectionState {
org: string; org: string;
repo: string; repo: string;
state: string; state: string;
issues: string[]; issues: string[];
// eslint-disable-next-line camelcase
comments_processed: number; comments_processed: number;
} }
@ -44,15 +46,19 @@ export class GitHubIssueConnection implements IConnection {
static readonly QueryRoomRegex = /#github_(.+)_(.+)_(\d+):.*/; static readonly QueryRoomRegex = /#github_(.+)_(.+)_(\d+):.*/;
static async onQueryRoom(result: RegExpExecArray, opts: IQueryRoomOpts): Promise<any> { static async onQueryRoom(result: RegExpExecArray, opts: IQueryRoomOpts): Promise<unknown> {
const parts = result!.slice(1); const parts = result?.slice(1);
if (!parts) {
log.error("Invalid alias pattern");
throw Error("Could not find issue");
}
const owner = parts[0]; const owner = parts[0];
const repo = parts[1]; const repo = parts[1];
const issueNumber = parseInt(parts[2], 10); const issueNumber = parseInt(parts[2], 10);
log.info(`Fetching ${owner}/${repo}/${issueNumber}`); log.info(`Fetching ${owner}/${repo}/${issueNumber}`);
let issue: Octokit.IssuesGetResponse; let issue: IssuesGetResponseData;
try { try {
issue = (await opts.octokit.issues.get({ issue = (await opts.octokit.issues.get({
owner, owner,
@ -146,7 +152,10 @@ export class GitHubIssueConnection implements IConnection {
} }
public async onCommentCreated(event: IGitHubWebhookEvent, updateState = true) { public async onCommentCreated(event: IGitHubWebhookEvent, updateState = true) {
const comment = event.comment!; const comment = event.comment;
if (!comment || !comment.user) {
throw Error('Comment undefined');
}
if (event.repository) { if (event.repository) {
// Delay to stop comments racing sends // Delay to stop comments racing sends
await new Promise((resolve) => setTimeout(resolve, 500)); await new Promise((resolve) => setTimeout(resolve, 500));
@ -241,7 +250,7 @@ export class GitHubIssueConnection implements IConnection {
} }
public async onMatrixIssueComment(event: MatrixEvent<MatrixMessageContent>, allowEcho: boolean = false) { public async onMatrixIssueComment(event: MatrixEvent<MatrixMessageContent>, allowEcho = false) {
const clientKit = await this.tokenStore.getOctokitForUser(event.sender); const clientKit = await this.tokenStore.getOctokitForUser(event.sender);
if (clientKit === null) { if (clientKit === null) {
await this.as.botClient.sendEvent(this.roomId, "m.reaction", { await this.as.botClient.sendEvent(this.roomId, "m.reaction", {
@ -273,25 +282,17 @@ export class GitHubIssueConnection implements IConnection {
return; // No changes made. return; // No changes made.
} }
if (event.changes.title) { if (event.issue && event.changes.title) {
await this.as.botIntent.underlyingClient.sendStateEvent(this.roomId, "m.room.name", "", { await this.as.botIntent.underlyingClient.sendStateEvent(this.roomId, "m.room.name", "", {
name: FormatUtil.formatIssueRoomName(event.issue!), name: FormatUtil.formatIssueRoomName(event.issue),
}); });
} }
} }
public onIssueStateChange(event: IGitHubWebhookEvent) { public onIssueStateChange() {
return this.syncIssueState(); return this.syncIssueState();
} }
public async onEvent() {
}
public async onStateUpdate() {
}
public async onMessageEvent(ev: MatrixEvent<MatrixMessageContent>) { public async onMessageEvent(ev: MatrixEvent<MatrixMessageContent>) {
if (ev.content.body === '!sync') { if (ev.content.body === '!sync') {
// Sync data. // Sync data.

View File

@ -1,9 +1,10 @@
import { IConnection } from "./IConnection"; import { IConnection } from "./IConnection";
import { Appservice } from "matrix-bot-sdk"; import { Appservice } from "matrix-bot-sdk";
import LogWrapper from "../LogWrapper"; import LogWrapper from "../LogWrapper";
import { Octokit } from "@octokit/rest"; import { ProjectsGetResponseData } from "@octokit/types";
export interface GitHubProjectConnectionState { export interface GitHubProjectConnectionState {
// eslint-disable-next-line camelcase
project_id: number; project_id: number;
state: "open"|"closed"; state: "open"|"closed";
} }
@ -20,7 +21,7 @@ export class GitHubProjectConnection implements IConnection {
GitHubProjectConnection.CanonicalEventType, // Legacy event, with an awful name. GitHubProjectConnection.CanonicalEventType, // Legacy event, with an awful name.
]; ];
static async onOpenProject(project: Octokit.ProjectsGetResponse, as: Appservice, inviteUser: string): Promise<GitHubProjectConnection> { static async onOpenProject(project: ProjectsGetResponseData, as: Appservice, inviteUser: string): Promise<GitHubProjectConnection> {
log.info(`Fetching ${project.name} ${project.id}`); log.info(`Fetching ${project.name} ${project.id}`);
// URL hack so we don't need to fetch the repo itself. // URL hack so we don't need to fetch the repo itself.
@ -57,14 +58,6 @@ export class GitHubProjectConnection implements IConnection {
return GitHubProjectConnection.EventTypes.includes(eventType) && this.stateKey === stateKey; return GitHubProjectConnection.EventTypes.includes(eventType) && this.stateKey === stateKey;
} }
public async onEvent() {
}
public async onStateUpdate() {
}
public toString() { public toString() {
return `GitHubProjectConnection ${this.state.project_id}}`; return `GitHubProjectConnection ${this.state.project_id}}`;
} }

View File

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { IConnection } from "./IConnection"; import { IConnection } from "./IConnection";
import { Appservice } from "matrix-bot-sdk"; import { Appservice } from "matrix-bot-sdk";
import { MatrixMessageContent, MatrixEvent, MatrixReactionContent } from "../MatrixEvent"; import { MatrixMessageContent, MatrixEvent, MatrixReactionContent } from "../MatrixEvent";
@ -11,6 +12,7 @@ import { FormatUtil } from "../FormatUtil";
import axios from "axios"; import axios from "axios";
import { BotCommands, handleCommand, botCommand, compileBotCommands } from "../BotCommands"; import { BotCommands, handleCommand, botCommand, compileBotCommands } from "../BotCommands";
import { IGitHubWebhookEvent } from "../GithubWebhooks"; import { IGitHubWebhookEvent } from "../GithubWebhooks";
import { ReposGetResponseData } from "@octokit/types";
const log = new LogWrapper("GitHubRepoConnection"); const log = new LogWrapper("GitHubRepoConnection");
const md = new markdown(); const md = new markdown();
@ -46,7 +48,7 @@ const ALLOWED_REACTIONS = {
"👐": "open", "👐": "open",
} }
function compareEmojiStrings(e0: string, e1: string, e0Index: number = 0) { function compareEmojiStrings(e0: string, e1: string, e0Index = 0) {
return e0.codePointAt(e0Index) === e1.codePointAt(0); return e0.codePointAt(e0Index) === e1.codePointAt(0);
} }
@ -62,15 +64,19 @@ export class GitHubRepoConnection implements IConnection {
static readonly QueryRoomRegex = /#github_(.+)_(.+):.*/; static readonly QueryRoomRegex = /#github_(.+)_(.+):.*/;
static async onQueryRoom(result: RegExpExecArray, opts: IQueryRoomOpts): Promise<any> { static async onQueryRoom(result: RegExpExecArray, opts: IQueryRoomOpts): Promise<unknown> {
const parts = result!.slice(1); const parts = result?.slice(1);
if (!parts) {
log.error("Invalid alias pattern");
throw Error("Could not find repo");
}
const owner = parts[0]; const owner = parts[0];
const repo = parts[1]; const repo = parts[1];
const issueNumber = parseInt(parts[2], 10); const issueNumber = parseInt(parts[2], 10);
log.info(`Fetching ${owner}/${repo}/${issueNumber}`); log.info(`Fetching ${owner}/${repo}/${issueNumber}`);
let repoRes: Octokit.ReposGetResponse; let repoRes: ReposGetResponseData;
try { try {
repoRes = (await opts.octokit.repos.get({ repoRes = (await opts.octokit.repos.get({
owner, owner,
@ -135,7 +141,7 @@ export class GitHubRepoConnection implements IConnection {
}; };
} }
static helpMessage: any; static helpMessage: MatrixMessageContent;
static botCommands: BotCommands; static botCommands: BotCommands;
constructor(public readonly roomId: string, constructor(public readonly roomId: string,
@ -153,7 +159,7 @@ export class GitHubRepoConnection implements IConnection {
return this.state.repo; return this.state.repo;
} }
public isInterestedInStateEvent(eventType: string) { public isInterestedInStateEvent() {
return false; return false;
} }
@ -251,13 +257,18 @@ export class GitHubRepoConnection implements IConnection {
public async onIssueCreated(event: IGitHubWebhookEvent) { public async onIssueCreated(event: IGitHubWebhookEvent) {
log.info(`onIssueCreated ${this.roomId} ${this.org}/${this.repo} #${event.issue?.number}`); log.info(`onIssueCreated ${this.roomId} ${this.org}/${this.repo} #${event.issue?.number}`);
const orgRepoName = event.issue!.repository_url.substr("https://api.github.com/repos/".length); if (!event.issue) {
const content = `New issue created [${orgRepoName}#${event.issue!.number}](${event.issue!.html_url}): "${event.issue!.title}"`; throw Error('No issue content!');
console.log(event.issue?.labels); }
const labelsHtml = event.issue?.labels.map((label) => if (!event.repository) {
throw Error('No repository content!');
}
const orgRepoName = event.issue.repository_url.substr("https://api.github.com/repos/".length);
const content = `New issue created [${orgRepoName}#${event.issue.number}](${event.issue.html_url}): "${event.issue.title}"`;
const labelsHtml = event.issue.labels.map((label: {color: string, name: string, description: string}) =>
`<span title="${label.description}" data-mx-color="#CCCCCC" data-mx-bg-color="#${label.color}">${label.name}</span>` `<span title="${label.description}" data-mx-color="#CCCCCC" data-mx-bg-color="#${label.color}">${label.name}</span>`
).join(" ") || ""; ).join(" ") || "";
const labels = event.issue?.labels.map((label) => const labels = event.issue?.labels.map((label: {name: string}) =>
label.name label.name
).join(", ") || ""; ).join(", ") || "";
await this.as.botIntent.sendEvent(this.roomId, { await this.as.botIntent.sendEvent(this.roomId, {
@ -265,21 +276,27 @@ export class GitHubRepoConnection implements IConnection {
body: content + (labels.length > 0 ? ` with labels ${labels}`: ""), body: content + (labels.length > 0 ? ` with labels ${labels}`: ""),
formatted_body: md.renderInline(content) + (labelsHtml.length > 0 ? ` with labels ${labelsHtml}`: ""), formatted_body: md.renderInline(content) + (labelsHtml.length > 0 ? ` with labels ${labelsHtml}`: ""),
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
...FormatUtil.getPartialBodyForIssue(event.repository!, event.issue!), ...FormatUtil.getPartialBodyForIssue(event.repository, event.issue),
}); });
} }
public async onIssueStateChange(event: IGitHubWebhookEvent) { public async onIssueStateChange(event: IGitHubWebhookEvent) {
log.info(`onIssueStateChange ${this.roomId} ${this.org}/${this.repo} #${event.issue?.number}`); log.info(`onIssueStateChange ${this.roomId} ${this.org}/${this.repo} #${event.issue?.number}`);
if (event.issue?.state === "closed") { if (!event.issue) {
const orgRepoName = event.issue!.repository_url.substr("https://api.github.com/repos/".length); throw Error('No issue content!');
const content = `**@${event.sender!.login}** closed issue [${orgRepoName}#${event.issue!.number}](${event.issue!.html_url}): "${event.issue!.title}"`; }
if (!event.repository) {
throw Error('No repository content!');
}
if (event.issue.state === "closed" && event.sender) {
const orgRepoName = event.issue.repository_url.substr("https://api.github.com/repos/".length);
const content = `**@${event.sender.login}** closed issue [${orgRepoName}#${event.issue.number}](${event.issue.html_url}): "${event.issue.title}"`;
await this.as.botIntent.sendEvent(this.roomId, { await this.as.botIntent.sendEvent(this.roomId, {
msgtype: "m.notice", msgtype: "m.notice",
body: content, body: content,
formatted_body: md.renderInline(content), formatted_body: md.renderInline(content),
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
...FormatUtil.getPartialBodyForIssue(event.repository!, event.issue!), ...FormatUtil.getPartialBodyForIssue(event.repository, event.issue),
}); });
} }
} }
@ -290,6 +307,7 @@ export class GitHubRepoConnection implements IConnection {
return; return;
} }
if (evt.type === 'm.reaction') { if (evt.type === 'm.reaction') {
// eslint-disable-next-line camelcase
const {event_id, key} = (evt.content as MatrixReactionContent)["m.relates_to"]; const {event_id, key} = (evt.content as MatrixReactionContent)["m.relates_to"];
const ev = await this.as.botClient.getEvent(this.roomId, event_id); const ev = await this.as.botClient.getEvent(this.roomId, event_id);
const issueContent = ev.content["uk.half-shot.matrix-github.issue"]; const issueContent = ev.content["uk.half-shot.matrix-github.issue"];
@ -297,7 +315,7 @@ export class GitHubRepoConnection implements IConnection {
return; // Not our event. return; // Not our event.
} }
const [,reactionName] = Object.entries(GITHUB_REACTION_CONTENT).find(([emoji, content]) => compareEmojiStrings(emoji, key)) || [];; const [,reactionName] = Object.entries(GITHUB_REACTION_CONTENT).find(([emoji]) => compareEmojiStrings(emoji, key)) || [];
const [,action] = Object.entries(ALLOWED_REACTIONS).find(([emoji]) => compareEmojiStrings(emoji, key)) || []; const [,action] = Object.entries(ALLOWED_REACTIONS).find(([emoji]) => compareEmojiStrings(emoji, key)) || [];
if (reactionName) { if (reactionName) {
log.info(`Sending reaction of ${reactionName} for ${this.org}${this.repo}#${issueContent.number}`) log.info(`Sending reaction of ${reactionName} for ${this.org}${this.repo}#${issueContent.number}`)
@ -332,13 +350,12 @@ export class GitHubRepoConnection implements IConnection {
} }
} }
public async onStateUpdate() { }
public toString() { public toString() {
return `GitHubRepo`; return `GitHubRepo`;
} }
} }
const res = compileBotCommands(GitHubRepoConnection.prototype); // eslint-disable-next-line @typescript-eslint/no-explicit-any
const res = compileBotCommands(GitHubRepoConnection.prototype as any);
GitHubRepoConnection.helpMessage = res.helpMessage; GitHubRepoConnection.helpMessage = res.helpMessage;
GitHubRepoConnection.botCommands = res.botCommands; GitHubRepoConnection.botCommands = res.botCommands;

View File

@ -15,6 +15,7 @@ export interface GitLabIssueConnectionState {
projects: string[]; projects: string[];
state: string; state: string;
issue: number; issue: number;
// eslint-disable-next-line camelcase
comments_processed: number; comments_processed: number;
} }
@ -44,7 +45,7 @@ export class GitLabIssueConnection implements IConnection {
static readonly QueryRoomRegex = /#gitlab_(.+)_(.+)_(\d+):.*/; static readonly QueryRoomRegex = /#gitlab_(.+)_(.+)_(\d+):.*/;
public static createRoomForIssue() { public static createRoomForIssue() {
// Fill me in
} }
public get projectPath() { public get projectPath() {
@ -169,7 +170,7 @@ export class GitLabIssueConnection implements IConnection {
// } // }
public async onMatrixIssueComment(event: MatrixEvent<MatrixMessageContent>, allowEcho: boolean = false) { public async onMatrixIssueComment(event: MatrixEvent<MatrixMessageContent>, allowEcho = false) {
console.log(this.messageClient, this.commentProcessor); console.log(this.messageClient, this.commentProcessor);
const clientKit = await this.tokenStore.getGitLabForUser(event.sender, this.instanceUrl); const clientKit = await this.tokenStore.getGitLabForUser(event.sender, this.instanceUrl);
@ -203,25 +204,13 @@ export class GitLabIssueConnection implements IConnection {
return; // No changes made. return; // No changes made.
} }
if (event.changes.title) { if (event.issue && event.changes.title) {
await this.as.botIntent.underlyingClient.sendStateEvent(this.roomId, "m.room.name", "", { await this.as.botIntent.underlyingClient.sendStateEvent(this.roomId, "m.room.name", "", {
name: FormatUtil.formatIssueRoomName(event.issue!), name: FormatUtil.formatIssueRoomName(event.issue),
}); });
} }
} }
// public onIssueStateChange(event: IGitHubWebhookEvent) {
// return this.syncIssueState();
// }
public async onEvent() {
}
public async onStateUpdate() {
}
public async onMessageEvent(ev: MatrixEvent<MatrixMessageContent>) { public async onMessageEvent(ev: MatrixEvent<MatrixMessageContent>) {
if (ev.content.body === '!sync') { if (ev.content.body === '!sync') {
// Sync data. // Sync data.

View File

@ -1,3 +1,5 @@
// We need to instantiate some functions which are not directly called, which confuses typescript.
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { IConnection } from "./IConnection"; import { IConnection } from "./IConnection";
import { UserTokenStore } from "../UserTokenStore"; import { UserTokenStore } from "../UserTokenStore";
import { Appservice } from "matrix-bot-sdk"; import { Appservice } from "matrix-bot-sdk";
@ -27,7 +29,7 @@ export class GitLabRepoConnection implements IConnection {
GitLabRepoConnection.CanonicalEventType, // Legacy event, with an awful name. GitLabRepoConnection.CanonicalEventType, // Legacy event, with an awful name.
]; ];
static helpMessage: any; static helpMessage: MatrixMessageContent;
static botCommands: BotCommands; static botCommands: BotCommands;
constructor(public readonly roomId: string, constructor(public readonly roomId: string,
@ -46,7 +48,7 @@ export class GitLabRepoConnection implements IConnection {
return this.state.repo; return this.state.repo;
} }
public isInterestedInStateEvent(eventType: string) { public isInterestedInStateEvent() {
return false; return false;
} }
@ -99,7 +101,7 @@ export class GitLabRepoConnection implements IConnection {
@botCommand("gl close", "Close an issue", ["number"], ["comment"], true) @botCommand("gl close", "Close an issue", ["number"], ["comment"], true)
// @ts-ignore // @ts-ignore
private async onClose(userId: string, number: string, comment?: string) { private async onClose(userId: string, number: string) {
const client = await this.tokenStore.getGitLabForUser(userId, this.instance.url); const client = await this.tokenStore.getGitLabForUser(userId, this.instance.url);
if (!client) { if (!client) {
await this.as.botIntent.sendText(this.roomId, "You must login to create an issue", "m.notice"); await this.as.botIntent.sendText(this.roomId, "You must login to create an issue", "m.notice");
@ -121,17 +123,13 @@ export class GitLabRepoConnection implements IConnection {
// } // }
public async onEvent(evt: MatrixEvent<unknown>) {
}
public async onStateUpdate() { }
public toString() { public toString() {
return `GitHubRepo`; return `GitHubRepo`;
} }
} }
const res = compileBotCommands(GitLabRepoConnection.prototype); // Typescript doesn't understand Prototypes very well yet.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const res = compileBotCommands(GitLabRepoConnection.prototype as any);
GitLabRepoConnection.helpMessage = res.helpMessage; GitLabRepoConnection.helpMessage = res.helpMessage;
GitLabRepoConnection.botCommands = res.botCommands; GitLabRepoConnection.botCommands = res.botCommands;

View File

@ -6,11 +6,11 @@ export interface IConnection {
/** /**
* When a room gets an update to it's state. * When a room gets an update to it's state.
*/ */
onStateUpdate: (ev: any) => Promise<void>; onStateUpdate?: (ev: MatrixEvent<unknown>) => Promise<void>;
/** /**
* When a room gets any event * When a room gets any event
*/ */
onEvent: (ev: MatrixEvent<unknown>) => Promise<void>; onEvent?: (ev: MatrixEvent<unknown>) => Promise<void>;
/** /**
* When a room gets a message event * When a room gets a message event

View File

@ -1,4 +1,4 @@
import { Octokit } from "@octokit/rest"; import { IssuesGetCommentResponseData, IssuesGetResponseData, ProjectsListForOrgResponseData, ProjectsListForUserResponseData, ProjectsListForRepoResponseData } from "@octokit/types";
interface IMinimalRepository { interface IMinimalRepository {
id: number; id: number;
@ -36,7 +36,7 @@ export class FormatUtil {
}; };
} }
public static getPartialBodyForIssue(repo: IMinimalRepository, issue: Octokit.IssuesGetResponse) { public static getPartialBodyForIssue(repo: IMinimalRepository, issue: IssuesGetResponseData) {
return { return {
...FormatUtil.getPartialBodyForRepo(repo), ...FormatUtil.getPartialBodyForRepo(repo),
"external_url": issue.html_url, "external_url": issue.html_url,
@ -50,9 +50,9 @@ export class FormatUtil {
}; };
} }
public static getPartialBodyForComment(comment: Octokit.IssuesGetCommentResponse, public static getPartialBodyForComment(comment: IssuesGetCommentResponseData,
repo?: IMinimalRepository, repo?: IMinimalRepository,
issue?: Octokit.IssuesGetResponse) { issue?: IssuesGetResponseData) {
return { return {
...(issue && repo ? FormatUtil.getPartialBodyForIssue(repo, issue) : undefined), ...(issue && repo ? FormatUtil.getPartialBodyForIssue(repo, issue) : undefined),
"external_url": comment.html_url, "external_url": comment.html_url,
@ -62,7 +62,7 @@ export class FormatUtil {
}; };
} }
public static projectListing(projectItem: Octokit.ProjectsListForOrgResponseItem|Octokit.ProjectsListForUserResponseItem|Octokit.ProjectsListForRepoResponseItem) { public static projectListing(projectItem: ProjectsListForOrgResponseData|ProjectsListForUserResponseData|ProjectsListForRepoResponseData) {
return `${projectItem.name} (#${projectItem.number}) - Project ID: ${projectItem.id}` return `${projectItem[0].name} (#${projectItem[0].number}) - Project ID: ${projectItem[0].id}`
} }
} }

View File

@ -1,4 +1,5 @@
import { createAppAuth } from "@octokit/auth-app"; import { createAppAuth } from "@octokit/auth-app";
import { createTokenAuth } from "@octokit/auth-token";
import { Octokit } from "@octokit/rest"; import { Octokit } from "@octokit/rest";
import { promises as fs } from "fs"; import { promises as fs } from "fs";
import { BridgeConfigGitHub } from "../Config"; import { BridgeConfigGitHub } from "../Config";
@ -6,6 +7,8 @@ import LogWrapper from "../LogWrapper";
const log = new LogWrapper("GithubInstance"); const log = new LogWrapper("GithubInstance");
const USER_AGENT = "matrix-github v0.0.1";
export class GithubInstance { export class GithubInstance {
private internalOctokit!: Octokit; private internalOctokit!: Octokit;
@ -17,6 +20,14 @@ export class GithubInstance {
} }
public static createUserOctokit(token: string) {
return new Octokit({
authStrategy: createTokenAuth,
auth: token,
userAgent: USER_AGENT,
});
}
public async start() { public async start() {
// TODO: Make this generic. // TODO: Make this generic.
const auth = { const auth = {
@ -28,7 +39,7 @@ export class GithubInstance {
this.internalOctokit = new Octokit({ this.internalOctokit = new Octokit({
authStrategy: createAppAuth, authStrategy: createAppAuth,
auth, auth,
userAgent: "matrix-github v0.0.1", userAgent: USER_AGENT,
}); });
try { try {

25
src/Github/Types.ts Normal file
View File

@ -0,0 +1,25 @@
import { IssuesGetResponseData, IssuesGetCommentResponseData, PullsListReviewsResponseData, ReposGetResponseData, PullsListRequestedReviewersResponseData } from "@octokit/types";
/* eslint-disable camelcase */
export interface GitHubUserNotification {
id: string;
reason: "assign"|"author"|"comment"|"invitation"|"manual"|"mention"|"review_requested"|
"security_alert"|"state_change"|"subscribed"|"team_mention";
unread: boolean;
updated_at: number;
last_read_at: number;
url: string;
subject: {
title: string;
url: string;
latest_comment_url: string|null;
type: "PullRequest"|"Issue"|"RepositoryVulnerabilityAlert";
// Probably.
url_data?: IssuesGetResponseData;
latest_comment_url_data?: IssuesGetCommentResponseData;
requested_reviewers?: PullsListRequestedReviewersResponseData;
reviews?: PullsListReviewsResponseData;
};
// Not quite the right type but good nuff.
repository: ReposGetResponseData;
}

View File

@ -1,5 +1,5 @@
import { Appservice, IAppserviceRegistration, RichRepliesPreprocessor, IRichReplyMetadata } from "matrix-bot-sdk"; import { Appservice, IAppserviceRegistration, RichRepliesPreprocessor, IRichReplyMetadata } from "matrix-bot-sdk";
import { Octokit } from "@octokit/rest"; import { ProjectsGetResponseData } from "@octokit/types";
import { BridgeConfig, GitLabInstance } from "./Config"; import { BridgeConfig, GitLabInstance } from "./Config";
import { IGitHubWebhookEvent, IOAuthRequest, IOAuthTokens, NotificationsEnableEvent, import { IGitHubWebhookEvent, IOAuthRequest, IOAuthTokens, NotificationsEnableEvent,
NotificationsDisableEvent } from "./GithubWebhooks"; NotificationsDisableEvent } from "./GithubWebhooks";
@ -10,7 +10,7 @@ import { UserTokenStore } from "./UserTokenStore";
import { MatrixEvent, MatrixMemberContent, MatrixMessageContent } from "./MatrixEvent"; import { MatrixEvent, MatrixMemberContent, MatrixMessageContent } from "./MatrixEvent";
import LogWrapper from "./LogWrapper"; import LogWrapper from "./LogWrapper";
import { MessageSenderClient } from "./MatrixSender"; import { MessageSenderClient } from "./MatrixSender";
import { UserNotificationsEvent } from "./UserNotificationWatcher"; import { UserNotificationsEvent } from "./Notifications/UserNotificationWatcher";
import { RedisStorageProvider } from "./Stores/RedisStorageProvider"; import { RedisStorageProvider } from "./Stores/RedisStorageProvider";
import { MemoryStorageProvider } from "./Stores/MemoryStorageProvider"; import { MemoryStorageProvider } from "./Stores/MemoryStorageProvider";
import { NotificationProcessor } from "./NotificationsProcessor"; import { NotificationProcessor } from "./NotificationsProcessor";
@ -113,7 +113,7 @@ export class GithubBridge {
public stop() { public stop() {
this.as.stop(); this.as.stop();
this.queue.stop(); if(this.queue.stop) this.queue.stop();
} }
public async start() { public async start() {
@ -189,60 +189,75 @@ export class GithubBridge {
this.queue.subscribe("merge_request.*"); this.queue.subscribe("merge_request.*");
this.queue.subscribe("gitlab.*"); this.queue.subscribe("gitlab.*");
this.queue.on<IGitHubWebhookEvent>("comment.created", async (msg) => { const validateRepoIssue = (data: IGitHubWebhookEvent) => {
const connections = this.getConnectionsForGithubIssue(msg.data.repository!.owner.login, msg.data.repository!.name, msg.data.issue!.number); if (!data.repository || !data.issue) {
throw Error("Malformed webhook event, missing repository or issue");
}
return {
repository: data.repository,
issue: data.issue,
};
}
this.queue.on<IGitHubWebhookEvent>("comment.created", async ({ data }) => {
const { repository, issue } = validateRepoIssue(data);
const connections = this.getConnectionsForGithubIssue(repository.owner.login, repository.name, issue.number);
connections.map(async (c) => { connections.map(async (c) => {
try { try {
if (c.onCommentCreated) if (c.onCommentCreated)
await c.onCommentCreated(msg.data); await c.onCommentCreated(data);
} catch (ex) { } catch (ex) {
log.warn(`Connection ${c.toString()} failed to handle comment.created:`, ex); log.warn(`Connection ${c.toString()} failed to handle comment.created:`, ex);
} }
}) })
}); });
this.queue.on<IGitHubWebhookEvent>("issue.opened", async (msg) => { this.queue.on<IGitHubWebhookEvent>("issue.opened", async ({ data }) => {
const connections = this.getConnectionsForGithubIssue(msg.data.repository!.owner.login, msg.data.repository!.name, msg.data.issue!.number); const { repository, issue } = validateRepoIssue(data);
const connections = this.getConnectionsForGithubIssue(repository.owner.login, repository.name, issue.number);
connections.map(async (c) => { connections.map(async (c) => {
try { try {
if (c.onIssueCreated) if (c.onIssueCreated)
await c.onIssueCreated(msg.data); await c.onIssueCreated(data);
} catch (ex) { } catch (ex) {
log.warn(`Connection ${c.toString()} failed to handle comment.created:`, ex); log.warn(`Connection ${c.toString()} failed to handle comment.created:`, ex);
} }
}) })
}); });
this.queue.on<IGitHubWebhookEvent>("issue.edited", async (msg) => { this.queue.on<IGitHubWebhookEvent>("issue.edited", async ({ data }) => {
const connections = this.getConnectionsForGithubIssue(msg.data.repository!.owner.login, msg.data.repository!.name, msg.data.issue!.number); const { repository, issue } = validateRepoIssue(data);
const connections = this.getConnectionsForGithubIssue(repository.owner.login, repository.name, issue.number);
connections.map(async (c) => { connections.map(async (c) => {
try { try {
if (c.onIssueEdited) if (c.onIssueEdited)
await c.onIssueEdited(msg.data); await c.onIssueEdited(data);
} catch (ex) { } catch (ex) {
log.warn(`Connection ${c.toString()} failed to handle comment.created:`, ex); log.warn(`Connection ${c.toString()} failed to handle comment.created:`, ex);
} }
}) })
}); });
this.queue.on<IGitHubWebhookEvent>("issue.closed", async (msg) => { this.queue.on<IGitHubWebhookEvent>("issue.closed", async ({ data }) => {
const connections = this.getConnectionsForGithubIssue(msg.data.repository!.owner.login, msg.data.repository!.name, msg.data.issue!.number); const { repository, issue } = validateRepoIssue(data);
const connections = this.getConnectionsForGithubIssue(repository.owner.login, repository.name, issue.number);
connections.map(async (c) => { connections.map(async (c) => {
try { try {
if (c.onIssueStateChange) if (c.onIssueStateChange)
await c.onIssueStateChange(msg.data); await c.onIssueStateChange(data);
} catch (ex) { } catch (ex) {
log.warn(`Connection ${c.toString()} failed to handle comment.created:`, ex); log.warn(`Connection ${c.toString()} failed to handle comment.created:`, ex);
} }
}) })
}); });
this.queue.on<IGitHubWebhookEvent>("issue.reopened", async (msg) => { this.queue.on<IGitHubWebhookEvent>("issue.reopened", async ({ data }) => {
const connections = this.getConnectionsForGithubIssue(msg.data.repository!.owner.login, msg.data.repository!.name, msg.data.issue!.number); const { repository, issue } = validateRepoIssue(data);
const connections = this.getConnectionsForGithubIssue(repository.owner.login, repository.name, issue.number);
connections.map(async (c) => { connections.map(async (c) => {
try { try {
if (c.onIssueStateChange) if (c.onIssueStateChange)
await c.onIssueStateChange(msg.data); await c.onIssueStateChange(data);
} catch (ex) { } catch (ex) {
log.warn(`Connection ${c.toString()} failed to handle comment.created:`, ex); log.warn(`Connection ${c.toString()} failed to handle comment.created:`, ex);
} }
@ -305,13 +320,12 @@ export class GithubBridge {
}); });
// Fetch all room state // Fetch all room state
let joinedRooms: string[]; let joinedRooms: string[]|undefined;
while(true) { while(joinedRooms === undefined) {
try { try {
log.info("Connecting to homeserver and fetching joined rooms.."); log.info("Connecting to homeserver and fetching joined rooms..");
joinedRooms = await this.as.botIntent.underlyingClient.getJoinedRooms(); joinedRooms = await this.as.botIntent.underlyingClient.getJoinedRooms();
log.info(`Found ${joinedRooms.length} rooms`); log.info(`Found ${joinedRooms.length} rooms`);
break;
} catch (ex) { } catch (ex) {
// This is our first interaction with the homeserver, so wait if it's not ready yet. // This is our first interaction with the homeserver, so wait if it's not ready yet.
log.warn("Failed to connect to homeserver:", ex, "retrying in 5s"); log.warn("Failed to connect to homeserver:", ex, "retrying in 5s");
@ -428,14 +442,17 @@ export class GithubBridge {
} }
const command = event.content.body; const command = event.content.body;
if (command) { const adminRoom = this.adminRooms.get(roomId);
await this.adminRooms.get(roomId)!.handleCommand(event.event_id, command); if (command && adminRoom) {
await adminRoom.handleCommand(event.event_id, command);
} }
} }
for (const connection of this.connections.filter((c) => c.roomId === roomId && c.onMessageEvent)) { for (const connection of this.connections.filter((c) => c.roomId === roomId)) {
try { try {
await connection.onMessageEvent!(event); if (connection.onMessageEvent) {
await connection.onMessageEvent(event);
}
} catch (ex) { } catch (ex) {
log.warn(`Connection ${connection.toString()} failed to handle message:`, ex); log.warn(`Connection ${connection.toString()} failed to handle message:`, ex);
} }
@ -461,7 +478,7 @@ export class GithubBridge {
if (event.state_key) { if (event.state_key) {
// A state update, hurrah! // A state update, hurrah!
const existingConnection = this.connections.find((c) => c.roomId === roomId && c.isInterestedInStateEvent(event.type, event.state_key || "")); const existingConnection = this.connections.find((c) => c.roomId === roomId && c.isInterestedInStateEvent(event.type, event.state_key || ""));
if (existingConnection) { if (existingConnection?.onStateUpdate) {
existingConnection.onStateUpdate(event); existingConnection.onStateUpdate(event);
} else { } else {
// Is anyone interested in this state? // Is anyone interested in this state?
@ -475,7 +492,7 @@ export class GithubBridge {
} }
// Alas, it's just an event. // Alas, it's just an event.
return this.connections.filter((c) => c.roomId === roomId).map((c) => c.onEvent(event)) return this.connections.filter((c) => c.roomId === roomId).map((c) => c.onEvent ? c.onEvent(event) : undefined);
} }
private async onQueryRoom(roomAlias: string) { private async onQueryRoom(roomAlias: string) {
@ -535,11 +552,13 @@ export class GithubBridge {
eventName: "notifications.user.enable", eventName: "notifications.user.enable",
sender: "GithubBridge", sender: "GithubBridge",
data: { data: {
user_id: adminRoom.userId, userId: adminRoom.userId,
room_id: adminRoom.roomId, roomId: adminRoom.roomId,
token, token,
since: await adminRoom.getNotifSince(), since: await adminRoom.getNotifSince(),
filter_participating: adminRoom.notificationsParticipating, filterParticipating: adminRoom.notificationsParticipating,
type: "github",
instanceUrl: undefined,
}, },
}); });
} else { } else {
@ -550,7 +569,9 @@ export class GithubBridge {
eventName: "notifications.user.disable", eventName: "notifications.user.disable",
sender: "GithubBridge", sender: "GithubBridge",
data: { data: {
user_id: adminRoom.userId, userId: adminRoom.userId,
type: "github",
instanceUrl: undefined,
}, },
}); });
} }
@ -561,12 +582,12 @@ export class GithubBridge {
roomId, accountData, this.as.botIntent, this.tokenStore, this.config, roomId, accountData, this.as.botIntent, this.tokenStore, this.config,
); );
adminRoom.on("settings.changed", this.onAdminRoomSettingsChanged.bind(this)); adminRoom.on("settings.changed", this.onAdminRoomSettingsChanged.bind(this));
adminRoom.on("open.project", async (project: Octokit.ProjectsGetResponse) => { adminRoom.on("open.project", async (project: ProjectsGetResponseData) => {
const connection = await GitHubProjectConnection.onOpenProject(project, this.as, adminRoom.userId); const connection = await GitHubProjectConnection.onOpenProject(project, this.as, adminRoom.userId);
this.connections.push(connection); this.connections.push(connection);
}); });
adminRoom.on("open.gitlab-issue", async (issueInfo: GetIssueOpts, res: GetIssueResponse, instance: GitLabInstance) => { adminRoom.on("open.gitlab-issue", async (issueInfo: GetIssueOpts, res: GetIssueResponse, instance: GitLabInstance) => {
let [ connection ] = this.getConnectionsForGitLabIssue(instance, issueInfo.projects, issueInfo.issue); const [ connection ] = this.getConnectionsForGitLabIssue(instance, issueInfo.projects, issueInfo.issue);
if (connection) { if (connection) {
return this.as.botClient.inviteUser(adminRoom.userId, connection.roomId); return this.as.botClient.inviteUser(adminRoom.userId, connection.roomId);
} }

View File

@ -1,24 +1,26 @@
import { BridgeConfig } from "./Config"; import { BridgeConfig } from "./Config";
import { Application, default as express, Request, Response } from "express"; import { Application, default as express, Request, Response } from "express";
import { createHmac } from "crypto"; import { createHmac } from "crypto";
import { Octokit } from "@octokit/rest"; import { IssuesGetResponseData, IssuesGetCommentResponseData, ReposGetResponseData } from "@octokit/types";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import { MessageQueue, createMessageQueue, MessageQueueMessage } from "./MessageQueue/MessageQueue"; import { MessageQueue, createMessageQueue, MessageQueueMessage } from "./MessageQueue/MessageQueue";
import LogWrapper from "./LogWrapper"; import LogWrapper from "./LogWrapper";
import qs from "querystring"; import qs from "querystring";
import { Server } from "http"; import { Server } from "http";
import axios from "axios"; import axios from "axios";
import { UserNotificationWatcher } from "./UserNotificationWatcher"; import { UserNotificationWatcher } from "./Notifications/UserNotificationWatcher";
import { IGitLabWebhookEvent } from "./Gitlab/WebhookTypes"; import { IGitLabWebhookEvent } from "./Gitlab/WebhookTypes";
const log = new LogWrapper("GithubWebhooks"); const log = new LogWrapper("GithubWebhooks");
export interface IGitHubWebhookEvent { export interface IGitHubWebhookEvent {
action: string; action: string;
issue?: Octokit.IssuesGetResponse; issue?: IssuesGetResponseData;
comment?: Octokit.IssuesGetCommentResponse; comment?: IssuesGetCommentResponseData;
repository?: Octokit.ReposGetResponse; repository?: ReposGetResponseData;
sender?: Octokit.IssuesGetResponseUser; sender?: {
login: string;
}
changes?: { changes?: {
title?: { title?: {
from: string; from: string;
@ -38,15 +40,19 @@ export interface IOAuthTokens {
} }
export interface NotificationsEnableEvent { export interface NotificationsEnableEvent {
user_id: string; userId: string;
room_id: string; roomId: string;
since: number; since: number;
token: string; token: string;
filter_participating: boolean; filterParticipating: boolean;
type: "github"|"gitlab";
instanceUrl?: string;
} }
export interface NotificationsDisableEvent { export interface NotificationsDisableEvent {
user_id: string; userId: string;
type: "github"|"gitlab";
instanceUrl?: string;
} }
export class GithubWebhooks extends EventEmitter { export class GithubWebhooks extends EventEmitter {
@ -69,7 +75,7 @@ export class GithubWebhooks extends EventEmitter {
this.userNotificationWatcher.addUser(msg.data); this.userNotificationWatcher.addUser(msg.data);
}); });
this.queue.on("notifications.user.disable", (msg: MessageQueueMessage<NotificationsDisableEvent>) => { this.queue.on("notifications.user.disable", (msg: MessageQueueMessage<NotificationsDisableEvent>) => {
this.userNotificationWatcher.removeUser(msg.data.user_id); this.userNotificationWatcher.removeUser(msg.data.userId, msg.data.type, msg.data.instanceUrl);
}); });
// This also listens for notifications for users, which is long polly. // This also listens for notifications for users, which is long polly.
@ -85,7 +91,9 @@ export class GithubWebhooks extends EventEmitter {
} }
public stop() { public stop() {
if (this.queue.stop) {
this.queue.stop(); this.queue.stop();
}
if (this.server) { if (this.server) {
this.server.close(); this.server.close();
} }
@ -125,7 +133,7 @@ export class GithubWebhooks extends EventEmitter {
log.debug(`New webhook: ${req.url}`); log.debug(`New webhook: ${req.url}`);
try { try {
let eventName: string|null = null; let eventName: string|null = null;
let body = req.body; const body = req.body;
res.sendStatus(200); res.sendStatus(200);
if (req.headers['x-hub-signature']) { if (req.headers['x-hub-signature']) {
eventName = this.onGitHubPayload(body); eventName = this.onGitHubPayload(body);

View File

@ -1,5 +1,5 @@
import axios from "axios"; import axios from "axios";
import { GetIssueResponse, GetUserResponse, CreateIssueOpts, CreateIssueResponse, GetIssueOpts, EditIssueOpts } from "./Types"; import { GetIssueResponse, GetUserResponse, CreateIssueOpts, CreateIssueResponse, GetIssueOpts, EditIssueOpts, GetTodosResponse } from "./Types";
export class GitLabClient { export class GitLabClient {
constructor(private instanceUrl: string, private token: string) { constructor(private instanceUrl: string, private token: string) {
@ -37,6 +37,10 @@ export class GitLabClient {
return (await axios.put(`${this.instanceUrl}/api/v4/projects/${opts.id}/issues/${opts.issue_iid}`, opts, this.defaultConfig)).data; return (await axios.put(`${this.instanceUrl}/api/v4/projects/${opts.id}/issues/${opts.issue_iid}`, opts, this.defaultConfig)).data;
} }
public async getTodos() {
return (await axios.get(`${this.instanceUrl}/api/v4/todos`, this.defaultConfig)).data as GetTodosResponse[];
}
get issues() { get issues() {
return { return {
create: this.createIssue.bind(this), create: this.createIssue.bind(this),

View File

@ -1,3 +1,15 @@
/* eslint-disable camelcase */
export interface GitLabAuthor {
author: {
id: number;
name: string;
username: string;
state: 'active';
avatar_url: string;
web_url: string;
};
}
export interface GetUserResponse { export interface GetUserResponse {
id: number; id: number;
username: string; username: string;
@ -78,14 +90,7 @@ export interface GetIssueResponse {
title: string; title: string;
description: string; description: string;
state: 'opened'|'closed'; state: 'opened'|'closed';
author: { author: GitLabAuthor;
id: number;
name: string;
username: string;
state: 'active';
avatar_url: string;
web_url: string;
};
references: { references: {
short: string; short: string;
relative: string; relative: string;
@ -93,3 +98,33 @@ export interface GetIssueResponse {
} }
web_url: string; web_url: string;
} }
export interface GetTodosResponse {
id: number;
author: GitLabAuthor;
action_name: string;
project: {
id: number;
name: string;
name_with_namespace: string;
path: string;
path_with_namespace: string;
};
target: {
title: string;
description: string;
state: 'opened'|'closed';
assignee: {
name: string;
username: string;
id: 1;
state: "active";
avatar_url: string;
web_url: string;
}
}
target_url: string;
body: string;
created_at: string;
updated_at: string;
}

View File

@ -1,3 +1,4 @@
/* eslint-disable camelcase */
export interface IGitLabWebhookEvent { export interface IGitLabWebhookEvent {
object_kind: string; object_kind: string;

View File

@ -4,7 +4,7 @@ import { Appservice } from "matrix-bot-sdk";
const log = new LogWrapper("IntentUtils"); const log = new LogWrapper("IntentUtils");
export async function getIntentForUser(user: Octokit.IssuesGetResponseUser, as: Appservice, octokit: Octokit) { export async function getIntentForUser(user: {avatar_url?: string, login: string}, as: Appservice, octokit: Octokit) {
const intent = as.getIntentForSuffix(user.login); const intent = as.getIntentForSuffix(user.login);
const displayName = `${user.login}`; const displayName = `${user.login}`;
// Verify up-to-date profile // Verify up-to-date profile

View File

@ -3,7 +3,7 @@ import { MessageQueue, createMessageQueue } from "./MessageQueue/MessageQueue";
import { MatrixEventContent, MatrixMessageContent } from "./MatrixEvent"; import { MatrixEventContent, MatrixMessageContent } from "./MatrixEvent";
import { Appservice, IAppserviceRegistration } from "matrix-bot-sdk"; import { Appservice, IAppserviceRegistration } from "matrix-bot-sdk";
import LogWrapper from "./LogWrapper"; import LogWrapper from "./LogWrapper";
import { v4 as uuid } from "uuid";
export interface IMatrixSendMessage { export interface IMatrixSendMessage {
sender: string|null; sender: string|null;
type: string; type: string;
@ -35,7 +35,7 @@ export class MatrixSender {
this.mq.subscribe("matrix.message"); this.mq.subscribe("matrix.message");
this.mq.on<IMatrixSendMessage>("matrix.message", async (msg) => { this.mq.on<IMatrixSendMessage>("matrix.message", async (msg) => {
try { try {
await this.sendMatrixMessage(msg.messageId!, msg.data); await this.sendMatrixMessage(msg.messageId || uuid(), msg.data);
} catch (ex) { } catch (ex) {
log.error(`Failed to send message (${msg.data.roomId}, ${msg.data.sender}, ${msg.data.type})`); log.error(`Failed to send message (${msg.data.roomId}, ${msg.data.sender}, ${msg.data.type})`);
} }
@ -43,8 +43,10 @@ export class MatrixSender {
} }
public stop() { public stop() {
if (this.mq.stop) {
this.mq.stop(); this.mq.stop();
} }
}
public async sendMatrixMessage(messageId: string, msg: IMatrixSendMessage) { public async sendMatrixMessage(messageId: string, msg: IMatrixSendMessage) {
const intent = msg.sender ? this.as.getIntentForUserId(msg.sender) : this.as.botIntent; const intent = msg.sender ? this.as.getIntentForUserId(msg.sender) : this.as.botIntent;
@ -65,7 +67,7 @@ export class MatrixSender {
export class MessageSenderClient { export class MessageSenderClient {
constructor(private queue: MessageQueue) { } constructor(private queue: MessageQueue) { }
public async sendMatrixText(roomId: string, text: string, msgtype: string = "m.text", public async sendMatrixText(roomId: string, text: string, msgtype = "m.text",
sender: string|null = null): Promise<string> { sender: string|null = null): Promise<string> {
return this.sendMatrixMessage(roomId, { return this.sendMatrixMessage(roomId, {
msgtype, msgtype,
@ -74,7 +76,7 @@ export class MessageSenderClient {
} }
public async sendMatrixMessage(roomId: string, public async sendMatrixMessage(roomId: string,
content: MatrixEventContent, eventType: string = "m.room.message", content: MatrixEventContent, eventType = "m.room.message",
sender: string|null = null): Promise<string> { sender: string|null = null): Promise<string> {
return (await this.queue.pushWait<IMatrixSendMessage, IMatrixSendMessageResponse>({ return (await this.queue.pushWait<IMatrixSendMessage, IMatrixSendMessageResponse>({
eventName: "matrix.message", eventName: "matrix.message",

View File

@ -1,7 +1,7 @@
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import { MessageQueue, MessageQueueMessage, DEFAULT_RES_TIMEOUT } from "./MessageQueue"; import { MessageQueue, MessageQueueMessage, DEFAULT_RES_TIMEOUT } from "./MessageQueue";
import micromatch from "micromatch"; import micromatch from "micromatch";
import uuid from "uuid/v4"; import {v4 as uuid} from "uuid";
export class LocalMQ extends EventEmitter implements MessageQueue { export class LocalMQ extends EventEmitter implements MessageQueue {
private subs: Set<string>; private subs: Set<string>;
@ -30,7 +30,6 @@ export class LocalMQ extends EventEmitter implements MessageQueue {
public async pushWait<T, X>(message: MessageQueueMessage<T>, public async pushWait<T, X>(message: MessageQueueMessage<T>,
timeout: number = DEFAULT_RES_TIMEOUT): Promise<X> { timeout: number = DEFAULT_RES_TIMEOUT): Promise<X> {
let awaitResponse: (response: MessageQueueMessage<X>) => void;
let resolve: (value: X) => void; let resolve: (value: X) => void;
let timer: NodeJS.Timer; let timer: NodeJS.Timer;
@ -41,7 +40,7 @@ export class LocalMQ extends EventEmitter implements MessageQueue {
}, timeout); }, timeout);
}); });
awaitResponse = (response: MessageQueueMessage<X>) => { const awaitResponse = (response: MessageQueueMessage<X>) => {
if (response.messageId === message.messageId) { if (response.messageId === message.messageId) {
clearTimeout(timer); clearTimeout(timer);
this.removeListener(`response.${message.eventName}`, awaitResponse); this.removeListener(`response.${message.eventName}`, awaitResponse);
@ -53,6 +52,4 @@ export class LocalMQ extends EventEmitter implements MessageQueue {
this.push(message); this.push(message);
return p; return p;
} }
public stop() { }
} }

View File

@ -7,22 +7,26 @@ export const DEFAULT_RES_TIMEOUT = 30000;
const staticLocalMq = new LocalMQ(); const staticLocalMq = new LocalMQ();
let staticRedisMq: RedisMQ|null = null; let staticRedisMq: RedisMQ|null = null;
export interface MessageQueueMessage<T> { export interface MessageQueueMessage<T> {
sender: string; sender: string;
eventName: string; eventName: string;
data: T; data: T;
ts?: number;
messageId?: string; messageId?: string;
for?: string; for?: string;
} }
export interface MessageQueueMessageOut<T> extends MessageQueueMessage<T> {
ts: number;
}
export interface MessageQueue { export interface MessageQueue {
subscribe: (eventGlob: string) => void; subscribe: (eventGlob: string) => void;
unsubscribe: (eventGlob: string) => void; unsubscribe: (eventGlob: string) => void;
push: <T>(data: MessageQueueMessage<T>, single?: boolean) => Promise<void>; push: <T>(data: MessageQueueMessage<T>, single?: boolean) => Promise<void>;
pushWait: <T, X>(data: MessageQueueMessage<T>, timeout?: number, single?: boolean) => Promise<X>; pushWait: <T, X>(data: MessageQueueMessage<T>, timeout?: number, single?: boolean) => Promise<X>;
on: <T>(eventName: string, cb: (data: MessageQueueMessage<T>) => void) => void; on: <T>(eventName: string, cb: (data: MessageQueueMessageOut<T>) => void) => void;
stop(): void; stop?(): void;
} }
export function createMessageQueue(config: BridgeConfig): MessageQueue { export function createMessageQueue(config: BridgeConfig): MessageQueue {

View File

@ -1,10 +1,10 @@
import { MessageQueue, MessageQueueMessage, DEFAULT_RES_TIMEOUT } from "./MessageQueue"; import { MessageQueue, MessageQueueMessage, DEFAULT_RES_TIMEOUT, MessageQueueMessageOut } from "./MessageQueue";
import { Redis, default as redis } from "ioredis"; import { Redis, default as redis } from "ioredis";
import { BridgeConfig } from "../Config"; import { BridgeConfig } from "../Config";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import LogWrapper from "../LogWrapper"; import LogWrapper from "../LogWrapper";
import uuid from "uuid/v4"; import {v4 as uuid} from "uuid";
const log = new LogWrapper("RedisMq"); const log = new LogWrapper("RedisMq");
@ -26,13 +26,13 @@ export class RedisMQ extends EventEmitter implements MessageQueue {
this.redisPub = new redis(config.queue.port, config.queue.host); this.redisPub = new redis(config.queue.port, config.queue.host);
this.redis = new redis(config.queue.port, config.queue.host); this.redis = new redis(config.queue.port, config.queue.host);
this.myUuid = uuid(); this.myUuid = uuid();
this.redisSub.on("pmessage", (pattern: string, channel: string, message: string) => { this.redisSub.on("pmessage", (_: string, channel: string, message: string) => {
const msg = JSON.parse(message) as MessageQueueMessage<unknown>; const msg = JSON.parse(message) as MessageQueueMessageOut<unknown>;
if (msg.for && msg.for !== this.myUuid) { if (msg.for && msg.for !== this.myUuid) {
log.debug(`Got message for ${msg.for}, dropping`); log.debug(`Got message for ${msg.for}, dropping`);
return; return;
} }
const delay = (process.hrtime()[1]) - msg.ts!; const delay = (process.hrtime()[1]) - msg.ts;
log.debug("Delay: ", delay / 1000000, "ms"); log.debug("Delay: ", delay / 1000000, "ms");
this.emit(channel, JSON.parse(message)); this.emit(channel, JSON.parse(message));
}); });
@ -49,7 +49,7 @@ export class RedisMQ extends EventEmitter implements MessageQueue {
this.redis.srem(`${CONSUMER_TRACK_PREFIX}${eventGlob}`, this.myUuid); this.redis.srem(`${CONSUMER_TRACK_PREFIX}${eventGlob}`, this.myUuid);
} }
public async push<T>(message: MessageQueueMessage<T>, single: boolean = false) { public async push<T>(message: MessageQueueMessage<T>, single = false) {
if (!message.messageId) { if (!message.messageId) {
message.messageId = uuid(); message.messageId = uuid();
} }
@ -60,9 +60,12 @@ export class RedisMQ extends EventEmitter implements MessageQueue {
} }
message.for = recipient; message.for = recipient;
} }
message.ts = process.hrtime()[1]; const outMsg: MessageQueueMessageOut<T> = {
...message,
ts: process.hrtime()[1],
}
try { try {
await this.redisPub.publish(message.eventName, JSON.stringify(message)); await this.redisPub.publish(message.eventName, JSON.stringify(outMsg));
log.debug(`Pushed ${message.eventName}`); log.debug(`Pushed ${message.eventName}`);
} catch (ex) { } catch (ex) {
log.warn("Failed to push an event:", ex); log.warn("Failed to push an event:", ex);
@ -71,9 +74,7 @@ export class RedisMQ extends EventEmitter implements MessageQueue {
} }
public async pushWait<T, X>(message: MessageQueueMessage<T>, public async pushWait<T, X>(message: MessageQueueMessage<T>,
timeout: number = DEFAULT_RES_TIMEOUT, timeout: number = DEFAULT_RES_TIMEOUT): Promise<X> {
single: boolean = false): Promise<X> {
let awaitResponse: (response: MessageQueueMessage<X>) => void;
let resolve: (value: X) => void; let resolve: (value: X) => void;
let timer: NodeJS.Timer; let timer: NodeJS.Timer;
@ -84,7 +85,7 @@ export class RedisMQ extends EventEmitter implements MessageQueue {
}, timeout); }, timeout);
}); });
awaitResponse = (response: MessageQueueMessage<X>) => { const awaitResponse = (response: MessageQueueMessage<X>) => {
if (response.messageId === message.messageId) { if (response.messageId === message.messageId) {
clearTimeout(timer); clearTimeout(timer);
this.removeListener(`response.${message.eventName}`, awaitResponse); this.removeListener(`response.${message.eventName}`, awaitResponse);

View File

@ -0,0 +1,140 @@
import { Octokit } from "@octokit/rest";
import { EventEmitter } from "events";
import { GithubInstance } from "../Github/GithubInstance";
import LogWrapper from "../LogWrapper";
import { NotificationWatcherTask } from "./NotificationWatcherTask";
import { RequestError } from "@octokit/request-error";
import { GitHubUserNotification } from "../Github/Types";
import { OctokitResponse } from "@octokit/types";
const log = new LogWrapper("GitHubWatcher");
const GH_API_THRESHOLD = 50;
const GH_API_RETRY_IN = 1000 * 60;
export class GitHubWatcher extends EventEmitter implements NotificationWatcherTask {
private static apiFailureCount: number;
private static globalRetryIn: number;
public static checkGitHubStatus() {
this.apiFailureCount = Math.min(this.apiFailureCount + 1, GH_API_THRESHOLD);
if (this.apiFailureCount < GH_API_THRESHOLD) {
log.warn(`API Failure count at ${this.apiFailureCount}`);
return;
}
// The API is actively failing.
if (this.globalRetryIn > 0) {
this.globalRetryIn = Date.now() + GH_API_RETRY_IN;
}
log.warn(`API Failure limit reached, holding off new requests for ${GH_API_RETRY_IN / 1000}s`);
}
private octoKit: Octokit;
public failureCount = 0;
private interval?: NodeJS.Timeout;
private lastReadTs = 0;
public readonly type = "github";
public readonly instanceUrl = undefined;
constructor(token: string, public userId: string, public roomId: string, public since: number, private participating = false) {
super();
this.octoKit = GithubInstance.createUserOctokit(token);
}
public start(intervalMs: number) {
this.interval = setTimeout(() => {
this.getNotifications();
}, intervalMs);
}
public stop() {
if (this.interval) {
clearInterval(this.interval);
}
}
private handleGitHubFailure(ex: RequestError) {
log.error("An error occured getting notifications:", ex);
if (ex.status === 401 || ex.status === 404) {
log.warn(`Got status ${ex.status} when handing user stream: ${ex.message}`);
this.failureCount++;
} else if (ex.status >= 500) {
setImmediate(() => GitHubWatcher.checkGitHubStatus());
}
this.emit("fetch_failure", this);
}
private async getNotifications() {
if (GitHubWatcher.globalRetryIn !== 0 && GitHubWatcher.globalRetryIn > Date.now()) {
log.info(`Not getting notifications for ${this.userId}, API is still down.`);
return;
}
log.info(`Getting notifications for ${this.userId} ${this.lastReadTs}`);
const since = this.lastReadTs !== 0 ? `&since=${new Date(this.lastReadTs).toISOString()}`: "";
let response: OctokitResponse<GitHubUserNotification[]>;
try {
response = await this.octoKit.request(
`/notifications?participating=${this.participating}${since}`,
);
// We were succesful, clear any timeouts.
GitHubWatcher.globalRetryIn = 0;
// To avoid a bouncing issue, gradually reduce the failure count.
GitHubWatcher.apiFailureCount = Math.max(0, GitHubWatcher.apiFailureCount - 2);
} catch (ex) {
await this.handleGitHubFailure(ex);
return;
}
log.info(`Got ${response.data.length} notifications`);
this.lastReadTs = Date.now();
const events: GitHubUserNotification[] = [];
for (const rawEvent of response.data) {
try {
await (async () => {
if (rawEvent.subject.url) {
const res = await this.octoKit.request(rawEvent.subject.url);
rawEvent.subject.url_data = res.data;
}
if (rawEvent.subject.latest_comment_url) {
const res = await this.octoKit.request(rawEvent.subject.latest_comment_url);
rawEvent.subject.latest_comment_url_data = res.data;
}
if (rawEvent.reason === "review_requested") {
if (!rawEvent.subject.url_data?.number) {
log.warn("review_requested was missing subject.url_data.number");
return;
}
rawEvent.subject.requested_reviewers = (await this.octoKit.pulls.listRequestedReviewers({
pull_number: rawEvent.subject.url_data.number,
owner: rawEvent.repository.owner.login,
repo: rawEvent.repository.name,
})).data;
rawEvent.subject.reviews = (await this.octoKit.pulls.listReviews({
pull_number: rawEvent.subject.url_data.number,
owner: rawEvent.repository.owner.login,
repo: rawEvent.repository.name,
})).data;
}
events.push(rawEvent);
})();
} catch (ex) {
log.warn(`Failed to pre-process ${rawEvent.id}: ${ex}`);
// If it fails, we can just push the raw thing.
events.push(rawEvent);
}
}
if (events.length > 0) {
this.emit("notification_events", {
eventName: "notifications.user.events",
data: {
roomId: this.roomId,
events,
lastReadTs: this.lastReadTs,
},
sender: "GithubWebhooks",
});
}
}
}

View File

@ -0,0 +1,13 @@
import { EventEmitter } from "events";
type NotificationTypes = "github"|"gitlab";
export interface NotificationWatcherTask extends EventEmitter {
userId: string;
type: NotificationTypes;
instanceUrl?: string;
roomId: string;
failureCount: number;
start(intervalMs: number): void;
stop(): void;
}

View File

@ -0,0 +1,77 @@
import { NotificationsEnableEvent } from "../GithubWebhooks";
import LogWrapper from "../LogWrapper";
import { MessageQueue } from "../MessageQueue/MessageQueue";
import { MessageSenderClient } from "../MatrixSender";
import { NotificationWatcherTask } from "./NotificationWatcherTask";
import { GitHubWatcher } from "./GitHubWatcher";
import { GitHubUserNotification } from "../Github/Types";
export interface UserNotificationsEvent {
roomId: string;
lastReadTs: number;
events: GitHubUserNotification[];
}
const MIN_INTERVAL_MS = 15000;
const FAILURE_THRESHOLD = 50;
const log = new LogWrapper("UserNotificationWatcher");
export class UserNotificationWatcher {
/* Key: userId:type:instanceUrl */
private userIntervals = new Map<string, NotificationWatcherTask>();
private matrixMessageSender: MessageSenderClient;
constructor(private queue: MessageQueue) {
this.matrixMessageSender = new MessageSenderClient(queue);
}
private static constructMapKey(userId: string, type: "github"|"gitlab", instanceUrl?: string) {
return `${userId}:${type}:${instanceUrl || ""}`;
}
public start() {
// No-op
}
public removeUser(userId: string, type: "github"|"gitlab", instanceUrl?: string) {
const key = UserNotificationWatcher.constructMapKey(userId, type, instanceUrl);
const task = this.userIntervals.get(key);
if (task) {
task.stop();
this.userIntervals.delete(key);
log.info(`Removed ${key} from the notif queue`);
}
}
private onFetchFailure(task: NotificationWatcherTask) {
if (task.failureCount > FAILURE_THRESHOLD) {
this.removeUser(task.userId, task.type, task.instanceUrl);
this.matrixMessageSender.sendMatrixText(
task.roomId,
`The bridge has been unable to process your notification stream for some time, and has disabled notifications.
Check your token is still valid, and then turn notifications back on.`, "m.notice",
);
}
}
public addUser(data: NotificationsEnableEvent) {
let task: NotificationWatcherTask;
const key = UserNotificationWatcher.constructMapKey(data.userId, data.type, data.instanceUrl);
if (data.type === "github") {
this.userIntervals.get(key)?.stop();
task = new GitHubWatcher(data.token, data.userId, data.roomId, data.since, data.filterParticipating);
task.start(MIN_INTERVAL_MS);
}/* else if (data.type === "gitlab") {
}*/ else {
throw Error('Notification type not known');
}
task.on("fetch_failure", this.onFetchFailure.bind(this));
task.on("new_events", (payload) => {
this.queue.push<UserNotificationsEvent>(payload);
});
this.userIntervals.set(key, task);
log.info(`Inserted ${key} into the notif queue`);
}
}

View File

@ -1,44 +1,49 @@
import { MessageSenderClient } from "./MatrixSender"; import { MessageSenderClient } from "./MatrixSender";
import { IStorageProvider } from "./Stores/StorageProvider"; import { IStorageProvider } from "./Stores/StorageProvider";
import { UserNotificationsEvent, UserNotification } from "./UserNotificationWatcher"; import { UserNotificationsEvent } from "./Notifications/UserNotificationWatcher";
import LogWrapper from "./LogWrapper"; import LogWrapper from "./LogWrapper";
import { AdminRoom } from "./AdminRoom"; import { AdminRoom } from "./AdminRoom";
import markdown from "markdown-it"; import markdown from "markdown-it";
import { Octokit } from "@octokit/rest";
import { FormatUtil } from "./FormatUtil"; import { FormatUtil } from "./FormatUtil";
import { IssuesListAssigneesResponseData, PullsGetResponseData, IssuesGetResponseData, PullsListRequestedReviewersResponseData, PullsListReviewsResponseData, IssuesGetCommentResponseData } from "@octokit/types";
import { GitHubUserNotification } from "./Github/Types";
const log = new LogWrapper("GithubBridge"); const log = new LogWrapper("GithubBridge");
const md = new markdown(); const md = new markdown();
export interface IssueDiff { export interface IssueDiff {
state: null|string; state: null|string;
assignee: null|Octokit.IssuesGetResponseAssignee; assignee: null|IssuesListAssigneesResponseData;
title: null|string; title: null|string;
merged: boolean; merged: boolean;
mergedBy: null|{ mergedBy: null|{
login: string; login: string;
// eslint-disable-next-line camelcase
html_url: string; html_url: string;
}; };
user: { user: {
login: string; login: string;
// eslint-disable-next-line camelcase
html_url: string; html_url: string;
}; };
} }
export interface CachedReviewData { export interface CachedReviewData {
requested_reviewers: Octokit.PullsListReviewRequestsResponse; // eslint-disable-next-line camelcase
reviews: Octokit.PullsListReviewsResponse; requested_reviewers: PullsListRequestedReviewersResponseData;
reviews: PullsListReviewsResponseData;
} }
type PROrIssue = Octokit.IssuesGetResponse|Octokit.PullsGetResponse; type PROrIssue = IssuesGetResponseData|PullsGetResponseData;
export class NotificationProcessor { export class NotificationProcessor {
// eslint-disable-next-line camelcase
private static formatUser(user: {login: string, html_url: string}) { private static formatUser(user: {login: string, html_url: string}) {
return `**[${user.login}](${user.html_url})**`; return `**[${user.login}](${user.html_url})**`;
} }
private static formatNotification(notif: UserNotification, diff: IssueDiff|null, newComment: boolean) { private static formatNotification(notif: GitHubUserNotification, diff: IssueDiff|null, newComment: boolean) {
const user = diff ? ` by ${this.formatUser(diff?.user)}` : ""; const user = diff ? ` by ${this.formatUser(diff?.user)}` : "";
let plain = let plain =
`${this.getEmojiForNotifType(notif)} [${notif.subject.title}](${notif.subject.url_data?.html_url})${user}`; `${this.getEmojiForNotifType(notif)} [${notif.subject.title}](${notif.subject.url_data?.html_url})${user}`;
@ -61,11 +66,11 @@ export class NotificationProcessor {
plain += `\n\n Title changed to: ${diff.title}`; plain += `\n\n Title changed to: ${diff.title}`;
} }
if (diff.assignee) { if (diff.assignee) {
plain += `\n\n Assigned to: ${diff.assignee.login}`; plain += `\n\n Assigned to: ${diff.assignee[0].login}`;
} }
} }
if (newComment) { if (newComment) {
const comment = notif.subject.latest_comment_url_data as Octokit.IssuesGetCommentResponse; const comment = notif.subject.latest_comment_url_data as IssuesGetCommentResponseData;
plain += `\n\n ${NotificationProcessor.formatUser(comment.user)}:\n\n > ${comment.body}`; plain += `\n\n ${NotificationProcessor.formatUser(comment.user)}:\n\n > ${comment.body}`;
} }
return { return {
@ -74,7 +79,7 @@ export class NotificationProcessor {
}; };
} }
private static getEmojiForNotifType(notif: UserNotification): string { private static getEmojiForNotifType(notif: GitHubUserNotification): string {
let reasonFlag = ""; let reasonFlag = "";
switch (notif.reason) { switch (notif.reason) {
case "review_requested": case "review_requested":
@ -167,7 +172,7 @@ export class NotificationProcessor {
// } // }
// } // }
private formatSecurityAlert(notif: UserNotification) { private formatSecurityAlert(notif: GitHubUserNotification) {
const body = `⚠️ ${notif.subject.title} - ` const body = `⚠️ ${notif.subject.title} - `
+ `for **[${notif.repository.full_name}](${notif.repository.html_url})**`; + `for **[${notif.repository.full_name}](${notif.repository.html_url})**`;
return { return {
@ -182,26 +187,26 @@ export class NotificationProcessor {
private diffIssueChanges(curr: PROrIssue, prev: PROrIssue): IssueDiff { private diffIssueChanges(curr: PROrIssue, prev: PROrIssue): IssueDiff {
let merged = false; let merged = false;
let mergedBy = null; let mergedBy = null;
if ((curr as Octokit.PullsGetResponse).merged !== (prev as Octokit.PullsGetResponse).merged) { if ((curr as PullsGetResponseData).merged !== (prev as PullsGetResponseData).merged) {
merged = true; merged = true;
mergedBy = (curr as Octokit.PullsGetResponse).merged_by; mergedBy = (curr as PullsGetResponseData).merged_by;
} }
const diff: IssueDiff = { const diff: IssueDiff = {
state: curr.state === prev.state ? null : curr.state, state: curr.state === prev.state ? null : curr.state,
merged, merged,
mergedBy, mergedBy,
assignee: curr.assignee?.id === prev.assignee?.id ? null : curr.assignee, assignee: curr.assignee?.id === prev.assignee?.id ? null : [curr.assignee],
title: curr.title === prev.title ? null : curr.title, title: curr.title === prev.title ? null : curr.title,
user: curr.user, user: curr.user,
}; };
return diff; return diff;
} }
private async formatIssueOrPullRequest(roomId: string, notif: UserNotification) { private async formatIssueOrPullRequest(roomId: string, notif: GitHubUserNotification) {
const issueNumber = notif.subject.url_data?.number.toString(); const issueNumber = notif.subject.url_data?.number.toString();
let diff = null; let diff = null;
if (issueNumber) { if (issueNumber) {
const prevIssue: Octokit.IssuesGetResponse|null = await this.storage.getGithubIssue( const prevIssue: IssuesGetResponseData|null = await this.storage.getGithubIssue(
notif.repository.full_name, issueNumber, roomId); notif.repository.full_name, issueNumber, roomId);
if (prevIssue && notif.subject.url_data) { if (prevIssue && notif.subject.url_data) {
diff = this.diffIssueChanges(notif.subject.url_data, prevIssue); diff = this.diffIssueChanges(notif.subject.url_data, prevIssue);
@ -240,7 +245,7 @@ export class NotificationProcessor {
return this.matrixSender.sendMatrixMessage(roomId, body); return this.matrixSender.sendMatrixMessage(roomId, body);
} }
private async handleUserNotification(roomId: string, notif: UserNotification) { private async handleUserNotification(roomId: string, notif: GitHubUserNotification) {
log.info("New notification event:", notif); log.info("New notification event:", notif);
if (notif.reason === "security_alert") { if (notif.reason === "security_alert") {
return this.matrixSender.sendMatrixMessage(roomId, this.formatSecurityAlert(notif)); return this.matrixSender.sendMatrixMessage(roomId, this.formatSecurityAlert(notif));

View File

@ -1,36 +1,37 @@
import { MemoryStorageProvider as MSP } from "matrix-bot-sdk"; import { MemoryStorageProvider as MSP } from "matrix-bot-sdk";
import { IStorageProvider } from "./StorageProvider"; import { IStorageProvider } from "./StorageProvider";
import { IssuesGetResponseData } from "@octokit/types";
export class MemoryStorageProvider extends MSP implements IStorageProvider { export class MemoryStorageProvider extends MSP implements IStorageProvider {
private issues: Map<string, any> = new Map(); private issues: Map<string, IssuesGetResponseData> = new Map();
private issuesLastComment: Map<string, string> = new Map(); private issuesLastComment: Map<string, string> = new Map();
private reviewData: Map<string, string> = new Map(); private reviewData: Map<string, string> = new Map();
constructor() { constructor() {
super(); super();
} }
public async setGithubIssue(repo: string, issueNumber: string, data: any, scope: string = "") { public async setGithubIssue(repo: string, issueNumber: string, data: IssuesGetResponseData, scope = "") {
this.issues.set(`${scope}${repo}/${issueNumber}`, data); this.issues.set(`${scope}${repo}/${issueNumber}`, data);
} }
public async getGithubIssue(repo: string, issueNumber: string, scope: string = "") { public async getGithubIssue(repo: string, issueNumber: string, scope = "") {
return this.issues.get(`${scope}${repo}/${issueNumber}`) || null; return this.issues.get(`${scope}${repo}/${issueNumber}`) || null;
} }
public async setLastNotifCommentUrl(repo: string, issueNumber: string, url: string, scope: string = "") { public async setLastNotifCommentUrl(repo: string, issueNumber: string, url: string, scope = "") {
this.issuesLastComment.set(`${scope}${repo}/${issueNumber}`, url); this.issuesLastComment.set(`${scope}${repo}/${issueNumber}`, url);
} }
public async getLastNotifCommentUrl(repo: string, issueNumber: string, scope: string = "") { public async getLastNotifCommentUrl(repo: string, issueNumber: string, scope = "") {
return this.issuesLastComment.get(`${scope}${repo}/${issueNumber}`) || null; return this.issuesLastComment.get(`${scope}${repo}/${issueNumber}`) || null;
} }
public async setPRReviewData(repo: string, issueNumber: string, data: any, scope: string = "") { public async setPRReviewData(repo: string, issueNumber: string, data: any, scope = "") {
const key = `${scope}:${repo}/${issueNumber}`; const key = `${scope}:${repo}/${issueNumber}`;
this.reviewData.set(key, data); this.reviewData.set(key, data);
} }
public async getPRReviewData(repo: string, issueNumber: string, scope: string = "") { public async getPRReviewData(repo: string, issueNumber: string, scope = "") {
const key = `${scope}:${repo}/${issueNumber}`; const key = `${scope}:${repo}/${issueNumber}`;
return this.reviewData.get(key) || null; return this.reviewData.get(key) || null;
} }

View File

@ -1,3 +1,4 @@
import { IssuesGetResponseData } from "@octokit/types";
import { Redis, default as redis } from "ioredis"; import { Redis, default as redis } from "ioredis";
import LogWrapper from "../LogWrapper"; import LogWrapper from "../LogWrapper";
@ -20,7 +21,7 @@ export class RedisStorageProvider implements IStorageProvider {
constructor(host: string, port: number) { constructor(host: string, port: number) {
this.redis = new redis(port, host); this.redis = new redis(port, host);
this.redis.expire(COMPLETED_TRANSACTIONS_KEY, COMPLETED_TRANSACTIONS_EXPIRE_AFTER).catch((ex) => { this.redis.expire(COMPLETED_TRANSACTIONS_KEY, COMPLETED_TRANSACTIONS_EXPIRE_AFTER).catch((ex) => {
log.warn("Failed to set expiry time on as.completed_transactions"); log.warn("Failed to set expiry time on as.completed_transactions", ex);
}); });
} }
@ -40,35 +41,35 @@ export class RedisStorageProvider implements IStorageProvider {
return (await this.redis.sismember(COMPLETED_TRANSACTIONS_KEY, transactionId)) === 1; return (await this.redis.sismember(COMPLETED_TRANSACTIONS_KEY, transactionId)) === 1;
} }
public async setGithubIssue(repo: string, issueNumber: string, data: any, scope: string = "") { public async setGithubIssue(repo: string, issueNumber: string, data: IssuesGetResponseData, scope = "") {
const key = `${scope}${GH_ISSUES_KEY}:${repo}/${issueNumber}`; const key = `${scope}${GH_ISSUES_KEY}:${repo}/${issueNumber}`;
await this.redis.set(key, JSON.stringify(data)); await this.redis.set(key, JSON.stringify(data));
await this.redis.expire(key, ISSUES_EXPIRE_AFTER); await this.redis.expire(key, ISSUES_EXPIRE_AFTER);
} }
public async getGithubIssue(repo: string, issueNumber: string, scope: string = "") { public async getGithubIssue(repo: string, issueNumber: string, scope = "") {
const res = await this.redis.get(`${scope}:${GH_ISSUES_KEY}:${repo}/${issueNumber}`); const res = await this.redis.get(`${scope}:${GH_ISSUES_KEY}:${repo}/${issueNumber}`);
return res ? JSON.parse(res) : null; return res ? JSON.parse(res) : null;
} }
public async setLastNotifCommentUrl(repo: string, issueNumber: string, url: string, scope: string = "") { public async setLastNotifCommentUrl(repo: string, issueNumber: string, url: string, scope = "") {
const key = `${scope}${GH_ISSUES_LAST_COMMENT_KEY}:${repo}/${issueNumber}`; const key = `${scope}${GH_ISSUES_LAST_COMMENT_KEY}:${repo}/${issueNumber}`;
await this.redis.set(key, url); await this.redis.set(key, url);
await this.redis.expire(key, ISSUES_LAST_COMMENT_EXPIRE_AFTER); await this.redis.expire(key, ISSUES_LAST_COMMENT_EXPIRE_AFTER);
} }
public async getLastNotifCommentUrl(repo: string, issueNumber: string, scope: string = "") { public async getLastNotifCommentUrl(repo: string, issueNumber: string, scope = "") {
const res = await this.redis.get(`${scope}:${GH_ISSUES_LAST_COMMENT_KEY}:${repo}/${issueNumber}`); const res = await this.redis.get(`${scope}:${GH_ISSUES_LAST_COMMENT_KEY}:${repo}/${issueNumber}`);
return res ? res : null; return res ? res : null;
} }
public async setPRReviewData(repo: string, issueNumber: string, url: string, scope: string = "") { public async setPRReviewData(repo: string, issueNumber: string, url: string, scope = "") {
const key = `${scope}${GH_ISSUES_REVIEW_DATA_KEY}:${repo}/${issueNumber}`; const key = `${scope}${GH_ISSUES_REVIEW_DATA_KEY}:${repo}/${issueNumber}`;
await this.redis.set(key, url); await this.redis.set(key, url);
await this.redis.expire(key, ISSUES_LAST_COMMENT_EXPIRE_AFTER); await this.redis.expire(key, ISSUES_LAST_COMMENT_EXPIRE_AFTER);
} }
public async getPRReviewData(repo: string, issueNumber: string, scope: string = "") { public async getPRReviewData(repo: string, issueNumber: string, scope = "") {
const res = await this.redis.get(`${scope}:${GH_ISSUES_REVIEW_DATA_KEY}:${repo}/${issueNumber}`); const res = await this.redis.get(`${scope}:${GH_ISSUES_REVIEW_DATA_KEY}:${repo}/${issueNumber}`);
return res ? res : null; return res ? res : null;
} }

View File

@ -1,10 +1,11 @@
import { IAppserviceStorageProvider } from "matrix-bot-sdk"; import { IAppserviceStorageProvider } from "matrix-bot-sdk";
import { IssuesGetResponseData } from "@octokit/types";
export interface IStorageProvider extends IAppserviceStorageProvider { export interface IStorageProvider extends IAppserviceStorageProvider {
setGithubIssue(repo: string, issueNumber: string, data: any, scope?: string): Promise<void>; setGithubIssue(repo: string, issueNumber: string, data: IssuesGetResponseData, scope?: string): Promise<void>;
getGithubIssue(repo: string, issueNumber: string, scope?: string): Promise<any|null>; getGithubIssue(repo: string, issueNumber: string, scope?: string): Promise<IssuesGetResponseData|null>;
setLastNotifCommentUrl(repo: string, issueNumber: string, url: string, scope?: string): Promise<void>; setLastNotifCommentUrl(repo: string, issueNumber: string, url: string, scope?: string): Promise<void>;
getLastNotifCommentUrl(repo: string, issueNumber: string, scope?: string): Promise<string|null>; getLastNotifCommentUrl(repo: string, issueNumber: string, scope?: string): Promise<string|null>;
setPRReviewData(repo: string, issueNumber: string, data: any, scope?: string): Promise<void>; setPRReviewData(repo: string, issueNumber: string, data: unknown, scope?: string): Promise<void>;
getPRReviewData(repo: string, issueNumber: string, scope?: string): Promise<any|null>; getPRReviewData(repo: string, issueNumber: string, scope?: string): Promise<any|null>;
} }

View File

@ -1,204 +0,0 @@
import { NotificationsEnableEvent } from "./GithubWebhooks";
import { Octokit } from "@octokit/rest";
import { createTokenAuth } from "@octokit/auth-token";
import { RequestError } from "@octokit/request-error";
import LogWrapper from "./LogWrapper";
import { MessageQueue } from "./MessageQueue/MessageQueue";
import { MessageSenderClient } from "./MatrixSender";
interface UserStream {
octoKit: Octokit;
userId: string;
roomId: string;
lastReadTs: number;
participating: boolean;
failureCount: number;
}
export interface UserNotificationsEvent {
roomId: string;
lastReadTs: number;
events: UserNotification[];
}
export interface UserNotification {
id: string;
reason: "assign"|"author"|"comment"|"invitation"|"manual"|"mention"|"review_requested"|
"security_alert"|"state_change"|"subscribed"|"team_mention";
unread: boolean;
updated_at: number;
last_read_at: number;
url: string;
subject: {
title: string;
url: string;
latest_comment_url: string|null;
type: "PullRequest"|"Issue"|"RepositoryVulnerabilityAlert";
// Probably.
url_data?: Octokit.IssuesGetResponse;
latest_comment_url_data?: Octokit.IssuesGetCommentResponse;
requested_reviewers?: Octokit.PullsListReviewRequestsResponse;
reviews?: Octokit.PullsListReviewsResponse;
};
repository: Octokit.ActivityGetThreadResponseRepository;
}
const MIN_INTERVAL_MS = 15000;
const FAILURE_THRESHOLD = 50;
const GH_API_THRESHOLD = 50;
const GH_API_RETRY_IN = 1000 * 60;
const log = new LogWrapper("UserNotificationWatcher");
export class UserNotificationWatcher {
private userIntervals: Map<string, NodeJS.Timeout> = new Map();
private matrixMessageSender: MessageSenderClient;
private apiFailureCount: number = 0;
private globalRetryIn: number = 0;
constructor(private queue: MessageQueue) {
this.matrixMessageSender = new MessageSenderClient(queue);
}
public checkGitHubStatus() {
this.apiFailureCount = Math.min(this.apiFailureCount + 1, GH_API_THRESHOLD);
if (this.apiFailureCount < GH_API_THRESHOLD) {
log.warn(`API Failure count at ${this.apiFailureCount}`);
return;
}
// The API is actively failing.
if (this.globalRetryIn > 0) {
this.globalRetryIn = Date.now() + GH_API_RETRY_IN;
}
log.warn(`API Failure limit reached, holding off new requests for ${GH_API_RETRY_IN / 1000}s`);
}
public start() {
// No-op
}
public async fetchUserNotifications(stream: UserStream) {
if (this.globalRetryIn !== 0 && this.globalRetryIn > Date.now()) {
log.info(`Not getting notifications for ${stream.userId}, API is still down.`);
return stream;
}
log.info(`Getting notifications for ${stream.userId} ${stream.lastReadTs}`);
const since = stream.lastReadTs !== 0 ? `&since=${new Date(stream.lastReadTs).toISOString()}`: "";
let response: Octokit.AnyResponse;
try {
response = await stream.octoKit.request(
`/notifications?participating=${stream.participating}${since}`,
);
// We were succesful, clear any timeouts.
this.globalRetryIn = 0;
// To avoid a bouncing issue, gradually reduce the failure count.
this.apiFailureCount = Math.max(0, this.apiFailureCount - 2);
} catch (ex) {
await this.handleGitHubFailure(stream, ex);
return stream;
}
log.info(`Got ${response.data.length} notifications`);
stream.lastReadTs = Date.now();
const events: UserNotification[] = [];
for (const rawEvent of response.data as UserNotification[]) {
try {
await (async () => {
if (rawEvent.subject.url) {
const res = await stream.octoKit.request(rawEvent.subject.url);
rawEvent.subject.url_data = res.data;
}
if (rawEvent.subject.latest_comment_url) {
const res = await stream.octoKit.request(rawEvent.subject.latest_comment_url);
rawEvent.subject.latest_comment_url_data = res.data;
}
if (rawEvent.reason === "review_requested") {
rawEvent.subject.requested_reviewers = (await stream.octoKit.pulls.listReviewRequests({
pull_number: rawEvent.subject.url_data?.number!,
owner: rawEvent.repository.owner.login,
repo: rawEvent.repository.name,
})).data;
rawEvent.subject.reviews = (await stream.octoKit.pulls.listReviews({
pull_number: rawEvent.subject.url_data?.number!,
owner: rawEvent.repository.owner.login,
repo: rawEvent.repository.name,
})).data;
}
events.push(rawEvent);
})();
} catch (ex) {
log.warn(`Failed to pre-process ${rawEvent.id}: ${ex}`);
// If it fails, we can just push the raw thing.
events.push(rawEvent);
}
}
if (events.length > 0) {
await this.queue.push<UserNotificationsEvent>({
eventName: "notifications.user.events",
data: {
roomId: stream.roomId,
events,
lastReadTs: stream.lastReadTs,
},
sender: "GithubWebhooks",
});
}
return stream;
}
public handleGitHubFailure(stream: UserStream, ex: RequestError) {
log.error("An error occured getting notifications:", ex);
if (ex.status === 401 || ex.status === 404) {
log.warn(`Got status ${ex.status} when handing user stream: ${ex.message}`);
stream.failureCount++;
} else if (ex.status >= 500) {
setImmediate(() => this.checkGitHubStatus());
}
if (stream.failureCount > FAILURE_THRESHOLD) {
this.removeUser(stream.userId);
return this.matrixMessageSender.sendMatrixText(
stream.roomId,
`The bridge has been unable to process your notification stream for some time, and has disabled notifications.
Check your GitHub token is still valid, and then turn notifications back on.`, "m.notice",
);
}
return null;
}
public removeUser(userId: string) {
const timer = this.userIntervals.get(userId);
if (timer) {
clearInterval(timer);
log.info(`Removed ${userId} to notif queue`);
}
}
public addUser(data: NotificationsEnableEvent) {
const clientKit = new Octokit({
authStrategy: createTokenAuth,
auth: data.token,
userAgent: "matrix-github v0.0.1",
});
const userId = data.user_id;
this.removeUser(userId);
let stream: UserStream = {
octoKit: clientKit,
userId,
roomId: data.room_id,
lastReadTs: data.since,
participating: data.filter_participating,
failureCount: 0,
};
log.info(`Inserted ${userId} into the notif queue`);
const interval = setInterval(async () => {
stream = await this.fetchUserNotifications(stream);
}, MIN_INTERVAL_MS);
this.userIntervals.set(userId, interval);
return;
}
}

View File

@ -2,9 +2,8 @@ import { Intent } from "matrix-bot-sdk";
import { promises as fs } from "fs"; import { promises as fs } from "fs";
import { publicEncrypt, privateDecrypt } from "crypto"; import { publicEncrypt, privateDecrypt } from "crypto";
import LogWrapper from "./LogWrapper"; import LogWrapper from "./LogWrapper";
import { Octokit } from "@octokit/rest";
import { createTokenAuth } from "@octokit/auth-token";
import { GitLabClient } from "./Gitlab/Client"; import { GitLabClient } from "./Gitlab/Client";
import { GithubInstance } from "./Github/GithubInstance";
const ACCOUNT_DATA_TYPE = "uk.half-shot.matrix-github.password-store:"; const ACCOUNT_DATA_TYPE = "uk.half-shot.matrix-github.password-store:";
const ACCOUNT_DATA_GITLAB_TYPE = "uk.half-shot.matrix-github.gitlab.password-store:"; const ACCOUNT_DATA_GITLAB_TYPE = "uk.half-shot.matrix-github.gitlab.password-store:";
@ -23,7 +22,7 @@ export class UserTokenStore {
} }
public async storeUserToken(type: "github"|"gitlab", userId: string, token: string, instance?: string): Promise<void> { public async storeUserToken(type: "github"|"gitlab", userId: string, token: string, instance?: string): Promise<void> {
let prefix = type === "github" ? ACCOUNT_DATA_TYPE : ACCOUNT_DATA_GITLAB_TYPE; const prefix = type === "github" ? ACCOUNT_DATA_TYPE : ACCOUNT_DATA_GITLAB_TYPE;
await this.intent.underlyingClient.setAccountData(`${prefix}${userId}`, { await this.intent.underlyingClient.setAccountData(`${prefix}${userId}`, {
encrypted: publicEncrypt(this.key, Buffer.from(token)).toString("base64"), encrypted: publicEncrypt(this.key, Buffer.from(token)).toString("base64"),
instance: instance, instance: instance,
@ -33,8 +32,9 @@ export class UserTokenStore {
} }
public async getUserToken(type: "github"|"gitlab", userId: string, instance?: string): Promise<string|null> { public async getUserToken(type: "github"|"gitlab", userId: string, instance?: string): Promise<string|null> {
if (this.userTokens.has(userId)) { const existingToken = this.userTokens.get(userId);
return this.userTokens.get(userId)!; if (existingToken) {
return existingToken;
} }
let obj; let obj;
try { try {
@ -61,11 +61,7 @@ export class UserTokenStore {
if (!senderToken) { if (!senderToken) {
return null; return null;
} }
return new Octokit({ return GithubInstance.createUserOctokit(senderToken);
authStrategy: createTokenAuth,
auth: senderToken,
userAgent: "matrix-github v0.0.1",
});
} }
public async getGitLabForUser(userId: string, instanceUrl: string) { public async getGitLabForUser(userId: string, instanceUrl: string) {

View File

@ -5,13 +5,15 @@ const SIMPLE_ISSUE = {
number: 123, number: 123,
state: "open", state: "open",
title: "A simple title", title: "A simple title",
full_name: "evilcorp/lab",
url: "https://github.com/evilcorp/lab/issues/123",
html_url: "https://github.com/evilcorp/lab/issues/123", html_url: "https://github.com/evilcorp/lab/issues/123",
repository_url: "https://api.github.com/repos/evilcorp/lab", repository_url: "https://api.github.com/repos/evilcorp/lab",
}; };
describe("FormatUtilTest", () => { describe("FormatUtilTest", () => {
it("correctly formats a room name", () => { it("correctly formats a room name", () => {
expect(FormatUtil.formatRoomName(SIMPLE_ISSUE)).to.equal( expect(FormatUtil.formatRepoRoomName(SIMPLE_ISSUE)).to.equal(
"evilcorp/lab#123: A simple title", "evilcorp/lab#123: A simple title",
); );
}); });

View File

@ -26,6 +26,7 @@ describe("MessageQueueTest", () => {
eventName: "fakeevent", eventName: "fakeevent",
messageId: "foooo", messageId: "foooo",
data: 51, data: 51,
ts: 0,
}); });
}); });
it("should be able to push an event, and respond to it", async () => { it("should be able to push an event, and respond to it", async () => {
@ -43,6 +44,7 @@ describe("MessageQueueTest", () => {
eventName: "response.fakeevent2", eventName: "response.fakeevent2",
messageId: "foooo", messageId: "foooo",
data: "worked", data: "worked",
ts: 0,
}); });
}); });
const response = await mq.pushWait<number, string>({ const response = await mq.pushWait<number, string>({
@ -50,6 +52,7 @@ describe("MessageQueueTest", () => {
eventName: "fakeevent2", eventName: "fakeevent2",
messageId: "foooo", messageId: "foooo",
data: 49, data: 49,
ts: 0,
}); });
expect(response).to.equal("worked"); expect(response).to.equal("worked");
}); });

1803
yarn.lock

File diff suppressed because it is too large Load Diff