Properly handle multiple installations

This commit is contained in:
Will Hunt 2021-11-29 18:13:49 +00:00
parent 3efd2b2bd3
commit 38cfdc5d8a
8 changed files with 132 additions and 59 deletions

View File

@ -19,6 +19,7 @@ import { ProjectsListResponseData } from "./Github/Types";
import { NotifFilter, NotificationFilterStateContent } from "./NotificationFilters"; import { NotifFilter, NotificationFilterStateContent } from "./NotificationFilters";
import { JiraBotCommands } from "./Jira/AdminCommands"; import { JiraBotCommands } from "./Jira/AdminCommands";
import { AdminAccountData, AdminRoomCommandHandler } from "./AdminRoomCommandHandler"; import { AdminAccountData, AdminRoomCommandHandler } from "./AdminRoomCommandHandler";
import { CommandError } from "./errors";
type ProjectsListForRepoResponseData = Endpoints["GET /repos/{owner}/{repo}/projects"]["response"]; type ProjectsListForRepoResponseData = Endpoints["GET /repos/{owner}/{repo}/projects"]["response"];
type ProjectsListForUserResponseData = Endpoints["GET /users/{username}/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']) @botCommand("github setpersonaltoken", "Set your personal access token for GitHub", ['accessToken'])
// @ts-ignore - property is used public async setGHPersonalAccessToken(accessToken: string) {
private async setGHPersonalAccessToken(accessToken: string) {
if (!this.config.github) { 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; let me;
try { try {
@ -156,10 +156,9 @@ export class AdminRoom extends AdminRoomCommandHandler {
} }
@botCommand("github hastoken", "Check if you have a token stored for GitHub") @botCommand("github hastoken", "Check if you have a token stored for GitHub")
// @ts-ignore - property is used public async hasPersonalToken() {
private async hasPersonalToken() {
if (!this.config.github) { 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); const result = await this.tokenStore.getUserToken("github", this.userId);
if (result === null) { if (result === null) {
@ -170,10 +169,9 @@ export class AdminRoom extends AdminRoomCommandHandler {
} }
@botCommand("github startoauth", "Start the OAuth process with GitHub") @botCommand("github startoauth", "Start the OAuth process with GitHub")
// @ts-ignore - property is used public async beginOAuth() {
private async beginOAuth() {
if (!this.config.github) { 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. // If this is already set, calling this command will invalidate the previous session.
this.pendingOAuthState = uuid(); this.pendingOAuthState = uuid();

View File

@ -20,7 +20,7 @@ import { MessageQueue, createMessageQueue } from "./MessageQueue";
import { MessageSenderClient } from "./MatrixSender"; import { MessageSenderClient } from "./MatrixSender";
import { NotifFilter, NotificationFilterStateContent } from "./NotificationFilters"; import { NotifFilter, NotificationFilterStateContent } from "./NotificationFilters";
import { NotificationProcessor } from "./NotificationsProcessor"; import { NotificationProcessor } from "./NotificationsProcessor";
import { GitHubOAuthTokens, NotificationsEnableEvent, NotificationsDisableEvent, GenericWebhookEvent,} from "./Webhooks"; import { GitHubOAuthTokens, NotificationsEnableEvent, NotificationsDisableEvent, GenericWebhookEvent } from "./Webhooks";
import { ProjectsGetResponseData } from "./Github/Types"; import { ProjectsGetResponseData } from "./Github/Types";
import { RedisStorageProvider } from "./Stores/RedisStorageProvider"; import { RedisStorageProvider } from "./Stores/RedisStorageProvider";
import { retry } from "./PromiseUtil"; import { retry } from "./PromiseUtil";
@ -29,6 +29,7 @@ import { UserTokenStore } from "./UserTokenStore";
import * as GitHubWebhookTypes from "@octokit/webhooks-types"; import * as GitHubWebhookTypes from "@octokit/webhooks-types";
import LogWrapper from "./LogWrapper"; import LogWrapper from "./LogWrapper";
import { OAuthRequest } from "./WebhookTypes"; import { OAuthRequest } from "./WebhookTypes";
import { promises as fs } from "fs";
const log = new LogWrapper("Bridge"); const log = new LogWrapper("Bridge");
export class Bridge { export class Bridge {
@ -84,7 +85,7 @@ export class Bridge {
} }
if (this.config.github) { 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(); 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>( this.bindHandlerToQueue<GitHubWebhookTypes.IssueCommentCreatedEvent, GitHubIssueConnection>(
"github.issue_comment.created", "github.issue_comment.created",
(data) => { (data) => {
@ -401,6 +416,7 @@ export class Bridge {
await this.tokenStore.storeUserToken("jira", adminRoom.userId, JSON.stringify(msg.data)); await this.tokenStore.storeUserToken("jira", adminRoom.userId, JSON.stringify(msg.data));
await adminRoom.sendNotice(`Logged into Jira`); await adminRoom.sendNotice(`Logged into Jira`);
}); });
this.bindHandlerToQueue<GenericWebhookEvent, GenericHookConnection>( this.bindHandlerToQueue<GenericWebhookEvent, GenericHookConnection>(
"generic-webhook.event", "generic-webhook.event",
(data) => connManager.getConnectionsForGenericWebhook(data.hookId), (data) => connManager.getConnectionsForGenericWebhook(data.hookId),
@ -693,7 +709,7 @@ export class Bridge {
tokenStore: this.tokenStore, tokenStore: this.tokenStore,
messageClient: this.messageClient, messageClient: this.messageClient,
commentProcessor: this.commentProcessor, commentProcessor: this.commentProcessor,
octokit: this.github.octokit, githubInstance: this.github,
}); });
} catch (ex) { } catch (ex) {
log.error(`Could not handle alias with GitHubIssueConnection`, ex); log.error(`Could not handle alias with GitHubIssueConnection`, ex);
@ -708,7 +724,7 @@ export class Bridge {
} }
try { try {
return await GitHubDiscussionSpace.onQueryRoom(res, { return await GitHubDiscussionSpace.onQueryRoom(res, {
octokit: this.github.octokit, githubInstance: this.github,
as: this.as, as: this.as,
}); });
} catch (ex) { } catch (ex) {
@ -728,7 +744,7 @@ export class Bridge {
tokenStore: this.tokenStore, tokenStore: this.tokenStore,
messageClient: this.messageClient, messageClient: this.messageClient,
commentProcessor: this.commentProcessor, commentProcessor: this.commentProcessor,
octokit: this.github.octokit, githubInstance: this.github,
}); });
} catch (ex) { } catch (ex) {
log.error(`Could not handle alias with GitHubRepoConnection`, ex); log.error(`Could not handle alias with GitHubRepoConnection`, ex);
@ -743,7 +759,7 @@ export class Bridge {
} }
try { try {
return await GitHubUserSpace.onQueryRoom(res, { return await GitHubUserSpace.onQueryRoom(res, {
octokit: this.github.octokit, githubInstance: this.github,
as: this.as, as: this.as,
}); });
} catch (ex) { } catch (ex) {

View File

@ -5,6 +5,7 @@ import { Octokit } from "@octokit/rest";
import { ReposGetResponseData } from "../Github/Types"; import { ReposGetResponseData } from "../Github/Types";
import axios from "axios"; import axios from "axios";
import { GitHubDiscussionConnection } from "./GithubDiscussion"; import { GitHubDiscussionConnection } from "./GithubDiscussion";
import { GithubInstance } from "../Github/GithubInstance";
const log = new LogWrapper("GitHubDiscussionSpace"); const log = new LogWrapper("GitHubDiscussionSpace");
@ -27,7 +28,7 @@ export class GitHubDiscussionSpace implements IConnection {
static readonly QueryRoomRegex = /#github_disc_(.+)_(.+):.*/; 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) { if (!result || result.length < 2) {
log.error(`Invalid alias pattern '${result}'`); log.error(`Invalid alias pattern '${result}'`);
throw Error("Could not find issue"); throw Error("Could not find issue");
@ -37,9 +38,10 @@ export class GitHubDiscussionSpace implements IConnection {
log.info(`Fetching ${owner}/${repo}`); log.info(`Fetching ${owner}/${repo}`);
let repoRes: ReposGetResponseData; let repoRes: ReposGetResponseData;
const octokit = opts.githubInstance.getOctokitForRepo(owner, repo);
try { try {
// TODO: Determine if the repo has discussions? // TODO: Determine if the repo has discussions?
repoRes = (await opts.octokit.repos.get({ repoRes = (await octokit.repos.get({
owner, owner,
repo, repo,
})).data; })).data;
@ -58,7 +60,7 @@ export class GitHubDiscussionSpace implements IConnection {
// URL hack so we don't need to fetch the repo itself. // URL hack so we don't need to fetch the repo itself.
let avatarUrl = undefined; let avatarUrl = undefined;
try { try {
const profile = await opts.octokit.users.getByUsername({ const profile = await octokit.users.getByUsername({
username: owner, username: owner,
}); });
if (profile.data.avatar_url) { if (profile.data.avatar_url) {

View File

@ -5,7 +5,6 @@ import markdown from "markdown-it";
import { UserTokenStore } from "../UserTokenStore"; import { UserTokenStore } from "../UserTokenStore";
import LogWrapper from "../LogWrapper"; import LogWrapper from "../LogWrapper";
import { CommentProcessor } from "../CommentProcessor"; import { CommentProcessor } from "../CommentProcessor";
import { Octokit } from "@octokit/rest";
import { MessageSenderClient } from "../MatrixSender"; import { MessageSenderClient } from "../MatrixSender";
import { getIntentForUser } from "../IntentUtils"; import { getIntentForUser } from "../IntentUtils";
import { FormatUtil } from "../FormatUtil"; import { FormatUtil } from "../FormatUtil";
@ -31,7 +30,7 @@ interface IQueryRoomOpts {
tokenStore: UserTokenStore; tokenStore: UserTokenStore;
commentProcessor: CommentProcessor; commentProcessor: CommentProcessor;
messageClient: MessageSenderClient; messageClient: MessageSenderClient;
octokit: Octokit; githubInstance: GithubInstance;
} }
/** /**
@ -61,8 +60,9 @@ export class GitHubIssueConnection implements IConnection {
log.info(`Fetching ${owner}/${repo}/${issueNumber}`); log.info(`Fetching ${owner}/${repo}/${issueNumber}`);
let issue: IssuesGetResponseData; let issue: IssuesGetResponseData;
const octokit = opts.githubInstance.getOctokitForRepo(owner, repo);
try { try {
issue = (await opts.octokit.issues.get({ issue = (await octokit.issues.get({
owner, owner,
repo, repo,
issue_number: issueNumber, issue_number: issueNumber,
@ -78,7 +78,7 @@ export class GitHubIssueConnection implements IConnection {
const orgRepoName = issue.repository_url.substr("https://api.github.com/repos/".length); const orgRepoName = issue.repository_url.substr("https://api.github.com/repos/".length);
let avatarUrl = undefined; let avatarUrl = undefined;
try { try {
const profile = await opts.octokit.users.getByUsername({ const profile = await octokit.users.getByUsername({
username: owner, username: owner,
}); });
if (profile.data.avatar_url) { if (profile.data.avatar_url) {
@ -200,7 +200,7 @@ export class GitHubIssueConnection implements IConnection {
public async syncIssueState() { public async syncIssueState() {
log.debug("Syncing issue state for", this.roomId); 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, owner: this.state.org,
repo: this.state.repo, repo: this.state.repo,
issue_number: this.issueNumber, issue_number: this.issueNumber,
@ -231,7 +231,7 @@ export class GitHubIssueConnection implements IConnection {
} }
if (this.state.comments_processed !== issue.data.comments) { 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, owner: this.state.org,
repo: this.state.repo, repo: this.state.repo,
issue_number: this.issueNumber, issue_number: this.issueNumber,

View File

@ -8,15 +8,14 @@ import { IssuesOpenedEvent, IssuesReopenedEvent, IssuesEditedEvent, PullRequestO
import { MatrixMessageContent, MatrixEvent, MatrixReactionContent } from "../MatrixEvent"; import { MatrixMessageContent, MatrixEvent, MatrixReactionContent } from "../MatrixEvent";
import { MessageSenderClient } from "../MatrixSender"; import { MessageSenderClient } from "../MatrixSender";
import { CommandError, NotLoggedInError } from "../errors"; import { CommandError, NotLoggedInError } from "../errors";
import { RequestError } from "@octokit/types";
import { Octokit } from "@octokit/rest";
import { ReposGetResponseData } from "../Github/Types"; import { ReposGetResponseData } from "../Github/Types";
import { UserTokenStore } from "../UserTokenStore"; import { UserTokenStore } from "../UserTokenStore";
import axios, { Axios, AxiosError } from "axios"; import axios, { AxiosError } from "axios";
import emoji from "node-emoji"; import emoji from "node-emoji";
import LogWrapper from "../LogWrapper"; import LogWrapper from "../LogWrapper";
import markdown from "markdown-it"; import markdown from "markdown-it";
import { CommandConnection } from "./CommandConnection"; import { CommandConnection } from "./CommandConnection";
import { GithubInstance } from "../Github/GithubInstance";
const log = new LogWrapper("GitHubRepoConnection"); const log = new LogWrapper("GitHubRepoConnection");
const md = new markdown(); const md = new markdown();
@ -25,7 +24,7 @@ interface IQueryRoomOpts {
tokenStore: UserTokenStore; tokenStore: UserTokenStore;
commentProcessor: CommentProcessor; commentProcessor: CommentProcessor;
messageClient: MessageSenderClient; messageClient: MessageSenderClient;
octokit: Octokit; githubInstance: GithubInstance;
} }
export interface GitHubRepoConnectionState { export interface GitHubRepoConnectionState {
@ -83,8 +82,9 @@ export class GitHubRepoConnection extends CommandConnection implements IConnecti
log.info(`Fetching ${owner}/${repo}/${issueNumber}`); log.info(`Fetching ${owner}/${repo}/${issueNumber}`);
let repoRes: ReposGetResponseData; let repoRes: ReposGetResponseData;
const octokit = opts.githubInstance.getOctokitForRepo(owner, repo);
try { try {
repoRes = (await opts.octokit.repos.get({ repoRes = (await octokit.repos.get({
owner, owner,
repo, repo,
})).data; })).data;
@ -97,7 +97,7 @@ export class GitHubRepoConnection extends CommandConnection implements IConnecti
const orgRepoName = repoRes.url.substr("https://api.github.com/repos/".length); const orgRepoName = repoRes.url.substr("https://api.github.com/repos/".length);
let avatarUrl = undefined; let avatarUrl = undefined;
try { try {
const profile = await opts.octokit.users.getByUsername({ const profile = await octokit.users.getByUsername({
username: owner, username: owner,
}); });
if (profile.data.avatar_url) { if (profile.data.avatar_url) {

View File

@ -1,9 +1,9 @@
import { IConnection } from "./IConnection"; import { IConnection } from "./IConnection";
import { Appservice, Space } from "matrix-bot-sdk"; import { Appservice, Space } from "matrix-bot-sdk";
import LogWrapper from "../LogWrapper"; import LogWrapper from "../LogWrapper";
import { Octokit } from "@octokit/rest";
import axios from "axios"; import axios from "axios";
import { GitHubDiscussionSpace } from "."; import { GitHubDiscussionSpace } from ".";
import { GithubInstance } from "../Github/GithubInstance";
const log = new LogWrapper("GitHubOwnerSpace"); const log = new LogWrapper("GitHubOwnerSpace");
@ -25,7 +25,7 @@ export class GitHubUserSpace implements IConnection {
static readonly QueryRoomRegex = /#github_(.+):.*/; 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) { if (!result || result.length < 1) {
log.error(`Invalid alias pattern '${result}'`); log.error(`Invalid alias pattern '${result}'`);
throw Error("Could not find issue"); throw Error("Could not find issue");
@ -37,9 +37,10 @@ export class GitHubUserSpace implements IConnection {
let state: GitHubUserSpaceConnectionState; let state: GitHubUserSpaceConnectionState;
let avatarUrl: string|undefined; let avatarUrl: string|undefined;
let name: string; let name: string;
const octokit = opts.githubInstance.getOctokitForRepo(username);
try { try {
// TODO: Determine if the repo has discussions? // TODO: Determine if the repo has discussions?
const userRes = (await opts.octokit.users.getByUsername({ const userRes = (await octokit.users.getByUsername({
username, username,
})).data; })).data;
if (!userRes) { if (!userRes) {

View File

@ -1,22 +1,33 @@
import { createAppAuth } from "@octokit/auth-app"; import { createAppAuth } from "@octokit/auth-app";
import { createTokenAuth } from "@octokit/auth-token"; import { createTokenAuth } from "@octokit/auth-token";
import { Octokit } from "@octokit/rest"; import { Octokit } from "@octokit/rest";
import { promises as fs } from "fs";
import { BridgeConfigGitHub } from "../Config/Config";
import LogWrapper from "../LogWrapper"; import LogWrapper from "../LogWrapper";
import { DiscussionQLResponse, DiscussionQL } from "./Discussion"; 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 log = new LogWrapper("GithubInstance");
const USER_AGENT = "matrix-hookshot v0.0.1"; 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 { export class GithubInstance {
private internalOctokit!: Octokit; private internalOctokit!: Octokit;
public get octokit() { private readonly installationsCache = new Map<number, Installation>();
return this.internalOctokit;
}
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) { public static createUserOctokit(token: string) {
return new Octokit({ 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() { public async start() {
// TODO: Make this generic. // TODO: Make this generic.
const auth = { const auth = {
appId: parseInt(this.config.auth.id as string, 10), appId: this.appId,
privateKey: await fs.readFile(this.config.auth.privateKeyFile, "utf-8"), privateKey: this.privateKey,
}; };
this.internalOctokit = new Octokit({ this.internalOctokit = new Octokit({
@ -41,26 +75,45 @@ export class GithubInstance {
userAgent: USER_AGENT, userAgent: USER_AGENT,
}); });
try { let installPageSize = 100;
const installation = (await this.octokit.apps.listInstallations()).data[0]; let page = 1;
if (!installation) { do {
throw Error("App has no installations, cannot continue. Please ensure you've installed the app somewhere (https://github.com/settings/installations)"); 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})`) installPageSize = installations.data.length;
this.internalOctokit = new Octokit({ } while(installPageSize === 100)
authStrategy: createAppAuth,
auth: { log.info(`Found ${this.installationsCache.size} installations`);
...auth, }
installationId: installation.id,
}, private async addInstallation(install: InstallationDataType, repos?: {full_name: string}[]) {
userAgent: USER_AGENT, let matchesRepository: string[] = [];
}); if (install.repository_selection === "all") {
await this.octokit.rateLimit.get(); matchesRepository = [`${install.account?.login}/*`.toLowerCase()];
log.info("Auth check success"); } else if (repos) {
} catch (ex) { matchesRepository = repos.map(r => r.full_name.toLowerCase());
log.warn("Auth check failed:", ex); } else {
throw Error("Attempting to verify GitHub authentication configration failed"); 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);
} }
} }

View File

@ -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 PullsGetResponseData = Endpoints["GET /repos/{owner}/{repo}/pulls"]["response"]["data"];
export type PullGetResponseData = Endpoints["GET /repos/{owner}/{repo}/pulls/{pull_number}"]["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 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 */ /* eslint-disable camelcase */
export interface GitHubUserNotification { export interface GitHubUserNotification {
id: string; id: string;