mirror of
https://github.com/matrix-org/matrix-hookshot.git
synced 2025-03-10 21:19:13 +00:00
Properly handle multiple installations
This commit is contained in:
parent
3efd2b2bd3
commit
38cfdc5d8a
@ -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();
|
||||
|
@ -20,7 +20,7 @@ import { MessageQueue, createMessageQueue } from "./MessageQueue";
|
||||
import { MessageSenderClient } from "./MatrixSender";
|
||||
import { NotifFilter, NotificationFilterStateContent } from "./NotificationFilters";
|
||||
import { NotificationProcessor } from "./NotificationsProcessor";
|
||||
import { 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";
|
||||
@ -29,6 +29,7 @@ import { UserTokenStore } from "./UserTokenStore";
|
||||
import * as GitHubWebhookTypes from "@octokit/webhooks-types";
|
||||
import LogWrapper from "./LogWrapper";
|
||||
import { OAuthRequest } from "./WebhookTypes";
|
||||
import { promises as fs } from "fs";
|
||||
const log = new LogWrapper("Bridge");
|
||||
|
||||
export class Bridge {
|
||||
@ -84,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();
|
||||
}
|
||||
|
||||
@ -159,6 +160,20 @@ export class Bridge {
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
this.queue.on<GitHubWebhookTypes.InstallationCreatedEvent>("github.installation.created", async (data) => {
|
||||
this.github?.onInstallationCreated(data.data);
|
||||
});
|
||||
this.queue.on<GitHubWebhookTypes.InstallationUnsuspendEvent>("github.installation.unsuspend", async (data) => {
|
||||
this.github?.onInstallationCreated(data.data);
|
||||
});
|
||||
this.queue.on<GitHubWebhookTypes.InstallationDeletedEvent>("github.installation.deleted", async (data) => {
|
||||
this.github?.onInstallationRemoved(data.data);
|
||||
});
|
||||
this.queue.on<GitHubWebhookTypes.InstallationSuspendEvent>("github.installation.suspend", async (data) => {
|
||||
this.github?.onInstallationRemoved(data.data);
|
||||
});
|
||||
|
||||
this.bindHandlerToQueue<GitHubWebhookTypes.IssueCommentCreatedEvent, GitHubIssueConnection>(
|
||||
"github.issue_comment.created",
|
||||
(data) => {
|
||||
@ -401,6 +416,7 @@ export class Bridge {
|
||||
await this.tokenStore.storeUserToken("jira", adminRoom.userId, JSON.stringify(msg.data));
|
||||
await adminRoom.sendNotice(`Logged into Jira`);
|
||||
});
|
||||
|
||||
this.bindHandlerToQueue<GenericWebhookEvent, GenericHookConnection>(
|
||||
"generic-webhook.event",
|
||||
(data) => connManager.getConnectionsForGenericWebhook(data.hookId),
|
||||
@ -693,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);
|
||||
@ -708,7 +724,7 @@ export class Bridge {
|
||||
}
|
||||
try {
|
||||
return await GitHubDiscussionSpace.onQueryRoom(res, {
|
||||
octokit: this.github.octokit,
|
||||
githubInstance: this.github,
|
||||
as: this.as,
|
||||
});
|
||||
} catch (ex) {
|
||||
@ -728,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);
|
||||
@ -743,7 +759,7 @@ export class Bridge {
|
||||
}
|
||||
try {
|
||||
return await GitHubUserSpace.onQueryRoom(res, {
|
||||
octokit: this.github.octokit,
|
||||
githubInstance: this.github,
|
||||
as: this.as,
|
||||
});
|
||||
} catch (ex) {
|
||||
|
@ -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<Record<string, unknown>> {
|
||||
static async onQueryRoom(result: RegExpExecArray, opts: {githubInstance: GithubInstance, as: Appservice}): Promise<Record<string, unknown>> {
|
||||
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) {
|
||||
|
@ -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,
|
||||
|
@ -8,15 +8,14 @@ import { IssuesOpenedEvent, IssuesReopenedEvent, IssuesEditedEvent, PullRequestO
|
||||
import { MatrixMessageContent, MatrixEvent, MatrixReactionContent } from "../MatrixEvent";
|
||||
import { MessageSenderClient } from "../MatrixSender";
|
||||
import { CommandError, NotLoggedInError } from "../errors";
|
||||
import { RequestError } from "@octokit/types";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import { ReposGetResponseData } from "../Github/Types";
|
||||
import { UserTokenStore } from "../UserTokenStore";
|
||||
import axios, { Axios, AxiosError } 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();
|
||||
|
||||
@ -25,7 +24,7 @@ interface IQueryRoomOpts {
|
||||
tokenStore: UserTokenStore;
|
||||
commentProcessor: CommentProcessor;
|
||||
messageClient: MessageSenderClient;
|
||||
octokit: Octokit;
|
||||
githubInstance: GithubInstance;
|
||||
}
|
||||
|
||||
export interface GitHubRepoConnectionState {
|
||||
@ -83,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;
|
||||
@ -97,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) {
|
||||
|
@ -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<Record<string, unknown>> {
|
||||
static async onQueryRoom(result: RegExpExecArray, opts: {githubInstance: GithubInstance, as: Appservice}): Promise<Record<string, unknown>> {
|
||||
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) {
|
||||
|
@ -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<number, Installation>();
|
||||
|
||||
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,11 +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"),
|
||||
appId: this.appId,
|
||||
privateKey: this.privateKey,
|
||||
};
|
||||
|
||||
this.internalOctokit = new Octokit({
|
||||
@ -41,26 +75,45 @@ export class GithubInstance {
|
||||
userAgent: USER_AGENT,
|
||||
});
|
||||
|
||||
try {
|
||||
const installation = (await this.octokit.apps.listInstallations()).data[0];
|
||||
if (!installation) {
|
||||
throw Error("App has no installations, cannot continue. Please ensure you've installed the app somewhere (https://github.com/settings/installations)");
|
||||
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);
|
||||
}
|
||||
log.info(`Using installation ${installation.id} (${installation.app_slug})`)
|
||||
this.internalOctokit = new Octokit({
|
||||
authStrategy: createAppAuth,
|
||||
auth: {
|
||||
...auth,
|
||||
installationId: installation.id,
|
||||
},
|
||||
userAgent: USER_AGENT,
|
||||
});
|
||||
await this.octokit.rateLimit.get();
|
||||
log.info("Auth check success");
|
||||
} catch (ex) {
|
||||
log.warn("Auth check failed:", ex);
|
||||
throw Error("Attempting to verify GitHub authentication configration failed");
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
Loading…
x
Reference in New Issue
Block a user