diff --git a/config.sample.yml b/config.sample.yml index 9b4854eb..b716d752 100644 --- a/config.sample.yml +++ b/config.sample.yml @@ -11,7 +11,6 @@ bridge: github: # (Optional) Configure this to enable GitHub support # - installationId: 6854059 auth: id: 123 privateKeyFile: github-key.pem diff --git a/package.json b/package.json index 13107eaa..79749257 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "lib/app.js", "repository": "https://github.com/Half-Shot/matrix-hookshot", "author": "Half-Shot", - "license": "Apache2", + "license": "Apache-2.0", "private": false, "napi": { "name": "matrix-hookshot-rs" diff --git a/src/AdminRoom.ts b/src/AdminRoom.ts index 695d50cf..e81e6868 100644 --- a/src/AdminRoom.ts +++ b/src/AdminRoom.ts @@ -19,6 +19,7 @@ import { ProjectsListResponseData } from "./Github/Types"; import { NotifFilter, NotificationFilterStateContent } from "./NotificationFilters"; import { JiraBotCommands } from "./Jira/AdminCommands"; import { AdminAccountData, AdminRoomCommandHandler } from "./AdminRoomCommandHandler"; +import { CommandError } from "./errors"; type ProjectsListForRepoResponseData = Endpoints["GET /repos/{owner}/{repo}/projects"]["response"]; type ProjectsListForUserResponseData = Endpoints["GET /users/{username}/projects"]["response"]; @@ -137,10 +138,9 @@ export class AdminRoom extends AdminRoomCommandHandler { } @botCommand("github setpersonaltoken", "Set your personal access token for GitHub", ['accessToken']) - // @ts-ignore - property is used - private async setGHPersonalAccessToken(accessToken: string) { + public async setGHPersonalAccessToken(accessToken: string) { if (!this.config.github) { - return this.sendNotice("The bridge is not configured with GitHub support"); + throw new CommandError("no-github-support", "The bridge is not configured with GitHub support"); } let me; try { @@ -156,10 +156,9 @@ export class AdminRoom extends AdminRoomCommandHandler { } @botCommand("github hastoken", "Check if you have a token stored for GitHub") - // @ts-ignore - property is used - private async hasPersonalToken() { + public async hasPersonalToken() { if (!this.config.github) { - return this.sendNotice("The bridge is not configured with GitHub support"); + throw new CommandError("no-github-support", "The bridge is not configured with GitHub support"); } const result = await this.tokenStore.getUserToken("github", this.userId); if (result === null) { @@ -170,10 +169,9 @@ export class AdminRoom extends AdminRoomCommandHandler { } @botCommand("github startoauth", "Start the OAuth process with GitHub") - // @ts-ignore - property is used - private async beginOAuth() { + public async beginOAuth() { if (!this.config.github) { - return this.sendNotice("The bridge is not configured with GitHub support"); + throw new CommandError("no-github-support", "The bridge is not configured with GitHub support"); } // If this is already set, calling this command will invalidate the previous session. this.pendingOAuthState = uuid(); diff --git a/src/Bridge.ts b/src/Bridge.ts index d3842baf..b8671903 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -1,26 +1,26 @@ +import { AdminAccountData } from "./AdminRoomCommandHandler"; import { AdminRoom, BRIDGE_ROOM_TYPE, LEGACY_BRIDGE_ROOM_TYPE } from "./AdminRoom"; import { Appservice, IAppserviceRegistration, RichRepliesPreprocessor, IRichReplyMetadata, StateEvent, PantalaimonClient, MatrixClient } from "matrix-bot-sdk"; import { BridgeConfig, GitLabInstance } from "./Config/Config"; import { BridgeWidgetApi } from "./Widgets/BridgeWidgetApi"; import { CommentProcessor } from "./CommentProcessor"; import { ConnectionManager } from "./ConnectionManager"; +import { GenericHookConnection } from "./Connections"; import { GetIssueResponse, GetIssueOpts } from "./Gitlab/Types" import { GithubInstance } from "./Github/GithubInstance"; -import { GitHubIssueConnection } from "./Connections/GithubIssue"; -import { GitHubProjectConnection } from "./Connections/GithubProject"; -import { GitHubRepoConnection } from "./Connections/GithubRepo"; -import { GitLabIssueConnection } from "./Connections/GitlabIssue"; import { IBridgeStorageProvider } from "./Stores/StorageProvider"; -import { IConnection, GitHubDiscussionSpace, GitHubDiscussionConnection, GitHubUserSpace } from "./Connections"; -import { IGitLabWebhookIssueStateEvent, IGitLabWebhookMREvent, IGitLabWebhookNoteEvent } from "./Gitlab/WebhookTypes"; +import { IConnection, GitHubDiscussionSpace, GitHubDiscussionConnection, GitHubUserSpace, JiraProjectConnection, GitLabRepoConnection, + GitHubIssueConnection, GitHubProjectConnection, GitHubRepoConnection, GitLabIssueConnection } from "./Connections"; +import { IGitLabWebhookIssueStateEvent, IGitLabWebhookMREvent, IGitLabWebhookNoteEvent, IGitLabWebhookTagPushEvent } from "./Gitlab/WebhookTypes"; import { JiraIssueEvent, JiraIssueUpdatedEvent } from "./Jira/WebhookTypes"; +import { JiraOAuthResult } from "./Jira/Types"; import { MatrixEvent, MatrixMemberContent, MatrixMessageContent } from "./MatrixEvent"; import { MemoryStorageProvider } from "./Stores/MemoryStorageProvider"; -import { MessageQueue, createMessageQueue } from "./MessageQueue/MessageQueue"; +import { MessageQueue, createMessageQueue } from "./MessageQueue"; import { MessageSenderClient } from "./MatrixSender"; import { NotifFilter, NotificationFilterStateContent } from "./NotificationFilters"; import { NotificationProcessor } from "./NotificationsProcessor"; -import { OAuthRequest, GitHubOAuthTokens, NotificationsEnableEvent, NotificationsDisableEvent, GenericWebhookEvent,} from "./Webhooks"; +import { GitHubOAuthTokens, NotificationsEnableEvent, NotificationsDisableEvent, GenericWebhookEvent } from "./Webhooks"; import { ProjectsGetResponseData } from "./Github/Types"; import { RedisStorageProvider } from "./Stores/RedisStorageProvider"; import { retry } from "./PromiseUtil"; @@ -28,8 +28,8 @@ import { UserNotificationsEvent } from "./Notifications/UserNotificationWatcher" import { UserTokenStore } from "./UserTokenStore"; import * as GitHubWebhookTypes from "@octokit/webhooks-types"; import LogWrapper from "./LogWrapper"; -import { JiraOAuthResult } from "./Jira/Types"; -import { AdminAccountData } from "./AdminRoomCommandHandler"; +import { OAuthRequest } from "./WebhookTypes"; +import { promises as fs } from "fs"; const log = new LogWrapper("Bridge"); export class Bridge { @@ -85,7 +85,7 @@ export class Bridge { } if (this.config.github) { - this.github = new GithubInstance(this.config.github); + this.github = new GithubInstance(this.config.github.auth.id, await fs.readFile(this.config.github.auth.privateKeyFile, 'utf-8')); await this.github.start(); } @@ -160,160 +160,136 @@ export class Bridge { }; } - this.queue.on("github.issue_comment.created", async ({ data }) => { - const { repository, issue, owner } = validateRepoIssue(data); - const connections = connManager.getConnectionsForGithubIssue(owner, repository.name, issue.number); - connections.map(async (c) => { - try { - if (c instanceof GitHubIssueConnection) - await c.onIssueCommentCreated(data); - } catch (ex) { - log.warn(`Connection ${c.toString()} failed to handle github.issue_comment.created:`, ex); - } - }) + + this.queue.on("github.installation.created", async (data) => { + this.github?.onInstallationCreated(data.data); + }); + this.queue.on("github.installation.unsuspend", async (data) => { + this.github?.onInstallationCreated(data.data); + }); + this.queue.on("github.installation.deleted", async (data) => { + this.github?.onInstallationRemoved(data.data); + }); + this.queue.on("github.installation.suspend", async (data) => { + this.github?.onInstallationRemoved(data.data); }); - this.queue.on("github.issues.opened", async ({ data }) => { - const { repository, owner } = validateRepoIssue(data); - const connections = connManager.getConnectionsForGithubRepo(owner, repository.name); - connections.map(async (c) => { - try { - await c.onIssueCreated(data); - } catch (ex) { - log.warn(`Connection ${c.toString()} failed to handle github.issues.opened:`, ex); - } - }) - }); + this.bindHandlerToQueue( + "github.issue_comment.created", + (data) => { + const { repository, issue, owner } = validateRepoIssue(data); + return connManager.getConnectionsForGithubIssue(owner, repository.name, issue.number).filter(c => c instanceof GitHubIssueConnection) as GitHubIssueConnection[]; + }, + (c, data) => c.onIssueCommentCreated(data), + ); - this.queue.on("github.issues.edited", async ({ data }) => { - const { repository, issue, owner } = validateRepoIssue(data); - const connections = connManager.getConnectionsForGithubIssue(owner, repository.name, issue.number); - connections.map(async (c) => { - try { - // TODO: Needs impls - if (c instanceof GitHubIssueConnection /* || c instanceof GitHubRepoConnection*/) - await c.onIssueEdited(data); - } catch (ex) { - log.warn(`Connection ${c.toString()} failed to handle github.issues.edited:`, ex); - } - }) - }); + this.bindHandlerToQueue( + "github.issues.opened", + (data) => { + const { repository, owner } = validateRepoIssue(data); + return connManager.getConnectionsForGithubRepo(owner, repository.name); + }, + (c, data) => c.onIssueCreated(data), + ); - this.queue.on("github.issues.closed", async ({ data }) => { - const { repository, issue, owner } = validateRepoIssue(data); - const connections = connManager.getConnectionsForGithubIssue(owner, repository.name, issue.number); - connections.map(async (c) => { - try { - if (c instanceof GitHubIssueConnection || c instanceof GitHubRepoConnection) - await c.onIssueStateChange(data); - } catch (ex) { - log.warn(`Connection ${c.toString()} failed to handle github.issues.closed:`, ex); - } - }) - }); + this.bindHandlerToQueue( + "github.issues.edited", + (data) => { + const { repository, issue, owner } = validateRepoIssue(data); + return connManager.getConnectionsForGithubIssue(owner, repository.name, issue.number); + }, + (c, data) => c.onIssueEdited(data), + ); - this.queue.on("github.issues.reopened", async ({ data }) => { - const { repository, issue, owner } = validateRepoIssue(data); - const connections = connManager.getConnectionsForGithubIssue(owner, repository.name, issue.number); - connections.map(async (c) => { - try { - if (c.onIssueStateChange) { - await c.onIssueStateChange(data); - } - } catch (ex) { - log.warn(`Connection ${c.toString()} failed to handle github.issues.reopened:`, ex); - } - }) - }); + this.bindHandlerToQueue( + "github.issues.closed", + (data) => { + const { repository, issue, owner } = validateRepoIssue(data); + return connManager.getConnectionsForGithubIssue(owner, repository.name, issue.number); + }, + (c, data) => c.onIssueStateChange(data), + ); - this.queue.on("github.issues.edited", async ({ data }) => { - const { repository, owner } = validateRepoIssue(data); - const connections = connManager.getConnectionsForGithubRepo(owner, repository.name); - connections.map(async (c) => { - try { - await c.onIssueEdited(data); - } catch (ex) { - log.warn(`Connection ${c.toString()} failed to handle github.issues.edited:`, ex); - } - }) - }); + this.bindHandlerToQueue( + "github.issues.reopened", + (data) => { + const { repository, issue, owner } = validateRepoIssue(data); + return connManager.getConnectionsForGithubIssue(owner, repository.name, issue.number); + }, + (c, data) => c.onIssueStateChange(data), + ); - this.queue.on("github.pull_request.opened", async ({ data }) => { - const connections = connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name); - connections.map(async (c) => { - try { - await c.onPROpened(data); - } catch (ex) { - log.warn(`Connection ${c.toString()} failed to handle github.pull_request.opened:`, ex); - } - }) - }); + this.bindHandlerToQueue( + "github.issues.edited", + (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), + (c, data) => c.onIssueEdited(data), + ); - this.queue.on("github.pull_request.closed", async ({ data }) => { - const connections = connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name); - connections.map(async (c) => { - try { - await c.onPRClosed(data); - } catch (ex) { - log.warn(`Connection ${c.toString()} failed to handle github.pull_request.closed:`, ex); - } - }) - }); + this.bindHandlerToQueue( + "github.pull_request.opened", + (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), + (c, data) => c.onPROpened(data), + ); - this.queue.on("github.pull_request.ready_for_review", async ({ data }) => { - const connections = connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name); - connections.map(async (c) => { - try { - await c.onPRReadyForReview(data); - } catch (ex) { - log.warn(`Connection ${c.toString()} failed to handle github.pull_request.closed:`, ex); - } - }) - }); + this.bindHandlerToQueue( + "github.pull_request.closed", + (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), + (c, data) => c.onPRClosed(data), + ); - this.queue.on("github.pull_request_review.submitted", async ({ data }) => { - const connections = connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name); - connections.map(async (c) => { - try { - await c.onPRReviewed(data); - } catch (ex) { - log.warn(`Connection ${c.toString()} failed to handle github.pull_request.closed:`, ex); - } - }) - }); + this.bindHandlerToQueue( + "github.pull_request_review.ready_for_review", + (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), + (c, data) => c.onPRReadyForReview(data), + ); - this.queue.on("github.release.created", async ({ data }) => { - const connections = connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name); - connections.map(async (c) => { - try { - await c.onReleaseCreated(data); - } catch (ex) { - log.warn(`Connection ${c.toString()} failed to handle github.pull_request.closed:`, ex); - } - }) - }); + this.bindHandlerToQueue( + "github.pull_request_review.submitted", + (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), + (c, data) => c.onPRReviewed(data), + ); - this.queue.on("gitlab.merge_request.open", async (msg) => { - const connections = connManager.getConnectionsForGitLabRepo(msg.data.project.path_with_namespace); - connections.map(async (c) => { - try { - await c.onMergeRequestOpened(msg.data); - } catch (ex) { - log.warn(`Connection ${c.toString()} failed to handle gitlab.merge_request.open:`, ex); - } - }) - }); + this.bindHandlerToQueue( + "github.release.created", + (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), + (c, data) => c.onReleaseCreated(data), + ); - this.queue.on("gitlab.tag_push", async (msg) => { - const connections = connManager.getConnectionsForGitLabRepo(msg.data.project.path_with_namespace); - connections.map(async (c) => { - try { - await c.onMergeRequestOpened(msg.data); - } catch (ex) { - log.warn(`Connection ${c.toString()} failed to handle gitlab.tag_push:`, ex); - } - }) - }); + this.bindHandlerToQueue( + "gitlab.merge_request.open", + (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), + (c, data) => c.onMergeRequestOpened(data), + ); + + this.bindHandlerToQueue( + "gitlab.merge_request.merge", + (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), + (c, data) => c.onMergeRequestReviewed(data), + ); + + this.bindHandlerToQueue( + "gitlab.merge_request.merge", + (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), + (c, data) => c.onMergeRequestReviewed(data), + ); + + this.bindHandlerToQueue( + "gitlab.merge_request.approved", + (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), + (c, data) => c.onMergeRequestReviewed(data), + ); + + this.bindHandlerToQueue( + "gitlab.merge_request.unapproved", + (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), + (c, data) => c.onMergeRequestReviewed(data), + ); + + this.bindHandlerToQueue( + "gitlab.tag_push", + (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), + (c, data) => c.onGitLabTagPush(data), + ); this.queue.on("notifications.user.events", async (msg) => { const adminRoom = this.adminRooms.get(msg.data.roomId); @@ -344,50 +320,29 @@ export class Bridge { await this.tokenStore.storeUserToken("github", adminRoom.userId, msg.data.access_token); }); - this.queue.on("gitlab.note.created", async ({data}) => { - const connections = connManager.getConnectionsForGitLabIssueWebhook(data.repository.homepage, data.issue.iid); - connections.map(async (c) => { - try { - if (c.onCommentCreated) - await c.onCommentCreated(data); - } catch (ex) { - log.warn(`Connection ${c.toString()} failed to handle comment.created:`, ex); - } - }) - }); + this.bindHandlerToQueue( + "gitlab.note.created", + (data) => connManager.getConnectionsForGitLabIssueWebhook(data.repository.homepage, data.issue.iid), + (c, data) => c.onCommentCreated(data), + ); - this.queue.on("gitlab.issue.reopen", async ({data}) => { - const connections = connManager.getConnectionsForGitLabIssueWebhook(data.repository.homepage, data.object_attributes.iid); - connections.map(async (c) => { - try { - await c.onIssueReopened(); - } catch (ex) { - log.warn(`Connection ${c.toString()} failed to handle comment.created:`, ex); - } - }) - }); + this.bindHandlerToQueue( + "gitlab.issue.reopen", + (data) => connManager.getConnectionsForGitLabIssueWebhook(data.repository.homepage, data.object_attributes.iid), + (c) => c.onIssueReopened(), + ); - this.queue.on("gitlab.issue.close", async ({data}) => { - const connections = connManager.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); - } - }) - }); + this.bindHandlerToQueue( + "gitlab.issue.close", + (data) => connManager.getConnectionsForGitLabIssueWebhook(data.repository.homepage, data.object_attributes.iid), + (c) => c.onIssueClosed(), + ); - this.queue.on("github.discussion_comment.created", async ({data}) => { - const connections = connManager.getConnectionsForGithubDiscussion(data.repository.owner.login, data.repository.name, data.discussion.number); - connections.map(async (c) => { - try { - await c.onDiscussionCommentCreated(data); - } catch (ex) { - log.warn(`Connection ${c.toString()} failed to handle comment.created:`, ex); - } - }) - }); + this.bindHandlerToQueue( + "github.discussion_comment.created", + (data) => connManager.getConnectionsForGithubDiscussion(data.repository.owner.login, data.repository.name, data.discussion.number), + (c, data) => c.onDiscussionCommentCreated(data), + ); this.queue.on("github.discussion.created", async ({data}) => { if (!this.github) { @@ -428,33 +383,18 @@ export class Bridge { } }) }); - - this.queue.on("jira.issue_created", async ({data}) => { - log.info(`JIRA issue created for project ${data.issue.fields.project.id}, issue id ${data.issue.id}`); - const connections = connManager.getConnectionsForJiraProject(data.issue.fields.project, "jira.issue_created"); - - connections.forEach(async (c) => { - try { - await c.onJiraIssueCreated(data); - } catch (ex) { - log.warn(`Failed to handle jira.issue_created:`, ex); - } - }); - }); - - this.queue.on("jira.issue_updated", async ({data}) => { - log.info(`JIRA issue created for project ${data.issue.fields.project.id}, issue id ${data.issue.id}`); - const connections = connManager.getConnectionsForJiraProject(data.issue.fields.project, "jira.issue_updated"); - - connections.forEach(async (c) => { - try { - await c.onJiraIssueUpdated(data as JiraIssueUpdatedEvent); - } catch (ex) { - log.warn(`Failed to handle jira.issue_updated:`, ex); - } - }); - }); + this.bindHandlerToQueue( + "jira.issue_created", + (data) => connManager.getConnectionsForJiraProject(data.issue.fields.project, "jira.issue_created"), + (c, data) => c.onJiraIssueCreated(data), + ); + + this.bindHandlerToQueue( + "jira.issue_updated", + (data) => connManager.getConnectionsForJiraProject(data.issue.fields.project, "jira.issue_updated"), + (c, data) => c.onJiraIssueUpdated(data), + ); this.queue.on("jira.oauth.response", async (msg) => { const adminRoom = [...this.adminRooms.values()].find((r) => r.jiraOAuthState === msg.data.state); @@ -477,18 +417,11 @@ export class Bridge { await adminRoom.sendNotice(`Logged into Jira`); }); - this.queue.on("generic-webhook.event", async ({data}) => { - log.info(`Incoming generic hook ${data.hookId}`); - const connections = connManager.getConnectionsForGenericWebhook(data.hookId); - - connections.forEach(async (c) => { - try { - await c.onGenericHook(data.hookData); - } catch (ex) { - log.warn(`Failed to handle generic-webhook.event:`, ex); - } - }); - }); + this.bindHandlerToQueue( + "generic-webhook.event", + (data) => connManager.getConnectionsForGenericWebhook(data.hookId), + (c, data) => c.onGenericHook(data.hookData), + ); // Fetch all room state let joinedRooms: string[]|undefined; @@ -597,6 +530,20 @@ export class Bridge { this.ready = true; } + private async bindHandlerToQueue(event: string, connectionFetcher: (data: EventType) => ConnType[], handler: (c: ConnType, data: EventType) => Promise) { + this.queue.on(event, (msg) => { + const connections = connectionFetcher.bind(this)(msg.data); + log.debug(`${event} for ${connections.map(c => c.toString()).join(', ')}`); + connections.forEach(async (c) => { + try { + await handler(c, msg.data); + } catch (ex) { + log.warn(`Connection ${c.toString()} failed to handle ${event}:`, ex); + } + }) + }); + } + private async onRoomInvite(roomId: string, event: MatrixEvent) { if (this.as.isNamespacedUser(event.sender)) { /* Do not handle invites from our users */ @@ -762,7 +709,7 @@ export class Bridge { tokenStore: this.tokenStore, messageClient: this.messageClient, commentProcessor: this.commentProcessor, - octokit: this.github.octokit, + githubInstance: this.github, }); } catch (ex) { log.error(`Could not handle alias with GitHubIssueConnection`, ex); @@ -777,7 +724,7 @@ export class Bridge { } try { return await GitHubDiscussionSpace.onQueryRoom(res, { - octokit: this.github.octokit, + githubInstance: this.github, as: this.as, }); } catch (ex) { @@ -797,7 +744,7 @@ export class Bridge { tokenStore: this.tokenStore, messageClient: this.messageClient, commentProcessor: this.commentProcessor, - octokit: this.github.octokit, + githubInstance: this.github, }); } catch (ex) { log.error(`Could not handle alias with GitHubRepoConnection`, ex); @@ -812,7 +759,7 @@ export class Bridge { } try { return await GitHubUserSpace.onQueryRoom(res, { - octokit: this.github.octokit, + githubInstance: this.github, as: this.as, }); } catch (ex) { diff --git a/src/Config/Config.ts b/src/Config/Config.ts index c95df978..166cefbd 100644 --- a/src/Config/Config.ts +++ b/src/Config/Config.ts @@ -20,7 +20,6 @@ export interface BridgeConfigGitHub { // eslint-disable-next-line camelcase redirect_uri: string; }; - installationId: number|string; } export interface GitLabInstance { diff --git a/src/Config/Defaults.ts b/src/Config/Defaults.ts index 3caec358..cb989039 100644 --- a/src/Config/Defaults.ts +++ b/src/Config/Defaults.ts @@ -35,7 +35,6 @@ export const DefaultConfig = new BridgeConfig({ avatar: "mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d" }, github: { - installationId: 6854059, auth: { id: 123, privateKeyFile: "github-key.pem", diff --git a/src/ConnectionManager.ts b/src/ConnectionManager.ts index feb4dac1..d6d9e5de 100644 --- a/src/ConnectionManager.ts +++ b/src/ConnectionManager.ts @@ -43,7 +43,6 @@ export class ConnectionManager { // NOTE: Double loop for (const connection of connections) { if (!this.connections.find((c) => c === connection)) { - console.log("PUSH!"); this.connections.push(connection); } } @@ -110,7 +109,7 @@ export class ConnectionManager { if (!instance) { throw Error('Instance name not recognised'); } - return new GitLabRepoConnection(roomId, this.as, state.content, this.tokenStore, instance); + return new GitLabRepoConnection(roomId, this.as, state.content, state.stateKey, this.tokenStore, instance); } if (GitLabIssueConnection.EventTypes.includes(state.type)) { diff --git a/src/Connections/GithubDiscussionSpace.ts b/src/Connections/GithubDiscussionSpace.ts index 469b44e0..5d3b8b62 100644 --- a/src/Connections/GithubDiscussionSpace.ts +++ b/src/Connections/GithubDiscussionSpace.ts @@ -5,6 +5,7 @@ import { Octokit } from "@octokit/rest"; import { ReposGetResponseData } from "../Github/Types"; import axios from "axios"; import { GitHubDiscussionConnection } from "./GithubDiscussion"; +import { GithubInstance } from "../Github/GithubInstance"; const log = new LogWrapper("GitHubDiscussionSpace"); @@ -27,7 +28,7 @@ export class GitHubDiscussionSpace implements IConnection { static readonly QueryRoomRegex = /#github_disc_(.+)_(.+):.*/; - static async onQueryRoom(result: RegExpExecArray, opts: {octokit: Octokit, as: Appservice}): Promise> { + static async onQueryRoom(result: RegExpExecArray, opts: {githubInstance: GithubInstance, as: Appservice}): Promise> { if (!result || result.length < 2) { log.error(`Invalid alias pattern '${result}'`); throw Error("Could not find issue"); @@ -37,9 +38,10 @@ export class GitHubDiscussionSpace implements IConnection { log.info(`Fetching ${owner}/${repo}`); let repoRes: ReposGetResponseData; + const octokit = opts.githubInstance.getOctokitForRepo(owner, repo); try { // TODO: Determine if the repo has discussions? - repoRes = (await opts.octokit.repos.get({ + repoRes = (await octokit.repos.get({ owner, repo, })).data; @@ -58,7 +60,7 @@ export class GitHubDiscussionSpace implements IConnection { // URL hack so we don't need to fetch the repo itself. let avatarUrl = undefined; try { - const profile = await opts.octokit.users.getByUsername({ + const profile = await octokit.users.getByUsername({ username: owner, }); if (profile.data.avatar_url) { diff --git a/src/Connections/GithubIssue.ts b/src/Connections/GithubIssue.ts index eca6f1e6..2d7b61f2 100644 --- a/src/Connections/GithubIssue.ts +++ b/src/Connections/GithubIssue.ts @@ -5,7 +5,6 @@ import markdown from "markdown-it"; import { UserTokenStore } from "../UserTokenStore"; import LogWrapper from "../LogWrapper"; import { CommentProcessor } from "../CommentProcessor"; -import { Octokit } from "@octokit/rest"; import { MessageSenderClient } from "../MatrixSender"; import { getIntentForUser } from "../IntentUtils"; import { FormatUtil } from "../FormatUtil"; @@ -31,7 +30,7 @@ interface IQueryRoomOpts { tokenStore: UserTokenStore; commentProcessor: CommentProcessor; messageClient: MessageSenderClient; - octokit: Octokit; + githubInstance: GithubInstance; } /** @@ -61,8 +60,9 @@ export class GitHubIssueConnection implements IConnection { log.info(`Fetching ${owner}/${repo}/${issueNumber}`); let issue: IssuesGetResponseData; + const octokit = opts.githubInstance.getOctokitForRepo(owner, repo); try { - issue = (await opts.octokit.issues.get({ + issue = (await octokit.issues.get({ owner, repo, issue_number: issueNumber, @@ -78,7 +78,7 @@ export class GitHubIssueConnection implements IConnection { const orgRepoName = issue.repository_url.substr("https://api.github.com/repos/".length); let avatarUrl = undefined; try { - const profile = await opts.octokit.users.getByUsername({ + const profile = await octokit.users.getByUsername({ username: owner, }); if (profile.data.avatar_url) { @@ -200,7 +200,7 @@ export class GitHubIssueConnection implements IConnection { public async syncIssueState() { log.debug("Syncing issue state for", this.roomId); - const issue = await this.github.octokit.issues.get({ + const issue = await this.github.getOctokitForRepo(this.org, this.repo).issues.get({ owner: this.state.org, repo: this.state.repo, issue_number: this.issueNumber, @@ -231,7 +231,7 @@ export class GitHubIssueConnection implements IConnection { } if (this.state.comments_processed !== issue.data.comments) { - const comments = (await this.github.octokit.issues.listComments({ + const comments = (await this.github.getOctokitForRepo(this.org, this.repo).issues.listComments({ owner: this.state.org, repo: this.state.repo, issue_number: this.issueNumber, diff --git a/src/Connections/GithubRepo.ts b/src/Connections/GithubRepo.ts index 3e5d1171..9d22d2bd 100644 --- a/src/Connections/GithubRepo.ts +++ b/src/Connections/GithubRepo.ts @@ -7,15 +7,15 @@ import { IConnection } from "./IConnection"; import { IssuesOpenedEvent, IssuesReopenedEvent, IssuesEditedEvent, PullRequestOpenedEvent, IssuesClosedEvent, PullRequestClosedEvent, PullRequestReadyForReviewEvent, PullRequestReviewSubmittedEvent, ReleaseCreatedEvent } from "@octokit/webhooks-types"; import { MatrixMessageContent, MatrixEvent, MatrixReactionContent } from "../MatrixEvent"; import { MessageSenderClient } from "../MatrixSender"; -import { NotLoggedInError } from "../errors"; -import { Octokit } from "@octokit/rest"; +import { CommandError, NotLoggedInError } from "../errors"; import { ReposGetResponseData } from "../Github/Types"; import { UserTokenStore } from "../UserTokenStore"; -import axios from "axios"; +import axios, { AxiosError } from "axios"; import emoji from "node-emoji"; import LogWrapper from "../LogWrapper"; import markdown from "markdown-it"; import { CommandConnection } from "./CommandConnection"; +import { GithubInstance } from "../Github/GithubInstance"; const log = new LogWrapper("GitHubRepoConnection"); const md = new markdown(); @@ -24,7 +24,7 @@ interface IQueryRoomOpts { tokenStore: UserTokenStore; commentProcessor: CommentProcessor; messageClient: MessageSenderClient; - octokit: Octokit; + githubInstance: GithubInstance; } export interface GitHubRepoConnectionState { @@ -82,8 +82,9 @@ export class GitHubRepoConnection extends CommandConnection implements IConnecti log.info(`Fetching ${owner}/${repo}/${issueNumber}`); let repoRes: ReposGetResponseData; + const octokit = opts.githubInstance.getOctokitForRepo(owner, repo); try { - repoRes = (await opts.octokit.repos.get({ + repoRes = (await octokit.repos.get({ owner, repo, })).data; @@ -96,7 +97,7 @@ export class GitHubRepoConnection extends CommandConnection implements IConnecti const orgRepoName = repoRes.url.substr("https://api.github.com/repos/".length); let avatarUrl = undefined; try { - const profile = await opts.octokit.users.getByUsername({ + const profile = await octokit.users.getByUsername({ username: owner, }); if (profile.data.avatar_url) { @@ -148,7 +149,7 @@ export class GitHubRepoConnection extends CommandConnection implements IConnecti constructor(roomId: string, private readonly as: Appservice, - private readonly state: GitHubRepoConnectionState, + private state: GitHubRepoConnectionState, private readonly tokenStore: UserTokenStore, private readonly stateKey: string) { super( @@ -168,14 +169,17 @@ export class GitHubRepoConnection extends CommandConnection implements IConnecti return this.state.repo.toLowerCase(); } + public async onStateUpdate(stateEv: MatrixEvent) { + const state = stateEv.content as GitHubRepoConnectionState; + this.state = state; + } public isInterestedInStateEvent(eventType: string, stateKey: string) { return GitHubRepoConnection.EventTypes.includes(eventType) && this.stateKey === stateKey; } @botCommand("create", "Create an issue for this repo", ["title"], ["description", "labels"], true) - // @ts-ignore - private async onCreateIssue(userId: string, title: string, description?: string, labels?: string) { + public async onCreateIssue(userId: string, title: string, description?: string, labels?: string) { const octokit = await this.tokenStore.getOctokitForUser(userId); if (!octokit) { throw new NotLoggedInError(); @@ -199,11 +203,10 @@ export class GitHubRepoConnection extends CommandConnection implements IConnecti } @botCommand("assign", "Assign an issue to a user", ["number", "...users"], [], true) - // @ts-ignore - private async onAssign(userId: string, number: string, ...users: string[]) { + public async onAssign(userId: string, number: string, ...users: string[]) { const octokit = await this.tokenStore.getOctokitForUser(userId); if (!octokit) { - return this.as.botIntent.sendText(this.roomId, "You must login to assign an issue", "m.notice"); + throw new NotLoggedInError(); } if (users.length === 1) { @@ -219,11 +222,10 @@ export class GitHubRepoConnection extends CommandConnection implements IConnecti } @botCommand("close", "Close an issue", ["number"], ["comment"], true) - // @ts-ignore - private async onClose(userId: string, number: string, comment?: string) { + public async onClose(userId: string, number: string, comment?: string) { const octokit = await this.tokenStore.getOctokitForUser(userId); if (!octokit) { - return this.as.botIntent.sendText(this.roomId, "You must login to close an issue", "m.notice"); + throw new NotLoggedInError(); } if (comment) { @@ -243,6 +245,58 @@ export class GitHubRepoConnection extends CommandConnection implements IConnecti }); } + @botCommand("workflow run", "Run a GitHub Actions workflow. Args should be specified in \"key=value,key2='value 2'\" format.", ["name"], ["args", "ref"], true) + public async onWorkflowRun(userId: string, name: string, args?: string, ref?: string) { + const octokit = await this.tokenStore.getOctokitForUser(userId); + if (!octokit) { + throw new NotLoggedInError(); + } + const workflowArgs: Record = {}; + if (args) { + args.split(',').forEach((arg) => { const [key,value] = arg.split('='); workflowArgs[key] = value || "" }); + } + + const workflows = await octokit.actions.listRepoWorkflows({ + repo: this.state.repo, + owner: this.state.org, + }); + + const workflow = workflows.data.workflows.find(w => w.name.toLowerCase().trim() === name.toLowerCase().trim()); + if (!workflow) { + const workflowNames = workflows.data.workflows.map(w => w.name).join(', '); + await this.as.botIntent.sendText(this.roomId, `Could not find a workflow by the name of "${name}". The workflows on this repository are ${workflowNames}`, "m.notice"); + return; + } + try { + if (!ref) { + ref = (await octokit.repos.get({ + repo: this.state.repo, + owner: this.state.org, + })).data.default_branch; + } + } catch (ex) { + throw new CommandError(ex.message, `Could not determine default ref (maybe pass one in)`); + } + + try { + await octokit.actions.createWorkflowDispatch({ + repo: this.state.repo, + owner: this.state.org, + workflow_id: workflow.id, + ref, + inputs: workflowArgs, + }); + } catch (ex) { + const httpError = ex as AxiosError; + if (httpError.response?.data) { + throw new CommandError(httpError.response?.data.message, httpError.response?.data.message); + } + throw ex; + } + + await this.as.botIntent.sendText(this.roomId, `Workflow started`, "m.notice"); + } + public async onIssueCreated(event: IssuesOpenedEvent) { if (this.shouldSkipHook('issue.created', 'issue')) { return; diff --git a/src/Connections/GithubUserSpace.ts b/src/Connections/GithubUserSpace.ts index 6e32d3af..fd795971 100644 --- a/src/Connections/GithubUserSpace.ts +++ b/src/Connections/GithubUserSpace.ts @@ -1,9 +1,9 @@ import { IConnection } from "./IConnection"; import { Appservice, Space } from "matrix-bot-sdk"; import LogWrapper from "../LogWrapper"; -import { Octokit } from "@octokit/rest"; import axios from "axios"; import { GitHubDiscussionSpace } from "."; +import { GithubInstance } from "../Github/GithubInstance"; const log = new LogWrapper("GitHubOwnerSpace"); @@ -25,7 +25,7 @@ export class GitHubUserSpace implements IConnection { static readonly QueryRoomRegex = /#github_(.+):.*/; - static async onQueryRoom(result: RegExpExecArray, opts: {octokit: Octokit, as: Appservice}): Promise> { + static async onQueryRoom(result: RegExpExecArray, opts: {githubInstance: GithubInstance, as: Appservice}): Promise> { if (!result || result.length < 1) { log.error(`Invalid alias pattern '${result}'`); throw Error("Could not find issue"); @@ -37,9 +37,10 @@ export class GitHubUserSpace implements IConnection { let state: GitHubUserSpaceConnectionState; let avatarUrl: string|undefined; let name: string; + const octokit = opts.githubInstance.getOctokitForRepo(username); try { // TODO: Determine if the repo has discussions? - const userRes = (await opts.octokit.users.getByUsername({ + const userRes = (await octokit.users.getByUsername({ username, })).data; if (!userRes) { diff --git a/src/Connections/GitlabRepo.ts b/src/Connections/GitlabRepo.ts index b987046c..0294b2d3 100644 --- a/src/Connections/GitlabRepo.ts +++ b/src/Connections/GitlabRepo.ts @@ -1,19 +1,20 @@ // 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 { UserTokenStore } from "../UserTokenStore"; import { Appservice } from "matrix-bot-sdk"; -import { BotCommands, handleCommand, botCommand, compileBotCommands } from "../BotCommands"; +import { BotCommands, botCommand, compileBotCommands } from "../BotCommands"; import { MatrixEvent, MatrixMessageContent } from "../MatrixEvent"; import markdown from "markdown-it"; import LogWrapper from "../LogWrapper"; import { GitLabInstance } from "../Config/Config"; -import { IGitLabWebhookMREvent } from "../Gitlab/WebhookTypes"; +import { IGitLabWebhookMREvent, IGitLabWebhookTagPushEvent } from "../Gitlab/WebhookTypes"; +import { CommandConnection } from "./CommandConnection"; export interface GitLabRepoConnectionState { instance: string; path: string; - state: string; + ignoreHooks?: string[], + commandPrefix?: string; } const log = new LogWrapper("GitLabRepoConnection"); @@ -22,7 +23,7 @@ const md = new markdown(); /** * Handles rooms connected to a github repo. */ -export class GitLabRepoConnection implements IConnection { +export class GitLabRepoConnection extends CommandConnection { static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.gitlab.repository"; static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.gitlab.repository"; @@ -32,12 +33,21 @@ export class GitLabRepoConnection implements IConnection { ]; static botCommands: BotCommands; + static helpMessage: (cmdPrefix?: string | undefined) => MatrixMessageContent; constructor(public readonly roomId: string, private readonly as: Appservice, - private readonly state: GitLabRepoConnectionState, + private state: GitLabRepoConnectionState, + private readonly stateKey: string, private readonly tokenStore: UserTokenStore, private readonly instance: GitLabInstance) { + super( + roomId, + as.botClient, + GitLabRepoConnection.botCommands, + GitLabRepoConnection.helpMessage, + "!gl" + ) if (!state.path || !state.instance) { throw Error('Invalid state, missing `path` or `instance`'); } @@ -47,37 +57,17 @@ export class GitLabRepoConnection implements IConnection { return this.state.path?.toString(); } - - public isInterestedInStateEvent() { - return false; + public async onStateUpdate(stateEv: MatrixEvent) { + const state = stateEv.content as GitLabRepoConnectionState; + this.state = state; } - public async onMessageEvent(ev: MatrixEvent) { - const { error, handled } = await handleCommand(ev.sender, ev.content.body, GitLabRepoConnection.botCommands, this); - if (!handled) { - // Not for us. - return; - } - if (error) { - log.error(error); - await this.as.botIntent.sendEvent(this.roomId,{ - msgtype: "m.notice", - body: "Failed to handle command", - }); - return; - } - await this.as.botClient.sendEvent(this.roomId, "m.reaction", { - "m.relates_to": { - rel_type: "m.annotation", - event_id: ev.event_id, - key: "✅", - } - }); + public isInterestedInStateEvent(eventType: string, stateKey: string) { + return GitLabRepoConnection.EventTypes.includes(eventType) && this.stateKey === stateKey; } @botCommand("gl create", "Create an issue for this repo", ["title"], ["description", "labels"], true) - // @ts-ignore - private async onCreateIssue(userId: string, title: string, description?: string, labels?: string) { + public async onCreateIssue(userId: string, title: string, description?: string, labels?: string) { const client = await this.tokenStore.getGitLabForUser(userId, this.instance.url); if (!client) { await this.as.botIntent.sendText(this.roomId, "You must login to create an issue", "m.notice"); @@ -100,8 +90,7 @@ export class GitLabRepoConnection implements IConnection { } @botCommand("gl close", "Close an issue", ["number"], ["comment"], true) - // @ts-ignore - private async onClose(userId: string, number: string) { + public async onClose(userId: string, number: string) { const client = await this.tokenStore.getGitLabForUser(userId, this.instance.url); if (!client) { await this.as.botIntent.sendText(this.roomId, "You must login to create an issue", "m.notice"); @@ -115,14 +104,21 @@ export class GitLabRepoConnection implements IConnection { }); } - public async onMergeRequestOpened(event: IGitLabWebhookMREvent) { - log.info(`onMergeRequestOpened ${this.roomId} ${this.path} #${event.object_attributes.iid}`); + private validateMREvent(event: IGitLabWebhookMREvent) { if (!event.object_attributes) { throw Error('No merge_request content!'); } if (!event.project) { throw Error('No repository content!'); } + } + + public async onMergeRequestOpened(event: IGitLabWebhookMREvent) { + log.info(`onMergeRequestOpened ${this.roomId} ${this.path} #${event.object_attributes.iid}`); + if (this.shouldSkipHook('merge_request.open')) { + return; + } + this.validateMREvent(event); const orgRepoName = event.project.path_with_namespace; const content = `**${event.user.username}** opened a new MR [${orgRepoName}#${event.object_attributes.iid}](${event.object_attributes.url}): "${event.object_attributes.title}"`; await this.as.botIntent.sendEvent(this.roomId, { @@ -133,12 +129,80 @@ export class GitLabRepoConnection implements IConnection { }); } + public async onMergeRequestMerged(event: IGitLabWebhookMREvent) { + log.info(`onMergeRequestOpened ${this.roomId} ${this.path} #${event.object_attributes.iid}`); + if (this.shouldSkipHook('merge_request.merge')) { + return; + } + this.validateMREvent(event); + const orgRepoName = event.project.path_with_namespace; + const content = `**${event.user.username}** merged MR [${orgRepoName}#${event.object_attributes.iid}](${event.object_attributes.url}): "${event.object_attributes.title}"`; + await this.as.botIntent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: content, + formatted_body: md.renderInline(content), + format: "org.matrix.custom.html", + }); + } + + public async onMergeRequestReviewed(event: IGitLabWebhookMREvent) { + if (this.shouldSkipHook('merge_request.review', `merge_request.${event.object_attributes.action}`)) { + return; + } + log.info(`onMergeRequestReviewed ${this.roomId} ${this.instance}/${this.path} ${event.object_attributes.iid}`); + this.validateMREvent(event); + if (event.object_attributes.action !== "approved" && event.object_attributes.action !== "unapproved") { + // Not interested. + return; + } + const emojiForReview = { + 'approved': '✅', + 'unapproved': '🔴' + }[event.object_attributes.action]; + const orgRepoName = event.project.path_with_namespace; + const content = `**${event.user.username}** ${emojiForReview} ${event.object_attributes.action} MR [${orgRepoName}#${event.object_attributes.iid}](${event.object_attributes.url}): "${event.object_attributes.title}"`; + await this.as.botIntent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: content, + formatted_body: md.renderInline(content), + format: "org.matrix.custom.html", + }); + } + + public async onGitLabTagPush(event: IGitLabWebhookTagPushEvent) { + log.info(`onGitLabTagPush ${this.roomId} ${this.instance}/${this.path} ${event.ref}`); + if (this.shouldSkipHook('tag_push')) { + return; + } + const tagname = event.ref.replace("refs/tags/", ""); + const url = `${event.project.homepage}/-/tree/${tagname}`; + const content = `**${event.user_name}** pushed tag [\`${tagname}\`](${url}) for ${event.project.path_with_namespace}`; + await this.as.botIntent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: content, + formatted_body: md.renderInline(content), + format: "org.matrix.custom.html", + }); + } + public toString() { - return `GitHubRepo`; + return `GitLabRepo ${this.instance}/${this.path}`; + } + + private shouldSkipHook(...hookName: string[]) { + if (this.state.ignoreHooks) { + for (const name of hookName) { + if (this.state.ignoreHooks?.includes(name)) { + return true; + } + } + } + return false; } } // Typescript doesn't understand Prototypes very well yet. // eslint-disable-next-line @typescript-eslint/no-explicit-any -const res = compileBotCommands(GitLabRepoConnection.prototype as any); +const res = compileBotCommands(GitLabRepoConnection.prototype as any, CommandConnection.prototype as any); +GitLabRepoConnection.helpMessage = res.helpMessage; GitLabRepoConnection.botCommands = res.botCommands; \ No newline at end of file diff --git a/src/Connections/index.ts b/src/Connections/index.ts index 21a456b2..35444d5b 100644 --- a/src/Connections/index.ts +++ b/src/Connections/index.ts @@ -4,7 +4,8 @@ export * from "./GithubIssue"; export * from "./GithubProject"; export * from "./GithubRepo"; export * from "./GithubUserSpace"; - export * from "./GitlabIssue"; export * from "./GitlabRepo"; +export * from "./JiraProject"; +export * from "./GenericHook" export * from "./IConnection"; \ No newline at end of file diff --git a/src/Github/GithubInstance.ts b/src/Github/GithubInstance.ts index 5164d1b9..131b19a1 100644 --- a/src/Github/GithubInstance.ts +++ b/src/Github/GithubInstance.ts @@ -1,22 +1,33 @@ import { createAppAuth } from "@octokit/auth-app"; import { createTokenAuth } from "@octokit/auth-token"; import { Octokit } from "@octokit/rest"; -import { promises as fs } from "fs"; -import { BridgeConfigGitHub } from "../Config/Config"; import LogWrapper from "../LogWrapper"; import { DiscussionQLResponse, DiscussionQL } from "./Discussion"; +import * as GitHubWebhookTypes from "@octokit/webhooks-types"; +import { InstallationDataType } from "./Types"; +import e from "express"; const log = new LogWrapper("GithubInstance"); const USER_AGENT = "matrix-hookshot v0.0.1"; + +interface Installation { + account: { + login?: string; + } | null; + id: number; + repository_selection: "selected"|"all"; + matchesRepository: string[]; +} + export class GithubInstance { private internalOctokit!: Octokit; - public get octokit() { - return this.internalOctokit; - } + private readonly installationsCache = new Map(); - constructor (private config: BridgeConfigGitHub) { } + constructor (private readonly appId: number|string, private readonly privateKey: string) { + this.appId = parseInt(appId as string, 10); + } public static createUserOctokit(token: string) { return new Octokit({ @@ -28,12 +39,34 @@ export class GithubInstance { }); } + public getOctokitForRepo(orgName: string, repoName?: string) { + const targetName = (repoName ? `${orgName}/${repoName}` : orgName).toLowerCase(); + for (const install of this.installationsCache.values()) { + if (install.matchesRepository.includes(targetName) || install.matchesRepository.includes(`${targetName.split('/')[0]}/*`)) { + return this.createOctokitForInstallation(install.id); + } + } + // TODO: Refresh cache? + throw Error(`No installation found to handle ${targetName}`); + } + + private createOctokitForInstallation(installationId: number) { + return new Octokit({ + authStrategy: createAppAuth, + auth: { + appId: this.appId, + privateKey: this.privateKey, + installationId, + }, + userAgent: USER_AGENT, + }); + } + public async start() { // TODO: Make this generic. const auth = { - appId: parseInt(this.config.auth.id as string, 10), - privateKey: await fs.readFile(this.config.auth.privateKeyFile, "utf-8"), - installationId: parseInt(this.config.installationId as string, 10), + appId: this.appId, + privateKey: this.privateKey, }; this.internalOctokit = new Octokit({ @@ -42,13 +75,45 @@ export class GithubInstance { userAgent: USER_AGENT, }); - try { - await this.octokit.rateLimit.get(); - log.info("Auth check success"); - } catch (ex) { - log.info("Auth check failed:", ex); - throw Error("Attempting to verify GitHub authentication configration failed"); + let installPageSize = 100; + let page = 1; + do { + const installations = await this.internalOctokit.apps.listInstallations({ per_page: 100, page: page++ }); + for (const install of installations.data) { + await this.addInstallation(install); + } + installPageSize = installations.data.length; + } while(installPageSize === 100) + + log.info(`Found ${this.installationsCache.size} installations`); + } + + private async addInstallation(install: InstallationDataType, repos?: {full_name: string}[]) { + let matchesRepository: string[] = []; + if (install.repository_selection === "all") { + matchesRepository = [`${install.account?.login}/*`.toLowerCase()]; + } else if (repos) { + matchesRepository = repos.map(r => r.full_name.toLowerCase()); + } else { + const installOctokit = this.createOctokitForInstallation(install.id); + const repos = await installOctokit.apps.listReposAccessibleToInstallation({ per_page: 100 }); + matchesRepository.push(...repos.data.repositories.map(r => r.full_name.toLowerCase())); } + this.installationsCache.set(install.id, { + account: install.account, + id: install.id, + repository_selection: install.repository_selection, + matchesRepository, + }); + + } + + public onInstallationCreated(data: GitHubWebhookTypes.InstallationCreatedEvent|GitHubWebhookTypes.InstallationUnsuspendEvent) { + this.addInstallation(data.installation as InstallationDataType, data.repositories); + } + + public onInstallationRemoved(data: GitHubWebhookTypes.InstallationDeletedEvent|GitHubWebhookTypes.InstallationSuspendEvent) { + this.installationsCache.delete(data.installation.id); } } diff --git a/src/Github/Types.ts b/src/Github/Types.ts index 9d07f702..241a9705 100644 --- a/src/Github/Types.ts +++ b/src/Github/Types.ts @@ -14,6 +14,9 @@ export type IssuesListAssigneesResponseData = Endpoints["GET /repos/{owner}/{rep 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"]; +export type InstallationDataType = Endpoints["GET /app/installations/{installation_id}"]["response"]["data"]; +export type CreateInstallationAccessTokenDataType = Endpoints["POST /app/installations/{installation_id}/access_tokens"]["response"]["data"]; + /* eslint-disable camelcase */ export interface GitHubUserNotification { id: string; diff --git a/src/Gitlab/WebhookTypes.ts b/src/Gitlab/WebhookTypes.ts index e3aa1bc4..25d44a7d 100644 --- a/src/Gitlab/WebhookTypes.ts +++ b/src/Gitlab/WebhookTypes.ts @@ -26,6 +26,7 @@ export interface IGitlabRepository { export interface IGitlabProject { path_with_namespace: string; web_url: string; + homepage: string; } export interface IGitlabIssue { @@ -39,6 +40,11 @@ export interface IGitlabMergeRequest { iid: number; author_id: number; state: 'opened'|'closed'|'merged'; + +} + +export interface IGitLabMergeRequestObjectAttributes extends IGitlabMergeRequest { + action: "open"|"close"|"reopen"|"approved"|"unapproved"|"merge"; } export interface IGitLabWebhookMREvent { @@ -46,9 +52,27 @@ export interface IGitLabWebhookMREvent { user: IGitlabUser; project: IGitlabProject; repository: IGitlabRepository; - object_attributes: IGitlabMergeRequest; + object_attributes: IGitLabMergeRequestObjectAttributes; } +export interface IGitLabWebhookTagPushEvent { + object_kind: "tag_push"; + user_id: number; + ref: string; + user_name: string; + /** + * Commit hash before push + */ + before: string; + /** + * Commit hash after push + */ + after: string; + project: IGitlabProject; + repository: IGitlabRepository; +} + + export interface IGitLabWebhookNoteEvent { user: IGitlabUser; project: IGitlabProject; diff --git a/src/Jira/Router.ts b/src/Jira/Router.ts index 64e4a7cc..600a92ae 100644 --- a/src/Jira/Router.ts +++ b/src/Jira/Router.ts @@ -2,8 +2,8 @@ import axios from "axios"; import { Router, Request, Response } from "express"; import { BridgeConfigJira } from "../Config/Config"; import LogWrapper from "../LogWrapper"; -import { MessageQueue } from "../MessageQueue/MessageQueue"; -import { OAuthRequest } from "../Webhooks"; +import { MessageQueue } from "../MessageQueue"; +import { OAuthRequest } from "../WebhookTypes"; import { JiraOAuthResult } from "./Types"; const log = new LogWrapper("JiraRouter"); diff --git a/src/MatrixSender.ts b/src/MatrixSender.ts index 37ae5ddf..b01725aa 100644 --- a/src/MatrixSender.ts +++ b/src/MatrixSender.ts @@ -1,5 +1,5 @@ import { BridgeConfig } from "./Config/Config"; -import { MessageQueue, createMessageQueue } from "./MessageQueue/MessageQueue"; +import { MessageQueue, createMessageQueue } from "./MessageQueue"; import { MatrixEventContent, MatrixMessageContent } from "./MatrixEvent"; import { Appservice, IAppserviceRegistration } from "matrix-bot-sdk"; import LogWrapper from "./LogWrapper"; diff --git a/src/MessageQueue/LocalMQ.ts b/src/MessageQueue/LocalMQ.ts index 30b8549c..3101b839 100644 --- a/src/MessageQueue/LocalMQ.ts +++ b/src/MessageQueue/LocalMQ.ts @@ -1,5 +1,5 @@ import { EventEmitter } from "events"; -import { MessageQueue, MessageQueueMessage, DEFAULT_RES_TIMEOUT } from "./MessageQueue"; +import { MessageQueue, MessageQueueMessage, DEFAULT_RES_TIMEOUT } from "./Types"; import micromatch from "micromatch"; import {v4 as uuid} from "uuid"; diff --git a/src/MessageQueue/MessageQueue.ts b/src/MessageQueue/MessageQueue.ts index efd6baba..2fe0a791 100644 --- a/src/MessageQueue/MessageQueue.ts +++ b/src/MessageQueue/MessageQueue.ts @@ -1,34 +1,11 @@ import { BridgeConfig } from "../Config/Config"; import { LocalMQ } from "./LocalMQ"; import { RedisMQ } from "./RedisQueue"; - -export const DEFAULT_RES_TIMEOUT = 30000; +import { MessageQueue } from "./Types"; const staticLocalMq = new LocalMQ(); let staticRedisMq: RedisMQ|null = null; - -export interface MessageQueueMessage { - sender: string; - eventName: string; - data: T; - messageId?: string; - for?: string; -} - -export interface MessageQueueMessageOut extends MessageQueueMessage { - ts: number; -} - -export interface MessageQueue { - subscribe: (eventGlob: string) => void; - unsubscribe: (eventGlob: string) => void; - push: (data: MessageQueueMessage, single?: boolean) => Promise; - pushWait: (data: MessageQueueMessage, timeout?: number, single?: boolean) => Promise; - on: (eventName: string, cb: (data: MessageQueueMessageOut) => void) => void; - stop?(): void; -} - export function createMessageQueue(config: BridgeConfig): MessageQueue { if (config.queue.monolithic) { return staticLocalMq; diff --git a/src/MessageQueue/RedisQueue.ts b/src/MessageQueue/RedisQueue.ts index 9bb9914a..4c16613d 100644 --- a/src/MessageQueue/RedisQueue.ts +++ b/src/MessageQueue/RedisQueue.ts @@ -1,4 +1,5 @@ -import { MessageQueue, MessageQueueMessage, DEFAULT_RES_TIMEOUT, MessageQueueMessageOut } from "./MessageQueue"; + +import { MessageQueue, MessageQueueMessage, DEFAULT_RES_TIMEOUT, MessageQueueMessageOut } from "./Types"; import { Redis, default as redis } from "ioredis"; import { BridgeConfig } from "../Config/Config"; import { EventEmitter } from "events"; diff --git a/src/MessageQueue/Types.ts b/src/MessageQueue/Types.ts new file mode 100644 index 00000000..dd24d932 --- /dev/null +++ b/src/MessageQueue/Types.ts @@ -0,0 +1,22 @@ +export interface MessageQueueMessage { + sender: string; + eventName: string; + data: T; + messageId?: string; + for?: string; +} + +export interface MessageQueueMessageOut extends MessageQueueMessage { + ts: number; +} + +export interface MessageQueue { + subscribe: (eventGlob: string) => void; + unsubscribe: (eventGlob: string) => void; + push: (data: MessageQueueMessage, single?: boolean) => Promise; + pushWait: (data: MessageQueueMessage, timeout?: number, single?: boolean) => Promise; + on: (eventName: string, cb: (data: MessageQueueMessageOut) => void) => void; + stop?(): void; +} + +export const DEFAULT_RES_TIMEOUT = 30000; \ No newline at end of file diff --git a/src/MessageQueue/index.ts b/src/MessageQueue/index.ts new file mode 100644 index 00000000..f0118fa9 --- /dev/null +++ b/src/MessageQueue/index.ts @@ -0,0 +1,2 @@ +export * from "./Types"; +export * from "./MessageQueue"; \ No newline at end of file diff --git a/src/Notifications/UserNotificationWatcher.ts b/src/Notifications/UserNotificationWatcher.ts index bbba1393..8e1170d8 100644 --- a/src/Notifications/UserNotificationWatcher.ts +++ b/src/Notifications/UserNotificationWatcher.ts @@ -1,6 +1,6 @@ import { NotificationsDisableEvent, NotificationsEnableEvent } from "../Webhooks"; import LogWrapper from "../LogWrapper"; -import { createMessageQueue, MessageQueue, MessageQueueMessage } from "../MessageQueue/MessageQueue"; +import { createMessageQueue, MessageQueue, MessageQueueMessage } from "../MessageQueue"; import { MessageSenderClient } from "../MatrixSender"; import { NotificationWatcherTask } from "./NotificationWatcherTask"; import { GitHubWatcher } from "./GitHubWatcher"; diff --git a/src/WebhookTypes.ts b/src/WebhookTypes.ts new file mode 100644 index 00000000..d3dc5b23 --- /dev/null +++ b/src/WebhookTypes.ts @@ -0,0 +1,3 @@ +export interface OAuthRequest { + state: string; +} \ No newline at end of file diff --git a/src/Webhooks.ts b/src/Webhooks.ts index b0556c17..66e9fc7a 100644 --- a/src/Webhooks.ts +++ b/src/Webhooks.ts @@ -1,7 +1,7 @@ import { BridgeConfig } from "./Config/Config"; import { Application, default as express, Request, Response } from "express"; import { EventEmitter } from "events"; -import { MessageQueue, createMessageQueue } from "./MessageQueue/MessageQueue"; +import { MessageQueue, createMessageQueue } from "./MessageQueue"; import LogWrapper from "./LogWrapper"; import qs from "querystring"; import { Server } from "http"; @@ -10,6 +10,7 @@ import { IGitLabWebhookEvent } from "./Gitlab/WebhookTypes"; import { EmitterWebhookEvent, Webhooks as OctokitWebhooks } from "@octokit/webhooks" import { IJiraWebhookEvent } from "./Jira/WebhookTypes"; import JiraRouter from "./Jira/Router"; +import { OAuthRequest } from "./WebhookTypes"; const log = new LogWrapper("GithubWebhooks"); export interface GenericWebhookEvent { @@ -17,10 +18,6 @@ export interface GenericWebhookEvent { hookId: string; } -export interface OAuthRequest { - state: string; -} - export interface GitHubOAuthTokens { // eslint-disable-next-line camelcase access_token: string;