From 450d0d93557e3a283648629d23251df8c95cd78e Mon Sep 17 00:00:00 2001 From: Apoorv Mishra Date: Sat, 23 Mar 2024 19:39:28 +0530 Subject: [PATCH] Github integration (#6414) Co-authored-by: Tom Moor --- .env.sample | 10 + .env.test | 4 + app/components/HoverPreview/Components.tsx | 26 +- app/components/HoverPreview/HoverPreview.tsx | 29 +- .../HoverPreview/HoverPreviewIssue.tsx | 90 +++++ .../HoverPreview/HoverPreviewPullRequest.tsx | 72 ++++ app/components/Icons/IssueStatusIcon.tsx | 74 ++++ app/components/Icons/PullRequestIcon.tsx | 72 ++++ app/models/Integration.ts | 11 +- app/stores/IntegrationsStore.ts | 8 + app/stores/base/Store.ts | 21 +- app/utils/urls.ts | 4 + package.json | 1 + plugins/github/client/Icon.tsx | 26 ++ plugins/github/client/Settings.tsx | 154 ++++++++ .../github/client/components/GitHubButton.tsx | 21 + plugins/github/plugin.json | 6 + plugins/github/server/api/github.ts | 114 ++++++ plugins/github/server/api/schema.ts | 33 ++ plugins/github/server/env.ts | 40 ++ plugins/github/server/github.ts | 168 ++++++++ plugins/github/server/index.ts | 32 ++ plugins/github/server/uninstall.ts | 15 + plugins/github/shared/GitHubUtils.ts | 86 ++++ plugins/iframely/server/iframely.ts | 7 +- plugins/slack/server/auth/slack.ts | 11 +- server/index.ts | 10 + .../20240203061519-integration-soft-delete.js | 15 + server/models/Integration.ts | 38 +- server/presenters/unfurls/document.ts | 4 +- server/presenters/unfurls/mention.ts | 4 +- server/presenters/unfurls/unfurl.ts | 6 +- .../processors/IntegrationCreatedProcessor.ts | 24 ++ .../processors/IntegrationDeletedProcessor.ts | 34 ++ .../api/integrations/integrations.test.ts | 9 +- .../routes/api/integrations/integrations.ts | 38 +- server/routes/api/integrations/schema.ts | 2 +- server/routes/api/urls/urls.test.ts | 4 +- server/routes/api/urls/urls.ts | 6 +- server/types.ts | 2 +- server/utils/CacheHelper.ts | 19 +- server/utils/PluginManager.ts | 4 +- shared/editor/embeds/Gist.tsx | 2 +- shared/i18n/locales/en_US/translation.json | 12 +- shared/types.ts | 52 ++- shared/utils/color.ts | 14 + yarn.lock | 369 +++++++++++++++++- 47 files changed, 1710 insertions(+), 93 deletions(-) create mode 100644 app/components/HoverPreview/HoverPreviewIssue.tsx create mode 100644 app/components/HoverPreview/HoverPreviewPullRequest.tsx create mode 100644 app/components/Icons/IssueStatusIcon.tsx create mode 100644 app/components/Icons/PullRequestIcon.tsx create mode 100644 plugins/github/client/Icon.tsx create mode 100644 plugins/github/client/Settings.tsx create mode 100644 plugins/github/client/components/GitHubButton.tsx create mode 100644 plugins/github/plugin.json create mode 100644 plugins/github/server/api/github.ts create mode 100644 plugins/github/server/api/schema.ts create mode 100644 plugins/github/server/env.ts create mode 100644 plugins/github/server/github.ts create mode 100644 plugins/github/server/index.ts create mode 100644 plugins/github/server/uninstall.ts create mode 100644 plugins/github/shared/GitHubUtils.ts create mode 100644 server/migrations/20240203061519-integration-soft-delete.js create mode 100644 server/queues/processors/IntegrationCreatedProcessor.ts create mode 100644 server/queues/processors/IntegrationDeletedProcessor.ts diff --git a/.env.sample b/.env.sample index 355e2f1998..b05ca77a41 100644 --- a/.env.sample +++ b/.env.sample @@ -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:///api/github.callback +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +GITHUB_APP_NAME= +GITHUB_APP_ID= +GITHUB_APP_PRIVATE_KEY= # –––––––––––––––– OPTIONAL –––––––––––––––– diff --git a/.env.test b/.env.test index 3ca155f2e9..ceee6d86a5 100644 --- a/.env.test +++ b/.env.test @@ -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 diff --git a/app/components/HoverPreview/Components.tsx b/app/components/HoverPreview/Components.tsx index d2486f3789..91d4669a4a 100644 --- a/app/components/HoverPreview/Components.tsx +++ b/app/components/HoverPreview/Components.tsx @@ -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; diff --git a/app/components/HoverPreview/HoverPreview.tsx b/app/components/HoverPreview/HoverPreview.tsx index 1e908daf84..e48b18e1ba 100644 --- a/app/components/HoverPreview/HoverPreview.tsx +++ b/app/components/HoverPreview/HoverPreview.tsx @@ -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) => ( {data.type === UnfurlType.Mention ? ( + ) : data.type === UnfurlType.Issue ? ( + + ) : data.type === UnfurlType.Pull ? ( + ) : ( ; + /** 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 +) { + const authorName = author.name; + + return ( + + + + + + + <IssueStatusIcon status={status.name} color={status.color} /> + <span> + {title} <Text type="tertiary">{identifier}</Text> + </span> + + + + + + {{ authorName }} created{" "} + + + + {description} + + + {labels.map((label, index) => ( + + ))} + + + + + + + ); +}); + +export default HoverPreviewIssue; diff --git a/app/components/HoverPreview/HoverPreviewPullRequest.tsx b/app/components/HoverPreview/HoverPreviewPullRequest.tsx new file mode 100644 index 0000000000..616c23eb19 --- /dev/null +++ b/app/components/HoverPreview/HoverPreviewPullRequest.tsx @@ -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 + ) { + const authorName = author.name; + + return ( + + + + + + + <PullRequestIcon status={status.name} color={status.color} /> + <span> + {title} <Text type="tertiary">{identifier}</Text> + </span> + + + + + + {{ authorName }} opened{" "} + + + + {description} + + + + + + ); + } +); + +export default HoverPreviewPullRequest; diff --git a/app/components/Icons/IssueStatusIcon.tsx b/app/components/Icons/IssueStatusIcon.tsx new file mode 100644 index 0000000000..0b3e77c3c3 --- /dev/null +++ b/app/components/Icons/IssueStatusIcon.tsx @@ -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 ( + + + + ); +} + +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 ( + + + + + ); + case "closed": + return ( + + + + + ); + case "canceled": + return ( + + + + ); + default: + return null; + } +} diff --git a/app/components/Icons/PullRequestIcon.tsx b/app/components/Icons/PullRequestIcon.tsx new file mode 100644 index 0000000000..90c61e4556 --- /dev/null +++ b/app/components/Icons/PullRequestIcon.tsx @@ -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 ( + + + + ); +} + +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 ( + + + + ); + case "merged": + return ( + + + + ); + case "closed": + return ( + + + + ); + default: + return null; + } +} diff --git a/app/models/Integration.ts b/app/models/Integration.ts index 84984ce565..4be2871988 100644 --- a/app/models/Integration.ts +++ b/app/models/Integration.ts @@ -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 extends Model { static modelName = "Integration"; @@ -18,6 +20,13 @@ class Integration extends Model { collectionId: string; + userId: string; + + @Relation(() => User, { onDelete: "cascade" }) + user: User; + + teamId: string; + @Field @observable events: string[]; diff --git a/app/stores/IntegrationsStore.ts b/app/stores/IntegrationsStore.ts index be14bf8542..051971734a 100644 --- a/app/stores/IntegrationsStore.ts +++ b/app/stores/IntegrationsStore.ts @@ -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 { get orderedData(): Integration[] { return naturalSort(Array.from(this.data.values()), "name"); } + + @computed + get github(): Integration[] { + return this.orderedData.filter( + (integration) => integration.service === IntegrationService.GitHub + ); + } } export default IntegrationsStore; diff --git a/app/stores/base/Store.ts b/app/stores/base/Store.ts index e1b30fb628..dcd142e127 100644 --- a/app/stores/base/Store.ts +++ b/app/stores/base/Store.ts @@ -289,17 +289,28 @@ export default abstract class Store { }; @action - fetchAll = async (): Promise => { + fetchAll = async (params?: Record): Promise => { 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 diff --git a/app/utils/urls.ts b/app/utils/urls.ts index 49dc41c9bb..f73e86c4bd 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -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; +} diff --git a/package.json b/package.json index 20dc669081..4276d53504 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/plugins/github/client/Icon.tsx b/plugins/github/client/Icon.tsx new file mode 100644 index 0000000000..0ba71755c4 --- /dev/null +++ b/plugins/github/client/Icon.tsx @@ -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 ( + + + + ); +} diff --git a/plugins/github/client/Settings.tsx b/plugins/github/client/Settings.tsx new file mode 100644 index 0000000000..585efb5d65 --- /dev/null +++ b/plugins/github/client/Settings.tsx @@ -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 ( + }> + GitHub + + {error === "access_denied" && ( + + + Whoops, you need to accept the permissions in GitHub to connect{" "} + {{ appName }} to your workspace. Try again? + + + )} + {error === "unauthenticated" && ( + + + Something went wrong while authenticating your request. Please try + logging in again. + + + )} + {installRequest === "true" && ( + + + The owner of GitHub account has been requested to install the{" "} + {{ githubAppName }} GitHub app. Once approved, previews will be + shown for respective links. + + + )} + {env.GITHUB_CLIENT_ID ? ( + <> + + + Enable previews of GitHub issues and pull requests in documents by + connecting a GitHub organization or specific repositories to{" "} + {appName}. + + + + {integrations.github.length ? ( + <> + + + {t("Connected")} + } /> + + + + {integrations.github.map((integration) => { + const githubAccount = + integration.settings?.github?.installation.account; + const integrationCreatedBy = integration.user + ? integration.user.name + : undefined; + + return ( + + Enabled by {{ integrationCreatedBy }}{" "} + ·{" "} + + + ) : ( +

+ } /> +

+ )} + + ) : ( + + + The GitHub integration is currently disabled. Please set the + associated environment variables and restart the server to enable + the integration. + + + )} +
+ ); +} + +export default observer(GitHub); diff --git a/plugins/github/client/components/GitHubButton.tsx b/plugins/github/client/components/GitHubButton.tsx new file mode 100644 index 0000000000..cf4da512df --- /dev/null +++ b/plugins/github/client/components/GitHubButton.tsx @@ -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) { + const { t } = useTranslation(); + const team = useCurrentTeam(); + + return ( + + ); +} diff --git a/plugins/github/plugin.json b/plugins/github/plugin.json new file mode 100644 index 0000000000..5a47613621 --- /dev/null +++ b/plugins/github/plugin.json @@ -0,0 +1,6 @@ +{ + "id": "github", + "name": "GitHub", + "priority": 10, + "description": "Adds a GitHub integration for link unfurling." +} diff --git a/plugins/github/server/api/github.ts b/plugins/github/server/api/github.ts new file mode 100644 index 0000000000..84dec19c4f --- /dev/null +++ b/plugins/github/server/api/github.ts @@ -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) => { + 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; diff --git a/plugins/github/server/api/schema.ts b/plugins/github/server/api/schema.ts new file mode 100644 index 0000000000..d537c239a3 --- /dev/null +++ b/plugins/github/server/api/schema.ts @@ -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; diff --git a/plugins/github/server/env.ts b/plugins/github/server/env.ts new file mode 100644 index 0000000000..80e877e7fb --- /dev/null +++ b/plugins/github/server/env.ts @@ -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(); diff --git a/plugins/github/server/github.ts b/plugins/github/server/github.ts new file mode 100644 index 0000000000..daf497ff98 --- /dev/null +++ b/plugins/github/server/github.ts @@ -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 => { + 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; + + 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; + } +} diff --git a/plugins/github/server/index.ts b/plugins/github/server/index.ts new file mode 100644 index 0000000000..244817a4be --- /dev/null +++ b/plugins/github/server/index.ts @@ -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, + }, + ]); +} diff --git a/plugins/github/server/uninstall.ts b/plugins/github/server/uninstall.ts new file mode 100644 index 0000000000..f15bb7e852 --- /dev/null +++ b/plugins/github/server/uninstall.ts @@ -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 +) { + if (integration.service === IntegrationService.GitHub) { + const installationId = integration.settings?.github?.installation.id; + + if (installationId) { + return githubApp.deleteInstallation(installationId); + } + } +} diff --git a/plugins/github/shared/GitHubUtils.ts b/plugins/github/shared/GitHubUtils.ts new file mode 100644 index 0000000000..cadf12b75c --- /dev/null +++ b/plugins/github/shared/GitHubUtils.ts @@ -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"; + } + } +} diff --git a/plugins/iframely/server/iframely.ts b/plugins/iframely/server/iframely.ts index c0247df64e..f408b14b9c 100644 --- a/plugins/iframely/server/iframely.ts +++ b/plugins/iframely/server/iframely.ts @@ -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 { 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 { + public static async unfurl(url: string): Promise { return Iframely.fetch(url); } } diff --git a/plugins/slack/server/auth/slack.ts b/plugins/slack/server/auth/slack.ts index 66f4cb7432..5dab1f8926 100644 --- a/plugins/slack/server/auth/slack.ts +++ b/plugins/slack/server/auth/slack.ts @@ -55,15 +55,6 @@ const scopes = [ "identity.team", ]; -function redirectOnClient(ctx: Context, url: string) { - ctx.type = "text/html"; - ctx.body = ` - - - -`; -} - 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, diff --git a/server/index.ts b/server/index.ts index 1d2b1363d6..5502e33cd1 100644 --- a/server/index.ts +++ b/server/index.ts @@ -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 = ` + + + +`; + }; + // Add a health check endpoint to all services router.get("/_health", async (ctx) => { try { diff --git a/server/migrations/20240203061519-integration-soft-delete.js b/server/migrations/20240203061519-integration-soft-delete.js new file mode 100644 index 0000000000..d021fcc69c --- /dev/null +++ b/server/migrations/20240203061519-integration-soft-delete.js @@ -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"); + }, +}; diff --git a/server/models/Integration.ts b/server/models/Integration.ts index 81e41ab9a4..fc67a61969 100644 --- a/server/models/Integration.ts +++ b/server/models/Integration.ts @@ -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 extends IdModel< +class Integration extends ParanoidModel< InferAttributes>, Partial>> > { @@ -77,6 +82,23 @@ class Integration 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; diff --git a/server/presenters/unfurls/document.ts b/server/presenters/unfurls/document.ts index 7e70bc7a5b..03ea58a019 100644 --- a/server/presenters/unfurls/document.ts +++ b/server/presenters/unfurls/document.ts @@ -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 { +): UnfurlResponse { return { url: document.url, type: UnfurlType.Document, diff --git a/server/presenters/unfurls/mention.ts b/server/presenters/unfurls/mention.ts index abeb9042ff..4fa0ad593a 100644 --- a/server/presenters/unfurls/mention.ts +++ b/server/presenters/unfurls/mention.ts @@ -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> { +): Promise> { const lastOnlineInfo = presentLastOnlineInfoFor(user); const lastViewedInfo = await presentLastViewedInfoFor(user, document); diff --git a/server/presenters/unfurls/unfurl.ts b/server/presenters/unfurls/unfurl.ts index 6d8e6d6542..28f932bf3c 100644 --- a/server/presenters/unfurls/unfurl.ts +++ b/server/presenters/unfurls/unfurl.ts @@ -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, }; } diff --git a/server/queues/processors/IntegrationCreatedProcessor.ts b/server/queues/processors/IntegrationCreatedProcessor.ts new file mode 100644 index 0000000000..cdf5d7e0cb --- /dev/null +++ b/server/queues/processors/IntegrationCreatedProcessor.ts @@ -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)); + } +} diff --git a/server/queues/processors/IntegrationDeletedProcessor.ts b/server/queues/processors/IntegrationDeletedProcessor.ts new file mode 100644 index 0000000000..725c5a8589 --- /dev/null +++ b/server/queues/processors/IntegrationDeletedProcessor.ts @@ -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 }); + } +} diff --git a/server/routes/api/integrations/integrations.test.ts b/server/routes/api/integrations/integrations.test.ts index 6a72eb1324..323a457364 100644 --- a/server/routes/api/integrations/integrations.test.ts +++ b/server/routes/api/integrations/integrations.test.ts @@ -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(); }); }); diff --git a/server/routes/api/integrations/integrations.ts b/server/routes/api/integrations/integrations.ts index 2da5821c15..7c20024567 100644 --- a/server/routes/api/integrations/integrations.ts +++ b/server/routes/api/integrations/integrations.ts @@ -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( { diff --git a/server/routes/api/integrations/schema.ts b/server/routes/api/integrations/schema.ts index 30a4981a63..cbc3f1e1d7 100644 --- a/server/routes/api/integrations/schema.ts +++ b/server/routes/api/integrations/schema.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { - IntegrationType, IntegrationService, + IntegrationType, UserCreatableIntegrationService, } from "@shared/types"; import { Integration } from "@server/models"; diff --git a/server/routes/api/urls/urls.test.ts b/server/routes/api/urls/urls.test.ts index a5fff4252b..178c172659 100644 --- a/server/routes/api/urls/urls.test.ts +++ b/server/routes/api/urls/urls.test.ts @@ -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(); diff --git a/server/routes/api/urls/urls.ts b/server/routes/api/urls/urls.ts index a80abd6719..7ce698df49 100644 --- a/server/routes/api/urls/urls.ts +++ b/server/routes/api/urls/urls.ts @@ -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 ); diff --git a/server/types.ts b/server/types.ts index 2d1f4ed56d..11edbf11d9 100644 --- a/server/types.ts +++ b/server/types.ts @@ -508,5 +508,5 @@ export type CollectionJSONExport = { }; export type UnfurlResolver = { - unfurl: (url: string) => Promise; + unfurl: (url: string, actor?: User) => Promise; }; diff --git a/server/utils/CacheHelper.ts b/server/utils/CacheHelper.ts index de5a49e392..38dcb317e6 100644 --- a/server/utils/CacheHelper.ts +++ b/server/utils/CacheHelper.ts @@ -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); + }) + ); + } } diff --git a/server/utils/PluginManager.ts b/server/utils/PluginManager.ts index e203e1bede..2db602b6f4 100644 --- a/server/utils/PluginManager.ts +++ b/server/utils/PluginManager.ts @@ -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; + [Hook.Uninstall]: UninstallSignature; [Hook.UnfurlProvider]: { unfurl: UnfurlSignature; cacheExpiry: number }; }; diff --git a/shared/editor/embeds/Gist.tsx b/shared/editor/embeds/Gist.tsx index 265381e2ea..2d8eb81b36 100644 --- a/shared/editor/embeds/Gist.tsx +++ b/shared/editor/embeds/Gist.tsx @@ -24,7 +24,7 @@ function Gist(props: Props) { height="200px" scrolling="no" id={`gist-${id}`} - title="Github Gist" + title="GitHub Gist" /> ); } diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index b09a861004..e84140e5ca 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -228,6 +228,8 @@ "{{ count }} member": "{{ count }} member", "{{ count }} member_plural": "{{ count }} members", "Group members": "Group members", + "{{authorName}} created <3>": "{{authorName}} created <3>", + "{{authorName}} opened <3>": "{{authorName}} opened <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 {{ command }} slash command to search for documents without leaving your chat.": "Get rich previews of {{ appName }} links shared in Slack and use the {{ command }} 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?", diff --git a/shared/types.ts b/shared/types.ts index 320327a56b..65afb6cfb3 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -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 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 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 = - | { - url?: string; - type: T; - title: string; - description?: string; - thumbnailUrl?: string | null; - meta?: Record; - } +export type UnfurlResponse> = { + 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; +export type UnfurlSignature = ( + url: string, + actor?: any +) => Promise; + +export type UninstallSignature = ( + integration: Record +) => Promise; export type JSONValue = | string diff --git a/shared/utils/color.ts b/shared/utils/color.ts index 5d0344fab3..e259e0534d 100644 --- a/shared/utils/color.ts +++ b/shared/utils/color.ts @@ -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"; +}; diff --git a/yarn.lock b/yarn.lock index 1d84dbac57..df181e58f1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"