feat: GitLab integration (#10861)

Co-authored-by: Tom Moor <tom@getoutline.com>
closes #6795
This commit is contained in:
Salihu
2026-02-21 23:52:27 +01:00
committed by GitHub
parent 00ef17b913
commit cad670f19c
38 changed files with 2472 additions and 17 deletions
+5
View File
@@ -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=
+3
View File
@@ -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
+1
View File
@@ -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>
+7
View File
@@ -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(
+18
View File
@@ -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;
}
+2
View File
@@ -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",
+145
View File
@@ -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} &middot; </>}
<Trans>
Enabled by {{ integrationCreatedBy }}
</Trans>{" "}
&middot;{" "}
<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;
+25
View File
@@ -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>
);
}
+18
View File
@@ -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")),
},
},
]);
+7
View File
@@ -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 });
}
}
});
}
}
+312
View File
@@ -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;
+25
View File
@@ -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();
+393
View File
@@ -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;
}
}
+27
View File
@@ -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,
},
]);
+41
View File
@@ -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,
});
}
}
+166
View File
@@ -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();
});
});
});
+267
View File
@@ -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";
}
}
+18 -5
View File
@@ -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;
+34
View File
@@ -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(),
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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;
+99
View File
@@ -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();
});
});
});
+98
View File
@@ -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} />;
}
}
+7
View File
@@ -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}>
+4 -2
View File
@@ -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
View File
@@ -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;
};
+57 -1
View File
@@ -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"