Github integration (#6414)
Co-authored-by: Tom Moor <tom.moor@gmail.com>
This commit is contained in:
114
plugins/github/server/api/github.ts
Normal file
114
plugins/github/server/api/github.ts
Normal 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;
|
||||
33
plugins/github/server/api/schema.ts
Normal file
33
plugins/github/server/api/schema.ts
Normal 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>;
|
||||
40
plugins/github/server/env.ts
Normal file
40
plugins/github/server/env.ts
Normal 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();
|
||||
168
plugins/github/server/github.ts
Normal file
168
plugins/github/server/github.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
32
plugins/github/server/index.ts
Normal file
32
plugins/github/server/index.ts
Normal 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,
|
||||
},
|
||||
]);
|
||||
}
|
||||
15
plugins/github/server/uninstall.ts
Normal file
15
plugins/github/server/uninstall.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user