Github integration (#6414)

Co-authored-by: Tom Moor <tom.moor@gmail.com>
This commit is contained in:
Apoorv Mishra
2024-03-23 19:39:28 +05:30
committed by GitHub
parent a648625700
commit 450d0d9355
47 changed files with 1710 additions and 93 deletions

View File

@@ -0,0 +1,114 @@
import Router from "koa-router";
import { IntegrationService, IntegrationType } from "@shared/types";
import Logger from "@server/logging/Logger";
import auth from "@server/middlewares/authentication";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { IntegrationAuthentication, Integration, Team } from "@server/models";
import { APIContext } from "@server/types";
import { GitHubUtils } from "../../shared/GitHubUtils";
import { GitHubUser } from "../github";
import * as T from "./schema";
const router = new Router();
router.get(
"github.callback",
auth({
optional: true,
}),
validate(T.GitHubCallbackSchema),
transaction(),
async (ctx: APIContext<T.GitHubCallbackReq>) => {
const {
code,
state: teamId,
error,
installation_id: installationId,
setup_action: setupAction,
} = ctx.input.query;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
if (error) {
ctx.redirect(GitHubUtils.errorUrl(error));
return;
}
if (setupAction === T.SetupAction.request) {
ctx.redirect(GitHubUtils.installRequestUrl());
return;
}
// this code block accounts for the root domain being unable to
// access authentication for subdomains. We must forward to the appropriate
// subdomain to complete the oauth flow
if (!user) {
if (teamId) {
try {
const team = await Team.findByPk(teamId, {
rejectOnEmpty: true,
transaction,
});
return ctx.redirectOnClient(
GitHubUtils.callbackUrl({
baseUrl: team.url,
params: ctx.request.querystring,
})
);
} catch (err) {
Logger.error(`Error fetching team for teamId: ${teamId}!`, err);
return ctx.redirect(GitHubUtils.errorUrl("unauthenticated"));
}
} else {
return ctx.redirect(GitHubUtils.errorUrl("unauthenticated"));
}
}
const githubUser = new GitHubUser({ code: code!, state: teamId });
let installation;
try {
installation = await githubUser.getInstallation(installationId!);
} catch (err) {
Logger.error("Failed to fetch GitHub App installation", err);
return ctx.redirect(GitHubUtils.errorUrl("unauthenticated"));
}
const authentication = await IntegrationAuthentication.create(
{
service: IntegrationService.GitHub,
userId: user.id,
teamId: user.teamId,
},
{ transaction }
);
await Integration.create(
{
service: IntegrationService.GitHub,
type: IntegrationType.Embed,
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
settings: {
github: {
installation: {
id: installationId!,
account: {
id: installation.account?.id,
name:
// @ts-expect-error Property 'login' does not exist on type
installation.account?.login,
avatarUrl: installation.account?.avatar_url,
},
},
},
},
},
{ transaction }
);
ctx.redirect(GitHubUtils.url);
}
);
export default router;

View File

@@ -0,0 +1,33 @@
import isEmpty from "lodash/isEmpty";
import isUndefined from "lodash/isUndefined";
import { z } from "zod";
import { BaseSchema } from "@server/routes/api/schema";
export enum SetupAction {
install = "install",
request = "request",
}
export const GitHubCallbackSchema = BaseSchema.extend({
query: z
.object({
code: z.string().nullish(),
state: z.string().uuid().nullish(),
error: z.string().nullish(),
installation_id: z.coerce.number().optional(),
setup_action: z.nativeEnum(SetupAction),
})
.refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), {
message: "one of code or error is required",
})
.refine(
(req) =>
!(
req.setup_action === SetupAction.install &&
isUndefined(req.installation_id)
),
{ message: "installation_id is required for installation" }
),
});
export type GitHubCallbackReq = z.infer<typeof GitHubCallbackSchema>;

View File

@@ -0,0 +1,40 @@
import { IsOptional } from "class-validator";
import { Environment } from "@server/env";
import { Public } from "@server/utils/decorators/Public";
import environment from "@server/utils/environment";
import { CannotUseWithout } from "@server/utils/validators";
class GitHubPluginEnvironment extends Environment {
/**
* GitHub OAuth2 client credentials. To enable integration with GitHub.
*/
@Public
@IsOptional()
public GITHUB_CLIENT_ID = this.toOptionalString(environment.GITHUB_CLIENT_ID);
@Public
@IsOptional()
@CannotUseWithout("GITHUB_CLIENT_ID")
public GITHUB_APP_NAME = this.toOptionalString(environment.GITHUB_APP_NAME);
/**
* GitHub OAuth2 client credentials. To enable integration with GitHub.
*/
@IsOptional()
@CannotUseWithout("GITHUB_CLIENT_ID")
public GITHUB_CLIENT_SECRET = this.toOptionalString(
environment.GITHUB_CLIENT_SECRET
);
@IsOptional()
@CannotUseWithout("GITHUB_APP_PRIVATE_KEY")
public GITHUB_APP_ID = this.toOptionalString(environment.GITHUB_APP_ID);
@IsOptional()
@CannotUseWithout("GITHUB_APP_ID")
public GITHUB_APP_PRIVATE_KEY = this.toOptionalString(
environment.GITHUB_APP_PRIVATE_KEY
);
}
export default new GitHubPluginEnvironment();

View File

@@ -0,0 +1,168 @@
import { createOAuthUserAuth } from "@octokit/auth-oauth-user";
import find from "lodash/find";
import { App, Octokit } from "octokit";
import pluralize from "pluralize";
import {
IntegrationService,
IntegrationType,
Unfurl,
UnfurlResponse,
} from "@shared/types";
import Logger from "@server/logging/Logger";
import { Integration, User } from "@server/models";
import { GitHubUtils } from "../shared/GitHubUtils";
import env from "./env";
/**
* It exposes a GitHub REST client for accessing APIs which
* particulary require the client to authenticate as a GitHub App
*/
class GitHubApp {
/** Required to authenticate as GitHub App */
private static id = env.GITHUB_APP_ID;
private static key = env.GITHUB_APP_PRIVATE_KEY
? Buffer.from(env.GITHUB_APP_PRIVATE_KEY, "base64").toString("ascii")
: undefined;
/** GitHub App instance */
private app: App;
constructor() {
if (GitHubApp.id && GitHubApp.key) {
this.app = new App({
appId: GitHubApp.id!,
privateKey: GitHubApp.key!,
});
}
}
/**
* Given an `installationId`, removes that GitHub App installation
* @param installationId
*/
public async deleteInstallation(installationId: number) {
await this.app.octokit.request(
"DELETE /app/installations/{installation_id}",
{
installation_id: installationId,
}
);
}
/**
*
* @param url GitHub resource url - could be a url of a pull request or an issue
* @param installationId Id corresponding to the GitHub App installation
* @returns {object} An object container the resource details - could be a pull request
* details or an issue details
*/
unfurl = async (url: string, actor: User): Promise<Unfurl | undefined> => {
const { owner, repo, resourceType, resourceId } = GitHubUtils.parseUrl(url);
if (!owner) {
return;
}
const integration = (await Integration.findOne({
where: {
service: IntegrationService.GitHub,
teamId: actor.teamId,
"settings.github.installation.account.name": owner,
},
})) as Integration<IntegrationType.Embed>;
if (!integration) {
return;
}
try {
const octokit = await this.app.getInstallationOctokit(
integration.settings.github!.installation.id
);
const { data } = await octokit.request(
`GET /repos/{owner}/{repo}/${pluralize(resourceType)}/{ref}`,
{
owner,
repo,
ref: resourceId,
headers: {
Accept: "application/vnd.github.text+json",
"X-GitHub-Api-Version": "2022-11-28",
},
}
);
const status = data.merged ? "merged" : data.state;
return {
url,
type: pluralize.singular(resourceType) as UnfurlResponse["type"],
title: data.title,
description: data.body_text,
author: {
name: data.user.login,
avatarUrl: data.user.avatar_url,
},
createdAt: data.created_at,
meta: {
identifier: `#${data.number}`,
labels: data.labels.map((label: { name: string; color: string }) => ({
name: label.name,
color: `#${label.color}`,
})),
status: {
name: status,
color: GitHubUtils.getColorForStatus(status),
},
},
};
} catch (err) {
Logger.warn("Failed to fetch resource from GitHub", err);
return;
}
};
}
export const githubApp = new GitHubApp();
/**
* It exposes a GitHub REST client for accessing APIs which
* particularly require the client to authenticate as a user
* through the user access token
*/
export class GitHubUser {
private static clientId = env.GITHUB_CLIENT_ID;
private static clientSecret = env.GITHUB_CLIENT_SECRET;
private static clientType = "github-app";
/** GitHub client for accessing its APIs */
private client: Octokit;
constructor(options: { code: string; state?: string | null }) {
this.client = new Octokit({
authStrategy: createOAuthUserAuth,
auth: {
clientId: GitHubUser.clientId,
clientSecret: GitHubUser.clientSecret,
clientType: GitHubUser.clientType,
code: options.code,
state: options.state,
},
});
}
/**
* @param installationId Identifies a GitHub App installation
* @returns {object} An object containing details about the GitHub App installation,
* e.g, installation target, account which installed the app etc.
*/
public async getInstallation(installationId: number) {
const installations = await this.client.paginate("GET /user/installations");
const installation = find(installations, (i) => i.id === installationId);
if (!installation) {
Logger.warn("installationId mismatch!");
throw Error("Invalid installationId!");
}
return installation;
}
}

View File

@@ -0,0 +1,32 @@
import { Minute } from "@shared/utils/time";
import { PluginManager, Hook } from "@server/utils/PluginManager";
import config from "../plugin.json";
import router from "./api/github";
import env from "./env";
import { githubApp } from "./github";
import { uninstall } from "./uninstall";
const enabled =
!!env.GITHUB_CLIENT_ID &&
!!env.GITHUB_CLIENT_SECRET &&
!!env.GITHUB_APP_NAME &&
!!env.GITHUB_APP_ID &&
!!env.GITHUB_APP_PRIVATE_KEY;
if (enabled) {
PluginManager.add([
{
...config,
type: Hook.API,
value: router,
},
{
type: Hook.UnfurlProvider,
value: { unfurl: githubApp.unfurl, cacheExpiry: Minute },
},
{
type: Hook.Uninstall,
value: uninstall,
},
]);
}

View File

@@ -0,0 +1,15 @@
import { IntegrationService, IntegrationType } from "@shared/types";
import { Integration } from "@server/models";
import { githubApp } from "./github";
export async function uninstall(
integration: Integration<IntegrationType.Embed>
) {
if (integration.service === IntegrationService.GitHub) {
const installationId = integration.settings?.github?.installation.id;
if (installationId) {
return githubApp.deleteInstallation(installationId);
}
}
}