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,26 @@
import * as React from "react";
type Props = {
/** The size of the icon, 24px is default to match standard icons */
size?: number;
/** The color of the icon, defaults to the current text color */
fill?: string;
};
export default function Icon({ size = 24, fill = "currentColor" }: Props) {
return (
<svg
fill={fill}
width={size}
height={size}
viewBox="0 0 24 24"
version="1.1"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M11.9762 4C7.56555 4 4 7.59184 4 12.0354C4 15.5874 6.28457 18.5941 9.45388 19.6583C9.85012 19.7383 9.99527 19.4854 9.99527 19.2727C9.99527 19.0864 9.9822 18.4478 9.9822 17.7825C7.76343 18.2616 7.30139 16.8247 7.30139 16.8247C6.94482 15.8934 6.41649 15.654 6.41649 15.654C5.69029 15.1618 6.46939 15.1618 6.46939 15.1618C7.27494 15.215 7.69763 15.9866 7.69763 15.9866C8.41061 17.2104 9.55951 16.8647 10.0217 16.6518C10.0877 16.1329 10.2991 15.7737 10.5236 15.5742C8.75396 15.3879 6.89208 14.6962 6.89208 11.6096C6.89208 10.7316 7.20882 10.0132 7.71069 9.45453C7.63151 9.25502 7.35412 8.43004 7.79004 7.32588C7.79004 7.32588 8.46351 7.11298 9.98204 8.15069C10.6322 7.9748 11.3027 7.88532 11.9762 7.88457C12.6496 7.88457 13.3362 7.9778 13.9701 8.15069C15.4888 7.11298 16.1623 7.32588 16.1623 7.32588C16.5982 8.43004 16.3207 9.25502 16.2415 9.45453C16.7566 10.0132 17.0602 10.7316 17.0602 11.6096C17.0602 14.6962 15.1984 15.3745 13.4155 15.5742C13.7061 15.8269 13.9569 16.3058 13.9569 17.0642C13.9569 18.1417 13.9438 19.0065 13.9438 19.2725C13.9438 19.4854 14.0891 19.7383 14.4852 19.6584C17.6545 18.594 19.9391 15.5874 19.9391 12.0354C19.9522 7.59184 16.3736 4 11.9762 4Z"
/>
</svg>
);
}

View File

@@ -0,0 +1,154 @@
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { IntegrationService } from "@shared/types";
import { ConnectedButton } from "~/scenes/Settings/components/ConnectedButton";
import { AvatarSize } from "~/components/Avatar/Avatar";
import Flex from "~/components/Flex";
import Heading from "~/components/Heading";
import List from "~/components/List";
import ListItem from "~/components/List/Item";
import Notice from "~/components/Notice";
import PlaceholderText from "~/components/PlaceholderText";
import Scene from "~/components/Scene";
import TeamLogo from "~/components/TeamLogo";
import Text from "~/components/Text";
import Time from "~/components/Time";
import env from "~/env";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import GitHubIcon from "./Icon";
import { GitHubConnectButton } from "./components/GitHubButton";
function GitHub() {
const { integrations } = useStores();
const { t } = useTranslation();
const query = useQuery();
const error = query.get("error");
const installRequest = query.get("install_request");
const appName = env.APP_NAME;
const githubAppName = env.GITHUB_APP_NAME;
React.useEffect(() => {
void integrations.fetchAll({
service: IntegrationService.GitHub,
withRelations: true,
});
}, [integrations]);
return (
<Scene title="GitHub" icon={<GitHubIcon />}>
<Heading>GitHub</Heading>
{error === "access_denied" && (
<Notice>
<Trans>
Whoops, you need to accept the permissions in GitHub to connect{" "}
{{ appName }} to your workspace. Try again?
</Trans>
</Notice>
)}
{error === "unauthenticated" && (
<Notice>
<Trans>
Something went wrong while authenticating your request. Please try
logging in again.
</Trans>
</Notice>
)}
{installRequest === "true" && (
<Notice>
<Trans>
The owner of GitHub account has been requested to install the{" "}
{{ githubAppName }} GitHub app. Once approved, previews will be
shown for respective links.
</Trans>
</Notice>
)}
{env.GITHUB_CLIENT_ID ? (
<>
<Text as="p">
<Trans>
Enable previews of GitHub issues and pull requests in documents by
connecting a GitHub organization or specific repositories to{" "}
{appName}.
</Trans>
</Text>
{integrations.github.length ? (
<>
<Heading as="h2">
<Flex justify="space-between" auto>
{t("Connected")}
<GitHubConnectButton icon={<PlusIcon />} />
</Flex>
</Heading>
<List>
{integrations.github.map((integration) => {
const githubAccount =
integration.settings?.github?.installation.account;
const integrationCreatedBy = integration.user
? integration.user.name
: undefined;
return (
<ListItem
key={githubAccount?.id}
small
title={githubAccount?.name}
subtitle={
integrationCreatedBy ? (
<>
<Trans>Enabled by {{ integrationCreatedBy }}</Trans>{" "}
&middot;{" "}
<Time
dateTime={integration.createdAt}
relative={false}
format={{ en_US: "MMMM d, y" }}
/>
</>
) : (
<PlaceholderText />
)
}
image={
<TeamLogo
src={githubAccount?.avatarUrl}
size={AvatarSize.Large}
showBorder={false}
/>
}
actions={
<ConnectedButton
onClick={integration.delete}
confirmationMessage={t(
"Disconnecting will prevent previewing GitHub links from this organization in documents. Are you sure?"
)}
/>
}
/>
);
})}
</List>
</>
) : (
<p>
<GitHubConnectButton icon={<GitHubIcon />} />
</p>
)}
</>
) : (
<Notice>
<Trans>
The GitHub integration is currently disabled. Please set the
associated environment variables and restart the server to enable
the integration.
</Trans>
</Notice>
)}
</Scene>
);
}
export default observer(GitHub);

View File

@@ -0,0 +1,21 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import Button, { type Props } from "~/components/Button";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { redirectTo } from "~/utils/urls";
import { GitHubUtils } from "../../shared/GitHubUtils";
export function GitHubConnectButton(props: Props<HTMLButtonElement>) {
const { t } = useTranslation();
const team = useCurrentTeam();
return (
<Button
onClick={() => redirectTo(GitHubUtils.authUrl(team.id))}
neutral
{...props}
>
{t("Connect")}
</Button>
);
}

View File

@@ -0,0 +1,6 @@
{
"id": "github",
"name": "GitHub",
"priority": 10,
"description": "Adds a GitHub integration for link unfurling."
}

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);
}
}
}

View File

@@ -0,0 +1,86 @@
import queryString from "query-string";
import env from "@shared/env";
import { integrationSettingsPath } from "@shared/utils/routeHelpers";
export class GitHubUtils {
public static clientId = env.GITHUB_CLIENT_ID;
public static allowedResources = ["pull", "issues"];
static get url() {
return integrationSettingsPath("github");
}
/**
* @param error
* @returns URL to be redirected to upon authorization error from GitHub
*/
public static errorUrl(error: string) {
return `${this.url}?error=${error}`;
}
/**
* @returns Callback URL configured for GitHub, to which users will be redirected upon authorization
*/
public static callbackUrl(
{ baseUrl, params }: { baseUrl: string; params?: string } = {
baseUrl: `${env.URL}`,
params: undefined,
}
) {
return params
? `${baseUrl}/api/github.callback?${params}`
: `${baseUrl}/api/github.callback`;
}
static authUrl(state: string): string {
const baseUrl = `https://github.com/apps/${env.GITHUB_APP_NAME}/installations/new`;
const params = {
client_id: this.clientId,
redirect_uri: this.callbackUrl(),
state,
};
return `${baseUrl}?${queryString.stringify(params)}`;
}
static installRequestUrl(): string {
return `${this.url}?install_request=true`;
}
/**
* Parses a GitHub like URL to obtain info like repo name, owner, resource type(issue or PR).
*
* @param url URL to parse
* @returns An object containing repository, owner, resource type(issue or pull request) and resource id
*/
public static parseUrl(url: string) {
const { hostname, pathname } = new URL(url);
if (hostname !== "github.com") {
return {};
}
const [, owner, repo, resourceType, resourceId] = pathname.split("/");
if (!this.allowedResources.includes(resourceType)) {
return {};
}
return { owner, repo, resourceType, resourceId };
}
public static getColorForStatus(status: string) {
switch (status) {
case "open":
return "#238636";
case "done":
return "#a371f7";
case "closed":
return "#f85149";
case "merged":
return "#8250df";
case "canceled":
default:
return "#848d97";
}
}
}