Merge remote-tracking branch 'origin/master' into hs/github-discussions

(sorry I messed this one up)
This commit is contained in:
Will Hunt 2021-04-25 19:40:02 +01:00
commit 5bde8ca6fe
31 changed files with 1672 additions and 2268 deletions

View File

@ -1,4 +1,10 @@
node_modules/
lib/
tests/
config.yml
public/
public/
.github/
logging.txt
tsconfig.tsbuildinfo
*.pem
registration.yml

View File

@ -28,7 +28,7 @@ jobs:
with:
node-version: 12
- run: yarn
- run: node lib/Config/Defaults.js > expected-config.sample.yml
- run: node lib/Config/Defaults.js --config > expected-config.sample.yml
- run: cmp --silent config.sample.yml expected-config.sample.yml
test:
runs-on: ubuntu-latest

View File

@ -11,8 +11,9 @@ RUN yarn
FROM node:12-alpine
COPY --from=builder /src/lib/ /bin/matrix-github/
COPY --from=builder /src/public/ /bin/matrix-github/
COPY --from=builder /src/package*.json /bin/matrix-github/
COPY --from=builder /src/public/ /bin/matrix-github/public/
COPY --from=builder /src/package.json /bin/matrix-github/
COPY --from=builder /src/yarn.lock /bin/matrix-github/
WORKDIR /bin/matrix-github
RUN yarn --production

View File

@ -1,6 +1,6 @@
{
"name": "matrix-github",
"version": "0.0.1",
"version": "0.1.0",
"description": "A bridge that displays GitHub issues/PRs as rooms.",
"main": "lib/app.js",
"repository": "https://github.com/Half-Shot/matrix-github",
@ -19,56 +19,56 @@
"start:matrixsender": "node --require source-map-support/register lib/App/MatrixSenderApp.js",
"test": "mocha -r ts-node/register tests/*.ts",
"lint": "eslint -c .eslintrc.js src/**/*.ts",
"generate-default-config": "node lib/Config/Defaults.js > config.sample.yml"
"generate-default-config": "node lib/Config/Defaults.js --config > config.sample.yml"
},
"dependencies": {
"@octokit/auth-app": "2.10.2",
"@octokit/auth-token": "^2.4.4",
"@octokit/rest": "18.0.9",
"@octokit/auth-app": "^3.3.0",
"@octokit/auth-token": "^2.4.5",
"@octokit/rest": "^18.5.2",
"@octokit/webhooks": "^9.1.2",
"axios": "^0.21.1",
"cors": "^2.8.5",
"express": "^4.17.1",
"fontsource-open-sans": "^3.1.5",
"ioredis": "^4.19.2",
"markdown-it": "^12.0.2",
"matrix-bot-sdk": "^0.5.8",
"matrix-widget-api": "^0.1.0-beta.10",
"micromatch": "^4.0.2",
"mime": "^2.4.6",
"mini.css": "^3.0.1",
"ioredis": "^4.26.0",
"markdown-it": "^12.0.4",
"matrix-bot-sdk": "^0.5.17",
"matrix-widget-api": "^0.1.0-beta.13",
"micromatch": "^4.0.4",
"mime": "^2.5.2",
"mocha": "^8.2.1",
"node-emoji": "^1.10.0",
"reflect-metadata": "^0.1.13",
"source-map-support": "^0.5.19",
"string-argv": "v0.3.1",
"uuid": "^8.3.1",
"string-argv": "^0.3.1",
"uuid": "^8.3.2",
"winston": "^3.3.3",
"yaml": "^2.0.0-1"
"yaml": "^1.10.0"
},
"devDependencies": {
"@prefresh/snowpack": "^2.2.0",
"@snowpack/plugin-typescript": "^1.1.1",
"@types/chai": "^4.2.14",
"@types/cors": "^2.8.9",
"@types/express": "^4.17.9",
"@types/ioredis": "^4.17.8",
"@types/markdown-it": "^10.0.3",
"@fontsource/open-sans": "^4.2.2",
"@prefresh/snowpack": "^3.1.2",
"@snowpack/plugin-typescript": "^1.2.1",
"@types/chai": "^4.2.16",
"@types/cors": "^2.8.10",
"@types/express": "^4.17.11",
"@types/ioredis": "^4.22.3",
"@types/markdown-it": "^12.0.1",
"@types/micromatch": "^4.0.1",
"@types/mime": "^2.0.3",
"@types/mocha": "^8.0.4",
"@types/mocha": "^8.2.2",
"@types/node": "^12",
"@types/node-emoji": "^1.8.1",
"@types/uuid": "^8.3.0",
"@typescript-eslint/eslint-plugin": "^4.8.1",
"@typescript-eslint/parser": "^4.8.1",
"autoprefixer": "^10.1.0",
"chai": "^4.2.0",
"eslint": "^7.14.0",
"eslint-plugin-mocha": "^8.0.0",
"preact": "^10.5.7",
"snowpack": "^2.18.3",
"@typescript-eslint/eslint-plugin": "^4.21.0",
"@typescript-eslint/parser": "^4.21.0",
"chai": "^4.3.4",
"eslint": "^7.24.0",
"eslint-plugin-mocha": "^8.1.0",
"mini.css": "^3.0.1",
"preact": "^10.5.13",
"snowpack": "^3.2.2",
"tailwind": "^4.0.0",
"ts-node": "^9.0.0",
"typescript": "^4.1.2"
"ts-node": "^9.1.1",
"typescript": "^4.2.4"
}
}

View File

@ -7,25 +7,12 @@ module.exports = {
'@prefresh/snowpack',
['@snowpack/plugin-typescript', '--project tsconfig.web.json'],
],
install: [
/* ... */
],
installOptions: {
packageOptions: {
installTypes: true,
polyfillNode: true,
},
devOptions: {
/* ... */
},
buildOptions: {
out: 'public'
/* ... */
},
proxy: {
/* ... */
},
alias: {
/* ... */
},
};

View File

@ -14,8 +14,13 @@ import { GitLabClient } from "./Gitlab/Client";
import { GetUserResponse } from "./Gitlab/Types";
import { GithubGraphQLClient, GithubInstance } from "./Github/GithubInstance";
import { MatrixMessageContent } from "./MatrixEvent";
import { ProjectsListForUserResponseData, ProjectsListForRepoResponseData } from "@octokit/types";
import { BridgeRoomState, BridgeRoomStateGitHub } from "./Widgets/BridgeWidgetInterface";
import { Endpoints } from "@octokit/types";
import { ProjectsListResponseData } from "./Github/Types";
import { NotifFilter, NotificationFilterStateContent } from "./NotificationFilters";
type ProjectsListForRepoResponseData = Endpoints["GET /repos/{owner}/{repo}/projects"]["response"];
type ProjectsListForUserResponseData = Endpoints["GET /users/{username}/projects"]["response"];
const md = new markdown();
@ -41,25 +46,32 @@ export interface AdminAccountData {
}
}
}
}
export class AdminRoom extends EventEmitter {
public static helpMessage: MatrixMessageContent;
private widgetAccessToken = `abcdef`;
static botCommands: BotCommands;
private pendingOAuthState: string|null = null;
public readonly notifFilter: NotifFilter;
constructor(public readonly roomId: string,
public readonly data: AdminAccountData,
private data: AdminAccountData,
notifContent: NotificationFilterStateContent,
private botIntent: Intent,
private tokenStore: UserTokenStore,
private config: BridgeConfig) {
super();
this.notifFilter = new NotifFilter(notifContent);
// TODO: Move this
this.backfillAccessToken();
}
public get accountData() {
return {...this.data};
}
public get userId() {
return this.data.admin_user;
}
@ -83,7 +95,7 @@ export class AdminRoom extends EventEmitter {
);
}
public notificationsParticipating(type: string) {
public notificationsParticipating(type: "github"|"gitlab") {
if (type !== "github") {
return false;
}
@ -191,9 +203,8 @@ export class AdminRoom extends EventEmitter {
}
@botCommand("github notifications toggle", "Toggle enabling/disabling GitHub notifications in this room")
// @ts-ignore - property is used
private async setGitHubNotificationsStateToggle() {
const data = await this.saveAccountData((data) => {
public async setGitHubNotificationsStateToggle() {
const newData = await this.saveAccountData((data) => {
return {
...data,
github: {
@ -204,29 +215,44 @@ export class AdminRoom extends EventEmitter {
},
};
});
await this.sendNotice(`${data.github?.notifications?.enabled ? "En" : "Dis"}abled GitHub notifcations`);
await this.sendNotice(`${newData.github?.notifications?.enabled ? "En" : "Dis"}abled GitHub notifcations`);
}
@botCommand("github notifications filter participating", "Toggle enabling/disabling GitHub notifications in this room")
// @ts-ignore - property is used
private async setGitHubNotificationsStateParticipating() {
const data = await this.saveAccountData((data) => {
const newData = await this.saveAccountData((data) => {
if (!data.github?.notifications?.enabled) {
throw Error('Notifications are not enabled')
}
const oldState = data.github?.notifications?.participating ?? false;
return {
...data,
github: {
notifications: {
participating: !(data.github?.notifications?.participating ?? false),
participating: !oldState,
enabled: true,
},
},
};
});
await this.sendNotice(`${data.github?.notifications?.enabled ? "" : "Not"} filtering for events you are participating in`);
console.log(newData);
if (newData.github?.notifications?.participating) {
return this.sendNotice(`Filtering for events you are participating in`);
}
return this.sendNotice(`Showing all events`);
}
@botCommand("github notifications", "Show the current notification settings")
// @ts-ignore - property is used
private async getGitHubNotificationsState() {
if (!this.notificationsEnabled("github")) {
return this.sendNotice(`Notifications are disabled`);
}
return this.sendNotice(`Notifications are enabled, ${this.notificationsParticipating("github") ? "Showing only events you are particiapting in" : "Showing all events"}`);
}
@botCommand("github project list-for-user", "List GitHub projects for a user", [], ['user', 'repo'])
// @ts-ignore - property is used
private async listProjects(username?: string, repo?: string) {
@ -240,10 +266,11 @@ export class AdminRoom extends EventEmitter {
if (!username) {
const me = await octokit.users.getAuthenticated();
username = me.data.name;
// TODO: Fix
username = me.data.name!;
}
let res: ProjectsListForUserResponseData|ProjectsListForRepoResponseData;
let res: ProjectsListResponseData;
try {
if (repo) {
res = (await octokit.projects.listForRepo({
@ -259,7 +286,7 @@ export class AdminRoom extends EventEmitter {
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${FormatUtil.projectListing(res)}\n`;
return this.botIntent.sendEvent(this.roomId,{
msgtype: "m.notice",
body: content,
@ -285,11 +312,11 @@ export class AdminRoom extends EventEmitter {
res = (await octokit.projects.listForRepo({
repo,
owner: org,
})).data;
}));
}
res = (await octokit.projects.listForOrg({
org,
})).data;
}));
} catch (ex) {
if (ex.status === 404) {
return this.sendNotice('Not found');
@ -298,7 +325,7 @@ export class AdminRoom extends EventEmitter {
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.data.map(r => ` - ${FormatUtil.projectListing([r])}\n`).join("\n");
return this.botIntent.sendEvent(this.roomId,{
msgtype: "m.notice",
body: content,
@ -428,8 +455,7 @@ export class AdminRoom extends EventEmitter {
}
@botCommand("gitlab notifications toggle", "Toggle enabling/disabling GitHub notifications in this room", ["instanceName"])
// @ts-ignore - property is used
private async setGitLabNotificationsStateToggle(instanceName: string) {
public async setGitLabNotificationsStateToggle(instanceName: string) {
if (!this.config.gitlab) {
return this.sendNotice("The bridge is not configured with GitLab support");
}
@ -457,7 +483,55 @@ export class AdminRoom extends EventEmitter {
},
};
});
await this.sendNotice(`${newValue ? "En" : "Dis"}abled GitLab notifications for ${instanceName}`);
return this.sendNotice(`${newValue ? "En" : "Dis"}abled GitLab notifications for ${instanceName}`);
}
@botCommand("filters list", "List your saved filters")
public async getFilters() {
if (this.notifFilter.empty) {
return this.sendNotice("You do not currently have any filters");
}
const filterText = Object.entries(this.notifFilter.filters).map(([name, value]) => {
const userText = value.users.length ? `users: ${value.users.join("|")}` : '';
const reposText = value.repos.length ? `users: ${value.repos.join("|")}` : '';
const orgsText = value.orgs.length ? `users: ${value.orgs.join("|")}` : '';
return `${name}: ${userText} ${reposText} ${orgsText}`
}).join("\n");
const enabledForInvites = [...this.notifFilter.forInvites].join(', ');
const enabledForNotifications = [...this.notifFilter.forNotifications].join(', ');
return this.sendNotice(`Your filters:\n ${filterText}\nEnabled for automatic room invites: ${enabledForInvites}\nEnabled for notifications: ${enabledForNotifications}`);
}
@botCommand("filters set", "Create (or update) a filter. You can use 'orgs:', 'users:' or 'repos:' as filter parameters.", ["name", "...parameters"])
public async setFilter(name: string, ...parameters: string[]) {
const orgs = parameters.filter(param => param.toLowerCase().startsWith("orgs:")).map(param => param.toLowerCase().substring("orgs:".length).split(",")).flat();
const users = parameters.filter(param => param.toLowerCase().startsWith("users:")).map(param => param.toLowerCase().substring("users:".length).split(",")).flat();
const repos = parameters.filter(param => param.toLowerCase().startsWith("repos:")).map(param => param.toLowerCase().substring("repos:".length).split(",")).flat();
if (orgs.length + users.length + repos.length === 0) {
return this.sendNotice("You must specify some filter options like 'orgs:matrix-org,half-shot', 'users:Half-Shot' or 'repos:matrix-github'");
}
this.notifFilter.setFilter(name, {
orgs,
users,
repos,
});
await this.botIntent.underlyingClient.sendStateEvent(this.roomId, NotifFilter.StateType, "", this.notifFilter.getStateContent());
return this.sendNotice(`Stored new filter "${name}". You can now apply the filter by saying 'filters notifications toggle $name'`);
}
@botCommand("filters notifications toggle", "Apply a filter as a whitelist to your notifications", ["name"])
public async setFiltersNotificationsToggle(name: string) {
if (!this.notifFilter.filters[name]) {
return this.sendNotice(`Filter "${name}" doesn't exist'`);
}
if (this.notifFilter.forNotifications.has(name)) {
this.notifFilter.forNotifications.delete(name);
await this.sendNotice(`Filter "${name}" disabled for notifications`);
} else {
this.notifFilter.forNotifications.add(name);
await this.sendNotice(`Filter "${name}" enabled for notifications`);
}
return this.botIntent.underlyingClient.sendStateEvent(this.roomId, NotifFilter.StateType, "", this.notifFilter.getStateContent());
}
private async saveAccountData(updateFn: (record: AdminAccountData) => AdminAccountData) {
@ -467,6 +541,7 @@ export class AdminRoom extends EventEmitter {
const newData = updateFn(oldData);
await this.botIntent.underlyingClient.setRoomAccountData(BRIDGE_ROOM_TYPE, this.roomId, newData);
this.emit("settings.changed", this, oldData, newData);
this.data = newData;
return newData;
}
@ -560,6 +635,10 @@ export class AdminRoom extends EventEmitter {
log.info(`No widget access token for ${this.roomId}`);
}
}
public toString() {
return `AdminRoom(${this.roomId}, ${this.userId})`;
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any

View File

@ -6,7 +6,7 @@ import { MatrixMessageContent, MatrixEvent } from "./MatrixEvent";
import LogWrapper from "./LogWrapper";
import axios from "axios";
import { FormatUtil } from "./FormatUtil";
import { IssuesGetCommentResponseData, ReposGetResponseData, IssuesGetResponseData } from "@octokit/types";
import { IssuesGetCommentResponseData, ReposGetResponseData, IssuesGetResponseData } from "./Github/Types"
import { IGitLabWebhookNoteEvent } from "./Gitlab/WebhookTypes";
const REGEX_MENTION = /(^|\s)(@[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38})(\s|$)/ig;
@ -59,7 +59,10 @@ export class CommentProcessor {
public async getEventBodyForGitHubComment(comment: IssuesGetCommentResponseData,
repo?: ReposGetResponseData,
issue?: IssuesGetResponseData): Promise<IMatrixCommentEvent> {
issue?: IssuesGetResponseData): Promise<IMatrixCommentEvent|undefined> {
if (!comment.body) {
return undefined;
}
let body = comment.body;
body = this.replaceMentions(body);
body = await this.replaceImages(body, true);

View File

@ -6,6 +6,7 @@ export function configKey(comment?: string, optional = false) {
return Reflect.metadata(configKeyMetadataKey, [comment, optional]);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function getConfigKeyMetadata(target: any, propertyKey: string): [string, boolean] {
return Reflect.getMetadata(configKeyMetadataKey, target, propertyKey);
}

View File

@ -65,11 +65,11 @@ function renderSection(doc: YAML.Document, obj: Record<string, unknown>, parentN
const entries = Object.entries(obj);
entries.forEach(([key, value]) => {
let newNode: Node;
if (typeof value === "object") {
newNode = doc.createNode({});
renderSection(doc, value as any, newNode as YAMLSeq);
if (typeof value === "object" && !Array.isArray(value)) {
newNode = YAML.createNode({});
renderSection(doc, value as Record<string, unknown>, newNode as YAMLSeq);
} else {
newNode = doc.createNode(value);
newNode = YAML.createNode(value);
}
const metadata = getConfigKeyMetadata(obj, key);
@ -86,10 +86,10 @@ function renderSection(doc: YAML.Document, obj: Record<string, unknown>, parentN
}
function renderDefaultConfig() {
const doc = new YAML.Document({});
const doc = new YAML.Document();
doc.contents = YAML.createNode({});
doc.commentBefore = ' This is an example configuration file';
// Needed because the entries syntax below would not work otherwise
//const typeLessDefaultConfig = DefaultConfig as any;
renderSection(doc, DefaultConfig as any);
return doc.toString();
}

View File

@ -9,10 +9,10 @@ import { Octokit } from "@octokit/rest";
import { MessageSenderClient } from "../MatrixSender";
import { getIntentForUser } from "../IntentUtils";
import { FormatUtil } from "../FormatUtil";
import { IGitHubWebhookEvent } from "../GithubWebhooks";
import axios from "axios";
import { GithubInstance } from "../Github/GithubInstance";
import { IssuesGetResponseData } from "@octokit/types";
import { IssuesGetCommentResponseData, IssuesGetResponseData, ReposGetResponseData} from "../Github/Types";
import { IssuesEditedEvent, IssueCommentCreatedEvent } from "@octokit/webhooks-types";
export interface GitHubIssueConnectionState {
org: string;
@ -151,7 +151,20 @@ export class GitHubIssueConnection implements IConnection {
return this.state.repo;
}
public async onCommentCreated(event: IGitHubWebhookEvent, updateState = true) {
public async onIssueCommentCreated(event: IssueCommentCreatedEvent) {
return this.onCommentCreated({
// TODO: Fix types,
comment: event.comment as any,
action: event.action,
})
}
private async onCommentCreated(event: {
comment: IssuesGetCommentResponseData,
action: string,
repository?: ReposGetResponseData,
issue?: IssuesGetResponseData,
}, updateState = true) {
const comment = event.comment;
if (!comment || !comment.user) {
throw Error('Comment undefined');
@ -168,8 +181,10 @@ export class GitHubIssueConnection implements IConnection {
avatarUrl: comment.user.avatar_url,
}, this.as);
const matrixEvent = await this.commentProcessor.getEventBodyForGitHubComment(comment, event.repository, event.issue);
await this.messageClient.sendMatrixMessage(this.roomId, matrixEvent, "m.room.message", commentIntent.userId);
// Comment body may be blank
if (matrixEvent) {
await this.messageClient.sendMatrixMessage(this.roomId, matrixEvent, "m.room.message", commentIntent.userId);
}
if (!updateState) {
return;
}
@ -182,7 +197,7 @@ export class GitHubIssueConnection implements IConnection {
);
}
private async syncIssueState() {
public async syncIssueState() {
log.debug("Syncing issue state for", this.roomId);
const issue = await this.github.octokit.issues.get({
owner: this.state.org,
@ -193,8 +208,9 @@ export class GitHubIssueConnection implements IConnection {
if (this.state.comments_processed === -1) {
// This has a side effect of creating a profile for the user.
const creator = await getIntentForUser({
login: issue.data.user.login,
avatarUrl: issue.data.user.avatar_url
// TODO: Fix
login: issue.data.user?.login as string,
avatarUrl: issue.data.user?.avatar_url || undefined
}, this.as);
// We've not sent any messages into the room yet, let's do it!
if (issue.data.body) {
@ -232,11 +248,12 @@ export class GitHubIssueConnection implements IConnection {
if (this.state.state !== issue.data.state) {
if (issue.data.state === "closed") {
const closedUserId = this.as.getUserIdForSuffix(issue.data.closed_by.login);
// TODO: Fix
const closedUserId = this.as.getUserIdForSuffix(issue.data.closed_by?.login as string);
await this.messageClient.sendMatrixMessage(this.roomId, {
msgtype: "m.notice",
body: `closed the ${issue.data.pull_request ? "pull request" : "issue"} at ${issue.data.closed_at}`,
external_url: issue.data.closed_by.html_url,
external_url: issue.data.closed_by?.html_url,
}, "m.room.message", closedUserId);
}
@ -282,12 +299,13 @@ export class GitHubIssueConnection implements IConnection {
}
}
public async onIssueEdited(event: IGitHubWebhookEvent) {
public async onIssueEdited(event: IssuesEditedEvent) {
if (!event.changes) {
log.debug("No changes given");
return; // No changes made.
}
// TODO: Fix types
if (event.issue && event.changes.title) {
await this.as.botIntent.underlyingClient.sendStateEvent(this.roomId, "m.room.name", "", {
name: FormatUtil.formatIssueRoomName(event.issue),

View File

@ -1,7 +1,7 @@
import { IConnection } from "./IConnection";
import { Appservice } from "matrix-bot-sdk";
import LogWrapper from "../LogWrapper";
import { ProjectsGetResponseData } from "@octokit/types";
import { ProjectsGetResponseData } from "../Github/Types";
export interface GitHubProjectConnectionState {
// eslint-disable-next-line camelcase

View File

@ -11,9 +11,9 @@ import { MessageSenderClient } from "../MatrixSender";
import { FormatUtil } from "../FormatUtil";
import axios from "axios";
import { BotCommands, handleCommand, botCommand, compileBotCommands } from "../BotCommands";
import { IGitHubWebhookEvent } from "../GithubWebhooks";
import { ReposGetResponseData } from "@octokit/types";
import { ReposGetResponseData } from "../Github/Types";
import { IssuesOpenedEvent, IssuesEditedEvent } from "@octokit/webhooks-types";
import emoji from "node-emoji";
const log = new LogWrapper("GitHubRepoConnection");
const md = new markdown();
@ -152,11 +152,11 @@ export class GitHubRepoConnection implements IConnection {
}
public get org() {
return this.state.org;
return this.state.org.toLowerCase();
}
public get repo() {
return this.state.repo;
return this.state.repo.toLowerCase();
}
public isInterestedInStateEvent() {
@ -255,7 +255,7 @@ export class GitHubRepoConnection implements IConnection {
});
}
public async onIssueCreated(event: IGitHubWebhookEvent) {
public async onIssueCreated(event: IssuesOpenedEvent) {
log.info(`onIssueCreated ${this.roomId} ${this.org}/${this.repo} #${event.issue?.number}`);
if (!event.issue) {
throw Error('No issue content!');
@ -264,23 +264,27 @@ export class GitHubRepoConnection implements IConnection {
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>`
const content = emoji.emojify(`${event.issue.user?.login} created new issue [${orgRepoName}#${event.issue.number}](${event.issue.html_url}): "${event.issue.title}"`);
const labelsHtml = (event.issue.labels || []).map((label: {color?: string|null, name?: string, description?: string|null}|string) =>
typeof(label) === "string" ?
`<span>${label}</span>` :
`<span title="${label.description}" data-mx-color="#CCCCCC" data-mx-bg-color="#${label.color}">${label.name}</span>`
).join(" ") || "";
const labels = event.issue?.labels.map((label: {name: string}) =>
label.name
const labels = (event.issue?.labels || []).map((label: {name?: string}|string) =>
typeof(label) === "string" ? label : label.name
).join(", ") || "";
await this.as.botIntent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content + (labels.length > 0 ? ` with labels ${labels}`: ""),
formatted_body: md.renderInline(content) + (labelsHtml.length > 0 ? ` with labels ${labelsHtml}`: ""),
format: "org.matrix.custom.html",
...FormatUtil.getPartialBodyForIssue(event.repository, event.issue),
// TODO: Fix types.
...FormatUtil.getPartialBodyForIssue(event.repository, event.issue as any),
});
}
public async onIssueStateChange(event: IGitHubWebhookEvent) {
public async onIssueStateChange(event: IssuesEditedEvent) {
log.info(`onIssueStateChange ${this.roomId} ${this.org}/${this.repo} #${event.issue?.number}`);
if (!event.issue) {
throw Error('No issue content!');
@ -290,13 +294,14 @@ export class GitHubRepoConnection implements IConnection {
}
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}"`;
const content = `**@${event.sender.login}** closed issue [${orgRepoName}#${event.issue.number}](${event.issue.html_url}): "${emoji.emojify(event.issue.title)}"`;
await this.as.botIntent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.renderInline(content),
format: "org.matrix.custom.html",
...FormatUtil.getPartialBodyForIssue(event.repository, event.issue),
// TODO: Fix types
...FormatUtil.getPartialBodyForIssue(event.repository, event.issue as any),
});
}
}

View File

@ -1,13 +1,11 @@
import { IConnection } from "./IConnection";
import { Appservice } from "matrix-bot-sdk";
import { MatrixMessageContent, MatrixEvent } from "../MatrixEvent";
import markdown from "markdown-it";
import { UserTokenStore } from "../UserTokenStore";
import LogWrapper from "../LogWrapper";
import { CommentProcessor } from "../CommentProcessor";
import { MessageSenderClient } from "../MatrixSender";
import { FormatUtil } from "../FormatUtil";
import { IGitHubWebhookEvent } from "../GithubWebhooks";
import { GitLabInstance } from "../Config/Config";
import { GetIssueResponse } from "../Gitlab/Types";
import { IGitLabWebhookNoteEvent } from "../Gitlab/WebhookTypes";
@ -23,9 +21,6 @@ export interface GitLabIssueConnectionState {
}
const log = new LogWrapper("GitLabIssueConnection");
const md = new markdown();
md.render("foo");
// interface IQueryRoomOpts {
// as: Appservice;
@ -180,19 +175,6 @@ export class GitLabIssueConnection implements IConnection {
});
}
public async onIssueEdited(event: IGitHubWebhookEvent) {
if (!event.changes) {
log.debug("No changes given");
return; // No changes made.
}
if (event.issue && event.changes.title) {
await this.as.botIntent.underlyingClient.sendStateEvent(this.roomId, "m.room.name", "", {
name: FormatUtil.formatIssueRoomName(event.issue),
});
}
}
public async onMessageEvent(ev: MatrixEvent<MatrixMessageContent>) {
if (ev.content.body === '!sync') {
// Sync data.

View File

@ -1,5 +1,5 @@
import { MatrixEvent, MatrixMessageContent } from "../MatrixEvent";
import { IGitHubWebhookEvent } from "../GithubWebhooks";
import { IssuesOpenedEvent, IssuesEditedEvent } from "@octokit/webhooks-types";
export interface IConnection {
roomId: string;
@ -17,11 +17,11 @@ export interface IConnection {
*/
onMessageEvent?: (ev: MatrixEvent<MatrixMessageContent>) => Promise<void>;
onIssueCreated?: (ev: IGitHubWebhookEvent) => Promise<void>;
onIssueCreated?: (ev: IssuesOpenedEvent) => Promise<void>;
onIssueStateChange?: (ev: IGitHubWebhookEvent) => Promise<void>;
onIssueStateChange?: (ev: IssuesEditedEvent) => Promise<void>;
onIssueEdited? :(event: IGitHubWebhookEvent) => Promise<void>;
onIssueEdited? :(event: IssuesEditedEvent) => Promise<void>;
isInterestedInStateEvent: (eventType: string, stateKey: string) => boolean;

View File

@ -1,19 +1,30 @@
import { IssuesGetCommentResponseData, IssuesGetResponseData, ProjectsListForOrgResponseData, ProjectsListForUserResponseData, ProjectsListForRepoResponseData } from "@octokit/types";
/* eslint-disable camelcase */
import { IssuesGetCommentResponseData, IssuesGetResponseData, ProjectsListResponseData } from './Github/Types';
import emoji from "node-emoji";
interface IMinimalRepository {
id: number;
full_name: string;
html_url: string;
description: string | null;
}
interface IMinimalIssue {
html_url: string;
id: number;
number: number;
title: string;
repository_url: string;
pull_request?: any;
}
export class FormatUtil {
public static formatIssueRoomName(issue: {number: number, title: string, repository_url: string}) {
public static formatIssueRoomName(issue: IMinimalIssue) {
const orgRepoName = issue.repository_url.substr("https://api.github.com/repos/".length);
return `${orgRepoName}#${issue.number}: ${issue.title}`;
return emoji.emojify(`${orgRepoName}#${issue.number}: ${issue.title}`);
}
public static formatRepoRoomName(repo: {full_name: string, description: string}) {
return `${repo.full_name}: ${repo.description}`;
public static formatRepoRoomName(repo: IMinimalRepository) {
return emoji.emojify(repo.description ? `${repo.full_name}: ${repo.description}` : repo.full_name);
}
public static formatRoomTopic(repo: {state: string, html_url: string}) {
@ -35,7 +46,7 @@ export class FormatUtil {
};
}
public static getPartialBodyForIssue(repo: IMinimalRepository, issue: IssuesGetResponseData) {
public static getPartialBodyForIssue(repo: IMinimalRepository, issue: IMinimalIssue) {
return {
...FormatUtil.getPartialBodyForRepo(repo),
"external_url": issue.html_url,
@ -49,9 +60,9 @@ export class FormatUtil {
};
}
public static getPartialBodyForComment(comment: IssuesGetCommentResponseData,
public static getPartialBodyForComment(comment: {id: number, html_url: string},
repo?: IMinimalRepository,
issue?: IssuesGetResponseData) {
issue?: IMinimalIssue) {
return {
...(issue && repo ? FormatUtil.getPartialBodyForIssue(repo, issue) : undefined),
"external_url": comment.html_url,
@ -61,7 +72,11 @@ export class FormatUtil {
};
}
public static projectListing(projectItem: ProjectsListForOrgResponseData|ProjectsListForUserResponseData|ProjectsListForRepoResponseData) {
return `${projectItem[0].name} (#${projectItem[0].number}) - Project ID: ${projectItem[0].id}`
public static projectListing(projects: ProjectsListResponseData): string {
let f = '';
for (const projectItem of projects) {
f += ` - ${projectItem.name} (#${projectItem.number}) - Project ID: ${projectItem.id}`;
}
return f;
}
}

View File

@ -1,5 +1,19 @@
import { IssuesGetResponseData, IssuesGetCommentResponseData, PullsListReviewsResponseData, ReposGetResponseData, PullsListRequestedReviewersResponseData } from "@octokit/types";
import { Endpoints } from "@octokit/types";
export type IssuesGetResponseData = Endpoints["GET /repos/{owner}/{repo}/issues/{issue_number}"]["response"]["data"];
export type IssuesGetCommentResponseData = Endpoints["GET /repos/{owner}/{repo}/issues/comments/{comment_id}"]["response"]["data"];
export type PullsListReviewsResponseData = Endpoints["GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews"]["response"]["data"];
export type PullsListRequestedReviewersResponseData = Endpoints["GET /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers"]["response"]["data"];
export type ReposGetResponseData = Endpoints["GET /repos/{owner}/{repo}"]["response"]["data"];
export type ProjectsGetResponseData = Endpoints["GET /projects/{project_id}"]["response"]["data"];
export type ProjectsListForTeamsResponseData = Endpoints["GET /teams/{team_id}/projects"]["response"]["data"];
export type ProjectsListForRepoResponseData = Endpoints["GET /repos/{owner}/{repo}/projects"]["response"]["data"];
export type ProjectsListForUserResponseData = Endpoints["GET /users/{username}/projects"]["response"]["data"];
export type ProjectsListResponseData = ProjectsListForTeamsResponseData|ProjectsListForRepoResponseData|ProjectsListForUserResponseData;
export type IssuesListAssigneesResponseData = Endpoints["GET /repos/{owner}/{repo}/issues"]["response"]["data"];
export type PullsGetResponseData = Endpoints["GET /repos/{owner}/{repo}/pulls"]["response"]["data"];
export type PullGetResponseData = Endpoints["GET /repos/{owner}/{repo}/pulls/{pull_number}"]["response"]["data"];
export type DiscussionDataType = Endpoints["GET /repos/{owner}/{repo}/pulls/{pull_number}"]["response"]["data"];
/* eslint-disable camelcase */
export interface GitHubUserNotification {
id: string;

View File

@ -1,8 +1,6 @@
import { Appservice, IAppserviceRegistration, RichRepliesPreprocessor, IRichReplyMetadata } from "matrix-bot-sdk";
import { ProjectsGetResponseData } from "@octokit/types";
import { Appservice, IAppserviceRegistration, RichRepliesPreprocessor, IRichReplyMetadata, StateEvent } from "matrix-bot-sdk";
import { BridgeConfig, GitLabInstance } from "./Config/Config";
import { IGitHubWebhookEvent, IOAuthRequest, IOAuthTokens, NotificationsEnableEvent,
NotificationsDisableEvent } from "./GithubWebhooks";
import { IOAuthRequest, IOAuthTokens, NotificationsEnableEvent, NotificationsDisableEvent,} from "./GithubWebhooks";
import { CommentProcessor } from "./CommentProcessor";
import { MessageQueue, createMessageQueue } from "./MessageQueue/MessageQueue";
import { AdminRoom, BRIDGE_ROOM_TYPE, AdminAccountData } from "./AdminRoom";
@ -29,6 +27,9 @@ import { GitLabClient } from "./Gitlab/Client";
import { BridgeWidgetApi } from "./Widgets/BridgeWidgetApi";
import { GitHubDiscussionConnection } from "./Connections/GithubDiscussion";
import { Discussion } from "./Github/Discussion";
import { ProjectsGetResponseData } from "./Github/Types";
import { NotifFilter, NotificationFilterStateContent } from "./NotificationFilters";
import * as GitHubWebhookTypes from "@octokit/webhooks-types";
const log = new LogWrapper("GithubBridge");
@ -47,7 +48,7 @@ export class GithubBridge {
constructor(private config: BridgeConfig, private registration: IAppserviceRegistration) { }
private createConnectionForState(roomId: string, state: MatrixEvent<any>) {
private async createConnectionForState(roomId: string, state: StateEvent<any>) {
log.debug(`Looking to create connection for ${roomId}`);
if (state.content.disabled === false) {
log.debug(`${roomId} has disabled state for ${state.type}`);
@ -66,7 +67,7 @@ export class GithubBridge {
throw Error('GitHub is not configured');
}
return new GitHubDiscussionConnection(
roomId, this.as, state.content, state.state_key || "", this.tokenStore, this.commentProcessor,
roomId, this.as, state.content, state.stateKey, this.tokenStore, this.commentProcessor,
this.messageClient,
);
}
@ -75,7 +76,9 @@ export class GithubBridge {
if (!this.github) {
throw Error('GitHub is not configured');
}
return new GitHubIssueConnection(roomId, this.as, state.content, state.state_key || "", this.tokenStore, this.commentProcessor, this.messageClient, this.github);
const issue = new GitHubIssueConnection(roomId, this.as, state.content, state.stateKey || "", this.tokenStore, this.commentProcessor, this.messageClient, this.github);
await issue.syncIssueState();
return issue;
}
if (GitLabRepoConnection.EventTypes.includes(state.type)) {
if (!this.config.gitlab) {
@ -97,7 +100,7 @@ export class GithubBridge {
roomId,
this.as,
state.content,
state.state_key as string,
state.stateKey as string,
this.tokenStore,
this.commentProcessor,
this.messageClient,
@ -108,7 +111,12 @@ export class GithubBridge {
private async createConnectionsForRoomId(roomId: string): Promise<IConnection[]> {
const state = await this.as.botClient.getRoomState(roomId);
return state.map((event) => this.createConnectionForState(roomId, event)).filter((connection) => !!connection) as unknown as IConnection[];
const connections: IConnection[] = [];
for (const event of state) {
const conn = await this.createConnectionForState(roomId, new StateEvent(event));
if (conn) { connections.push(conn); }
}
return connections;
}
private getConnectionsForGithubIssue(org: string, repo: string, issueNumber: number): (GitHubIssueConnection|GitLabRepoConnection)[] {
@ -117,6 +125,8 @@ export class GithubBridge {
}
private getConnectionsForGithubRepo(org: string, repo: string): GitHubRepoConnection[] {
org = org.toLowerCase();
repo = repo.toLowerCase();
return this.connections.filter((c) => (c instanceof GitHubRepoConnection && c.org === org && c.repo === repo)) as GitHubRepoConnection[];
}
@ -214,39 +224,41 @@ export class GithubBridge {
return this.onRoomJoin(roomId, event);
});
this.queue.subscribe("comment.*");
this.queue.subscribe("issue.*");
this.queue.subscribe("response.matrix.message");
this.queue.subscribe("notifications.user.events");
this.queue.subscribe("merge_request.*");
this.queue.subscribe("github.*");
this.queue.subscribe("gitlab.*");
const validateRepoIssue = (data: IGitHubWebhookEvent) => {
const validateRepoIssue = (data: GitHubWebhookTypes.IssuesEvent|GitHubWebhookTypes.IssueCommentEvent) => {
if (!data.repository || !data.issue) {
throw Error("Malformed webhook event, missing repository or issue");
}
if (!data.repository.owner?.login) {
throw Error('Cannot get connection for ownerless issue');
}
return {
owner: data.repository.owner?.login,
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);
this.queue.on<GitHubWebhookTypes.IssueCommentCreatedEvent>("github.issue_comment.created", async ({ data }) => {
const { repository, issue, owner } = validateRepoIssue(data);
const connections = this.getConnectionsForGithubIssue(owner, repository.name, issue.number);
connections.map(async (c) => {
try {
if (c instanceof GitHubIssueConnection)
await c.onCommentCreated(data);
await c.onIssueCommentCreated(data);
} catch (ex) {
log.warn(`Connection ${c.toString()} failed to handle comment.created:`, ex);
}
})
});
this.queue.on<IGitHubWebhookEvent>("issue.opened", async ({ data }) => {
const { repository } = validateRepoIssue(data);
const connections = this.getConnectionsForGithubRepo(repository.owner.login, repository.name);
this.queue.on<GitHubWebhookTypes.IssuesOpenedEvent>("github.issues.opened", async ({ data }) => {
const { repository, owner } = validateRepoIssue(data);
const connections = this.getConnectionsForGithubRepo(owner, repository.name);
connections.map(async (c) => {
try {
await c.onIssueCreated(data);
@ -256,12 +268,13 @@ export class GithubBridge {
})
});
this.queue.on<IGitHubWebhookEvent>("issue.edited", async ({ data }) => {
const { repository, issue } = validateRepoIssue(data);
const connections = this.getConnectionsForGithubIssue(repository.owner.login, repository.name, issue.number);
this.queue.on<GitHubWebhookTypes.IssuesEditedEvent>("github.issues.edited", async ({ data }) => {
const { repository, issue, owner } = validateRepoIssue(data);
const connections = this.getConnectionsForGithubIssue(owner, repository.name, issue.number);
connections.map(async (c) => {
try {
if (c instanceof GitHubIssueConnection)
// TODO: Needs impls
if (c instanceof GitHubIssueConnection /* || c instanceof GitHubRepoConnection*/)
await c.onIssueEdited(data);
} catch (ex) {
log.warn(`Connection ${c.toString()} failed to handle comment.created:`, ex);
@ -269,12 +282,12 @@ export class GithubBridge {
})
});
this.queue.on<IGitHubWebhookEvent>("issue.closed", async ({ data }) => {
const { repository, issue } = validateRepoIssue(data);
const connections = this.getConnectionsForGithubIssue(repository.owner.login, repository.name, issue.number);
this.queue.on<GitHubWebhookTypes.IssuesClosedEvent>("github.issues.closed", async ({ data }) => {
const { repository, issue, owner } = validateRepoIssue(data);
const connections = this.getConnectionsForGithubIssue(owner, repository.name, issue.number);
connections.map(async (c) => {
try {
if (c instanceof GitHubIssueConnection)
if (c instanceof GitHubIssueConnection || c instanceof GitHubRepoConnection)
await c.onIssueStateChange();
} catch (ex) {
log.warn(`Connection ${c.toString()} failed to handle comment.created:`, ex);
@ -282,12 +295,12 @@ export class GithubBridge {
})
});
this.queue.on<IGitHubWebhookEvent>("issue.reopened", async ({ data }) => {
const { repository, issue } = validateRepoIssue(data);
const connections = this.getConnectionsForGithubIssue(repository.owner.login, repository.name, issue.number);
this.queue.on<GitHubWebhookTypes.IssuesReopenedEvent>("github.issues.reopened", async ({ data }) => {
const { repository, issue, owner } = validateRepoIssue(data);
const connections = this.getConnectionsForGithubIssue(owner, repository.name, issue.number);
connections.map(async (c) => {
try {
if (c instanceof GitHubIssueConnection)
if (c instanceof GitHubIssueConnection || c instanceof GitHubRepoConnection)
await c.onIssueStateChange();
} catch (ex) {
log.warn(`Connection ${c.toString()} failed to handle comment.created:`, ex);
@ -295,7 +308,7 @@ export class GithubBridge {
})
});
this.queue.on<IGitLabWebhookMREvent>("merge_request.open", async (msg) => {
this.queue.on<IGitLabWebhookMREvent>("gitlab.merge_request.open", async (msg) => {
console.log(msg);
// const connections = this.(msg.data.project.namespace, msg.data.repository!.name, msg.data.issue!.number);
// connections.map(async (c) => {
@ -371,6 +384,17 @@ export class GithubBridge {
})
});
this.queue.on<IGitLabWebhookIssueStateEvent>("github.discussion_comment.created", async ({data}) => {
const connections = this.getConnectionsForGitLabIssueWebhook(data.repository.homepage, data.object_attributes.iid);
connections.map(async (c) => {
try {
await c.onIssueClosed();
} catch (ex) {
log.warn(`Connection ${c.toString()} failed to handle comment.created:`, ex);
}
})
});
// Fetch all room state
let joinedRooms: string[]|undefined;
while(joinedRooms === undefined) {
@ -414,21 +438,35 @@ export class GithubBridge {
log.error(`Unable to create connection for ${roomId}`, ex);
continue;
}
this.connections.push(...connections);
if (connections.length === 0) {
// TODO: Refactor this to be a connection
try {
const accountData = await this.as.botIntent.underlyingClient.getRoomAccountData(
BRIDGE_ROOM_TYPE, roomId,
);
const adminRoom = await this.setupAdminRoom(roomId, accountData);
// Call this on startup to set the state
await this.onAdminRoomSettingsChanged(adminRoom, accountData, { admin_user: accountData.admin_user });
} catch (ex) {
log.debug(`Room ${roomId} has no connections and is not an admin room`);
}
} else {
if (connections.length) {
log.info(`Room ${roomId} is connected to: ${connections.join(',')}`);
this.connections.push(...connections);
continue;
}
// TODO: Refactor this to be a connection
try {
const accountData = await this.as.botIntent.underlyingClient.getSafeRoomAccountData<AdminAccountData>(
BRIDGE_ROOM_TYPE, roomId,
);
if (!accountData) {
log.debug(`Room ${roomId} has no connections and is not an admin room`);
continue;
}
let notifContent;
try {
notifContent = await this.as.botIntent.underlyingClient.getRoomStateEvent(
roomId, NotifFilter.StateType, "",
);
} catch (ex) {
// No state yet
}
const adminRoom = await this.setupAdminRoom(roomId, accountData, notifContent || NotifFilter.getDefaultContent());
// Call this on startup to set the state
await this.onAdminRoomSettingsChanged(adminRoom, accountData, { admin_user: accountData.admin_user });
log.info(`Room ${roomId} is connected to: ${adminRoom.toString()}`);
} catch (ex) {
log.error(`Failed to setup admin room ${roomId}:`, ex);
}
}
if (this.config.widgets) {
@ -450,9 +488,9 @@ export class GithubBridge {
}
await retry(() => this.as.botIntent.joinRoom(roomId), 5);
if (event.content.is_direct) {
const room = await this.setupAdminRoom(roomId, {admin_user: event.sender});
const room = await this.setupAdminRoom(roomId, {admin_user: event.sender}, NotifFilter.getDefaultContent());
await this.as.botIntent.underlyingClient.setRoomAccountData(
BRIDGE_ROOM_TYPE, roomId, room.data,
BRIDGE_ROOM_TYPE, roomId, room.accountData,
);
}
// This is a group room, don't add the admin settings and just sit in the room.
@ -534,18 +572,14 @@ export class GithubBridge {
}
private async onRoomEvent(roomId: string, event: MatrixEvent<unknown>) {
if (event.sender === this.as.botUserId) {
// It's us
return;
}
if (event.state_key) {
// A state update, hurrah!
const existingConnection = this.connections.find((c) => c.roomId === roomId && c.isInterestedInStateEvent(event.type, event.state_key || ""));
if (existingConnection?.onStateUpdate) {
existingConnection.onStateUpdate(event);
} else {
} else if (!existingConnection) {
// Is anyone interested in this state?
const connection = await this.createConnectionForState(roomId, event);
const connection = await this.createConnectionForState(roomId, new StateEvent(event));
if (connection) {
log.info(`New connected added to ${roomId}: ${connection.toString()}`);
this.connections.push(connection);
@ -553,6 +587,10 @@ export class GithubBridge {
}
return null;
}
if (event.sender === this.as.botUserId) {
// It's us
return;
}
// Alas, it's just an event.
return this.connections.filter((c) => c.roomId === roomId).map((c) => c.onEvent ? c.onEvent(event) : undefined);
@ -674,9 +712,9 @@ export class GithubBridge {
}
private async setupAdminRoom(roomId: string, accountData: AdminAccountData) {
private async setupAdminRoom(roomId: string, accountData: AdminAccountData, notifContent: NotificationFilterStateContent) {
const adminRoom = new AdminRoom(
roomId, accountData, this.as.botIntent, this.tokenStore, this.config,
roomId, accountData, notifContent, this.as.botIntent, this.tokenStore, this.config,
);
adminRoom.on("settings.changed", this.onAdminRoomSettingsChanged.bind(this));
adminRoom.on("open.project", async (project: ProjectsGetResponseData) => {

View File

@ -1,7 +1,6 @@
import { BridgeConfig } from "./Config/Config";
import { Application, default as express, Request, Response } from "express";
import { createHmac } from "crypto";
import { IssuesGetResponseData, IssuesGetCommentResponseData, ReposGetResponseData } from "@octokit/types";
import { EventEmitter } from "events";
import { MessageQueue, createMessageQueue, MessageQueueMessage } from "./MessageQueue/MessageQueue";
import LogWrapper from "./LogWrapper";
@ -10,31 +9,17 @@ import { Server } from "http";
import axios from "axios";
import { UserNotificationWatcher } from "./Notifications/UserNotificationWatcher";
import { IGitLabWebhookEvent } from "./Gitlab/WebhookTypes";
import { Webhooks as OctokitWebhooks } from "@octokit/webhooks"
const log = new LogWrapper("GithubWebhooks");
export interface IGitHubWebhookEvent {
action: string;
issue?: IssuesGetResponseData;
comment?: IssuesGetCommentResponseData;
repository?: ReposGetResponseData;
sender?: {
login: string;
}
changes?: {
title?: {
from: string;
};
};
}
export interface IOAuthRequest {
code: string;
state: string;
}
export interface IOAuthTokens {
// eslint-disable-next-line camelcase
access_token: string;
// eslint-disable-next-line camelcase
token_type: string;
state: string;
}
@ -60,9 +45,26 @@ export class GithubWebhooks extends EventEmitter {
private queue: MessageQueue;
private userNotificationWatcher: UserNotificationWatcher;
private server?: Server;
private ghWebhooks?: OctokitWebhooks;
constructor(private config: BridgeConfig) {
super();
this.expressApp = express();
if (this.config.github?.webhook.secret) {
this.ghWebhooks = new OctokitWebhooks({
secret: config.github?.webhook.secret as string,
});
this.ghWebhooks.onAny(({id, name, payload}) => {
log.info(`Got GitHub webhook event ${id} ${name}`);
this.queue.push({
eventName: `github.name`,
sender: "GithubWebhooks",
data: payload,
}).catch((err) => {
log.error(`Failed to emit payload: ${err}`);
});
});
}
this.expressApp.use(express.json({
verify: this.verifyRequest.bind(this),
}));
@ -77,8 +79,6 @@ export class GithubWebhooks extends EventEmitter {
this.queue.on("notifications.user.disable", (msg: MessageQueueMessage<NotificationsDisableEvent>) => {
this.userNotificationWatcher.removeUser(msg.data.userId, msg.data.type, msg.data.instanceUrl);
});
// This also listens for notifications for users, which is long polly.
}
public listen() {
@ -99,23 +99,6 @@ export class GithubWebhooks extends EventEmitter {
}
}
private onGitHubPayload(body: IGitHubWebhookEvent) {
if (body.action === "created" && body.comment) {
return "comment.created";
} else if (body.action === "edited" && body.comment) {
return "comment.edited";
} else if (body.action === "opened" && body.issue) {
return "issue.opened";
} else if (body.action === "edited" && body.issue) {
return "issue.edited";
} else if (body.action === "closed" && body.issue) {
return "issue.closed";
} else if (body.action === "reopened" && body.issue) {
return "issue.reopened";
}
return null;
}
private onGitLabPayload(body: IGitLabWebhookEvent) {
log.info(`onGitLabPayload ${body.event_type}:`, body);
if (body.event_type === "merge_request") {
@ -134,10 +117,25 @@ export class GithubWebhooks extends EventEmitter {
try {
let eventName: string|null = null;
const body = req.body;
res.sendStatus(200);
if (req.headers['x-hub-signature']) {
eventName = this.onGitHubPayload(body);
if (!this.ghWebhooks) {
log.warn(`Not configured for GitHub webhooks, but got a GitHub event`)
res.sendStatus(500);
return;
}
res.sendStatus(200);
this.ghWebhooks.verifyAndReceive({
id: req.headers["x-github-delivery"] as string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
name: req.headers["x-github-event"] as any,
payload: req.body,
signature: req.headers["x-hub-signature-256"] as string,
}).catch((err) => {
log.error(`Failed handle GitHubEvent: ${err}`);
});
res.sendStatus(200);
} else if (req.headers['x-gitlab-token']) {
res.sendStatus(200);
eventName = this.onGitLabPayload(body);
}
if (eventName) {
@ -146,7 +144,7 @@ export class GithubWebhooks extends EventEmitter {
sender: "GithubWebhooks",
data: body,
}).catch((err) => {
log.info(`Failed to emit payload: ${err}`);
log.error(`Failed to emit payload: ${err}`);
});
} else {
log.debug("Unknown event:", req.body);
@ -181,6 +179,7 @@ export class GithubWebhooks extends EventEmitter {
redirect_uri: this.config.github.oauth.redirect_uri,
state: req.query.state as string,
})}`);
// eslint-disable-next-line camelcase
const result = qs.parse(accessTokenRes.data) as { access_token: string, token_type: string };
await this.queue.push<IOAuthTokens>({
eventName: "oauth.tokens",
@ -194,21 +193,9 @@ export class GithubWebhooks extends EventEmitter {
}
}
// Calculate the X-Hub-Signature header value.
private getSignature(buf: Buffer) {
if (!this.config.github) {
throw Error("Got GitHub oauth request but github was not configured!");
}
const hmac = createHmac("sha1", this.config.github.webhook.secret);
hmac.update(buf);
return "sha1=" + hmac.digest("hex");
}
// Verify function compatible with body-parser to retrieve the request payload.
// Read more: https://github.com/expressjs/body-parser#verify
private verifyRequest(req: Request, res: Response, buf: Buffer) {
private verifyRequest(req: Request, res: Response) {
if (req.headers['x-gitlab-token']) {
// This is a gitlab request!
// GitLab
if (!this.config.gitlab) {
log.error("Got a GitLab webhook, but the bridge is not set up for it.");
res.sendStatus(400);
@ -223,16 +210,9 @@ export class GithubWebhooks extends EventEmitter {
throw Error("Invalid signature.");
}
} else if (req.headers["x-hub-signature"]) {
const expected = req.headers["x-hub-signature"];
const calculated = this.getSignature(buf);
if (expected !== calculated) {
log.error(`${req.url} had an invalid signature`);
res.sendStatus(403);
throw Error("Invalid signature.");
} else {
log.debug('Verified GitHub request');
return true;
}
// GitHub
// Verified within handler.
return true;
}
log.error(`No signature on URL. Rejecting`);
res.sendStatus(400);

View File

@ -2,9 +2,8 @@ import { LogService } from "matrix-bot-sdk";
import util from "util";
import winston from "winston";
// Logs contain unknowns, ignore this.
// tslint:disable: no-any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type MsgType = string|Error|any|{error?: string};
export default class LogWrapper {
public static configureLogging(level: string) {
@ -24,7 +23,7 @@ export default class LogWrapper {
}),
],
});
const getMessageString = (...messageOrObject: any[]) => {
const getMessageString = (messageOrObject: MsgType[]) => {
messageOrObject = messageOrObject.flat();
const messageParts: string[] = [];
messageOrObject.forEach((obj) => {
@ -37,7 +36,7 @@ export default class LogWrapper {
return messageParts.join(" ");
};
LogService.setLogger({
info: (module: string, ...messageOrObject: any[]) => {
info: (module: string, ...messageOrObject: MsgType[]) => {
// These are noisy, redirect to debug.
if (module.startsWith("MatrixLiteClient")) {
log.debug(getMessageString(messageOrObject), { module });
@ -45,19 +44,28 @@ export default class LogWrapper {
}
log.info(getMessageString(messageOrObject), { module });
},
warn: (module: string, ...messageOrObject: any[]) => {
warn: (module: string, ...messageOrObject: MsgType[]) => {
const error = messageOrObject[0].error || messageOrObject[1].body?.error;
if (error === "Room account data not found") {
log.debug(getMessageString(messageOrObject), { module });
return; // This is just noise :|
}
log.warn(getMessageString(messageOrObject), { module });
},
error: (module: string, ...messageOrObject: any[]) => {
if (messageOrObject[0]?.error === "Room account data not found") {
error: (module: string, ...messageOrObject: MsgType[]) => {
const error = messageOrObject[0].error || messageOrObject[1]?.body?.error;
if (error === "Room account data not found") {
log.debug(getMessageString(messageOrObject), { module });
return; // This is just noise :|
}
log.error(getMessageString(messageOrObject), { module });
},
debug: (module: string, ...messageOrObject: any[]) => {
debug: (module: string, ...messageOrObject: MsgType[]) => {
log.debug(getMessageString(messageOrObject), { module });
},
trace: (module: string, ...messageOrObject: MsgType[]) => {
log.verbose(getMessageString(messageOrObject), { module });
},
});
LogService.info("LogWrapper", "Reconfigured logging");
}
@ -69,7 +77,7 @@ export default class LogWrapper {
* @param {string} module The module being logged
* @param {*[]} messageOrObject The data to log
*/
public debug(...messageOrObject: any[]) {
public debug(...messageOrObject: MsgType[]) {
LogService.debug(this.module, ...messageOrObject);
}
@ -77,7 +85,7 @@ export default class LogWrapper {
* Logs to the ERROR channel
* @param {*[]} messageOrObject The data to log
*/
public error(...messageOrObject: any[]) {
public error(...messageOrObject: MsgType[]) {
LogService.error(this.module, ...messageOrObject);
}
@ -85,7 +93,7 @@ export default class LogWrapper {
* Logs to the INFO channel
* @param {*[]} messageOrObject The data to log
*/
public info(...messageOrObject: any[]) {
public info(...messageOrObject: MsgType[]) {
LogService.info(this.module, ...messageOrObject);
}
@ -93,7 +101,7 @@ export default class LogWrapper {
* Logs to the WARN channel
* @param {*[]} messageOrObject The data to log
*/
public warn(...messageOrObject: any[]) {
public warn(...messageOrObject: MsgType[]) {
LogService.warn(this.module, ...messageOrObject);
}
}

View File

@ -0,0 +1,87 @@
interface FilterContent {
users: string[];
repos: string[];
orgs: string[];
}
export interface NotificationFilterStateContent {
filters: {
[name: string]: FilterContent;
};
forNotifications: string[];
forInvites: string[];
}
/**
* A notification filter is a set of keys that define what should be sent to the user.
*/
export class NotifFilter {
static readonly StateType = "uk.half-shot.matrix-github.notif-filter"
static getDefaultContent(): NotificationFilterStateContent {
return {
filters: {},
forNotifications: [],
forInvites: [],
}
}
public readonly forNotifications: Set<string>;
public readonly forInvites: Set<string>;
public filters: Record<string, FilterContent>;
constructor(stateContent: NotificationFilterStateContent) {
this.forNotifications = new Set(stateContent.forNotifications);
this.forInvites = new Set(stateContent.forInvites);
this.filters = stateContent.filters;
}
public get empty() {
return Object.values(this.filters).length === 0;
}
public getStateContent(): NotificationFilterStateContent {
return {
filters: this.filters,
forInvites: [...this.forInvites],
forNotifications: [...this.forNotifications],
};
}
public shouldInviteToRoom(user: string, repo: string, org: string): boolean {
return false;
}
public shouldSendNotification(user?: string, repo?: string, org?: string): boolean {
if (this.forNotifications.size === 0) {
// Default on.
return true;
}
for (const filterName of this.forNotifications) {
const filter = this.filters[filterName];
if (!filter) {
// Filter with this name exists.
continue;
}
if (user && filter.users.includes(user.toLowerCase())) {
// We have a user in this notif and we are filtering on users.
return true;
}
if (repo && filter.repos.includes(repo.toLowerCase())) {
// We have a repo in this notif and we are filtering on repos.
return true;
}
if (org && filter.orgs.includes(org.toLowerCase())) {
// We have an org in this notif and we are filtering on orgs.
return true;
}
// None of the filters matched, so exclude the result.
return false;
}
return false;
}
public setFilter(name: string, filter: FilterContent) {
this.filters[name] = filter;
}
}

View File

@ -13,8 +13,8 @@ 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;
private static apiFailureCount = 0;
private static globalRetryIn = 0;
public static checkGitHubStatus() {
this.apiFailureCount = Math.min(this.apiFailureCount + 1, GH_API_THRESHOLD);
@ -43,7 +43,8 @@ export class GitHubWatcher extends EventEmitter implements NotificationWatcherTa
}
public start(intervalMs: number) {
this.interval = setTimeout(() => {
log.info(`Starting for ${this.userId}`);
this.interval = setInterval(() => {
this.getNotifications();
}, intervalMs);
this.getNotifications();
@ -51,6 +52,7 @@ export class GitHubWatcher extends EventEmitter implements NotificationWatcherTa
public stop() {
if (this.interval) {
log.info(`Stopping for ${this.userId}`);
clearInterval(this.interval);
}
}
@ -71,7 +73,7 @@ export class GitHubWatcher extends EventEmitter implements NotificationWatcherTa
log.info(`Not getting notifications for ${this.userId}, API is still down.`);
return;
}
log.info(`Getting notifications for ${this.userId} ${this.lastReadTs}`);
log.debug(`Getting notifications for ${this.userId} ${this.lastReadTs}`);
const since = this.lastReadTs !== 0 ? `&since=${new Date(this.lastReadTs).toISOString()}`: "";
let response: OctokitResponse<GitHubUserNotification[]>;
try {
@ -86,9 +88,11 @@ export class GitHubWatcher extends EventEmitter implements NotificationWatcherTa
await this.handleGitHubFailure(ex);
return;
}
log.info(`Got ${response.data.length} notifications`);
this.lastReadTs = Date.now();
if (response.data.length) {
log.info(`Got ${response.data.length} notifications for ${this.userId}`);
}
for (const rawEvent of response.data) {
try {
if (rawEvent.subject.url) {
@ -104,6 +108,10 @@ export class GitHubWatcher extends EventEmitter implements NotificationWatcherTa
log.warn("review_requested was missing subject.url_data.number");
continue;
}
if (!rawEvent.repository.owner) {
log.warn("review_requested was missing repository.owner");
continue;
}
rawEvent.subject.requested_reviewers = (await this.octoKit.pulls.listRequestedReviewers({
pull_number: rawEvent.subject.url_data.number,
owner: rawEvent.repository.owner.login,

View File

@ -16,7 +16,7 @@ export class GitLabWatcher extends EventEmitter implements NotificationWatcherTa
}
public start(intervalMs: number) {
this.interval = setTimeout(() => {
this.interval = setInterval(() => {
this.getNotifications();
}, intervalMs);
}

View File

@ -5,15 +5,18 @@ import LogWrapper from "./LogWrapper";
import { AdminRoom } from "./AdminRoom";
import markdown from "markdown-it";
import { FormatUtil } from "./FormatUtil";
import { IssuesListAssigneesResponseData, PullsGetResponseData, IssuesGetResponseData, PullsListRequestedReviewersResponseData, PullsListReviewsResponseData, IssuesGetCommentResponseData } from "@octokit/types";
import { PullGetResponseData, IssuesGetResponseData, PullsListRequestedReviewersResponseData, PullsListReviewsResponseData, IssuesGetCommentResponseData } from "./Github/Types";
import { GitHubUserNotification } from "./Github/Types";
import { components } from "@octokit/openapi-types/dist-types/generated/types";
import { NotifFilter } from "./NotificationFilters";
const log = new LogWrapper("GithubBridge");
const log = new LogWrapper("NotificationProcessor");
const md = new markdown();
export interface IssueDiff {
state: null|string;
assignee: null|IssuesListAssigneesResponseData;
assignee: null|(components["schemas"]["simple-user"][]);
title: null|string;
merged: boolean;
mergedBy: null|{
@ -34,7 +37,7 @@ export interface CachedReviewData {
reviews: PullsListReviewsResponseData;
}
type PROrIssue = IssuesGetResponseData|PullsGetResponseData;
type PROrIssue = IssuesGetResponseData|PullGetResponseData;
export class NotificationProcessor {
@ -66,12 +69,13 @@ export class NotificationProcessor {
plain += `\n\n Title changed to: ${diff.title}`;
}
if (diff.assignee) {
plain += `\n\n Assigned to: ${diff.assignee[0].login}`;
plain += `\n\n Assigned to: ${diff.assignee.map(l => l?.login).join(", ")}`;
}
}
if (newComment) {
const comment = notif.subject.latest_comment_url_data as IssuesGetCommentResponseData;
plain += `\n\n ${NotificationProcessor.formatUser(comment.user)}:\n\n > ${comment.body}`;
const user = comment.user ? NotificationProcessor.formatUser(comment.user) : 'user';
plain += `\n\n ${user}:\n\n > ${comment.body}`;
}
return {
plain,
@ -107,7 +111,7 @@ export class NotificationProcessor {
for (const event of msg.events) {
const isIssueOrPR = event.subject.type === "Issue" || event.subject.type === "PullRequest";
try {
await this.handleUserNotification(msg.roomId, event);
await this.handleUserNotification(msg.roomId, event, adminRoom.notifFilter);
if (isIssueOrPR && event.subject.url_data) {
const issueNumber = event.subject.url_data.number.toString();
await this.storage.setGithubIssue(
@ -187,9 +191,12 @@ export class NotificationProcessor {
private diffIssueChanges(curr: PROrIssue, prev: PROrIssue): IssueDiff {
let merged = false;
let mergedBy = null;
if ((curr as PullsGetResponseData).merged !== (prev as PullsGetResponseData).merged) {
if ((curr as PullGetResponseData).merged !== (prev as PullGetResponseData).merged) {
merged = true;
mergedBy = (curr as PullsGetResponseData).merged_by;
mergedBy = (curr as PullGetResponseData).merged_by;
}
if (!curr.user) {
throw Error('No user for issue');
}
const diff: IssueDiff = {
state: curr.state === prev.state ? null : curr.state,
@ -217,7 +224,7 @@ export class NotificationProcessor {
(await this.storage.getLastNotifCommentUrl(notif.repository.full_name, issueNumber, roomId));
const formatted = NotificationProcessor.formatNotification(notif, diff, newComment);
let body: any = {
let body = {
msgtype: "m.text",
body: formatted.plain,
formatted_body: formatted.html,
@ -245,8 +252,15 @@ export class NotificationProcessor {
return this.matrixSender.sendMatrixMessage(roomId, body);
}
private async handleUserNotification(roomId: string, notif: GitHubUserNotification) {
private async handleUserNotification(roomId: string, notif: GitHubUserNotification, filter: NotifFilter) {
log.info("New notification event:", notif);
if (!filter.shouldSendNotification(
notif.subject.latest_comment_url_data?.user?.login,
notif.repository.full_name,
notif.repository.owner?.login)) {
log.debug(`Dropping notification because user is filtering it out`)
return;
}
if (notif.reason === "security_alert") {
return this.matrixSender.sendMatrixMessage(roomId, this.formatSecurityAlert(notif));
} else if (notif.subject.type === "Issue" || notif.subject.type === "PullRequest") {

View File

@ -1,6 +1,6 @@
import { MemoryStorageProvider as MSP } from "matrix-bot-sdk";
import { IStorageProvider } from "./StorageProvider";
import { IssuesGetResponseData } from "@octokit/types";
import { IssuesGetResponseData } from "../Github/Types";
export class MemoryStorageProvider extends MSP implements IStorageProvider {
private issues: Map<string, IssuesGetResponseData> = new Map();

View File

@ -1,4 +1,4 @@
import { IssuesGetResponseData } from "@octokit/types";
import { IssuesGetResponseData } from "../Github/Types";
import { Redis, default as redis } from "ioredis";
import LogWrapper from "../LogWrapper";

View File

@ -1,5 +1,5 @@
import { IAppserviceStorageProvider } from "matrix-bot-sdk";
import { IssuesGetResponseData } from "@octokit/types";
import { IssuesGetResponseData } from "../Github/Types";
export interface IStorageProvider extends IAppserviceStorageProvider {
setGithubIssue(repo: string, issueNumber: string, data: IssuesGetResponseData, scope?: string): Promise<void>;

View File

@ -46,12 +46,14 @@ export class UserTokenStore {
if (existingToken) {
return existingToken;
}
let obj;
try {
let obj;
if (type === "github") {
obj = await this.intent.underlyingClient.getAccountData(key);
obj = await this.intent.underlyingClient.getAccountData<{encrypted: string}>(key);
} else if (type === "gitlab") {
obj = await this.intent.underlyingClient.getAccountData(key);
obj = await this.intent.underlyingClient.getAccountData<{encrypted: string}>(key);
} else {
throw Error('Unknown type');
}
const encryptedTextB64 = obj.encrypted;
const encryptedText = Buffer.from(encryptedTextB64, "base64");

View File

@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { expect } from "chai";
import { AdminRoom } from "../src/AdminRoom";
import { NotifFilter } from "../src/NotificationFilters";
import { UserTokenStore } from "../src/UserTokenStore";
import { IntentMock } from "./utils/IntentMock";
@ -12,7 +13,7 @@ function createAdminRoom(data: any = {admin_user: "@admin:bar"}): [AdminRoom, In
data.admin_user = "@admin:bar";
}
const tokenStore = new UserTokenStore("notapath", intent);
return [new AdminRoom(ROOM_ID, data, intent, tokenStore, {} as any), intent];
return [new AdminRoom(ROOM_ID, data, NotifFilter.getDefaultContent(), intent, tokenStore, {} as any, ), intent];
}
describe("AdminRoom", () => {

View File

@ -2,7 +2,7 @@ import { h, render } from 'preact';
import 'preact/devtools';
import App from './App';
import "./styling.css";
import "fontsource-open-sans/files/open-sans-latin-400-normal.woff2";
import "@fontsource/open-sans/files/open-sans-latin-400-normal.woff2";
const root = document.getElementsByTagName('main')[0];

View File

@ -1,5 +1,5 @@
@import "fontsource-open-sans/400-normal.css";
@import "../node_modules/@fontsource/open-sans/400.css";
@import "mini.css";
body {

3089
yarn.lock

File diff suppressed because it is too large Load Diff