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

@@ -122,6 +122,16 @@ OIDC_DISPLAY_NAME=OpenID Connect
# Space separated auth scopes.
OIDC_SCOPES=openid profile email
# To configure the GitHub integration, you'll need to create a GitHub App at
# => https://github.com/settings/apps
#
# When configuring the Client ID, add a redirect URL under "Permissions & events":
# https://<URL>/api/github.callback
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GITHUB_APP_NAME=
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
# OPTIONAL

View File

@@ -13,6 +13,10 @@ GOOGLE_CLIENT_SECRET=123
SLACK_CLIENT_ID=123
SLACK_CLIENT_SECRET=123
GITHUB_CLIENT_ID=123;
GITHUB_CLIENT_SECRET=123;
GITHUB_APP_NAME=outline-test;
OIDC_CLIENT_ID=client-id
OIDC_CLIENT_SECRET=client-secret
OIDC_AUTH_URI=http://localhost/authorize

View File

@@ -2,6 +2,7 @@ import { transparentize } from "polished";
import { Link } from "react-router-dom";
import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import { getTextColor } from "@shared/utils/color";
import Text from "~/components/Text";
export const CARD_MARGIN = 10;
@@ -28,10 +29,12 @@ export const Preview = styled(Link)`
max-width: 375px;
`;
export const Title = styled.h2`
font-size: 1.25em;
margin: 0;
color: ${s("text")};
export const Title = styled(Text).attrs({ as: "h2", size: "large" })`
margin-bottom: 4px;
display: flex;
align-items: flex-start;
justify-content: flex-start;
gap: 4px;
`;
export const Info = styled(StyledText).attrs(() => ({
@@ -46,6 +49,7 @@ export const Description = styled(StyledText)`
margin-top: 0.5em;
line-height: var(--line-height);
max-height: calc(var(--line-height) * ${NUMBER_OF_LINES});
overflow: hidden;
`;
export const Thumbnail = styled.img`
@@ -54,6 +58,20 @@ export const Thumbnail = styled.img`
background: ${s("menuBackground")};
`;
export const Label = styled(Text).attrs({ size: "xsmall", weight: "bold" })<{
color?: string;
}>`
background-color: ${(props) =>
props.color ?? props.theme.secondaryBackground};
color: ${(props) =>
props.color ? getTextColor(props.color) : props.theme.text};
width: fit-content;
border-radius: 2em;
padding: 0 8px;
margin-right: 0.5em;
margin-top: 0.5em;
`;
export const CardContent = styled.div`
overflow: hidden;
user-select: none;

View File

@@ -15,8 +15,10 @@ import useStores from "~/hooks/useStores";
import { client } from "~/utils/ApiClient";
import { CARD_MARGIN } from "./Components";
import HoverPreviewDocument from "./HoverPreviewDocument";
import HoverPreviewIssue from "./HoverPreviewIssue";
import HoverPreviewLink from "./HoverPreviewLink";
import HoverPreviewMention from "./HoverPreviewMention";
import HoverPreviewPullRequest from "./HoverPreviewPullRequest";
const DELAY_CLOSE = 600;
const POINTER_HEIGHT = 22;
@@ -111,7 +113,11 @@ function HoverPreviewDesktop({ element, onClose }: Props) {
{(data) => (
<Animate
initial={{ opacity: 0, y: -20, pointerEvents: "none" }}
animate={{ opacity: 1, y: 0, pointerEvents: "auto" }}
animate={{
opacity: 1,
y: 0,
transitionEnd: { pointerEvents: "auto" },
}}
>
{data.type === UnfurlType.Mention ? (
<HoverPreviewMention
@@ -128,6 +134,27 @@ function HoverPreviewDesktop({ element, onClose }: Props) {
description={data.description}
info={data.meta.info}
/>
) : data.type === UnfurlType.Issue ? (
<HoverPreviewIssue
url={data.url}
title={data.title}
description={data.description}
author={data.author}
createdAt={data.createdAt}
identifier={data.meta.identifier}
labels={data.meta.labels}
status={data.meta.status}
/>
) : data.type === UnfurlType.Pull ? (
<HoverPreviewPullRequest
url={data.url}
title={data.title}
description={data.description}
author={data.author}
createdAt={data.createdAt}
identifier={data.meta.identifier}
status={data.meta.status}
/>
) : (
<HoverPreviewLink
url={data.url}

View File

@@ -0,0 +1,90 @@
import * as React from "react";
import { Trans } from "react-i18next";
import Flex from "~/components/Flex";
import Avatar from "../Avatar";
import { IssueStatusIcon } from "../Icons/IssueStatusIcon";
import Text from "../Text";
import Time from "../Time";
import {
Preview,
Title,
Description,
Card,
CardContent,
Label,
Info,
} from "./Components";
type Props = {
/** Issue url */
url: string;
/** Issue title */
title: string;
/** Issue description */
description: string;
/** Wehn the issue was created */
createdAt: string;
/** Author of the issue */
author: { name: string; avatarUrl: string };
/** Labels attached to the issue */
labels: Array<{ name: string; color: string }>;
/** Issue status */
status: { name: string; color: string };
/** Issue identifier */
identifier: string;
};
const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
{
url,
title,
identifier,
description,
author,
labels,
status,
createdAt,
}: Props,
ref: React.Ref<HTMLDivElement>
) {
const authorName = author.name;
return (
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
<Flex column ref={ref}>
<Card fadeOut={false}>
<CardContent>
<Flex gap={2} column>
<Title>
<IssueStatusIcon status={status.name} color={status.color} />
<span>
{title}&nbsp;<Text type="tertiary">{identifier}</Text>
</span>
</Title>
<Flex align="center" gap={4}>
<Avatar src={author.avatarUrl} />
<Info>
<Trans>
{{ authorName }} created{" "}
<Time dateTime={createdAt} addSuffix />
</Trans>
</Info>
</Flex>
<Description>{description}</Description>
<Flex wrap>
{labels.map((label, index) => (
<Label key={index} color={label.color}>
{label.name}
</Label>
))}
</Flex>
</Flex>
</CardContent>
</Card>
</Flex>
</Preview>
);
});
export default HoverPreviewIssue;

View File

@@ -0,0 +1,72 @@
import * as React from "react";
import { Trans } from "react-i18next";
import Flex from "~/components/Flex";
import Avatar from "../Avatar";
import { PullRequestIcon } from "../Icons/PullRequestIcon";
import Text from "../Text";
import Time from "../Time";
import {
Preview,
Title,
Description,
Card,
CardContent,
Info,
} from "./Components";
type Props = {
/** Pull request url */
url: string;
/** Pull request title */
title: string;
/** Pull request description */
description: string;
/** When the pull request was opened */
createdAt: string;
/** Author of the pull request */
author: { name: string; avatarUrl: string };
/** Pull request status */
status: { name: string; color: string };
/** Pull request identifier */
identifier: string;
};
const HoverPreviewPullRequest = React.forwardRef(
function _HoverPreviewPullRequest(
{ url, title, identifier, description, author, status, createdAt }: Props,
ref: React.Ref<HTMLDivElement>
) {
const authorName = author.name;
return (
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
<Flex column ref={ref}>
<Card fadeOut={false}>
<CardContent>
<Flex gap={2} column>
<Title>
<PullRequestIcon status={status.name} color={status.color} />
<span>
{title}&nbsp;<Text type="tertiary">{identifier}</Text>
</span>
</Title>
<Flex align="center" gap={4}>
<Avatar src={author.avatarUrl} />
<Info>
<Trans>
{{ authorName }} opened{" "}
<Time dateTime={createdAt} addSuffix />
</Trans>
</Info>
</Flex>
<Description>{description}</Description>
</Flex>
</CardContent>
</Card>
</Flex>
</Preview>
);
}
);
export default HoverPreviewPullRequest;

View File

@@ -0,0 +1,74 @@
import * as React from "react";
import styled from "styled-components";
type Props = {
status: string;
color: string;
size?: number;
className?: string;
};
/**
* Issue status icon based on GitHub issue status, but can be used for any git-style integration.
*/
export function IssueStatusIcon({ size, ...rest }: Props) {
return (
<Icon size={size}>
<BaseIcon {...rest} />
</Icon>
);
}
const Icon = styled.span<{ size?: number }>`
display: inline-flex;
flex-shrink: 0;
width: ${(props) => props.size ?? 24}px;
height: ${(props) => props.size ?? 24}px;
align-items: center;
justify-content: center;
`;
function BaseIcon(props: Props) {
switch (props.status) {
case "open":
return (
<svg
viewBox="0 0 16 16"
width="16"
height="16"
fill={props.color}
className={props.className}
>
<path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" />
<path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z" />
</svg>
);
case "closed":
return (
<svg
viewBox="0 0 16 16"
width="16"
height="16"
fill={props.color}
className={props.className}
>
<path d="M11.28 6.78a.75.75 0 0 0-1.06-1.06L7.25 8.69 5.78 7.22a.75.75 0 0 0-1.06 1.06l2 2a.75.75 0 0 0 1.06 0l3.5-3.5Z" />
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0Zm-1.5 0a6.5 6.5 0 1 0-13 0 6.5 6.5 0 0 0 13 0Z" />
</svg>
);
case "canceled":
return (
<svg
viewBox="0 0 16 16"
width="16"
height="16"
fill={props.color}
className={props.className}
>
<path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm9.78-2.22-5.5 5.5a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l5.5-5.5a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042Z" />
</svg>
);
default:
return null;
}
}

View File

@@ -0,0 +1,72 @@
import * as React from "react";
import styled from "styled-components";
type Props = {
status: string;
color: string;
size?: number;
className?: string;
};
/**
* Issue status icon based on GitHub pull requests, but can be used for any git-style integration.
*/
export function PullRequestIcon({ size, ...rest }: Props) {
return (
<Icon size={size}>
<BaseIcon {...rest} />
</Icon>
);
}
const Icon = styled.span<{ size?: number }>`
display: inline-flex;
flex-shrink: 0;
width: ${(props) => props.size ?? 24}px;
height: ${(props) => props.size ?? 24}px;
align-items: center;
justify-content: center;
`;
function BaseIcon(props: Props) {
switch (props.status) {
case "open":
return (
<svg
viewBox="0 0 16 16"
width="16"
height="16"
fill={props.color}
className={props.className}
>
<path d="M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z" />
</svg>
);
case "merged":
return (
<svg
viewBox="0 0 16 16"
width="16"
height="16"
fill={props.color}
className={props.className}
>
<path d="M5.45 5.154A4.25 4.25 0 0 0 9.25 7.5h1.378a2.251 2.251 0 1 1 0 1.5H9.25A5.734 5.734 0 0 1 5 7.123v3.505a2.25 2.25 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.95-.218ZM4.25 13.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm8.5-4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5ZM5 3.25a.75.75 0 1 0 0 .005V3.25Z" />
</svg>
);
case "closed":
return (
<svg
viewBox="0 0 16 16"
width="16"
height="16"
fill={props.color}
className={props.className}
>
<path d="M3.25 1A2.25 2.25 0 0 1 4 5.372v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.251 2.251 0 0 1 3.25 1Zm9.5 5.5a.75.75 0 0 1 .75.75v3.378a2.251 2.251 0 1 1-1.5 0V7.25a.75.75 0 0 1 .75-.75Zm-2.03-5.273a.75.75 0 0 1 1.06 0l.97.97.97-.97a.748.748 0 0 1 1.265.332.75.75 0 0 1-.205.729l-.97.97.97.97a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018l-.97-.97-.97.97a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l.97-.97-.97-.97a.75.75 0 0 1 0-1.06ZM2.5 3.25a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0ZM3.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm9.5 0a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z" />
</svg>
);
default:
return null;
}
}

View File

@@ -4,8 +4,10 @@ import type {
IntegrationSettings,
IntegrationType,
} from "@shared/types";
import User from "~/models/User";
import Model from "~/models/base/Model";
import Field from "./decorators/Field";
import Field from "~/models/decorators/Field";
import Relation from "~/models/decorators/Relation";
class Integration<T = unknown> extends Model {
static modelName = "Integration";
@@ -18,6 +20,13 @@ class Integration<T = unknown> extends Model {
collectionId: string;
userId: string;
@Relation(() => User, { onDelete: "cascade" })
user: User;
teamId: string;
@Field
@observable
events: string[];

View File

@@ -1,4 +1,5 @@
import { computed } from "mobx";
import { IntegrationService, IntegrationType } from "@shared/types";
import naturalSort from "@shared/utils/naturalSort";
import RootStore from "~/stores/RootStore";
import Store from "~/stores/base/Store";
@@ -13,6 +14,13 @@ class IntegrationsStore extends Store<Integration> {
get orderedData(): Integration[] {
return naturalSort(Array.from(this.data.values()), "name");
}
@computed
get github(): Integration<IntegrationType.Embed>[] {
return this.orderedData.filter(
(integration) => integration.service === IntegrationService.GitHub
);
}
}
export default IntegrationsStore;

View File

@@ -289,17 +289,28 @@ export default abstract class Store<T extends Model> {
};
@action
fetchAll = async (): Promise<T[]> => {
fetchAll = async (params?: Record<string, any>): Promise<T[]> => {
const limit = Pagination.defaultLimit;
const response = await this.fetchPage({ limit });
const response = await this.fetchPage({ ...params, limit });
const pages = Math.ceil(response[PAGINATION_SYMBOL].total / limit);
const fetchPages = [];
for (let page = 1; page < pages; page++) {
fetchPages.push(this.fetchPage({ offset: page * limit, limit }));
fetchPages.push(
this.fetchPage({ ...params, offset: page * limit, limit })
);
}
const results = await Promise.all(fetchPages);
return flatten(results);
const results = flatten(
fetchPages.length ? await Promise.all(fetchPages) : [response]
);
if (params?.withRelations) {
await Promise.all(
this.orderedData.map((integration) => integration.loadRelations())
);
}
return results;
};
@computed

View File

@@ -26,3 +26,7 @@ export function decodeURIComponentSafe(text: string) {
? decodeURIComponent(text.replace(/%(?![0-9][0-9a-fA-F]+)/g, "%25"))
: text;
}
export function redirectTo(url: string) {
window.location.href = url;
}

View File

@@ -150,6 +150,7 @@
"node-fetch": "2.7.0",
"nodemailer": "^6.9.9",
"outline-icons": "^3.2.1",
"octokit": "^3.1.2",
"oy-vey": "^0.12.1",
"passport": "^0.7.0",
"passport-google-oauth2": "^0.2.0",

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

View File

@@ -6,7 +6,10 @@ import env from "./env";
class Iframely {
public static defaultUrl = "https://iframe.ly";
public static async fetch(url: string, type = "oembed") {
public static async fetch(
url: string,
type = "oembed"
): Promise<Unfurl | void> {
const isDefaultHost = env.IFRAMELY_URL === this.defaultUrl;
// Cloud Iframely requires /api path, while self-hosted does not.
@@ -30,7 +33,7 @@ class Iframely {
* @param url
* @returns Preview data for the url
*/
public static async unfurl(url: string): Promise<Unfurl | false> {
public static async unfurl(url: string): Promise<Unfurl | void> {
return Iframely.fetch(url);
}
}

View File

@@ -55,15 +55,6 @@ const scopes = [
"identity.team",
];
function redirectOnClient(ctx: Context, url: string) {
ctx.type = "text/html";
ctx.body = `
<html>
<head>
<meta http-equiv="refresh" content="0;URL='${url}'"/>
</head>`;
}
if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
const strategy = new SlackStrategy(
{
@@ -164,7 +155,7 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
const team = await Team.findByPk(teamId, {
rejectOnEmpty: true,
});
return redirectOnClient(
return ctx.redirectOnClient(
ctx,
SlackUtils.connectUrl({
baseUrl: team.url,

View File

@@ -91,6 +91,16 @@ async function start(id: number, disconnect: () => void) {
// Apply default rate limit to all routes
app.use(defaultRateLimiter());
/** Perform a redirect on the browser so that the user's auth cookies are included in the request. */
app.context.redirectOnClient = function (url: string) {
this.type = "text/html";
this.body = `
<html>
<head>
<meta http-equiv="refresh" content="0;URL='${url}'"/>
</head>`;
};
// Add a health check endpoint to all services
router.get("/_health", async (ctx) => {
try {

View File

@@ -0,0 +1,15 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn("integrations", "deletedAt", {
type: Sequelize.DATE,
allowNull: true,
});
},
async down(queryInterface, Sequelize) {
await queryInterface.removeColumn("integrations", "deletedAt");
},
};

View File

@@ -1,4 +1,8 @@
import { InferAttributes, InferCreationAttributes } from "sequelize";
import {
InferAttributes,
InferCreationAttributes,
type InstanceDestroyOptions,
} from "sequelize";
import {
ForeignKey,
BelongsTo,
@@ -7,15 +11,16 @@ import {
DataType,
Scopes,
IsIn,
AfterDestroy,
} from "sequelize-typescript";
import { IntegrationType, IntegrationService } from "@shared/types";
import type { IntegrationSettings } from "@shared/types";
import Collection from "./Collection";
import IntegrationAuthentication from "./IntegrationAuthentication";
import Team from "./Team";
import User from "./User";
import IdModel from "./base/IdModel";
import Fix from "./decorators/Fix";
import Collection from "@server/models/Collection";
import IntegrationAuthentication from "@server/models/IntegrationAuthentication";
import Team from "@server/models/Team";
import User from "@server/models/User";
import ParanoidModel from "@server/models/base/ParanoidModel";
import Fix from "@server/models/decorators/Fix";
@Scopes(() => ({
withAuthentication: {
@@ -30,7 +35,7 @@ import Fix from "./decorators/Fix";
}))
@Table({ tableName: "integrations", modelName: "integration" })
@Fix
class Integration<T = unknown> extends IdModel<
class Integration<T = unknown> extends ParanoidModel<
InferAttributes<Integration<T>>,
Partial<InferCreationAttributes<Integration<T>>>
> {
@@ -77,6 +82,23 @@ class Integration<T = unknown> extends IdModel<
@ForeignKey(() => IntegrationAuthentication)
@Column(DataType.UUID)
authenticationId: string;
// hooks
@AfterDestroy
static async destoryIntegrationAuthentications(
model: Integration,
options?: InstanceDestroyOptions
) {
if (options?.force && model.authenticationId) {
await IntegrationAuthentication.destroy({
where: {
id: model.authenticationId,
},
...options,
});
}
}
}
export default Integration;

View File

@@ -1,11 +1,11 @@
import { Unfurl, UnfurlType } from "@shared/types";
import { UnfurlResponse, UnfurlType } from "@shared/types";
import { User, Document } from "@server/models";
import { presentLastActivityInfoFor } from "./common";
function presentDocument(
document: Document,
viewer: User
): Unfurl<UnfurlType.Document> {
): UnfurlResponse<UnfurlType.Document> {
return {
url: document.url,
type: UnfurlType.Document,

View File

@@ -1,11 +1,11 @@
import { Unfurl, UnfurlType } from "@shared/types";
import { UnfurlResponse, UnfurlType } from "@shared/types";
import { Document, User } from "@server/models";
import { presentLastOnlineInfoFor, presentLastViewedInfoFor } from "./common";
async function presentMention(
user: User,
document: Document
): Promise<Unfurl<UnfurlType.Mention>> {
): Promise<UnfurlResponse<UnfurlType.Mention>> {
const lastOnlineInfo = presentLastOnlineInfoFor(user);
const lastViewedInfo = await presentLastViewedInfoFor(user, document);

View File

@@ -1,12 +1,14 @@
import { Unfurl } from "@shared/types";
import { UnfurlResponse } from "@shared/types";
function presentUnfurl(data: any): Unfurl {
function presentUnfurl(data: any): UnfurlResponse {
return {
url: data.url,
type: data.type,
title: data.title,
createdAt: data.createdAt,
description: data.description,
thumbnailUrl: data.thumbnail_url,
author: data.author,
meta: data.meta,
};
}

View File

@@ -0,0 +1,24 @@
import { IntegrationType } from "@shared/types";
import { Integration } from "@server/models";
import BaseProcessor from "@server/queues/processors/BaseProcessor";
import { IntegrationEvent, Event } from "@server/types";
import { CacheHelper } from "@server/utils/CacheHelper";
export default class IntegrationCreatedProcessor extends BaseProcessor {
static applicableEvents: Event["name"][] = ["integrations.create"];
async perform(event: IntegrationEvent) {
const integration = await Integration.findOne({
where: {
id: event.modelId,
},
paranoid: false,
});
if (integration?.type !== IntegrationType.Embed) {
return;
}
// Clear the cache of unfurled data for the team as it may be stale now.
await CacheHelper.clearData(CacheHelper.getUnfurlKey(integration.teamId));
}
}

View File

@@ -0,0 +1,34 @@
import { IntegrationType } from "@shared/types";
import { Integration } from "@server/models";
import BaseProcessor from "@server/queues/processors/BaseProcessor";
import { IntegrationEvent, Event } from "@server/types";
import { CacheHelper } from "@server/utils/CacheHelper";
import { Hook, PluginManager } from "@server/utils/PluginManager";
export default class IntegrationDeletedProcessor extends BaseProcessor {
static applicableEvents: Event["name"][] = ["integrations.delete"];
async perform(event: IntegrationEvent) {
const integration = await Integration.findOne({
where: {
id: event.modelId,
},
paranoid: false,
});
if (!integration) {
return;
}
const uninstallHooks = PluginManager.getHooks(Hook.Uninstall);
for (const hook of uninstallHooks) {
await hook.value(integration);
}
// Clear the cache of unfurled data for the team as it may be stale now.
if (integration.type === IntegrationType.Embed) {
await CacheHelper.clearData(CacheHelper.getUnfurlKey(integration.teamId));
}
await integration.destroy({ force: true });
}
}

View File

@@ -1,5 +1,5 @@
import { IntegrationService, IntegrationType } from "@shared/types";
import { IntegrationAuthentication, User } from "@server/models";
import { User } from "@server/models";
import Integration from "@server/models/Integration";
import {
buildAdmin,
@@ -220,11 +220,6 @@ describe("#integrations.delete", () => {
expect(res.status).toEqual(200);
const intg = await Integration.findByPk(integration.id);
expect(intg).toBeNull();
const auth = await IntegrationAuthentication.findByPk(
integration.authenticationId
);
expect(auth).toBeNull();
expect(intg?.deletedAt).not.toBeNull();
});
});

View File

@@ -4,7 +4,7 @@ import { IntegrationType } from "@shared/types";
import auth from "@server/middlewares/authentication";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { Event, IntegrationAuthentication } from "@server/models";
import { Event } from "@server/models";
import Integration from "@server/models/Integration";
import { authorize } from "@server/policies";
import { presentIntegration, presentPolicies } from "@server/presenters";
@@ -46,16 +46,22 @@ router.post(
],
};
const integrations = await Integration.findAll({
where,
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
const [integrations, total] = await Promise.all([
await Integration.findAll({
where,
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
}),
Integration.count({
where,
}),
]);
ctx.body = {
pagination: ctx.state.pagination,
pagination: { ...ctx.state.pagination, total },
data: integrations.map(presentIntegration),
policies: presentPolicies(user, integrations),
};
}
);
@@ -80,6 +86,7 @@ router.post(
ctx.body = {
data: presentIntegration(integration),
policies: presentPolicies(user, [integration]),
};
}
);
@@ -127,6 +134,7 @@ router.post(
ctx.body = {
data: presentIntegration(integration),
policies: presentPolicies(user, [integration]),
};
}
);
@@ -141,19 +149,13 @@ router.post(
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const integration = await Integration.findByPk(id, { transaction });
const integration = await Integration.findByPk(id, {
rejectOnEmpty: true,
transaction,
});
authorize(user, "delete", integration);
await integration.destroy({ transaction });
// also remove the corresponding authentication if it exists
if (integration.authenticationId) {
await IntegrationAuthentication.destroy({
where: {
id: integration.authenticationId,
},
transaction,
});
}
await Event.create(
{

View File

@@ -1,7 +1,7 @@
import { z } from "zod";
import {
IntegrationType,
IntegrationService,
IntegrationType,
UserCreatableIntegrationService,
} from "@shared/types";
import { Integration } from "@server/models";

View File

@@ -17,7 +17,9 @@ jest.mock("dns", () => ({
},
}));
jest.spyOn(Iframely, "fetch").mockImplementation(() => Promise.resolve(false));
jest
.spyOn(Iframely, "fetch")
.mockImplementation(() => Promise.resolve(undefined));
const server = getTestServer();

View File

@@ -77,20 +77,20 @@ router.post(
// External resources
const cachedData = await CacheHelper.getData(
CacheHelper.getUnfurlKey(url, actor.teamId)
CacheHelper.getUnfurlKey(actor.teamId, url)
);
if (cachedData) {
return (ctx.body = presentUnfurl(cachedData));
}
for (const plugin of plugins) {
const data = await plugin.value.unfurl(url);
const data = await plugin.value.unfurl(url, actor);
if (data) {
if ("error" in data) {
return (ctx.response.status = 204);
} else {
await CacheHelper.setData(
CacheHelper.getUnfurlKey(url, actor.teamId),
CacheHelper.getUnfurlKey(actor.teamId, url),
data,
plugin.value.cacheExpiry
);

View File

@@ -508,5 +508,5 @@ export type CollectionJSONExport = {
};
export type UnfurlResolver = {
unfurl: (url: string) => Promise<any>;
unfurl: (url: string, actor?: User) => Promise<any>;
};

View File

@@ -57,10 +57,25 @@ export class CacheHelper {
/**
* Gets key against which unfurl response for the given url is stored
*
* @param url The url to generate a key for
* @param teamId The team ID to generate a key for
* @param url The url to generate a key for
*/
public static getUnfurlKey(url: string, teamId: string) {
public static getUnfurlKey(teamId: string, url = "") {
return `unfurl:${teamId}:${url}`;
}
/**
* Clears all cache data with the given prefix
*
* @param prefix Prefix to clear cache data
*/
public static async clearData(prefix: string) {
const keys = await Redis.defaultClient.keys(`${prefix}*`);
await Promise.all(
keys.map(async (key) => {
await Redis.defaultClient.del(key);
})
);
}
}

View File

@@ -3,7 +3,7 @@ import { glob } from "glob";
import type Router from "koa-router";
import isArray from "lodash/isArray";
import sortBy from "lodash/sortBy";
import { UnfurlSignature } from "@shared/types";
import { UnfurlSignature, UninstallSignature } from "@shared/types";
import type BaseEmail from "@server/emails/templates/BaseEmail";
import env from "@server/env";
import Logger from "@server/logging/Logger";
@@ -28,6 +28,7 @@ export enum Hook {
Processor = "processor",
Task = "task",
UnfurlProvider = "unfurl",
Uninstall = "uninstall",
}
/**
@@ -40,6 +41,7 @@ type PluginValueMap = {
[Hook.EmailTemplate]: typeof BaseEmail;
[Hook.Processor]: typeof BaseProcessor;
[Hook.Task]: typeof BaseTask<any>;
[Hook.Uninstall]: UninstallSignature;
[Hook.UnfurlProvider]: { unfurl: UnfurlSignature; cacheExpiry: number };
};

View File

@@ -24,7 +24,7 @@ function Gist(props: Props) {
height="200px"
scrolling="no"
id={`gist-${id}`}
title="Github Gist"
title="GitHub Gist"
/>
);
}

View File

@@ -228,6 +228,8 @@
"{{ count }} member": "{{ count }} member",
"{{ count }} member_plural": "{{ count }} members",
"Group members": "Group members",
"{{authorName}} created <3></3>": "{{authorName}} created <3></3>",
"{{authorName}} opened <3></3>": "{{authorName}} opened <3></3>",
"Show menu": "Show menu",
"Choose icon": "Choose icon",
"Loading": "Loading",
@@ -946,6 +948,14 @@
"This month": "This month",
"Last month": "Last month",
"This year": "This year",
"Connect": "Connect",
"Whoops, you need to accept the permissions in GitHub to connect {{appName}} to your workspace. Try again?": "Whoops, you need to accept the permissions in GitHub to connect {{appName}} to your workspace. Try again?",
"Something went wrong while authenticating your request. Please try logging in again.": "Something went wrong while authenticating your request. Please try logging in again.",
"The owner of GitHub account has been requested to install the {{githubAppName}} GitHub app. Once approved, previews will be shown for respective links.": "The owner of GitHub account has been requested to install the {{githubAppName}} GitHub app. Once approved, previews will be shown for respective links.",
"Enable previews of GitHub issues and pull requests in documents by connecting a GitHub organization or specific repositories to {appName}.": "Enable previews of GitHub issues and pull requests in documents by connecting a GitHub organization or specific repositories to {appName}.",
"Enabled by {{integrationCreatedBy}}": "Enabled by {{integrationCreatedBy}}",
"Disconnecting will prevent previewing GitHub links from this organization in documents. Are you sure?": "Disconnecting will prevent previewing GitHub links from this organization in documents. Are you sure?",
"The GitHub integration is currently disabled. Please set the associated environment variables and restart the server to enable the integration.": "The GitHub integration is currently disabled. Please set the associated environment variables and restart the server to enable the integration.",
"Add to Slack": "Add to Slack",
"document published": "document published",
"document updated": "document updated",
@@ -953,11 +963,9 @@
"These events should be posted to Slack": "These events should be posted to Slack",
"This will prevent any future updates from being posted to this Slack channel. Are you sure?": "This will prevent any future updates from being posted to this Slack channel. Are you sure?",
"Whoops, you need to accept the permissions in Slack to connect {{appName}} to your workspace. Try again?": "Whoops, you need to accept the permissions in Slack to connect {{appName}} to your workspace. Try again?",
"Something went wrong while authenticating your request. Please try logging in again.": "Something went wrong while authenticating your request. Please try logging in again.",
"Personal account": "Personal account",
"Link your {{appName}} account to Slack to enable searching the documents you have access to directly within chat.": "Link your {{appName}} account to Slack to enable searching the documents you have access to directly within chat.",
"Disconnecting your personal account will prevent searching for documents from Slack. Are you sure?": "Disconnecting your personal account will prevent searching for documents from Slack. Are you sure?",
"Connect": "Connect",
"Slash command": "Slash command",
"Get rich previews of {{ appName }} links shared in Slack and use the <em>{{ command }}</em> slash command to search for documents without leaving your chat.": "Get rich previews of {{ appName }} links shared in Slack and use the <em>{{ command }}</em> slash command to search for documents without leaving your chat.",
"This will remove the Outline slash command from your Slack workspace. Are you sure?": "This will remove the Outline slash command from your Slack workspace. Are you sure?",

View File

@@ -81,6 +81,7 @@ export enum IntegrationService {
Grist = "grist",
Slack = "slack",
GoogleAnalytics = "google-analytics",
GitHub = "github",
}
export type UserCreatableIntegrationService = Extract<
@@ -108,7 +109,15 @@ export enum DocumentPermission {
}
export type IntegrationSettings<T> = T extends IntegrationType.Embed
? { url: string }
? {
url: string;
github?: {
installation: {
id: number;
account: { id: number; name: string; avatarUrl: string };
};
};
}
: T extends IntegrationType.Analytics
? { measurementId: string }
: T extends IntegrationType.Post
@@ -117,6 +126,14 @@ export type IntegrationSettings<T> = T extends IntegrationType.Embed
? { serviceTeamId: string }
:
| { url: string }
| {
github?: {
installation: {
id: number;
account: { id?: number; name: string; avatarUrl?: string };
};
};
}
| { url: string; channel: string; channelId: string }
| { serviceTeamId: string }
| { measurementId: string }
@@ -257,6 +274,8 @@ export const NotificationEventDefaults = {
export enum UnfurlType {
Mention = "mention",
Document = "document",
Issue = "issue",
Pull = "pull",
}
export enum QueryNotices {
@@ -265,20 +284,31 @@ export enum QueryNotices {
export type OEmbedType = "photo" | "video" | "rich";
export type Unfurl<T = OEmbedType> =
| {
url?: string;
type: T;
title: string;
description?: string;
thumbnailUrl?: string | null;
meta?: Record<string, string>;
}
export type UnfurlResponse<S = OEmbedType, T = Record<string, any>> = {
url?: string;
type: S | ("issue" | "pull" | "commit");
title: string;
description?: string;
createdAt?: string;
thumbnailUrl?: string | null;
author?: { name: string; avatarUrl: string };
meta?: T;
};
export type Unfurl =
| UnfurlResponse
| {
error: string;
};
export type UnfurlSignature = (url: string) => Promise<Unfurl | false>;
export type UnfurlSignature = (
url: string,
actor?: any
) => Promise<Unfurl | void>;
export type UninstallSignature = (
integration: Record<string, any>
) => Promise<void>;
export type JSONValue =
| string

View File

@@ -35,3 +35,17 @@ export const stringToColor = (input: string) => {
*/
export const toRGB = (color: string) =>
Object.values(parseToRgb(color)).join(", ");
/**
* Returns the text color that contrasts the given background color
*
* @param background - A color string
* @returns A color string
*/
export const getTextColor = (background: string) => {
const r = parseInt(background.substring(1, 3), 16);
const g = parseInt(background.substring(3, 5), 16);
const b = parseInt(background.substring(5, 7), 16);
const yiq = (r * 299 + g * 587 + b * 114) / 1000;
return yiq >= 128 ? "black" : "white";
};

369
yarn.lock
View File

@@ -2062,6 +2062,229 @@
"@nodelib/fs.scandir" "2.1.5"
fastq "^1.6.0"
"@octokit/app@^14.0.2":
version "14.0.2"
resolved "https://registry.yarnpkg.com/@octokit/app/-/app-14.0.2.tgz#b47c52020221351fb58640f113eb38b2ad3998fe"
integrity sha512-NCSCktSx+XmjuSUVn2dLfqQ9WIYePGP95SDJs4I9cn/0ZkeXcPkaoCLl64Us3dRKL2ozC7hArwze5Eu+/qt1tg==
dependencies:
"@octokit/auth-app" "^6.0.0"
"@octokit/auth-unauthenticated" "^5.0.0"
"@octokit/core" "^5.0.0"
"@octokit/oauth-app" "^6.0.0"
"@octokit/plugin-paginate-rest" "^9.0.0"
"@octokit/types" "^12.0.0"
"@octokit/webhooks" "^12.0.4"
"@octokit/auth-app@^6.0.0":
version "6.0.3"
resolved "https://registry.yarnpkg.com/@octokit/auth-app/-/auth-app-6.0.3.tgz#4c0ba68e8d3b1a55c34d1e68ea0ca92ef018bb7a"
integrity sha512-9N7IlBAKEJR3tJgPSubCxIDYGXSdc+2xbkjYpk9nCyqREnH8qEMoMhiEB1WgoA9yTFp91El92XNXAi+AjuKnfw==
dependencies:
"@octokit/auth-oauth-app" "^7.0.0"
"@octokit/auth-oauth-user" "^4.0.0"
"@octokit/request" "^8.0.2"
"@octokit/request-error" "^5.0.0"
"@octokit/types" "^12.0.0"
deprecation "^2.3.1"
lru-cache "^10.0.0"
universal-github-app-jwt "^1.1.2"
universal-user-agent "^6.0.0"
"@octokit/auth-oauth-app@^7.0.0":
version "7.0.1"
resolved "https://registry.yarnpkg.com/@octokit/auth-oauth-app/-/auth-oauth-app-7.0.1.tgz#30fd8fcb4608ca52c29c265a3fc7032897796c8e"
integrity sha512-RE0KK0DCjCHXHlQBoubwlLijXEKfhMhKm9gO56xYvFmP1QTMb+vvwRPmQLLx0V+5AvV9N9I3lr1WyTzwL3rMDg==
dependencies:
"@octokit/auth-oauth-device" "^6.0.0"
"@octokit/auth-oauth-user" "^4.0.0"
"@octokit/request" "^8.0.2"
"@octokit/types" "^12.0.0"
"@types/btoa-lite" "^1.0.0"
btoa-lite "^1.0.0"
universal-user-agent "^6.0.0"
"@octokit/auth-oauth-device@^6.0.0":
version "6.0.1"
resolved "https://registry.yarnpkg.com/@octokit/auth-oauth-device/-/auth-oauth-device-6.0.1.tgz#38e5f7f8997c5e8b774f283463ecf4a7e42d7cee"
integrity sha512-yxU0rkL65QkjbqQedgVx3gmW7YM5fF+r5uaSj9tM/cQGVqloXcqP2xK90eTyYvl29arFVCW8Vz4H/t47mL0ELw==
dependencies:
"@octokit/oauth-methods" "^4.0.0"
"@octokit/request" "^8.0.0"
"@octokit/types" "^12.0.0"
universal-user-agent "^6.0.0"
"@octokit/auth-oauth-user@^4.0.0":
version "4.0.1"
resolved "https://registry.yarnpkg.com/@octokit/auth-oauth-user/-/auth-oauth-user-4.0.1.tgz#c8267883935c83f78318c726ff91d7e98de05517"
integrity sha512-N94wWW09d0hleCnrO5wt5MxekatqEJ4zf+1vSe8MKMrhZ7gAXKFOKrDEZW2INltvBWJCyDUELgGRv8gfErH1Iw==
dependencies:
"@octokit/auth-oauth-device" "^6.0.0"
"@octokit/oauth-methods" "^4.0.0"
"@octokit/request" "^8.0.2"
"@octokit/types" "^12.0.0"
btoa-lite "^1.0.0"
universal-user-agent "^6.0.0"
"@octokit/auth-token@^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@octokit/auth-token/-/auth-token-4.0.0.tgz#40d203ea827b9f17f42a29c6afb93b7745ef80c7"
integrity sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA==
"@octokit/auth-unauthenticated@^5.0.0":
version "5.0.1"
resolved "https://registry.yarnpkg.com/@octokit/auth-unauthenticated/-/auth-unauthenticated-5.0.1.tgz#d8032211728333068b2e07b53997c29e59a03507"
integrity sha512-oxeWzmBFxWd+XolxKTc4zr+h3mt+yofn4r7OfoIkR/Cj/o70eEGmPsFbueyJE2iBAGpjgTnEOKM3pnuEGVmiqg==
dependencies:
"@octokit/request-error" "^5.0.0"
"@octokit/types" "^12.0.0"
"@octokit/core@^5.0.0":
version "5.1.0"
resolved "https://registry.yarnpkg.com/@octokit/core/-/core-5.1.0.tgz#81dacf0197ed7855e6413f128bd6dd9e121e7d2f"
integrity sha512-BDa2VAMLSh3otEiaMJ/3Y36GU4qf6GI+VivQ/P41NC6GHcdxpKlqV0ikSZ5gdQsmS3ojXeRx5vasgNTinF0Q4g==
dependencies:
"@octokit/auth-token" "^4.0.0"
"@octokit/graphql" "^7.0.0"
"@octokit/request" "^8.0.2"
"@octokit/request-error" "^5.0.0"
"@octokit/types" "^12.0.0"
before-after-hook "^2.2.0"
universal-user-agent "^6.0.0"
"@octokit/endpoint@^9.0.0":
version "9.0.4"
resolved "https://registry.yarnpkg.com/@octokit/endpoint/-/endpoint-9.0.4.tgz#8afda5ad1ffc3073d08f2b450964c610b821d1ea"
integrity sha512-DWPLtr1Kz3tv8L0UvXTDP1fNwM0S+z6EJpRcvH66orY6Eld4XBMCSYsaWp4xIm61jTWxK68BrR7ibO+vSDnZqw==
dependencies:
"@octokit/types" "^12.0.0"
universal-user-agent "^6.0.0"
"@octokit/graphql@^7.0.0":
version "7.0.2"
resolved "https://registry.yarnpkg.com/@octokit/graphql/-/graphql-7.0.2.tgz#3df14b9968192f9060d94ed9e3aa9780a76e7f99"
integrity sha512-OJ2iGMtj5Tg3s6RaXH22cJcxXRi7Y3EBqbHTBRq+PQAqfaS8f/236fUrWhfSn8P4jovyzqucxme7/vWSSZBX2Q==
dependencies:
"@octokit/request" "^8.0.1"
"@octokit/types" "^12.0.0"
universal-user-agent "^6.0.0"
"@octokit/oauth-app@^6.0.0":
version "6.1.0"
resolved "https://registry.yarnpkg.com/@octokit/oauth-app/-/oauth-app-6.1.0.tgz#22c276f6ad2364c6999837bfdd5d9c1092838726"
integrity sha512-nIn/8eUJ/BKUVzxUXd5vpzl1rwaVxMyYbQkNZjHrF7Vk/yu98/YDF/N2KeWO7uZ0g3b5EyiFXFkZI8rJ+DH1/g==
dependencies:
"@octokit/auth-oauth-app" "^7.0.0"
"@octokit/auth-oauth-user" "^4.0.0"
"@octokit/auth-unauthenticated" "^5.0.0"
"@octokit/core" "^5.0.0"
"@octokit/oauth-authorization-url" "^6.0.2"
"@octokit/oauth-methods" "^4.0.0"
"@types/aws-lambda" "^8.10.83"
universal-user-agent "^6.0.0"
"@octokit/oauth-authorization-url@^6.0.2":
version "6.0.2"
resolved "https://registry.yarnpkg.com/@octokit/oauth-authorization-url/-/oauth-authorization-url-6.0.2.tgz#cc82ca29cc5e339c9921672f39f2b3f5c8eb6ef2"
integrity sha512-CdoJukjXXxqLNK4y/VOiVzQVjibqoj/xHgInekviUJV73y/BSIcwvJ/4aNHPBPKcPWFnd4/lO9uqRV65jXhcLA==
"@octokit/oauth-methods@^4.0.0":
version "4.0.1"
resolved "https://registry.yarnpkg.com/@octokit/oauth-methods/-/oauth-methods-4.0.1.tgz#90d22c662387056307778d7e5c4763ff559636c4"
integrity sha512-1NdTGCoBHyD6J0n2WGXg9+yDLZrRNZ0moTEex/LSPr49m530WNKcCfXDghofYptr3st3eTii+EHoG5k/o+vbtw==
dependencies:
"@octokit/oauth-authorization-url" "^6.0.2"
"@octokit/request" "^8.0.2"
"@octokit/request-error" "^5.0.0"
"@octokit/types" "^12.0.0"
btoa-lite "^1.0.0"
"@octokit/openapi-types@^19.1.0":
version "19.1.0"
resolved "https://registry.yarnpkg.com/@octokit/openapi-types/-/openapi-types-19.1.0.tgz#75ec7e64743870fc73e1ab4bc6ec252ecdd624dc"
integrity sha512-6G+ywGClliGQwRsjvqVYpklIfa7oRPA0vyhPQG/1Feh+B+wU0vGH1JiJ5T25d3g1JZYBHzR2qefLi9x8Gt+cpw==
"@octokit/plugin-paginate-graphql@^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@octokit/plugin-paginate-graphql/-/plugin-paginate-graphql-4.0.0.tgz#b26024fa454039c18b948f13bf754ff86b89e8b9"
integrity sha512-7HcYW5tP7/Z6AETAPU14gp5H5KmCPT3hmJrS/5tO7HIgbwenYmgw4OY9Ma54FDySuxMwD+wsJlxtuGWwuZuItA==
"@octokit/plugin-paginate-rest@^9.0.0":
version "9.1.5"
resolved "https://registry.yarnpkg.com/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-9.1.5.tgz#1705bcef4dcde1f4015ee58a63dc61b68648f480"
integrity sha512-WKTQXxK+bu49qzwv4qKbMMRXej1DU2gq017euWyKVudA6MldaSSQuxtz+vGbhxV4CjxpUxjZu6rM2wfc1FiWVg==
dependencies:
"@octokit/types" "^12.4.0"
"@octokit/plugin-rest-endpoint-methods@^10.0.0":
version "10.2.0"
resolved "https://registry.yarnpkg.com/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-10.2.0.tgz#eeaa4de97a2ae26404dea30ce3e17b11928e027c"
integrity sha512-ePbgBMYtGoRNXDyKGvr9cyHjQ163PbwD0y1MkDJCpkO2YH4OeXX40c4wYHKikHGZcpGPbcRLuy0unPUuafco8Q==
dependencies:
"@octokit/types" "^12.3.0"
"@octokit/plugin-retry@^6.0.0":
version "6.0.1"
resolved "https://registry.yarnpkg.com/@octokit/plugin-retry/-/plugin-retry-6.0.1.tgz#3257404f7cc418e1c1f13a7f2012c1db848b7693"
integrity sha512-SKs+Tz9oj0g4p28qkZwl/topGcb0k0qPNX/i7vBKmDsjoeqnVfFUquqrE/O9oJY7+oLzdCtkiWSXLpLjvl6uog==
dependencies:
"@octokit/request-error" "^5.0.0"
"@octokit/types" "^12.0.0"
bottleneck "^2.15.3"
"@octokit/plugin-throttling@^8.0.0":
version "8.1.3"
resolved "https://registry.yarnpkg.com/@octokit/plugin-throttling/-/plugin-throttling-8.1.3.tgz#7fb0e001c0cb9383c6be07740b8ec326ed990f6b"
integrity sha512-pfyqaqpc0EXh5Cn4HX9lWYsZ4gGbjnSmUILeu4u2gnuM50K/wIk9s1Pxt3lVeVwekmITgN/nJdoh43Ka+vye8A==
dependencies:
"@octokit/types" "^12.2.0"
bottleneck "^2.15.3"
"@octokit/request-error@^5.0.0":
version "5.0.1"
resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-5.0.1.tgz#277e3ce3b540b41525e07ba24c5ef5e868a72db9"
integrity sha512-X7pnyTMV7MgtGmiXBwmO6M5kIPrntOXdyKZLigNfQWSEQzVxR4a4vo49vJjTWX70mPndj8KhfT4Dx+2Ng3vnBQ==
dependencies:
"@octokit/types" "^12.0.0"
deprecation "^2.0.0"
once "^1.4.0"
"@octokit/request@^8.0.0", "@octokit/request@^8.0.1", "@octokit/request@^8.0.2":
version "8.1.6"
resolved "https://registry.yarnpkg.com/@octokit/request/-/request-8.1.6.tgz#a76a859c30421737a3918b40973c2ff369009571"
integrity sha512-YhPaGml3ncZC1NfXpP3WZ7iliL1ap6tLkAp6MvbK2fTTPytzVUyUesBBogcdMm86uRYO5rHaM1xIWxigWZ17MQ==
dependencies:
"@octokit/endpoint" "^9.0.0"
"@octokit/request-error" "^5.0.0"
"@octokit/types" "^12.0.0"
universal-user-agent "^6.0.0"
"@octokit/types@^12.0.0", "@octokit/types@^12.2.0", "@octokit/types@^12.3.0", "@octokit/types@^12.4.0":
version "12.4.0"
resolved "https://registry.yarnpkg.com/@octokit/types/-/types-12.4.0.tgz#8f97b601e91ce6b9776ed8152217e77a71be7aac"
integrity sha512-FLWs/AvZllw/AGVs+nJ+ELCDZZJk+kY0zMen118xhL2zD0s1etIUHm1odgjP7epxYU1ln7SZxEUWYop5bhsdgQ==
dependencies:
"@octokit/openapi-types" "^19.1.0"
"@octokit/webhooks-methods@^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@octokit/webhooks-methods/-/webhooks-methods-4.0.0.tgz#d1697930ba3d8e6b6d0f8a2c996bb440d2e1df1b"
integrity sha512-M8mwmTXp+VeolOS/kfRvsDdW+IO0qJ8kYodM/sAysk093q6ApgmBXwK1ZlUvAwXVrp/YVHp6aArj4auAxUAOFw==
"@octokit/webhooks-types@7.1.0":
version "7.1.0"
resolved "https://registry.yarnpkg.com/@octokit/webhooks-types/-/webhooks-types-7.1.0.tgz#d533dea253416e02dd6c2bfab25e533295bd5d3f"
integrity sha512-y92CpG4kFFtBBjni8LHoV12IegJ+KFxLgKRengrVjKmGE5XMeCuGvlfRe75lTRrgXaG6XIWJlFpIDTlkoJsU8w==
"@octokit/webhooks@^12.0.4":
version "12.0.11"
resolved "https://registry.yarnpkg.com/@octokit/webhooks/-/webhooks-12.0.11.tgz#4c7887390f506518420b96821c6304187ce59db1"
integrity sha512-YEQOb7v0TZ662nh5jsbY1CMgJyMajCEagKrHWC30LTCwCtnuIrLtEpE20vq4AtH0SuZI90+PtV66/Bnnw0jkvg==
dependencies:
"@octokit/request-error" "^5.0.0"
"@octokit/webhooks-methods" "^4.0.0"
"@octokit/webhooks-types" "7.1.0"
aggregate-error "^3.1.0"
"@opentelemetry/api@^1.0.0":
version "1.4.1"
resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.4.1.tgz#ff22eb2e5d476fbc2450a196e40dd243cc20c28f"
@@ -2743,6 +2966,11 @@
resolved "https://registry.yarnpkg.com/@types/async-lock/-/async-lock-1.1.5.tgz#a82f33e09aef451d6ded7bffae73f9d254723124"
integrity "sha1-qC8z4JrvRR1t7Xv/rnP50lRyMSQ= sha512-A9ClUfmj6wwZMLRz0NaYzb98YH1exlHdf/cdDSKBfMQJnPOdO8xlEW0Eh2QsTTntGzOFWURcEjYElkZ1IY4GCQ=="
"@types/aws-lambda@^8.10.83":
version "8.10.131"
resolved "https://registry.yarnpkg.com/@types/aws-lambda/-/aws-lambda-8.10.131.tgz#76fcd36e6a4a4666c7ea7503bf0e3e86c0a9cdb2"
integrity sha512-IWmFpqnVDvskYWnNSiu/qlRn80XlIOU0Gy5rKCl/NjhnI95pV8qIHs6L5b+bpHhyzuOSzjLgBcwgFSXrC1nZWA==
"@types/babel__core@^7.1.14":
version "7.1.17"
resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.17.tgz#f50ac9d20d64153b510578d84f9643f9a3afbe64"
@@ -2794,6 +3022,11 @@
resolved "https://registry.yarnpkg.com/@types/body-scroll-lock/-/body-scroll-lock-3.1.0.tgz#435f6abf682bf58640e1c2ee5978320b891970e7"
integrity "sha1-Q19qv2gr9YZA4cLuWXgyC4kZcOc= sha512-3owAC4iJub5WPqRhxd8INarF2bWeQq1yQHBgYhN0XLBJMpd5ED10RrJ3aKiAwlTyL5wK7RkBD4SZUQz2AAAMdA=="
"@types/btoa-lite@^1.0.0":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@types/btoa-lite/-/btoa-lite-1.0.2.tgz#82bb6aab00abf7cff3ca2825abe010c0cd536ae5"
integrity sha512-ZYbcE2x7yrvNFJiU7xJGrpF/ihpkM7zKgw8bha3LNJSesvTtUNxbpzaT7WXBIryf6jovisrxTBvymxMeLLj1Mg==
"@types/buffer-from@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@types/buffer-from/-/buffer-from-1.1.0.tgz#fed6287e90fe524dc2b412e0fbc2222c1889c21f"
@@ -3048,7 +3281,14 @@
"@types/jsonwebtoken@^8.5.9":
version "8.5.9"
resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-8.5.9.tgz#2c064ecb0b3128d837d2764aa0b117b0ff6e4586"
integrity "sha1-LAZOywsxKNg30nZKoLEXsP9uRYY= sha512-272FMnFGzAVMGtu9tkr29hRL6bZj4Zs1KZNeHLnKqAvp06tAIcarTMwOh8/8bz4FmKRcMxZhZNeUAQsNLoiPhg=="
integrity sha512-272FMnFGzAVMGtu9tkr29hRL6bZj4Zs1KZNeHLnKqAvp06tAIcarTMwOh8/8bz4FmKRcMxZhZNeUAQsNLoiPhg==
dependencies:
"@types/node" "*"
"@types/jsonwebtoken@^9.0.0":
version "9.0.5"
resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-9.0.5.tgz#0bd9b841c9e6c5a937c17656e2368f65da025588"
integrity sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==
dependencies:
"@types/node" "*"
@@ -3784,6 +4024,14 @@ agent-base@6:
dependencies:
debug "4"
aggregate-error@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a"
integrity sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==
dependencies:
clean-stack "^2.0.0"
indent-string "^4.0.0"
ajv@^6.12.4:
version "6.12.6"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4"
@@ -4409,6 +4657,11 @@ base64url@3.x.x:
resolved "https://registry.yarnpkg.com/base64url/-/base64url-3.0.1.tgz#6399d572e2bc3f90a9a8b22d5dbb0a32d33f788d"
integrity "sha1-Y5nVcuK8P5CpqLItXbsKMtM/eI0= sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A=="
before-after-hook@^2.2.0:
version "2.2.3"
resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-2.2.3.tgz#c51e809c81a4e354084422b9b26bad88249c517c"
integrity sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==
binary-extensions@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.1.0.tgz#30fa40c9e7fe07dbc895678cd287024dea241dd9"
@@ -4439,6 +4692,11 @@ boolbase@^1.0.0:
resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
integrity "sha1-aN/1++YMUes3cl6p4+0xDcwed24= sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="
bottleneck@^2.15.3:
version "2.19.5"
resolved "https://registry.yarnpkg.com/bottleneck/-/bottleneck-2.19.5.tgz#5df0b90f59fd47656ebe63c78a98419205cadd91"
integrity sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==
brace-expansion@^1.1.7:
version "1.1.11"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
@@ -4517,6 +4775,11 @@ bser@2.1.1:
dependencies:
node-int64 "^0.4.0"
btoa-lite@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/btoa-lite/-/btoa-lite-1.0.0.tgz#337766da15801210fdd956c22e9c6891ab9d0337"
integrity sha512-gvW7InbIyF8AicrqWoptdW08pUxuhq8BEgowNajy9RhiE86fmGAGl+bLKo6oB8QP0CkqHLowfN0oJdKC/J6LbA==
buffer-crc32@~0.2.3:
version "0.2.13"
resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242"
@@ -4751,6 +5014,11 @@ clean-css@^4.0.12:
dependencies:
source-map "~0.6.0"
clean-stack@^2.0.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b"
integrity sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==
cli-color@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/cli-color/-/cli-color-2.0.3.tgz#73769ba969080629670f3f2ef69a4bf4e7cc1879"
@@ -5787,6 +6055,11 @@ depd@~1.1.2:
resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
integrity "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak= sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ=="
deprecation@^2.0.0, deprecation@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/deprecation/-/deprecation-2.3.1.tgz#6368cbdb40abf3373b525ac87e4a260c3a700919"
integrity sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==
dequal@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be"
@@ -7744,6 +8017,11 @@ imurmurhash@^0.1.4:
resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea"
integrity "sha1-khi5srkoojixPcT7a21XbyMUU+o= sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="
indent-string@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251"
integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==
inflation@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/inflation/-/inflation-2.0.0.tgz#8b417e47c28f925a45133d914ca1fd389107f30f"
@@ -8860,15 +9138,21 @@ jsonpointer@^5.0.0:
resolved "https://registry.yarnpkg.com/jsonpointer/-/jsonpointer-5.0.1.tgz#2110e0af0900fd37467b5907ecd13a7884a1b559"
integrity "sha1-IRDgrwkA/TdGe1kH7NE6eIShtVk= sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="
jsonwebtoken@^9.0.0:
version "9.0.0"
resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz#d0faf9ba1cc3a56255fe49c0961a67e520c1926d"
integrity "sha1-0Pr5uhzDpWJV/knAlhpn5SDBkm0= sha512-tuGfYXxkQGDPnLJ7SibiQgVgeDgfbPq2k2ICcbgqW8WxWLBAxKQM/ZCu/IT8SOSwmaYl4dpTFCW5xZv7YbbWUw=="
jsonwebtoken@^9.0.0, jsonwebtoken@^9.0.2:
version "9.0.2"
resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz#65ff91f4abef1784697d40952bb1998c504caaf3"
integrity sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==
dependencies:
jws "^3.2.2"
lodash "^4.17.21"
lodash.includes "^4.3.0"
lodash.isboolean "^3.0.3"
lodash.isinteger "^4.0.4"
lodash.isnumber "^3.0.3"
lodash.isplainobject "^4.0.6"
lodash.isstring "^4.0.1"
lodash.once "^4.0.0"
ms "^2.1.1"
semver "^7.3.8"
semver "^7.5.4"
"jsx-ast-utils@^2.4.1 || ^3.0.0", jsx-ast-utils@^3.3.3:
version "3.3.5"
@@ -9343,11 +9627,41 @@ lodash.defaults@^4.2.0:
resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c"
integrity "sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw= sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="
lodash.includes@^4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f"
integrity sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==
lodash.isarguments@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a"
integrity "sha1-L1c9hcaiQon/AGY7SRwdM4/zRYo= sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="
lodash.isboolean@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6"
integrity sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==
lodash.isinteger@^4.0.4:
version "4.0.4"
resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343"
integrity sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==
lodash.isnumber@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc"
integrity sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==
lodash.isplainobject@^4.0.6:
version "4.0.6"
resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb"
integrity sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==
lodash.isstring@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451"
integrity sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==
lodash.merge@^4.6.2:
version "4.6.2"
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
@@ -9358,6 +9672,11 @@ lodash.mergewith@4.6.2:
resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz#617121f89ac55f59047c7aec1ccd6654c6590f55"
integrity "sha1-YXEh+JrFX1kEfHrsHM1mVMZZD1U= sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ=="
lodash.once@^4.0.0:
version "4.1.1"
resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac"
integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==
lodash.sortby@^4.7.0:
version "4.7.0"
resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
@@ -9417,6 +9736,11 @@ lop@^0.4.1:
option "~0.2.1"
underscore "^1.13.1"
lru-cache@^10.0.0:
version "10.1.0"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.1.0.tgz#2098d41c2dc56500e6c88584aa656c84de7d0484"
integrity sha512-/1clY/ui8CzjKFyjdvwPWJUYKiFVXG2I2cY0ssG7h4+hwk+XOIX7ZSG9Q7TW8TW3Kp3BUSqgFWBLgL4PJ+Blag==
lru-cache@^4.1.5:
version "4.1.5"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd"
@@ -10073,6 +10397,22 @@ object.values@^1.1.6, object.values@^1.1.7:
define-properties "^1.2.0"
es-abstract "^1.22.1"
octokit@^3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/octokit/-/octokit-3.1.2.tgz#e574e4f2f5f8712e10412ce81fb56a74c93d4cfa"
integrity sha512-MG5qmrTL5y8KYwFgE1A4JWmgfQBaIETE/lOlfwNYx1QOtCQHGVxkRJmdUJltFc1HVn73d61TlMhMyNTOtMl+ng==
dependencies:
"@octokit/app" "^14.0.2"
"@octokit/core" "^5.0.0"
"@octokit/oauth-app" "^6.0.0"
"@octokit/plugin-paginate-graphql" "^4.0.0"
"@octokit/plugin-paginate-rest" "^9.0.0"
"@octokit/plugin-rest-endpoint-methods" "^10.0.0"
"@octokit/plugin-retry" "^6.0.0"
"@octokit/plugin-throttling" "^8.0.0"
"@octokit/request-error" "^5.0.0"
"@octokit/types" "^12.0.0"
on-finished@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"
@@ -11813,7 +12153,7 @@ semver@^6.0.0, semver@^6.1.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0, semve
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
integrity "sha1-VW0u+GiRRuRtzqS/3QlfNDTf/LQ= sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="
semver@^7.3.8, semver@^7.5.0, semver@^7.5.2, semver@^7.5.3, semver@^7.5.4:
semver@^7.5.0, semver@^7.5.2, semver@^7.5.3, semver@^7.5.4:
version "7.5.4"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e"
integrity "sha1-SDmG7E7TjhxsSMNIlKkYLb/2im4= sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA=="
@@ -13027,6 +13367,19 @@ unique-string@^2.0.0:
dependencies:
crypto-random-string "^2.0.0"
universal-github-app-jwt@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/universal-github-app-jwt/-/universal-github-app-jwt-1.1.2.tgz#8c1867a394d7d9d42cda34f11d1bcb023797d8df"
integrity sha512-t1iB2FmLFE+yyJY9+3wMx0ejB+MQpEVkH0gQv7dR6FZyltyq+ZZO0uDpbopxhrZ3SLEO4dCEkIujOMldEQ2iOA==
dependencies:
"@types/jsonwebtoken" "^9.0.0"
jsonwebtoken "^9.0.2"
universal-user-agent@^6.0.0:
version "6.0.1"
resolved "https://registry.yarnpkg.com/universal-user-agent/-/universal-user-agent-6.0.1.tgz#15f20f55da3c930c57bddbf1734c6654d5fd35aa"
integrity sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==
universalify@^0.1.0:
version "0.1.2"
resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66"