mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
feat: GitLab integration (#10861)
Co-authored-by: Tom Moor <tom@getoutline.com> closes #6795
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -20,4 +20,5 @@ data/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
.yarn/releases
|
||||
!.yarn/sdks
|
||||
|
||||
@@ -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 (
|
||||
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
|
||||
@@ -58,7 +62,18 @@ const HoverPreviewIssue = React.forwardRef(function HoverPreviewIssue_(
|
||||
</Trans>
|
||||
</Info>
|
||||
</Flex>
|
||||
<Description>{description}</Description>
|
||||
{description && (
|
||||
<Description as="div">
|
||||
<React.Suspense fallback={<div />}>
|
||||
<Editor
|
||||
extensions={richExtensions}
|
||||
defaultValue={description}
|
||||
embedsDisabled
|
||||
readOnly
|
||||
/>
|
||||
</React.Suspense>
|
||||
</Description>
|
||||
)}
|
||||
|
||||
<Flex wrap>
|
||||
{labels.map((label, index) => (
|
||||
|
||||
@@ -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(
|
||||
</Trans>
|
||||
</Info>
|
||||
</Flex>
|
||||
<Description>{description}</Description>
|
||||
{description && (
|
||||
<Description as="div">
|
||||
<React.Suspense fallback={<div />}>
|
||||
<Editor
|
||||
extensions={richExtensions}
|
||||
defaultValue={description}
|
||||
embedsDisabled
|
||||
readOnly
|
||||
/>
|
||||
</React.Suspense>
|
||||
</Description>
|
||||
)}
|
||||
</Flex>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -28,6 +28,13 @@ class IntegrationsStore extends Store<Integration> {
|
||||
);
|
||||
}
|
||||
|
||||
@computed
|
||||
get gitlab(): Integration<IntegrationType.Embed>[] {
|
||||
return this.orderedData.filter(
|
||||
(integration) => integration.service === IntegrationService.GitLab
|
||||
);
|
||||
}
|
||||
|
||||
@computed
|
||||
get linear(): Integration<IntegrationType.Embed>[] {
|
||||
return this.orderedData.filter(
|
||||
|
||||
@@ -27,6 +27,16 @@ export const isURLMentionable = ({
|
||||
);
|
||||
}
|
||||
|
||||
case IntegrationService.GitLab: {
|
||||
const settings =
|
||||
integration.settings as IntegrationSettings<IntegrationType.Embed>;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
<IntegrationScene title="GitLab" icon={<GitLabIcon />}>
|
||||
<Heading>GitLab</Heading>
|
||||
|
||||
{error && (
|
||||
<Notice>
|
||||
{error === "access_denied" ? (
|
||||
<Trans>
|
||||
You need to accept the permissions in GitLab to connect{" "}
|
||||
{{ appName }} to your workspace. Try again?
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Something went wrong while authenticating your request. Please try
|
||||
again.
|
||||
</Trans>
|
||||
)}
|
||||
</Notice>
|
||||
)}
|
||||
{installRequest === "true" && (
|
||||
<Notice>
|
||||
<Trans>
|
||||
The owner of GitLab account has been requested to install the
|
||||
application. Once approved, the connection will be completed.
|
||||
</Trans>
|
||||
</Notice>
|
||||
)}
|
||||
<Text as="p">
|
||||
<Trans>
|
||||
Enable previews of GitLab issues and merge requests in documents by
|
||||
connecting a GitLab organization or specific repositories to {appName}
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
{integrations.gitlab.some((int) => int.settings.gitlab?.installation) ? (
|
||||
<>
|
||||
<Heading as="h2">
|
||||
<Flex justify="space-between" auto>
|
||||
{t("Connected")}
|
||||
<GitLabConnectButton icon={<PlusIcon />} />
|
||||
</Flex>
|
||||
</Heading>
|
||||
<List>
|
||||
{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 && (
|
||||
<ListItem
|
||||
key={gitlabAccount?.id}
|
||||
small
|
||||
title={gitlabAccount?.name}
|
||||
subtitle={
|
||||
integrationCreatedBy ? (
|
||||
<>
|
||||
{customUrl && <>{customUrl} · </>}
|
||||
<Trans>
|
||||
Enabled by {{ integrationCreatedBy }}
|
||||
</Trans>{" "}
|
||||
·{" "}
|
||||
<Time
|
||||
dateTime={integration.createdAt}
|
||||
relative={false}
|
||||
format={{ en_US: "MMMM d, y" }}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<PlaceholderText />
|
||||
)
|
||||
}
|
||||
image={
|
||||
<TeamLogo
|
||||
src={gitlabAccount?.avatarUrl}
|
||||
size={AvatarSize.Large}
|
||||
/>
|
||||
}
|
||||
actions={
|
||||
<ConnectedButton
|
||||
onClick={integration.delete}
|
||||
confirmationMessage={t(
|
||||
"Disconnecting will prevent previewing links from GitLab in documents. Are you sure?"
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</>
|
||||
) : (
|
||||
<p>
|
||||
<GitLabConnectButton icon={<GitLabIcon />} />
|
||||
</p>
|
||||
)}
|
||||
</IntegrationScene>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(GitLab);
|
||||
@@ -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<HTMLButtonElement>) {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
|
||||
const handleClick = () => {
|
||||
dialogs.openModal({
|
||||
title: t("Connect GitLab"),
|
||||
content: <GitLabConnectDialog />,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Button onClick={handleClick} neutral {...props}>
|
||||
{t("Connect")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<form onSubmit={handleConnectCustom}>
|
||||
<Flex column gap={12}>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans>Enter the details for your GitLab instance.</Trans>
|
||||
</Text>
|
||||
<Input
|
||||
label={t("GitLab URL")}
|
||||
placeholder="https://gitlab.example.com"
|
||||
value={customUrl}
|
||||
onChange={(ev) => setCustomUrl(ev.currentTarget.value)}
|
||||
pattern="https://.*"
|
||||
title={t("URL must start with https")}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<Input
|
||||
label={t("Client ID")}
|
||||
placeholder={t("OAuth application ID")}
|
||||
value={clientId}
|
||||
onChange={(ev) => setClientId(ev.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label={t("Client Secret")}
|
||||
placeholder={t("OAuth application secret")}
|
||||
value={clientSecret}
|
||||
onChange={(ev) => setClientSecret(ev.currentTarget.value)}
|
||||
type="password"
|
||||
required
|
||||
/>
|
||||
<Flex justify="flex-end" gap={8}>
|
||||
{env.GITLAB_CLIENT_ID && (
|
||||
<Button
|
||||
neutral
|
||||
onClick={() => setShowCustomForm(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
{t("Back")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
!customUrl.trim() ||
|
||||
!clientId.trim() ||
|
||||
!clientSecret.trim() ||
|
||||
saving
|
||||
}
|
||||
>
|
||||
{saving ? `${t("Connecting")}…` : t("Connect")}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex column gap={8}>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans>Choose which GitLab instance to connect to.</Trans>
|
||||
</Text>
|
||||
<Option
|
||||
onClick={handleConnectCloud}
|
||||
disabled={saving || !env.GITLAB_CLIENT_ID}
|
||||
>
|
||||
<OptionTitle>{t("GitLab Cloud")}</OptionTitle>
|
||||
<OptionDescription>
|
||||
{env.GITLAB_CLIENT_ID
|
||||
? t("Connect to your account on gitlab.com")
|
||||
: t("GitLab Cloud credentials are not configured")}
|
||||
</OptionDescription>
|
||||
</Option>
|
||||
<Option onClick={() => setShowCustomForm(true)} disabled={saving}>
|
||||
<OptionTitle>{t("Self-managed")}</OptionTitle>
|
||||
<OptionDescription>
|
||||
{t("Connect to a custom GitLab installation")}
|
||||
</OptionDescription>
|
||||
</Option>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -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 (
|
||||
<svg
|
||||
fill={fill}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="80 70 220 220"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M265.26416,174.37243l-.2134-.55822-21.19899-55.30908c-.4236-1.08359-1.18542-1.99642-2.17699-2.62689-.98837-.63373-2.14749-.93253-3.32305-.87014-1.1689.06239-2.29195.48925-3.20809,1.21821-.90957.73554-1.56629,1.73047-1.87493,2.85346l-14.31327,43.80662h-57.90965l-14.31327-43.80662c-.30864-1.12299-.96536-2.11791-1.87493-2.85346-.91614-.72895-2.03911-1.15582-3.20809-1.21821-1.17548-.06239-2.33468.23641-3.32297.87014-.99166.63047-1.75348,1.5433-2.17707,2.62689l-21.19891,55.31237-.21348.55493c-6.28158,16.38521-.92929,34.90803,13.05891,45.48782.02621.01641.04922.03611.07552.05582l.18719.14119,32.29094,24.17392,15.97151,12.09024,9.71951,7.34871c2.34117,1.77316,5.57877,1.77316,7.92002,0l9.71943-7.34871,15.96822-12.09024,32.48142-24.31511c.02958-.02299.05588-.04269.08538-.06568,13.97834-10.57977,19.32735-29.09604,13.04905-45.47796Z" />
|
||||
<path d="M265.26416,174.37243l-.2134-.55822c-10.5174,2.16062-20.20405,6.6099-28.49844,12.81593-.1346.0985-25.20497,19.05805-46.55171,35.19699,15.84998,11.98517,29.6477,22.40405,29.6477,22.40405l32.48142-24.31511c.02958-.02299.05588-.04269.08538-.06568,13.97834-10.57977,19.32735-29.09604,13.04905-45.47796Z" />
|
||||
<path d="M160.34962,244.23117l15.97151,12.09024,9.71951,7.34871c2.34117,1.77316,5.57877,1.77316,7.92002,0l9.71943-7.34871,15.96822-12.09024s-13.79772-10.41888-29.6477-22.40405c-15.85327,11.98517-29.65099,22.40405-29.65099,22.40405Z" />
|
||||
<path d="M143.44561,186.63014c-8.29111-6.20274-17.97446-10.65531-28.49507-12.81264l-.21348.55493c-6.28158,16.38521-.92929,34.90803,13.05891,45.48782.02621.01641.04922.03611.07552.05582l.18719.14119,32.29094,24.17392s13.79772-10.41888,29.65099-22.40405c-21.34673-16.13894-46.42031-35.09848-46.55499-35.19699Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -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")),
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"id": "gitlab",
|
||||
"name": "GitLab",
|
||||
"priority": 11,
|
||||
"description": "Adds a GitLab integration for link unfurling.",
|
||||
"after": "github"
|
||||
}
|
||||
@@ -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<IntegrationType.Embed>
|
||||
): Promise<IssueSource[]> {
|
||||
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<IssueSource>((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<string, unknown>;
|
||||
headers: Record<string, unknown>;
|
||||
}) {
|
||||
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<string, any>) {
|
||||
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<IntegrationType.Embed>;
|
||||
|
||||
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<string, any>) {
|
||||
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<IntegrationType.Embed>[];
|
||||
|
||||
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<string, any>) {
|
||||
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<string, any>) {
|
||||
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<IntegrationType.Embed>;
|
||||
|
||||
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<string, any>) {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<T.GitLabConnectReq>) => {
|
||||
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<Integration>,
|
||||
{ transaction }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const redirectUrl = GitLabUtils.authUrl(user.teamId, url, clientId);
|
||||
ctx.body = {
|
||||
data: { redirectUrl },
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
router.get(
|
||||
"gitlab.callback",
|
||||
auth({ optional: true }),
|
||||
validate(T.GitLabCallbackSchema),
|
||||
apexAuthRedirect<T.GitLabCallbackReq>({
|
||||
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<T.GitLabCallbackReq>) => {
|
||||
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;
|
||||
@@ -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();
|
||||
@@ -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<IntegrationType.Embed>[];
|
||||
|
||||
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<IntegrationType.Embed> | undefined;
|
||||
let resource: ReturnType<typeof GitLabUtils.parseUrl>;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
@@ -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<typeof GitLabCallbackSchema>;
|
||||
|
||||
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<typeof GitLabConnectSchema>;
|
||||
@@ -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<string, unknown>;
|
||||
payload: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export default class GitLabWebhookTask extends BaseTask<Props> {
|
||||
public async perform({ headers, payload }: Props): Promise<void> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> = {
|
||||
opened: isDraftMR ? "#848d97" : "#1f75cb",
|
||||
done: "#a371f7",
|
||||
closed: "#f85149",
|
||||
merged: "#8250df",
|
||||
canceled: "#848d97",
|
||||
};
|
||||
|
||||
return statusColors[status] ?? "#848d97";
|
||||
}
|
||||
}
|
||||
@@ -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<string | undefined>);
|
||||
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;
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width={size}
|
||||
height={size}
|
||||
fill={state.color}
|
||||
className={className}
|
||||
>
|
||||
<path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" />
|
||||
<path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z" />
|
||||
</svg>
|
||||
);
|
||||
case "closed":
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width={size}
|
||||
height={size}
|
||||
fill={state.color}
|
||||
className={className}
|
||||
>
|
||||
<path d="M11.28 6.78a.75.75 0 0 0-1.06-1.06L7.25 8.69 5.78 7.22a.75.75 0 0 0-1.06 1.06l2 2a.75.75 0 0 0 1.06 0l3.5-3.5Z" />
|
||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0Zm-1.5 0a6.5 6.5 0 1 0-13 0 6.5 6.5 0 0 0 13 0Z" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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 <GitHubIssueStatusIcon {...props} />;
|
||||
case IntegrationService.Linear:
|
||||
return <LinearIssueStatusIcon {...props} />;
|
||||
case IntegrationService.GitLab:
|
||||
return <GitLabIssueStatusIcon {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ const Icon = styled.span<{ size?: number }>`
|
||||
|
||||
function BaseIcon({ state }: Pick<Props, "state">) {
|
||||
switch (state.name) {
|
||||
case "opened":
|
||||
case "open":
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" fill={state.color}>
|
||||
@@ -46,6 +47,12 @@ function BaseIcon({ state }: Pick<Props, "state">) {
|
||||
<path d="M5.45 5.154A4.25 4.25 0 0 0 9.25 7.5h1.378a2.251 2.251 0 1 1 0 1.5H9.25A5.734 5.734 0 0 1 5 7.123v3.505a2.25 2.25 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.95-.218ZM4.25 13.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm8.5-4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5ZM5 3.25a.75.75 0 1 0 0 .005V3.25Z" />
|
||||
</svg>
|
||||
);
|
||||
case "locked":
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" fill={state.color}>
|
||||
<path d="M4.5 6V4a3.5 3.5 0 1 1 7 0v2h.5A1.5 1.5 0 0 1 13.5 7.5v6A1.5 1.5 0 0 1 12 15H4a1.5 1.5 0 0 1-1.5-1.5v-6A1.5 1.5 0 0 1 4 6h.5Zm1-2v2h5V4a2.5 2.5 0 0 0-5 0ZM4 7.5v6h8v-6H4Zm4 3a.75.75 0 1 0 0 1.5A.75.75 0 0 0 8 10.5Z" />
|
||||
</svg>
|
||||
);
|
||||
case "closed":
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" fill={state.color}>
|
||||
|
||||
@@ -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 (
|
||||
<a
|
||||
|
||||
@@ -1433,6 +1433,26 @@
|
||||
"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.",
|
||||
"Connect GitLab": "Connect GitLab",
|
||||
"Enter the details for your GitLab instance.": "Enter the details for your GitLab instance.",
|
||||
"GitLab URL": "GitLab URL",
|
||||
"URL must start with https": "URL must start with https",
|
||||
"Client ID": "Client ID",
|
||||
"OAuth application ID": "OAuth application ID",
|
||||
"Client Secret": "Client Secret",
|
||||
"OAuth application secret": "OAuth application secret",
|
||||
"Connecting": "Connecting",
|
||||
"Choose which GitLab instance to connect to.": "Choose which GitLab instance to connect to.",
|
||||
"GitLab Cloud": "GitLab Cloud",
|
||||
"Connect to your account on gitlab.com": "Connect to your account on gitlab.com",
|
||||
"GitLab Cloud credentials are not configured": "GitLab Cloud credentials are not configured",
|
||||
"Self-managed": "Self-managed",
|
||||
"Connect to a custom GitLab installation": "Connect to a custom GitLab installation",
|
||||
"You need to accept the permissions in GitLab to connect {{appName}} to your workspace. Try again?": "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.": "Something went wrong while authenticating your request. Please try again.",
|
||||
"The owner of GitLab account has been requested to install the application. Once approved, the connection will be completed.": "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}.": "Enable previews of GitLab issues and merge requests in documents by connecting a GitLab organization or specific repositories to {appName}.",
|
||||
"Disconnecting will prevent previewing links from GitLab in documents. Are you sure?": "Disconnecting will prevent previewing links from GitLab in documents. Are you sure?",
|
||||
"Google Analytics": "Google Analytics",
|
||||
"Add a Google Analytics 4 measurement ID to send document views and analytics from the workspace to your own Google Analytics account.": "Add a Google Analytics 4 measurement ID to send document views and analytics from the workspace to your own Google Analytics account.",
|
||||
"Measurement ID": "Measurement ID",
|
||||
|
||||
+25
-1
@@ -139,6 +139,7 @@ export enum IntegrationService {
|
||||
Matomo = "matomo",
|
||||
Umami = "umami",
|
||||
GitHub = "github",
|
||||
GitLab = "gitlab",
|
||||
Linear = "linear",
|
||||
Figma = "figma",
|
||||
Notion = "notion",
|
||||
@@ -155,11 +156,14 @@ export const ImportableIntegrationService = {
|
||||
|
||||
export type IssueTrackerIntegrationService = Extract<
|
||||
IntegrationService,
|
||||
IntegrationService.GitHub | IntegrationService.Linear
|
||||
| IntegrationService.GitHub
|
||||
| IntegrationService.GitLab
|
||||
| IntegrationService.Linear
|
||||
>;
|
||||
|
||||
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> = 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> = T extends IntegrationType.Embed
|
||||
};
|
||||
};
|
||||
};
|
||||
gitlab?: {
|
||||
url?: string;
|
||||
installation?: {
|
||||
id: number;
|
||||
account: {
|
||||
id?: number;
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
diagrams?: {
|
||||
url: string;
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user