diff --git a/.env.sample b/.env.sample index b2f1ca0dc7..fb177dc83f 100644 --- a/.env.sample +++ b/.env.sample @@ -212,6 +212,11 @@ GITHUB_APP_NAME= GITHUB_APP_ID= GITHUB_APP_PRIVATE_KEY= +# The GitLab integration allows previewing issue and merge request links +# DOCS: +GITLAB_CLIENT_ID= +GITLAB_CLIENT_SECRET= + # Linear integration allows previewing issue links as rich mentions LINEAR_CLIENT_ID= LINEAR_CLIENT_SECRET= diff --git a/.env.test b/.env.test index af6eac1895..1f045a3b14 100644 --- a/.env.test +++ b/.env.test @@ -18,6 +18,9 @@ GITHUB_CLIENT_ID=123; GITHUB_CLIENT_SECRET=123; GITHUB_APP_NAME=outline-test; +GITLAB_CLIENT_ID=123 +GITLAB_CLIENT_SECRET=123 + OIDC_CLIENT_ID=client-id OIDC_CLIENT_SECRET=client-secret OIDC_AUTH_URI=http://localhost/authorize diff --git a/.gitignore b/.gitignore index 1cdc54edcd..ba5f1314d6 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,5 @@ data/* !.yarn/patches !.yarn/plugins !.yarn/releases +.yarn/releases !.yarn/sdks diff --git a/app/components/HoverPreview/HoverPreviewIssue.tsx b/app/components/HoverPreview/HoverPreviewIssue.tsx index 65f268df7d..ae25c30a14 100644 --- a/app/components/HoverPreview/HoverPreviewIssue.tsx +++ b/app/components/HoverPreview/HoverPreviewIssue.tsx @@ -3,9 +3,11 @@ import { Trans } from "react-i18next"; import styled from "styled-components"; import { Backticks } from "@shared/components/Backticks"; import { IssueStatusIcon } from "@shared/components/IssueStatusIcon"; +import { richExtensions } from "@shared/editor/nodes"; import type { UnfurlResourceType, UnfurlResponse } from "@shared/types"; import { IntegrationService } from "@shared/types"; import { Avatar } from "~/components/Avatar"; +import Editor from "~/components/Editor"; import Flex from "~/components/Flex"; import Text from "../Text"; import Time from "../Time"; @@ -28,9 +30,11 @@ const HoverPreviewIssue = React.forwardRef(function HoverPreviewIssue_( const authorName = author.name; const urlObj = new URL(url); const service = - urlObj.hostname === "github.com" - ? IntegrationService.GitHub - : IntegrationService.Linear; + urlObj.hostname === "linear.app" + ? IntegrationService.Linear + : urlObj.hostname === "github.com" + ? IntegrationService.GitHub + : IntegrationService.GitLab; return ( @@ -58,7 +62,18 @@ const HoverPreviewIssue = React.forwardRef(function HoverPreviewIssue_( - {description} + {description && ( + + }> + + + + )} {labels.map((label, index) => ( diff --git a/app/components/HoverPreview/HoverPreviewPullRequest.tsx b/app/components/HoverPreview/HoverPreviewPullRequest.tsx index 460dd7cf2d..e9a4f41eca 100644 --- a/app/components/HoverPreview/HoverPreviewPullRequest.tsx +++ b/app/components/HoverPreview/HoverPreviewPullRequest.tsx @@ -3,8 +3,10 @@ import { Trans } from "react-i18next"; import styled from "styled-components"; import { Backticks } from "@shared/components/Backticks"; import { PullRequestIcon } from "@shared/components/PullRequestIcon"; +import { richExtensions } from "@shared/editor/nodes"; import type { UnfurlResourceType, UnfurlResponse } from "@shared/types"; import { Avatar } from "~/components/Avatar"; +import Editor from "~/components/Editor"; import Flex from "~/components/Flex"; import Text from "../Text"; import Time from "../Time"; @@ -48,7 +50,18 @@ const HoverPreviewPullRequest = React.forwardRef( - {description} + {description && ( + + }> + + + + )} diff --git a/app/stores/IntegrationsStore.ts b/app/stores/IntegrationsStore.ts index f442d1a9c5..ac497230c0 100644 --- a/app/stores/IntegrationsStore.ts +++ b/app/stores/IntegrationsStore.ts @@ -28,6 +28,13 @@ class IntegrationsStore extends Store { ); } + @computed + get gitlab(): Integration[] { + return this.orderedData.filter( + (integration) => integration.service === IntegrationService.GitLab + ); + } + @computed get linear(): Integration[] { return this.orderedData.filter( diff --git a/app/utils/mention.ts b/app/utils/mention.ts index 1c44dc8978..fcd7acedf5 100644 --- a/app/utils/mention.ts +++ b/app/utils/mention.ts @@ -27,6 +27,16 @@ export const isURLMentionable = ({ ); } + case IntegrationService.GitLab: { + const settings = + integration.settings as IntegrationSettings; + const gitlabHostname = settings.gitlab?.url + ? new URL(settings.gitlab?.url).hostname + : undefined; + + return hostname === "gitlab.com" || hostname === gitlabHostname; + } + default: return false; } @@ -57,6 +67,14 @@ export const determineMentionType = ({ return type === "issue" ? MentionType.Issue : undefined; } + case IntegrationService.GitLab: { + return pathname.includes("merge_requests") + ? MentionType.PullRequest + : pathname.includes("issues") + ? MentionType.Issue + : undefined; + } + default: return; } diff --git a/package.json b/package.json index a865be2ffe..83e11110e5 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "@fortawesome/free-solid-svg-icons": "^7.1.0", "@fortawesome/react-fontawesome": "^0.2.6", "@getoutline/react-roving-tabindex": "^3.2.4", + "@gitbeaker/rest": "^43.8.0", "@hocuspocus/extension-redis": "1.1.2", "@hocuspocus/extension-throttle": "1.1.2", "@hocuspocus/provider": "1.1.2", @@ -143,6 +144,7 @@ "i18next-http-backend": "^2.7.3", "invariant": "^2.2.4", "ioredis": "^5.8.2", + "ipaddr.js": "^2.3.0", "is-printable-key-event": "^1.0.0", "iso-639-3": "^3.0.1", "jsdom": "^22.1.0", diff --git a/plugins/gitlab/client/Settings.tsx b/plugins/gitlab/client/Settings.tsx new file mode 100644 index 0000000000..cfac9e3f60 --- /dev/null +++ b/plugins/gitlab/client/Settings.tsx @@ -0,0 +1,145 @@ +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 { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene"; +import { AvatarSize } from "~/components/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 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 GitLabIcon from "./components/Icon"; +import { GitLabConnectButton } from "./components/GitLabButton"; + +function GitLab() { + 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; + + React.useEffect(() => { + void integrations.fetchAll({ + service: IntegrationService.GitLab, + withRelations: true, + }); + }, [integrations]); + + return ( + }> + GitLab + + {error && ( + + {error === "access_denied" ? ( + + You need to accept the permissions in GitLab to connect{" "} + {{ appName }} to your workspace. Try again? + + ) : ( + + Something went wrong while authenticating your request. Please try + again. + + )} + + )} + {installRequest === "true" && ( + + + The owner of GitLab account has been requested to install the + application. Once approved, the connection will be completed. + + + )} + + + Enable previews of GitLab issues and merge requests in documents by + connecting a GitLab organization or specific repositories to {appName} + . + + + + {integrations.gitlab.some((int) => int.settings.gitlab?.installation) ? ( + <> + + + {t("Connected")} + } /> + + + + {integrations.gitlab.map((integration) => { + const gitlabAccount = + integration.settings?.gitlab?.installation?.account; + const integrationCreatedBy = integration.user + ? integration.user.name + : undefined; + + const customUrl = integration.settings?.gitlab?.url; + + return ( + gitlabAccount && ( + + {customUrl && <>{customUrl} · } + + Enabled by {{ integrationCreatedBy }} + {" "} + ·{" "} + + + ) : ( +

+ } /> +

+ )} +
+ ); +} + +export default observer(GitLab); diff --git a/plugins/gitlab/client/components/GitLabButton.tsx b/plugins/gitlab/client/components/GitLabButton.tsx new file mode 100644 index 0000000000..37c401e00f --- /dev/null +++ b/plugins/gitlab/client/components/GitLabButton.tsx @@ -0,0 +1,26 @@ +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import Button, { type Props } from "~/components/Button"; +import useStores from "~/hooks/useStores"; +import GitLabConnectDialog from "./GitLabConnectDialog"; + +/** + * Button that opens a dialog to connect to GitLab Cloud or a self-managed instance. + */ +export function GitLabConnectButton(props: Props) { + const { t } = useTranslation(); + const { dialogs } = useStores(); + + const handleClick = () => { + dialogs.openModal({ + title: t("Connect GitLab"), + content: , + }); + }; + + return ( + + ); +} diff --git a/plugins/gitlab/client/components/GitLabConnectDialog.tsx b/plugins/gitlab/client/components/GitLabConnectDialog.tsx new file mode 100644 index 0000000000..03b3c62032 --- /dev/null +++ b/plugins/gitlab/client/components/GitLabConnectDialog.tsx @@ -0,0 +1,186 @@ +import * as React from "react"; +import { useTranslation, Trans } from "react-i18next"; +import styled from "styled-components"; +import { toast } from "sonner"; +import { s } from "@shared/styles"; +import Button from "~/components/Button"; +import Flex from "~/components/Flex"; +import Input from "~/components/Input"; +import Text from "~/components/Text"; +import env from "~/env"; +import useStores from "~/hooks/useStores"; +import { client } from "~/utils/ApiClient"; +import { redirectTo } from "~/utils/urls"; + +/** + * Dialog that presents the user with options to connect to GitLab Cloud + * or a self-managed GitLab instance. + */ +function GitLabConnectDialog() { + const { t } = useTranslation(); + const { dialogs } = useStores(); + const [showCustomForm, setShowCustomForm] = React.useState( + !env.GITLAB_CLIENT_ID + ); + const [customUrl, setCustomUrl] = React.useState(""); + const [clientId, setClientId] = React.useState(""); + const [clientSecret, setClientSecret] = React.useState(""); + const [saving, setSaving] = React.useState(false); + + const handleConnect = React.useCallback( + async (params: { + url?: string; + clientId?: string; + clientSecret?: string; + }) => { + setSaving(true); + try { + const res = await client.post("/gitlab.connect", params); + dialogs.closeAllModals(); + redirectTo(res.data.redirectUrl); + } catch (err) { + toast.error(err.message); + } finally { + setSaving(false); + } + }, + [dialogs] + ); + + const handleConnectCloud = React.useCallback(async () => { + await handleConnect({}); + }, [handleConnect]); + + const handleConnectCustom = React.useCallback( + async (ev: React.FormEvent) => { + ev.preventDefault(); + const url = customUrl.trim().replace(/\/+$/, ""); + await handleConnect({ + url, + clientId: clientId.trim() || undefined, + clientSecret: clientSecret.trim() || undefined, + }); + }, + [customUrl, clientId, clientSecret, handleConnect] + ); + + if (showCustomForm) { + return ( +
+ + + Enter the details for your GitLab instance. + + setCustomUrl(ev.currentTarget.value)} + pattern="https://.*" + title={t("URL must start with https")} + required + autoFocus + /> + setClientId(ev.currentTarget.value)} + required + /> + setClientSecret(ev.currentTarget.value)} + type="password" + required + /> + + {env.GITLAB_CLIENT_ID && ( + + )} + + + +
+ ); + } + + return ( + + + Choose which GitLab instance to connect to. + + + + + ); +} + +const Option = styled.button` + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + padding: 12px; + border: 1px solid ${s("inputBorder")}; + border-radius: 8px; + background: ${s("background")}; + cursor: pointer; + text-align: left; + width: 100%; + + &:hover { + background: ${s("listItemHoverBackground")}; + border-color: ${s("textTertiary")}; + } + + &:disabled { + opacity: 0.5; + } +`; + +const OptionTitle = styled.span` + font-size: 14px; + font-weight: 500; + color: ${s("text")}; +`; + +const OptionDescription = styled.span` + font-size: 13px; + color: ${s("textTertiary")}; +`; + +export default GitLabConnectDialog; diff --git a/plugins/gitlab/client/components/Icon.tsx b/plugins/gitlab/client/components/Icon.tsx new file mode 100644 index 0000000000..bfb291c587 --- /dev/null +++ b/plugins/gitlab/client/components/Icon.tsx @@ -0,0 +1,25 @@ +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/gitlab/client/index.tsx b/plugins/gitlab/client/index.tsx new file mode 100644 index 0000000000..f684d201a3 --- /dev/null +++ b/plugins/gitlab/client/index.tsx @@ -0,0 +1,18 @@ +import { createLazyComponent } from "~/components/LazyLoad"; +import { Hook, PluginManager } from "~/utils/PluginManager"; +import config from "../plugin.json"; +import Icon from "./components/Icon"; + +PluginManager.add([ + { + ...config, + type: Hook.Settings, + value: { + group: "Integrations", + icon: Icon, + description: + "Connect your GitLab account to Outline to enable rich, realtime, issue and merge request previews inside documents.", + component: createLazyComponent(() => import("./Settings")), + }, + }, +]); diff --git a/plugins/gitlab/plugin.json b/plugins/gitlab/plugin.json new file mode 100644 index 0000000000..ad68333b94 --- /dev/null +++ b/plugins/gitlab/plugin.json @@ -0,0 +1,7 @@ +{ + "id": "gitlab", + "name": "GitLab", + "priority": 11, + "description": "Adds a GitLab integration for link unfurling.", + "after": "github" +} diff --git a/plugins/gitlab/server/GitLabIssueProvider.ts b/plugins/gitlab/server/GitLabIssueProvider.ts new file mode 100644 index 0000000000..ebe47c4034 --- /dev/null +++ b/plugins/gitlab/server/GitLabIssueProvider.ts @@ -0,0 +1,308 @@ +import type { IssueSource } from "@shared/schema"; +import { IntegrationService, type IntegrationType } from "@shared/types"; +import Logger from "@server/logging/Logger"; +import { Integration, IntegrationAuthentication } from "@server/models"; +import { BaseIssueProvider } from "@server/utils/BaseIssueProvider"; +import { GitLab } from "./gitlab"; +import { sequelize } from "@server/storage/database"; +import { Op } from "sequelize"; + +export class GitLabIssueProvider extends BaseIssueProvider { + constructor() { + super(IntegrationService.GitLab); + } + + async fetchSources( + integration: Integration + ): Promise { + await integration.reload({ + include: [ + { + model: IntegrationAuthentication, + as: "authentication", + required: true, + }, + ], + }); + + if (!integration.authentication) { + Logger.warn("GitLab integration without authentication"); + return []; + } + + const sources: IssueSource[] = []; + + try { + const projects = await GitLab.getProjects({ + accessToken: integration.authentication.token, + teamId: integration.teamId, + }); + + sources.push( + ...projects.map((project) => ({ + id: String(project.id), + name: project.name, + owner: { + id: String(project.namespace.id), + name: project.namespace.full_path, + }, + service: IntegrationService.GitLab, + })) + ); + } catch (err) { + Logger.warn("Failed to fetch projects from GitLab", err); + } + + return sources; + } + + async handleWebhook({ + payload, + headers, + }: { + payload: Record; + headers: Record; + }) { + const hookId = headers["x-gitlab-webhook-uuid"] as string; + const eventName = payload.event_name as string; + + if (!eventName) { + Logger.warn( + `Received GitLab webhook without event name; hookId: ${hookId}, eventName: ${eventName}` + ); + return; + } + + switch (eventName) { + case "project_update": + case "project_transfer": + case "project_rename": + await this.updateProject(payload); + break; + case "repository_update": + await this.createProject(payload); + break; + case "project_destroy": + await this.destroyProject(payload); + break; + case "group_rename": + case "user_rename": + await this.updateNamespace(payload); + break; + case "user_destroy": + case "group_destroy": + await this.destroyNamespace(payload); + break; + default: + break; + } + } + + private async updateNamespace(payload: Record) { + const name = payload.old_full_path ?? payload.old_username; + const where = { + service: IntegrationService.GitLab, + [Op.and]: sequelize.literal(`"issueSources"::jsonb @> :jsonCondition`), + }; + const jsonCondition = JSON.stringify([{ owner: { name } }]); + + await sequelize.transaction(async (transaction) => { + const integration = (await Integration.findOne({ + where, + replacements: { jsonCondition }, + lock: transaction.LOCK.UPDATE, + transaction, + })) as Integration; + + if (!integration) { + Logger.warn(`GitLab namespace_update event without integration;`); + return; + } + + const sources = integration.issueSources ?? []; + const updatedSources = sources.map((source) => { + if (source.owner.name === name) { + return { + ...source, + owner: { + id: payload.group_id || source.owner.id, + name: payload.full_path ?? payload.username, + }, + }; + } + return source; + }); + + integration.issueSources = updatedSources; + integration.changed("issueSources", true); + await integration.save({ transaction }); + }); + } + + private async destroyNamespace(payload: Record) { + let replacements = {}; + const whereCondition: any = { + service: IntegrationService.GitLab, + }; + + if (payload.user_id) { + whereCondition["settings.gitlab.installation.account.id"] = + payload.user_id; + } else if (payload.full_path) { + whereCondition[Op.and] = sequelize.literal( + `"issueSources"::jsonb @> :jsonCondition` + ); + replacements = { + jsonCondition: JSON.stringify([{ owner: { name: payload.full_path } }]), + }; + } + + await sequelize.transaction(async (transaction) => { + const integrations = (await Integration.findAll({ + where: whereCondition, + replacements, + lock: transaction.LOCK.UPDATE, + transaction, + })) as Integration[]; + + if (!integrations.length) { + Logger.warn(`GitLab namespace_destroy event without integration;`); + return; + } + + for (const integration of integrations) { + if (payload.full_path) { + const sources = + integration.issueSources?.filter( + (source) => payload.full_path !== source.owner.name + ) ?? []; + + integration.issueSources = sources; + integration.changed("issueSources", true); + await integration.save({ transaction }); + } else if (payload.user_id) { + await integration.destroy(); + } + } + }); + } + + private async destroyProject(payload: Record) { + await sequelize.transaction(async (transaction) => { + const integrations = await Integration.findAll({ + where: { + service: IntegrationService.GitLab, + [Op.and]: sequelize.where( + sequelize.literal(`"issueSources"::jsonb @> :projectJson`), + Op.eq, + true + ), + }, + replacements: { + projectJson: JSON.stringify([{ id: String(payload.project_id) }]), + }, + lock: transaction.LOCK.UPDATE, + transaction, + }); + + if (!integrations.length) { + Logger.warn(`GitLab project_destroy event without integration;`); + return; + } + + for (const integration of integrations) { + const sources = + integration.issueSources?.filter( + (source) => String(payload.project_id) !== source.id + ) ?? []; + + integration.issueSources = sources; + integration.changed("issueSources", true); + await integration.save({ transaction }); + } + }); + } + + private async createProject(payload: Record) { + const createEvent = payload.changes.some((p: { before: string }) => + /^0{40}$/.test(p.before) + ); + + if (!createEvent) { + return; + } + await sequelize.transaction(async (transaction) => { + const integration = (await Integration.findOne({ + where: { + service: IntegrationService.GitLab, + "settings.gitlab.installation.account.id": payload.user_id, + }, + lock: transaction.LOCK.UPDATE, + })) as Integration; + + if (!integration) { + Logger.warn(`GitLab project_create event without integration;`); + return; + } + + const project = payload.project; + const owner = { + id: "", // namespace.id is not provided in this webhook payload + name: project.path_with_namespace.split("/").slice(0, -1).join("/"), + }; + const sources = integration.issueSources ?? []; + sources.push({ + id: String(payload.project_id), + name: project.name, + service: IntegrationService.GitLab, + owner, + }); + + integration.issueSources = sources; + integration.changed("issueSources", true); + await integration.save({ transaction }); + }); + } + + private async updateProject(payload: Record) { + await sequelize.transaction(async (transaction) => { + const integrations = await Integration.findAll({ + where: { + service: IntegrationService.GitLab, + [Op.and]: sequelize.where( + sequelize.literal(`"issueSources"::jsonb @> :projectJson`), + Op.eq, + true + ), + }, + replacements: { + projectJson: JSON.stringify([{ id: String(payload.project_id) }]), + }, + lock: transaction.LOCK.UPDATE, + transaction, + }); + + if (!integrations.length) { + Logger.warn(`GitLab project_update event without integration;`); + return; + } + + for (const integration of integrations) { + const source = integration.issueSources?.find( + (s) => s.id === String(payload.project_id) + ); + + if (source) { + source.name = payload.name; + source.owner.name = payload.path_with_namespace + .split("/") + .slice(0, -1) + .join("/"); + source.owner.id = String(payload.project_namespace_id); + integration.changed("issueSources", true); + await integration.save({ transaction }); + } + } + }); + } +} diff --git a/plugins/gitlab/server/api/gitlab.ts b/plugins/gitlab/server/api/gitlab.ts new file mode 100644 index 0000000000..5351cce00a --- /dev/null +++ b/plugins/gitlab/server/api/gitlab.ts @@ -0,0 +1,312 @@ +import Router from "koa-router"; +import { Op } from "sequelize"; +import { IntegrationService, IntegrationType } from "@shared/types"; +import { createContext } from "@server/context"; +import apexAuthRedirect from "@server/middlewares/apexAuthRedirect"; +import auth from "@server/middlewares/authentication"; +import { transaction } from "@server/middlewares/transaction"; +import validate from "@server/middlewares/validate"; +import validateWebhook from "@server/middlewares/validateWebhook"; +import { IntegrationAuthentication, Integration } from "@server/models"; +import { authorize } from "@server/policies"; +import type { APIContext } from "@server/types"; +import { validateUrlNotPrivate } from "@server/utils/url"; +import { addSeconds } from "date-fns"; +import Logger from "@server/logging/Logger"; +import { GitLabUtils } from "../../shared/GitLabUtils"; +import { GitLab } from "../gitlab"; +import env from "../env"; +import GitLabWebhookTask from "../tasks/GitLabWebhookTask"; +import * as T from "../schema"; + +const router = new Router(); + +router.post( + "gitlab.connect", + auth(), + validate(T.GitLabConnectSchema), + transaction(), + async (ctx: APIContext) => { + const { url: rawUrl, clientId, clientSecret } = ctx.input.body; + const url = rawUrl?.replace(/\/+$/, ""); + const { user } = ctx.state.auth; + const { transaction } = ctx.state; + + authorize(user, "createIntegration", user.team); + + if (url) { + await validateUrlNotPrivate(url); + } + + if (url && clientId && clientSecret) { + // Clean up any stale pending auth records for this user/team/service + await IntegrationAuthentication.destroy({ + where: { + service: IntegrationService.GitLab, + userId: user.id, + teamId: user.teamId, + token: { [Op.is]: null } as never, + }, + transaction, + }); + + // Check if an integration already exists for this GitLab URL + const existing = await Integration.findOne({ + where: { + service: IntegrationService.GitLab, + teamId: user.teamId, + settings: { gitlab: { url } }, + }, + include: [ + { + model: IntegrationAuthentication, + as: "authentication", + required: false, + }, + ], + transaction, + }); + + if (existing?.authentication) { + // Update the existing authentication with new credentials and + // clear tokens so the callback treats it as pending + existing.authentication.clientId = clientId; + existing.authentication.clientSecret = clientSecret; + existing.authentication.setDataValue("token", null as never); + existing.authentication.setDataValue("refreshToken", null as never); + await existing.authentication.save({ transaction }); + } else { + // Create a pending IntegrationAuthentication with credentials + const pendingAuth = await IntegrationAuthentication.create( + { + service: IntegrationService.GitLab, + userId: user.id, + teamId: user.teamId, + clientId, + clientSecret, + }, + { transaction } + ); + + if (existing) { + // Link existing integration to the new authentication + await existing.update( + { authenticationId: pendingAuth.id }, + { transaction } + ); + } else { + // Create a new integration with the URL and link it + await Integration.create( + { + service: IntegrationService.GitLab, + type: IntegrationType.Embed, + userId: user.id, + teamId: user.teamId, + authenticationId: pendingAuth.id, + settings: { gitlab: { url } }, + } as Partial, + { transaction } + ); + } + } + } + + const redirectUrl = GitLabUtils.authUrl(user.teamId, url, clientId); + ctx.body = { + data: { redirectUrl }, + }; + } +); + +router.get( + "gitlab.callback", + auth({ optional: true }), + validate(T.GitLabCallbackSchema), + apexAuthRedirect({ + getTeamId: (ctx) => ctx.input.query.state, + getRedirectPath: (ctx, team) => + GitLabUtils.callbackUrl({ + baseUrl: team.url, + params: ctx.request.querystring, + }), + getErrorPath: () => GitLabUtils.errorUrl("unauthenticated"), + }), + transaction(), + async (ctx: APIContext) => { + const { code, error } = ctx.input.query; + const { user } = ctx.state.auth; + const { transaction } = ctx.state; + + if (error) { + ctx.redirect(GitLabUtils.errorUrl(error)); + return; + } + + try { + // Check for a pending IntegrationAuthentication with custom credentials + const pendingAuth = await IntegrationAuthentication.findOne({ + where: { + service: IntegrationService.GitLab, + userId: user.id, + teamId: user.teamId, + token: { [Op.is]: null } as never, + }, + transaction, + }); + + // Resolve the custom URL from the linked Integration (if any) + let customUrl: string | undefined; + let existingIntegration: Integration | null = null; + + if (pendingAuth) { + existingIntegration = await Integration.findOne({ + where: { + service: IntegrationService.GitLab, + teamId: user.teamId, + authenticationId: pendingAuth.id, + }, + transaction, + }); + customUrl = ( + existingIntegration?.settings as { gitlab?: { url?: string } } + )?.gitlab?.url; + } + + const oauth = await GitLab.oauthAccess({ + code, + customUrl, + clientId: pendingAuth?.clientId ?? undefined, + clientSecret: pendingAuth?.clientSecret ?? undefined, + }); + + const userInfo = await GitLab.getCurrentUser({ + accessToken: oauth.access_token, + customUrl, + }); + + let authentication: IntegrationAuthentication; + + if (pendingAuth) { + // Update the pending record with OAuth tokens + await pendingAuth.update( + { + token: oauth.access_token, + refreshToken: oauth.refresh_token, + expiresAt: oauth.expires_in + ? addSeconds(Date.now(), oauth.expires_in) + : undefined, + scopes: oauth.scope.split(" "), + }, + { transaction } + ); + authentication = pendingAuth; + } else { + authentication = await IntegrationAuthentication.create( + { + service: IntegrationService.GitLab, + userId: user.id, + teamId: user.teamId, + token: oauth.access_token, + refreshToken: oauth.refresh_token, + expiresAt: oauth.expires_in + ? addSeconds(Date.now(), oauth.expires_in) + : undefined, + scopes: oauth.scope.split(" "), + }, + { transaction } + ); + } + + const installationSettings = { + gitlab: { + ...(customUrl ? { url: customUrl } : {}), + installation: { + id: userInfo.id, + account: { + id: userInfo.id, + name: userInfo.username, + avatarUrl: userInfo.avatar_url, + }, + }, + }, + }; + + if (existingIntegration) { + // Update the existing Integration created during gitlab.connect + existingIntegration.settings = + installationSettings as Integration["settings"]; + await existingIntegration.save({ transaction }); + } else { + await Integration.createWithCtx(createContext({ user, transaction }), { + service: IntegrationService.GitLab, + type: IntegrationType.Embed, + userId: user.id, + teamId: user.teamId, + authenticationId: authentication.id, + settings: installationSettings, + }); + } + + ctx.redirect(GitLabUtils.url); + } catch (err) { + Logger.error("Encountered error during Gitlab OAuth callback", err); + ctx.redirect(GitLabUtils.errorUrl("unauthenticated")); + } + } +); + +router.post( + "gitlab.webhooks", + validateWebhook({ + hmacSign: false, + secretKey: async (ctx) => { + const instanceHeader = ctx.request.headers["x-gitlab-instance"]; + const instanceUrl = ( + Array.isArray(instanceHeader) ? instanceHeader[0] : instanceHeader + )?.replace(/\/+$/, ""); + + // Self-hosted instances store their client secret in the database, + // use the X-Gitlab-Instance header to find the matching integration. + if (instanceUrl && instanceUrl !== "https://gitlab.com") { + const integration = await Integration.findOne({ + where: { + service: IntegrationService.GitLab, + settings: { gitlab: { url: instanceUrl } }, + }, + include: [ + { + model: IntegrationAuthentication, + as: "authentication", + required: true, + }, + ], + }); + if (integration) { + return integration.authentication.clientSecret ?? undefined; + } + } + + // Default GitLab.com instance uses the env secret + return env.GITLAB_CLIENT_SECRET; + }, + getSignatureFromHeader: (ctx) => { + const { headers } = ctx.request; + const signatureHeader = headers["x-gitlab-token"]; + return Array.isArray(signatureHeader) + ? signatureHeader[0] + : signatureHeader; + }, + }), + async (ctx: APIContext) => { + const { headers, body } = ctx.request; + + await new GitLabWebhookTask().schedule({ + payload: body, + headers, + }); + + ctx.status = 202; + } +); + +export default router; diff --git a/plugins/gitlab/server/env.ts b/plugins/gitlab/server/env.ts new file mode 100644 index 0000000000..60888f7bad --- /dev/null +++ b/plugins/gitlab/server/env.ts @@ -0,0 +1,25 @@ +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 GitLabPluginEnvironment extends Environment { + /** + * GitLab OAuth2 client credentials. To enable integration with GitLab cloud. + */ + @Public + @IsOptional() + public GITLAB_CLIENT_ID = this.toOptionalString(environment.GITLAB_CLIENT_ID); + + /** + * GitLab OAuth2 client secret used for OAuth2 authentication with GitLab cloud. + */ + @IsOptional() + @CannotUseWithout("GITLAB_CLIENT_ID") + public GITLAB_CLIENT_SECRET = this.toOptionalString( + environment.GITLAB_CLIENT_SECRET + ); +} + +export default new GitLabPluginEnvironment(); diff --git a/plugins/gitlab/server/gitlab.ts b/plugins/gitlab/server/gitlab.ts new file mode 100644 index 0000000000..941027079f --- /dev/null +++ b/plugins/gitlab/server/gitlab.ts @@ -0,0 +1,393 @@ +import { Gitlab } from "@gitbeaker/rest"; +import type { + IssueSchemaWithExpandedLabels, + MergeRequestSchema, +} from "@gitbeaker/rest"; +import z from "zod"; +import { + type IntegrationType, + IntegrationService, + UnfurlResourceType, +} from "@shared/types"; +import Logger from "@server/logging/Logger"; +import type { User } from "@server/models"; +import { Integration, IntegrationAuthentication } from "@server/models"; +import type { UnfurlIssueOrPR, UnfurlSignature } from "@server/types"; +import fetch from "@server/utils/fetch"; +import { validateUrlNotPrivate } from "@server/utils/url"; +import { GitLabUtils } from "../shared/GitLabUtils"; +import env from "./env"; + +const AccessTokenResponseSchema = z.object({ + access_token: z.string(), + token_type: z.string(), + expires_in: z.number(), + refresh_token: z.string(), + scope: z.string(), + created_at: z.number(), +}); + +export class GitLab { + private static clientSecret = env.GITLAB_CLIENT_SECRET; + private static clientId = env.GITLAB_CLIENT_ID; + + /** + * Fetches the custom GitLab URL for a team from the first matching + * integration, falling back to the default. + * + * @param teamId - The team ID to fetch settings for. + * @returns The GitLab URL to use. + */ + public static async getGitLabUrl(teamId: string) { + const integration = await Integration.findOne({ + where: { service: IntegrationService.GitLab, teamId }, + }); + + const url = (integration?.settings as { gitlab?: { url?: string } })?.gitlab + ?.url; + + return url || GitLabUtils.defaultGitlabUrl; + } + + /** + * Creates a Gitbeaker client instance. + * + * @param accessToken - The access token for authentication. + * @param customUrl - Optional custom GitLab URL from integration settings. + * @returns A configured Gitbeaker client. + */ + public static async createClient(accessToken: string, customUrl?: string) { + const host = customUrl || GitLabUtils.defaultGitlabUrl; + + // Validate the URL to prevent SSRF as GitLab instance does not use our + // fetch wrapper which has built-in SSRF protection. + await validateUrlNotPrivate(host); + + return new Gitlab({ + host, + oauthToken: accessToken, + }); + } + + /** + * Fetches an issue from a GitLab project. + * + * @param accessToken - The access token for authentication. + * @param projectPath - The project path (owner/repo). + * @param issueIid - The issue IID (internal ID within the project). + * @param customUrl - Optional custom GitLab URL from integration settings. + * @returns The issue data. + */ + public static async getIssue( + accessToken: string, + projectPath: string, + issueIid: number, + customUrl?: string + ) { + const client = await this.createClient(accessToken, customUrl); + + const issues = await client.Issues.all({ + projectId: projectPath, + iids: [issueIid], + withLabelsDetails: true, + }); + + if (!issues || issues.length === 0) { + throw new Error(`Issue ${issueIid} not found in project ${projectPath}`); + } + + return issues[0]; + } + + /** + * Fetches a merge request from a GitLab project. + * + * @param accessToken - The access token for authentication. + * @param projectPath - The project path (owner/repo). + * @param mrIid - The merge request IID (internal ID within the project). + * @param customUrl - Optional custom GitLab URL from integration settings. + * @returns The merge request data. + */ + public static async getMergeRequest( + accessToken: string, + projectPath: string, + mrIid: number, + customUrl?: string + ) { + const client = await this.createClient(accessToken, customUrl); + const mr = await client.MergeRequests.show(projectPath, mrIid); + return mr; + } + + /** + * Fetches current user information. + * + * @param params.accessToken - Access token received from OAuth flow. + * @param params.customUrl - Optional custom GitLab URL. Falls back to default. + * @returns User information including the resolved URL. + */ + public static async getCurrentUser({ + accessToken, + customUrl, + }: { + accessToken: string; + customUrl?: string; + }) { + const url = customUrl || GitLabUtils.defaultGitlabUrl; + const client = await this.createClient(accessToken, url); + + const userData = await client.Users.showCurrentUser({ + showExpanded: false, + }); + return { ...userData, url }; + } + + /** + * Fetches projects accessible to the user. + * + * @param accessToken - Access token for authentication. + * @returns Array of projects. + */ + public static async getProjects({ + accessToken, + teamId, + }: { + accessToken: string; + teamId: string; + }) { + const customUrl = await this.getGitLabUrl(teamId); + const client = await this.createClient(accessToken, customUrl); + + const projects = await client.Projects.all({ + simple: true, + perPage: 100, + minAccessLevel: 40, // At least Maintainer access to reduce the sheer volume of projects + }); + return projects; + } + + /** + * @param url GitLab resource url + * @param actor User attempting to unfurl resource url + * @returns An object containing resource details e.g, a GitLab Merge Request details + */ + public static unfurl: UnfurlSignature = async (url: string, actor: User) => { + const integrations = (await Integration.findAll({ + where: { + service: IntegrationService.GitLab, + teamId: actor.teamId, + }, + include: [ + { + model: IntegrationAuthentication, + as: "authentication", + required: true, + }, + ], + })) as Integration[]; + + if (integrations.length === 0) { + Logger.debug( + "plugins", + `No GitLab integrations found for team ${actor.teamId}` + ); + return; + } + + // Try to parse the URL against each integration's custom URL + let matchedIntegration: Integration | undefined; + let resource: ReturnType; + + for (const integration of integrations) { + const customUrl = integration.settings?.gitlab?.url; + resource = GitLabUtils.parseUrl(url, customUrl); + if (resource) { + matchedIntegration = integration; + break; + } + } + + if (!resource) { + Logger.debug( + "plugins", + `Could not parse GitLab resource from URL: ${url}` + ); + return; + } + + if (!matchedIntegration?.authentication) { + Logger.debug( + "plugins", + `No authentication found for matched integration` + ); + return; + } + + try { + const customUrl = matchedIntegration.settings?.gitlab?.url; + const projectPath = `${resource.owner}/${resource.repo}`; + const { authentication } = matchedIntegration; + const token = await authentication.refreshTokenIfNeeded( + async (refreshToken: string) => + GitLab.refreshToken({ + refreshToken, + customUrl, + clientId: authentication.clientId ?? undefined, + clientSecret: authentication.clientSecret ?? undefined, + }) + ); + + if (resource.type === UnfurlResourceType.Issue) { + const issue = await this.getIssue( + token, + projectPath, + resource.id, + customUrl + ); + + return this.transformIssue(issue); + } else if (resource.type === UnfurlResourceType.PR) { + const mr = await this.getMergeRequest( + token, + projectPath, + resource.id, + customUrl + ); + return this.transformMR(mr); + } + + return { error: "Resource not found" }; + } catch (err) { + Logger.warn("Failed to fetch resource from GitLab", err); + return { error: err.message || "Unknown error" }; + } + }; + + /** + * Exchanges an authorization code for an access token. + * + * @param params.code - The authorization code from the OAuth callback. + * @param params.customUrl - Optional custom GitLab URL. Falls back to default. + * @param params.clientId - Optional custom client ID (falls back to env var). + * @param params.clientSecret - Optional custom client secret (falls back to env var). + * @returns The parsed access token response. + */ + public static oauthAccess = async ({ + code, + customUrl, + clientId, + clientSecret, + }: { + code?: string | null; + customUrl?: string; + clientId?: string; + clientSecret?: string; + }) => { + const url = customUrl || GitLabUtils.defaultGitlabUrl; + const res = await fetch(GitLabUtils.getOauthUrl(url) + "/token", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + code, + client_id: clientId || this.clientId, + client_secret: clientSecret || this.clientSecret, + grant_type: "authorization_code", + redirect_uri: GitLabUtils.callbackUrl(), + }), + }); + + if (res.status !== 200) { + throw new Error( + `Error while validating oauth code from GitLab; status: ${res.status}` + ); + } + + return AccessTokenResponseSchema.parse(await res.json()); + }; + + private static async refreshToken({ + refreshToken, + customUrl, + clientId, + clientSecret, + }: { + refreshToken: string; + customUrl?: string; + clientId?: string; + clientSecret?: string; + }) { + const queryParams = new URLSearchParams({ + client_id: clientId || this.clientId!, + client_secret: clientSecret || this.clientSecret!, + grant_type: "refresh_token", + refresh_token: refreshToken, + redirect_uri: GitLabUtils.callbackUrl(), + }); + + const res = await fetch( + `${GitLabUtils.getOauthUrl(customUrl)}/token?${queryParams.toString()}`, + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + } + ); + const resJson = await res.json(); + if (res.status !== 200) { + Logger.error("failed to refresh access token from GitLab", resJson); + throw new Error( + `Error while refreshing access token from GitLab; status: ${res.status}` + ); + } + + return AccessTokenResponseSchema.parse(resJson); + } + + private static transformIssue(issue: IssueSchemaWithExpandedLabels) { + return { + type: UnfurlResourceType.Issue, + url: issue.web_url, + id: `#${issue.iid}`, + title: issue.title, + description: issue.description ?? null, + author: { + name: issue.author?.username ?? "", + avatarUrl: issue.author?.avatar_url ?? "", + }, + labels: issue.labels.map((label) => ({ + name: label.name, + color: label.color, + })), + state: { + name: issue.state, + color: GitLabUtils.getColorForStatus(issue.state), + }, + createdAt: issue.created_at, + } satisfies UnfurlIssueOrPR; + } + + private static transformMR(mr: MergeRequestSchema) { + const mrState = mr.merged_at ? "merged" : mr.state; + return { + type: UnfurlResourceType.PR, + url: mr.web_url, + id: `!${mr.iid}`, + title: mr.title, + description: mr.description ?? "", + author: { + name: mr.author.username, + avatarUrl: mr.author.avatar_url, + }, + state: { + name: mrState, + color: GitLabUtils.getColorForStatus(mrState, !!mr.draft), + draft: mr.draft, + }, + createdAt: mr.created_at, + } satisfies UnfurlIssueOrPR; + } +} diff --git a/plugins/gitlab/server/index.ts b/plugins/gitlab/server/index.ts new file mode 100644 index 0000000000..c4f9c11d09 --- /dev/null +++ b/plugins/gitlab/server/index.ts @@ -0,0 +1,27 @@ +import { Minute } from "@shared/utils/time"; +import { PluginManager, Hook } from "@server/utils/PluginManager"; +import config from "../plugin.json"; +import { GitLabIssueProvider } from "./GitLabIssueProvider"; +import router from "./api/gitlab"; +import { GitLab } from "./gitlab"; +import GitLabWebhookTask from "./tasks/GitLabWebhookTask"; + +PluginManager.add([ + { + ...config, + type: Hook.API, + value: router, + }, + { + type: Hook.IssueProvider, + value: new GitLabIssueProvider(), + }, + { + type: Hook.UnfurlProvider, + value: { unfurl: GitLab.unfurl, cacheExpiry: Minute.seconds }, + }, + { + type: Hook.Task, + value: GitLabWebhookTask, + }, +]); diff --git a/plugins/gitlab/server/schema.ts b/plugins/gitlab/server/schema.ts new file mode 100644 index 0000000000..8766d056f2 --- /dev/null +++ b/plugins/gitlab/server/schema.ts @@ -0,0 +1,41 @@ +import isEmpty from "lodash/isEmpty"; +import { z } from "zod"; +import { BaseSchema } from "@server/routes/api/schema"; + +export const GitLabCallbackSchema = BaseSchema.extend({ + query: z + .object({ + code: z.string().nullish(), + state: z.string().uuid().nullish(), + error: z.string().nullish(), + }) + .refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), { + message: "one of code or error is required", + }), +}); + +export type GitLabCallbackReq = z.infer; + +export const GitLabConnectSchema = BaseSchema.extend({ + body: z + .object({ + url: z.url().startsWith("https://").optional(), + clientId: z.string().optional(), + clientSecret: z.string().optional(), + }) + .refine( + (data) => { + const { url, clientId, clientSecret } = data; + const allOrNone = + (url && clientId && clientSecret) || + (!url && !clientId && !clientSecret); + return allOrNone; + }, + { + message: + "Either all of url, clientId, and clientSecret must be provided, or none of them.", + } + ), +}); + +export type GitLabConnectReq = z.infer; diff --git a/plugins/gitlab/server/tasks/GitLabWebhookTask.ts b/plugins/gitlab/server/tasks/GitLabWebhookTask.ts new file mode 100644 index 0000000000..1b54eca230 --- /dev/null +++ b/plugins/gitlab/server/tasks/GitLabWebhookTask.ts @@ -0,0 +1,26 @@ +import { IntegrationService } from "@shared/types"; +import { BaseTask } from "@server/queues/tasks/base/BaseTask"; +import { Hook, PluginManager } from "@server/utils/PluginManager"; + +type Props = { + headers: Record; + payload: Record; +}; + +export default class GitLabWebhookTask extends BaseTask { + public async perform({ headers, payload }: Props): Promise { + const plugins = PluginManager.getHooks(Hook.IssueProvider); + const plugin = plugins.find( + (p) => p.value.service === IntegrationService.GitLab + ); + + if (!plugin) { + return; + } + + await plugin.value.handleWebhook({ + headers, + payload, + }); + } +} diff --git a/plugins/gitlab/shared/GitLabUtils.test.ts b/plugins/gitlab/shared/GitLabUtils.test.ts new file mode 100644 index 0000000000..fbf3c47875 --- /dev/null +++ b/plugins/gitlab/shared/GitLabUtils.test.ts @@ -0,0 +1,166 @@ +import { UnfurlResourceType } from "@shared/types"; +import { GitLabUtils } from "./GitLabUtils"; + +describe("GitLabUtils.parseUrl", () => { + describe("direct URLs", () => { + it("should parse an issue URL", () => { + const result = GitLabUtils.parseUrl( + "https://gitlab.com/speak/purser/-/issues/39" + ); + expect(result).toEqual({ + owner: "speak", + repo: "purser", + type: UnfurlResourceType.Issue, + id: 39, + url: "https://gitlab.com/speak/purser/-/issues/39", + }); + }); + + it("should parse a merge request URL", () => { + const result = GitLabUtils.parseUrl( + "https://gitlab.com/speak/purser/-/merge_requests/12" + ); + expect(result).toEqual({ + owner: "speak", + repo: "purser", + type: UnfurlResourceType.PR, + id: 12, + url: "https://gitlab.com/speak/purser/-/merge_requests/12", + }); + }); + + it("should parse a nested group URL", () => { + const result = GitLabUtils.parseUrl( + "https://gitlab.com/group/subgroup/repo/-/issues/5" + ); + expect(result).toEqual({ + owner: "group/subgroup", + repo: "repo", + type: UnfurlResourceType.Issue, + id: 5, + url: "https://gitlab.com/group/subgroup/repo/-/issues/5", + }); + }); + + it("should return undefined for unsupported resource type", () => { + const result = GitLabUtils.parseUrl( + "https://gitlab.com/speak/purser/-/pipelines/100" + ); + expect(result).toBeUndefined(); + }); + + it("should return undefined for a URL with too few path segments", () => { + const result = GitLabUtils.parseUrl("https://gitlab.com/speak/purser"); + expect(result).toBeUndefined(); + }); + + it("should return undefined for a mismatched hostname", () => { + const result = GitLabUtils.parseUrl( + "https://github.com/speak/purser/-/issues/1" + ); + expect(result).toBeUndefined(); + }); + }); + + describe("base64 show parameter URLs", () => { + it("should parse an issue URL with show parameter", () => { + const show = btoa( + JSON.stringify({ iid: "39", full_path: "speak/purser", id: 1215135 }) + ); + const result = GitLabUtils.parseUrl( + `https://gitlab.com/speak/purser/-/issues?show=${show}` + ); + expect(result).toEqual({ + owner: "speak", + repo: "purser", + type: UnfurlResourceType.Issue, + id: 39, + url: `https://gitlab.com/speak/purser/-/issues?show=${show}`, + }); + }); + + it("should parse a URL-encoded show parameter", () => { + const result = GitLabUtils.parseUrl( + "https://gitlab.com/speak/purser/-/issues?show=eyJpaWQiOiIzOSIsImZ1bGxfcGF0aCI6InNwZWFrL3B1cnNlciIsImlkIjoxMjE1MTM1fQ%3D%3D" + ); + expect(result).toEqual({ + owner: "speak", + repo: "purser", + type: UnfurlResourceType.Issue, + id: 39, + url: "https://gitlab.com/speak/purser/-/issues?show=eyJpaWQiOiIzOSIsImZ1bGxfcGF0aCI6InNwZWFrL3B1cnNlciIsImlkIjoxMjE1MTM1fQ%3D%3D", + }); + }); + + it("should parse a merge request URL with show parameter", () => { + const show = btoa( + JSON.stringify({ iid: "7", full_path: "speak/purser", id: 999 }) + ); + const result = GitLabUtils.parseUrl( + `https://gitlab.com/speak/purser/-/merge_requests?show=${show}` + ); + expect(result).toEqual({ + owner: "speak", + repo: "purser", + type: UnfurlResourceType.PR, + id: 7, + url: `https://gitlab.com/speak/purser/-/merge_requests?show=${show}`, + }); + }); + + it("should parse a nested group URL with show parameter", () => { + const show = btoa( + JSON.stringify({ iid: "2", full_path: "a/b/repo", id: 500 }) + ); + const result = GitLabUtils.parseUrl( + `https://gitlab.com/a/b/repo/-/issues?show=${show}` + ); + expect(result).toEqual({ + owner: "a/b", + repo: "repo", + type: UnfurlResourceType.Issue, + id: 2, + url: `https://gitlab.com/a/b/repo/-/issues?show=${show}`, + }); + }); + + it("should return undefined for invalid base64 in show parameter", () => { + const result = GitLabUtils.parseUrl( + "https://gitlab.com/speak/purser/-/issues?show=not-valid-base64!!!" + ); + expect(result).toBeUndefined(); + }); + + it("should return undefined when show parameter has no iid", () => { + const show = btoa(JSON.stringify({ full_path: "speak/purser", id: 1 })); + const result = GitLabUtils.parseUrl( + `https://gitlab.com/speak/purser/-/issues?show=${show}` + ); + expect(result).toBeUndefined(); + }); + }); + + describe("custom GitLab URL", () => { + it("should parse with a custom URL", () => { + const result = GitLabUtils.parseUrl( + "https://git.example.com/team/project/-/issues/10", + "https://git.example.com" + ); + expect(result).toEqual({ + owner: "team", + repo: "project", + type: UnfurlResourceType.Issue, + id: 10, + url: "https://git.example.com/team/project/-/issues/10", + }); + }); + + it("should not match default gitlab.com when custom URL is set", () => { + const result = GitLabUtils.parseUrl( + "https://gitlab.com/speak/purser/-/issues/1", + "https://git.example.com" + ); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/plugins/gitlab/shared/GitLabUtils.ts b/plugins/gitlab/shared/GitLabUtils.ts new file mode 100644 index 0000000000..c5ff72631c --- /dev/null +++ b/plugins/gitlab/shared/GitLabUtils.ts @@ -0,0 +1,267 @@ +import { Gitlab } from "@gitbeaker/rest"; +import env from "@shared/env"; +import { integrationSettingsPath } from "@shared/utils/routeHelpers"; +import { UnfurlResourceType } from "@shared/types"; + +export class GitLabUtils { + public static defaultGitlabUrl = "https://gitlab.com"; + + private static supportedResources = [ + UnfurlResourceType.Issue, + UnfurlResourceType.PR, + ]; + + /** + * Gets the GitLab URL, preferring the provided custom URL over the default. + * + * @param customUrl - Optional custom GitLab URL from integration settings. + * @returns The GitLab URL to use. + */ + private static getGitlabUrl(customUrl?: string): string { + return customUrl || this.defaultGitlabUrl; + } + + /** + * Gets the OAuth URL for the provided custom GitLab URL or default environment URL. + * + * @param customUrl - Optional custom GitLab URL from integration settings. + * @returns The OAuth URL. + */ + public static getOauthUrl(customUrl?: string): string { + return `${this.getGitlabUrl(customUrl)}/oauth`; + } + + public static get url() { + return integrationSettingsPath("gitlab"); + } + + /** + * Generates the error URL for GitLab authorization errors. + * + * @param error - The error message to include in the URL. + * @returns The URL to redirect to upon authorization error. + */ + public static errorUrl(error: string): string { + return `${this.url}?error=${encodeURIComponent(error)}`; + } + + /** + * Generates the callback URL for GitLab OAuth. + * + * @param baseUrl - The base URL of the application. + * @param params - Optional query parameters to include in the callback URL. + * @returns The full callback URL. + */ + public static callbackUrl( + { baseUrl, params }: { baseUrl: string; params?: string } = { + baseUrl: env.URL, + params: undefined, + } + ): string { + const callbackPath = "/api/gitlab.callback"; + return params + ? `${baseUrl}${callbackPath}?${params}` + : `${baseUrl}${callbackPath}`; + } + + /** + * Generates the authorization URL for GitLab OAuth. + * + * @param state - A unique state string to prevent CSRF attacks. + * @param customUrl - Optional custom GitLab URL from integration settings. + * @param customClientId - Optional custom OAuth client ID from integration settings. + * @returns The full URL to redirect the user to GitLab's OAuth authorization page. + */ + public static authUrl( + state: string, + customUrl?: string, + customClientId?: string + ): string { + const params = new URLSearchParams({ + client_id: customClientId || env.GITLAB_CLIENT_ID, + redirect_uri: this.callbackUrl(), + response_type: "code", + state, + scope: "read_api read_user", + }); + + return `${this.getOauthUrl(customUrl)}/authorize?${params.toString()}`; + } + + /** + * Generates the installation request URL. + * + * @returns The URL for installation requests. + */ + public static installRequestUrl(): string { + return `${this.url}?install_request=true`; + } + + /** + * Creates a Gitbeaker client instance. + * + * @param accessToken - The access token for authentication. + * @param customUrl - Optional custom GitLab URL from integration settings. + * @returns A configured Gitbeaker client. + */ + public static createClient(accessToken: string, customUrl?: string) { + return new Gitlab({ + host: this.getGitlabUrl(customUrl), + oauthToken: accessToken, + }); + } + + /** + * Parses a GitLab URL and extracts resource identifiers. + * + * @param url - The GitLab URL to parse. + * @param customUrl - Optional custom GitLab URL from integration settings. + * @returns An object containing resource identifiers or undefined if the URL is invalid. + */ + public static parseUrl(url: string, customUrl?: string) { + const parsed = new URL(url); + const urlHostname = new URL(this.getGitlabUrl(customUrl)).hostname; + + if (parsed.hostname !== urlHostname) { + return; + } + + const parts = parsed.pathname.split("/").filter(Boolean); + + // Try base64-encoded `show` query parameter first + // e.g. /owner/repo/-/issues?show=eyJ... + const showParam = parsed.searchParams.get("show"); + if (showParam && parts.length >= 4) { + const resourceType = parts.pop(); + parts.pop(); // separator ("-") + const repo = parts.pop(); + const owner = parts.join("/"); + + const type = + resourceType === "issues" + ? UnfurlResourceType.Issue + : resourceType === "merge_requests" + ? UnfurlResourceType.PR + : undefined; + + if (!type || !this.supportedResources.includes(type)) { + return; + } + + try { + const decoded = JSON.parse(atob(decodeURIComponent(showParam))); + const iid = Number(decoded.iid); + if (!iid) { + return; + } + return { owner, repo, type, id: iid, url }; + } catch { + return; + } + } + + if (parts.length < 5) { + return; + } + + // Direct URL: /owner/repo/-/issues/123 or /owner/repo/-/merge_requests/123 + const resourceId = parts.pop(); + const resourceType = parts.pop(); + parts.pop(); // separator ("-") + + const repo = parts.pop(); + const owner = parts.join("/"); + + const type = + resourceType === "issues" + ? UnfurlResourceType.Issue + : resourceType === "merge_requests" + ? UnfurlResourceType.PR + : undefined; + + if (!type || !this.supportedResources.includes(type)) { + return; + } + + return { + owner, + repo, + type, + id: Number(resourceId), + url, + }; + } + + /** + * Fetches an issue from a GitLab project. + * + * @param accessToken - The access token for authentication. + * @param projectPath - The project path (owner/repo). + * @param issueIid - The issue IID (internal ID within the project). + * @param customUrl - Optional custom GitLab URL from integration settings. + * @returns The issue data. + */ + public static async getIssue( + accessToken: string, + projectPath: string, + issueIid: number, + customUrl?: string + ) { + const client = this.createClient(accessToken, customUrl); + + const issues = await client.Issues.all({ + projectId: projectPath, + iids: [issueIid], + withLabelsDetails: true, + }); + + if (!issues || issues.length === 0) { + throw new Error(`Issue ${issueIid} not found in project ${projectPath}`); + } + + return issues[0]; + } + + /** + * Fetches a merge request from a GitLab project. + * + * @param accessToken - The access token for authentication. + * @param projectPath - The project path (owner/repo). + * @param mrIid - The merge request IID (internal ID within the project). + * @param customUrl - Optional custom GitLab URL from integration settings. + * @returns The merge request data. + */ + public static async getMergeRequest( + accessToken: string, + projectPath: string, + mrIid: number, + customUrl?: string + ) { + const client = this.createClient(accessToken, customUrl); + // MergeRequests.show properly accepts projectId and mergerequestIId + const mr = await client.MergeRequests.show(projectPath, mrIid); + return mr; + } + + /** + * Returns the color associated with a given status. + * + * @param status - The status of the resource. + * @param isDraftMR - Whether the resource is a draft merge request. + * @returns The color associated with the status. + */ + public static getColorForStatus( + status: string, + isDraftMR: boolean = false + ): string { + const statusColors: Record = { + opened: isDraftMR ? "#848d97" : "#1f75cb", + done: "#a371f7", + closed: "#f85149", + merged: "#8250df", + canceled: "#848d97", + }; + + return statusColors[status] ?? "#848d97"; + } +} diff --git a/server/middlewares/validateWebhook.ts b/server/middlewares/validateWebhook.ts index aab49976a2..b16d983e1b 100644 --- a/server/middlewares/validateWebhook.ts +++ b/server/middlewares/validateWebhook.ts @@ -6,9 +6,11 @@ import { safeEqual } from "@server/utils/crypto"; export default function validateWebhook({ secretKey, getSignatureFromHeader, + hmacSign = true, }: { - secretKey: string; + secretKey: string | ((ctx: APIContext) => Promise); getSignatureFromHeader: (ctx: APIContext) => string | undefined; + hmacSign?: boolean; }) { return async function validateWebhookMiddleware(ctx: APIContext, next: Next) { const { body } = ctx.request; @@ -20,10 +22,21 @@ export default function validateWebhook({ return; } - const computedSignature = crypto - .createHmac("sha256", secretKey) - .update(JSON.stringify(body)) - .digest("hex"); + const key = + typeof secretKey === "function" ? await secretKey(ctx) : secretKey; + + if (!key) { + ctx.status = 401; + ctx.body = "Invalid signature"; + return; + } + + const computedSignature = hmacSign + ? crypto + .createHmac("sha256", key) + .update(JSON.stringify(body)) + .digest("hex") + : key; if (!safeEqual(computedSignature, signatureFromHeader)) { ctx.status = 401; diff --git a/server/migrations/20260221131935-add-client-credentials-to-authentications.js b/server/migrations/20260221131935-add-client-credentials-to-authentications.js new file mode 100644 index 0000000000..62d3e21e91 --- /dev/null +++ b/server/migrations/20260221131935-add-client-credentials-to-authentications.js @@ -0,0 +1,19 @@ +"use strict"; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn("authentications", "clientId", { + type: Sequelize.STRING, + allowNull: true, + }); + await queryInterface.addColumn("authentications", "clientSecret", { + type: Sequelize.BLOB, + allowNull: true, + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn("authentications", "clientId"); + await queryInterface.removeColumn("authentications", "clientSecret"); + }, +}; diff --git a/server/models/IntegrationAuthentication.ts b/server/models/IntegrationAuthentication.ts index 063a72fa22..01c2058504 100644 --- a/server/models/IntegrationAuthentication.ts +++ b/server/models/IntegrationAuthentication.ts @@ -50,6 +50,13 @@ class IntegrationAuthentication extends IdModel< @Encrypted refreshToken: string; + @Column(DataType.STRING) + clientId: string | null; + + @Column(DataType.BLOB) + @Encrypted + clientSecret: string | null; + @Column(DataType.DATE) expiresAt: Date | null; diff --git a/server/routes/api/integrations/schema.ts b/server/routes/api/integrations/schema.ts index be70929e0f..7447e28766 100644 --- a/server/routes/api/integrations/schema.ts +++ b/server/routes/api/integrations/schema.ts @@ -63,6 +63,23 @@ export const IntegrationsCreateSchema = BaseSchema.extend({ diagrams: z.object({ url: z.url() }), }) ) + .or( + z.object({ + gitlab: z.object({ + url: z.url().optional(), + installation: z + .object({ + id: z.number(), + account: z.object({ + id: z.number(), + name: z.string(), + avatarUrl: z.string(), + }), + }) + .optional(), + }), + }) + ) .or(z.object({ serviceTeamId: z.string() })) .optional(), }), @@ -97,6 +114,23 @@ export const IntegrationsUpdateSchema = BaseSchema.extend({ diagrams: z.object({ url: z.url() }), }) ) + .or( + z.object({ + gitlab: z.object({ + url: z.url().optional(), + installation: z + .object({ + id: z.number(), + account: z.object({ + id: z.number(), + name: z.string(), + avatarUrl: z.string(), + }), + }) + .optional(), + }), + }) + ) .or(z.object({ serviceTeamId: z.string() })) .optional(), diff --git a/server/routes/oauth/schema.ts b/server/routes/oauth/schema.ts index 99c6ac250b..69ba9c74cd 100644 --- a/server/routes/oauth/schema.ts +++ b/server/routes/oauth/schema.ts @@ -29,7 +29,7 @@ export const RegisterSchema = BaseSchema.extend({ body: z.object({ client_name: z.string().min(1).max(OAuthClientValidation.maxNameLength), redirect_uris: z - .array(z.string().url().max(OAuthClientValidation.maxRedirectUriLength)) + .array(z.url().max(OAuthClientValidation.maxRedirectUriLength)) .min(1) .max(10), grant_types: z @@ -60,7 +60,7 @@ export const RegisterUpdateSchema = BaseSchema.extend({ body: z.object({ client_name: z.string().min(1).max(OAuthClientValidation.maxNameLength), redirect_uris: z - .array(z.string().url().max(OAuthClientValidation.maxRedirectUriLength)) + .array(z.url().max(OAuthClientValidation.maxRedirectUriLength)) .min(1) .max(10), client_uri: z diff --git a/server/utils/fetch.ts b/server/utils/fetch.ts index 289e655581..25568525a6 100644 --- a/server/utils/fetch.ts +++ b/server/utils/fetch.ts @@ -6,9 +6,9 @@ import { getProxyForUrl } from "proxy-from-env"; import tunnelAgent, { type TunnelAgent } from "tunnel-agent"; import { useAgent as useFilteringAgent } from "request-filtering-agent"; import env from "@server/env"; +import { InternalError } from "@server/errors"; import Logger from "@server/logging/Logger"; import { capitalize, defaults } from "lodash"; -import { InternalError } from "@server/errors"; interface UrlWithTunnel extends URL { tunnelMethod?: string; diff --git a/server/utils/url.test.ts b/server/utils/url.test.ts new file mode 100644 index 0000000000..cb37632ae4 --- /dev/null +++ b/server/utils/url.test.ts @@ -0,0 +1,99 @@ +import dns from "node:dns"; +import env from "@server/env"; +import { validateUrlNotPrivate } from "./url"; + +describe("validateUrlNotPrivate", () => { + let lookupSpy: jest.SpyInstance; + + beforeEach(() => { + lookupSpy = jest + .spyOn(dns.promises, "lookup") + .mockResolvedValue({ address: "93.184.216.34", family: 4 }); + }); + + afterEach(() => { + lookupSpy.mockRestore(); + env.ALLOWED_PRIVATE_IP_ADDRESSES = undefined; + }); + + it("should allow public IP addresses", async () => { + lookupSpy.mockResolvedValue({ address: "93.184.216.34", family: 4 }); + await expect( + validateUrlNotPrivate("https://example.com") + ).resolves.toBeUndefined(); + }); + + it("should reject private IP in URL", async () => { + await expect(validateUrlNotPrivate("https://10.0.0.1/api")).rejects.toThrow( + "is not allowed" + ); + }); + + it("should reject URL resolving to private IP", async () => { + lookupSpy.mockResolvedValue({ address: "192.168.1.1", family: 4 }); + await expect( + validateUrlNotPrivate("https://internal.example.com") + ).rejects.toThrow("is not allowed"); + }); + + it("should reject loopback address", async () => { + await expect( + validateUrlNotPrivate("https://127.0.0.1/api") + ).rejects.toThrow("is not allowed"); + }); + + it("should reject link-local address", async () => { + lookupSpy.mockResolvedValue({ address: "169.254.169.254", family: 4 }); + await expect( + validateUrlNotPrivate("https://metadata.internal") + ).rejects.toThrow("is not allowed"); + }); + + describe("with ALLOWED_PRIVATE_IP_ADDRESSES", () => { + it("should allow exact IP match", async () => { + env.ALLOWED_PRIVATE_IP_ADDRESSES = ["10.0.0.1"]; + await expect( + validateUrlNotPrivate("https://10.0.0.1/api") + ).resolves.toBeUndefined(); + }); + + it("should allow IP within CIDR range", async () => { + env.ALLOWED_PRIVATE_IP_ADDRESSES = ["192.168.1.0/24"]; + lookupSpy.mockResolvedValue({ address: "192.168.1.50", family: 4 }); + await expect( + validateUrlNotPrivate("https://gitlab.internal") + ).resolves.toBeUndefined(); + }); + + it("should reject IP outside CIDR range", async () => { + env.ALLOWED_PRIVATE_IP_ADDRESSES = ["192.168.1.0/24"]; + lookupSpy.mockResolvedValue({ address: "192.168.2.1", family: 4 }); + await expect( + validateUrlNotPrivate("https://gitlab.internal") + ).rejects.toThrow("is not allowed"); + }); + + it("should allow resolved hostname matching allowlist", async () => { + env.ALLOWED_PRIVATE_IP_ADDRESSES = ["10.0.0.5"]; + lookupSpy.mockResolvedValue({ address: "10.0.0.5", family: 4 }); + await expect( + validateUrlNotPrivate("https://gitlab.internal") + ).resolves.toBeUndefined(); + }); + + it("should still reject non-matching private IPs", async () => { + env.ALLOWED_PRIVATE_IP_ADDRESSES = ["10.0.0.1"]; + await expect( + validateUrlNotPrivate("https://10.0.0.2/api") + ).rejects.toThrow("is not allowed"); + }); + + it("should support multiple entries in allowlist", async () => { + env.ALLOWED_PRIVATE_IP_ADDRESSES = ["10.0.0.1", "172.16.0.0/12"]; + lookupSpy.mockResolvedValue({ address: "172.20.5.10", family: 4 }); + await expect( + validateUrlNotPrivate("https://gitlab.internal") + ).resolves.toBeUndefined(); + }); + }); +}); diff --git a/server/utils/url.ts b/server/utils/url.ts index 405e8bced0..24df346045 100644 --- a/server/utils/url.ts +++ b/server/utils/url.ts @@ -1,5 +1,103 @@ +import dns from "node:dns"; +import net from "node:net"; +import ipaddr from "ipaddr.js"; import { randomString } from "@shared/random"; +import env from "@server/env"; +import { InvalidRequestError } from "@server/errors"; const UrlIdLength = 10; +/** IP ranges that are not allowed for outbound requests. */ +const privateRanges = new Set([ + "private", + "loopback", + "linkLocal", + "uniqueLocal", + "unspecified", +]); + export const generateUrlId = () => randomString(UrlIdLength); + +/** + * Checks if an IP address is private, loopback, or link-local. + * + * @param ip - The IP address to check. + * @returns true if the IP is private. + */ +export function isPrivateIP(ip: string): boolean { + if (!ipaddr.isValid(ip)) { + return false; + } + return privateRanges.has(ipaddr.parse(ip).range()); +} + +/** + * Checks whether an IP address is present in the allowed private IP list, + * supporting both exact matches and CIDR ranges. + * + * @param ip - the IP address to check. + * @returns true if the IP is explicitly allowed. + */ +function isAllowedPrivateIP(ip: string): boolean { + const allowList = env.ALLOWED_PRIVATE_IP_ADDRESSES; + if (!allowList || allowList.length === 0) { + return false; + } + + if (!ipaddr.isValid(ip)) { + return false; + } + + const addr = ipaddr.parse(ip); + + for (const entry of allowList) { + if (net.isIP(entry)) { + if (entry === ip) { + return true; + } + } else if (ipaddr.isValid(entry.split("/")[0])) { + try { + if (addr.match(ipaddr.parseCIDR(entry))) { + return true; + } + } catch { + // Skip invalid CIDR entries + } + } + } + + return false; +} + +/** + * Validates that a URL does not resolve to a private or internal IP address. + * Respects the ALLOWED_PRIVATE_IP_ADDRESSES environment variable. + * + * @param url - the URL to validate. + * @throws InternalError if the URL resolves to a private IP that is not allowed. + */ +export async function validateUrlNotPrivate(url: string) { + const { hostname } = new URL(url); + + if (net.isIP(hostname)) { + if (isPrivateIP(hostname) && !isAllowedPrivateIP(hostname)) { + throw InvalidRequestError( + `DNS lookup ${hostname} is not allowed.` + + (env.isCloudHosted + ? "" + : " To allow this request, add the IP address or CIDR range to the ALLOWED_PRIVATE_IP_ADDRESSES environment variable.") + ); + } + return; + } + + const { address } = await dns.promises.lookup(hostname); + if (isPrivateIP(address) && !isAllowedPrivateIP(address)) { + throw InvalidRequestError( + `DNS lookup ${address} (${hostname}) is not allowed.` + + (env.isCloudHosted + ? "" + : " To allow this request, add the IP address or CIDR range to the ALLOWED_PRIVATE_IP_ADDRESSES environment variable.") + ); + } +} diff --git a/shared/components/IssueStatusIcon/GitLabIssueStatusIcon.tsx b/shared/components/IssueStatusIcon/GitLabIssueStatusIcon.tsx new file mode 100644 index 0000000000..e29ee5b1b8 --- /dev/null +++ b/shared/components/IssueStatusIcon/GitLabIssueStatusIcon.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import type { BaseIconProps } from "."; + +export function GitLabIssueStatusIcon(props: BaseIconProps) { + const { state, className, size = 16 } = props; + + switch (state.name) { + case "opened": + return ( + + + + + ); + case "closed": + return ( + + + + + ); + default: + return null; + } +} diff --git a/shared/components/IssueStatusIcon/index.tsx b/shared/components/IssueStatusIcon/index.tsx index bff6304dc7..d69263ab2c 100644 --- a/shared/components/IssueStatusIcon/index.tsx +++ b/shared/components/IssueStatusIcon/index.tsx @@ -8,6 +8,7 @@ import type { import { IntegrationService } from "../../types"; import { GitHubIssueStatusIcon } from "./GitHubIssueStatusIcon"; import { LinearIssueStatusIcon } from "./LinearIssueStatusIcon"; +import { GitLabIssueStatusIcon } from "./GitLabIssueStatusIcon"; export type BaseIconProps = { state: UnfurlResponse[UnfurlResourceType.Issue]["state"]; @@ -33,6 +34,8 @@ function getIcon(props: Props) { return ; case IntegrationService.Linear: return ; + case IntegrationService.GitLab: + return ; } } diff --git a/shared/components/PullRequestIcon.tsx b/shared/components/PullRequestIcon.tsx index 24d17a19c8..8a5a3f8867 100644 --- a/shared/components/PullRequestIcon.tsx +++ b/shared/components/PullRequestIcon.tsx @@ -30,6 +30,7 @@ const Icon = styled.span<{ size?: number }>` function BaseIcon({ state }: Pick) { switch (state.name) { + case "opened": case "open": return ( @@ -46,6 +47,12 @@ function BaseIcon({ state }: Pick) { ); + case "locked": + return ( + + + + ); case "closed": return ( diff --git a/shared/editor/components/Mentions.tsx b/shared/editor/components/Mentions.tsx index fc3f717914..5d679608cc 100644 --- a/shared/editor/components/Mentions.tsx +++ b/shared/editor/components/Mentions.tsx @@ -311,12 +311,14 @@ export const MentionIssue = observer((props: IssuePrProps) => { } const issue = unfurl as UnfurlResponse[UnfurlResourceType.Issue]; - const url = new URL(issue.url); + const service = url.hostname === "github.com" ? IntegrationService.GitHub - : IntegrationService.Linear; + : url.hostname === "linear.app" + ? IntegrationService.Linear + : IntegrationService.GitLab; return ( ; export const IssueTrackerIntegrationService = { GitHub: IntegrationService.GitHub, + GitLab: IntegrationService.GitLab, Linear: IntegrationService.Linear, } as const; @@ -170,6 +174,7 @@ export type UserCreatableIntegrationService = Extract< | IntegrationService.GoogleAnalytics | IntegrationService.Matomo | IntegrationService.Umami + | IntegrationService.GitLab >; export const UserCreatableIntegrationService = { @@ -178,6 +183,7 @@ export const UserCreatableIntegrationService = { GoogleAnalytics: IntegrationService.GoogleAnalytics, Matomo: IntegrationService.Matomo, Umami: IntegrationService.Umami, + GitLab: IntegrationService.GitLab, } as const; export enum CollectionPermission { @@ -206,6 +212,13 @@ export type IntegrationSettings = T extends IntegrationType.Embed account: { id: number; name: string; avatarUrl: string }; }; }; + gitlab?: { + url?: string; + installation?: { + id: number; + account: { id: number; name: string; avatarUrl: string }; + }; + }; linear?: { workspace: { id: string; name: string; key: string; logoUrl?: string }; }; @@ -248,6 +261,17 @@ export type IntegrationSettings = T extends IntegrationType.Embed }; }; }; + gitlab?: { + url?: string; + installation?: { + id: number; + account: { + id?: number; + name: string; + avatarUrl?: string; + }; + }; + }; diagrams?: { url: string; }; diff --git a/yarn.lock b/yarn.lock index bbc54afcb3..33ee4115de 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3517,6 +3517,39 @@ __metadata: languageName: node linkType: hard +"@gitbeaker/core@npm:^43.8.0": + version: 43.8.0 + resolution: "@gitbeaker/core@npm:43.8.0" + dependencies: + "@gitbeaker/requester-utils": "npm:^43.8.0" + qs: "npm:^6.14.0" + xcase: "npm:^2.0.1" + checksum: 10c0/5913aea8ae89b30f37693093fe68a94fbe4ab2737e661ba6795b3d22463ffa63806076b392b938d4c662d691c0114facc022bcdde29297e7b6617b146c449ff6 + languageName: node + linkType: hard + +"@gitbeaker/requester-utils@npm:^43.8.0": + version: 43.8.0 + resolution: "@gitbeaker/requester-utils@npm:43.8.0" + dependencies: + picomatch-browser: "npm:^2.2.6" + qs: "npm:^6.14.0" + rate-limiter-flexible: "npm:^8.0.1" + xcase: "npm:^2.0.1" + checksum: 10c0/07fd5af56fa3b577009bac6fc59a9b54b3e188a8b412c926b253b21528fb46f8ab02dbeba5c032a9a77d169a69208550147a099c71681d5b9193c87224db8d19 + languageName: node + linkType: hard + +"@gitbeaker/rest@npm:^43.8.0": + version: 43.8.0 + resolution: "@gitbeaker/rest@npm:43.8.0" + dependencies: + "@gitbeaker/core": "npm:^43.8.0" + "@gitbeaker/requester-utils": "npm:^43.8.0" + checksum: 10c0/f3b25c6a67c25f9d5aac4674bacd36321c06bedd55e4fe859cba569e6c0121ac00d41838094ca7e19dc3158f59e57f8bae7d565b2e7d308904a3bbe234152b8b + languageName: node + linkType: hard + "@graphql-typed-document-node/core@npm:^3.1.0": version: 3.2.0 resolution: "@graphql-typed-document-node/core@npm:3.2.0" @@ -14125,7 +14158,7 @@ __metadata: languageName: node linkType: hard -"ipaddr.js@npm:^2.1.0": +"ipaddr.js@npm:^2.1.0, ipaddr.js@npm:^2.3.0": version: 2.3.0 resolution: "ipaddr.js@npm:2.3.0" checksum: 10c0/084bab99e2f6875d7a62adc3325e1c64b038a12c9521e35fb967b5e263a8b3afb1b8884dd77c276092331f5d63298b767491e10997ef147c62da01b143780bbd @@ -17517,6 +17550,7 @@ __metadata: "@fortawesome/free-solid-svg-icons": "npm:^7.1.0" "@fortawesome/react-fontawesome": "npm:^0.2.6" "@getoutline/react-roving-tabindex": "npm:^3.2.4" + "@gitbeaker/rest": "npm:^43.8.0" "@hocuspocus/extension-redis": "npm:1.1.2" "@hocuspocus/extension-throttle": "npm:1.1.2" "@hocuspocus/provider": "npm:1.1.2" @@ -17665,6 +17699,7 @@ __metadata: invariant: "npm:^2.2.4" ioredis: "npm:^5.8.2" ioredis-mock: "npm:^8.13.1" + ipaddr.js: "npm:^2.3.0" is-printable-key-event: "npm:^1.0.0" iso-639-3: "npm:^3.0.1" jest-cli: "npm:^30.2.0" @@ -18420,6 +18455,13 @@ __metadata: languageName: node linkType: hard +"picomatch-browser@npm:^2.2.6": + version: 2.2.6 + resolution: "picomatch-browser@npm:2.2.6" + checksum: 10c0/bf97d3e6f77dee776fe4cc7728037931b681c56e1fd964023ed797de341a0e32dcc1e90a5552cc74923cb97566464870a37be188b09e3db7279f9e9a9b12d977 + languageName: node + linkType: hard + "picomatch@npm:^2.0.4, picomatch@npm:^2.2.1, picomatch@npm:^2.2.2, picomatch@npm:^2.2.3, picomatch@npm:^2.3.1": version: 2.3.1 resolution: "picomatch@npm:2.3.1" @@ -19069,6 +19111,13 @@ __metadata: languageName: node linkType: hard +"rate-limiter-flexible@npm:^8.0.1": + version: 8.3.0 + resolution: "rate-limiter-flexible@npm:8.3.0" + checksum: 10c0/30b0aad1128b5a5cff44b289c6083be6ee782389015c1a93f854b1f07a682e539fcd48a3b983c828271b8f8ade3c930055edc2cf376915a3300a0d572d911244 + languageName: node + linkType: hard + "raw-body@npm:^2.3.3": version: 2.5.3 resolution: "raw-body@npm:2.5.3" @@ -23034,6 +23083,13 @@ __metadata: languageName: node linkType: hard +"xcase@npm:^2.0.1": + version: 2.0.1 + resolution: "xcase@npm:2.0.1" + checksum: 10c0/11b8ae8f6734b29d442a5acf1dff3a896cabbf49e7ffa01472ff6fa687a6e6f6a25889d06c10a41950e7a90fe89239fa78d95eab0c5eb654ca75f0ccd71ba8ed + languageName: node + linkType: hard + "xml-name-validator@npm:^4.0.0": version: 4.0.0 resolution: "xml-name-validator@npm:4.0.0"