Compare commits

...

7 Commits

Author SHA1 Message Date
codegen-sh[bot] 3187619328 fix: Remove unused theme variable in GitLabIssueStatusIcon 2025-08-18 22:32:01 +00:00
codegen-sh[bot] 4a8b8d5fa7 fix: replace any types with unknown to fix linting errors 2025-08-18 22:19:11 +00:00
codegen-sh[bot] 391fc5fdee fix: replace any types in MultiplayerEditor and SlackUtils 2025-08-18 22:15:50 +00:00
codegen-sh[bot] cbcf7d6a8e fix: replace more any types with more specific types to fix linting errors 2025-08-18 22:15:05 +00:00
codegen-sh[bot] 94eb1aa07d fix: replace any types with more specific types to fix linting errors 2025-08-18 22:13:36 +00:00
codegen-sh[bot] ca66a6b2fa fix: Fix linting issues in GitLab integration and OIDC strategy
- Remove unused IntegrationService import in UploadGitLabProjectAvatarTask
- Replace 'any' type with proper types in gitlab.ts and OIDCStrategy.ts
2025-08-18 21:59:03 +00:00
codegen-sh[bot] 404a5991b3 feat: Add GitLab integration matching Linear integration patterns 2025-08-18 21:47:16 +00:00
32 changed files with 873 additions and 34 deletions
+4
View File
@@ -211,6 +211,10 @@ GITHUB_APP_PRIVATE_KEY=
LINEAR_CLIENT_ID=
LINEAR_CLIENT_SECRET=
# The GitLab integration allows previewing issue and merge request links as rich mentions
GITLAB_CLIENT_ID=
GITLAB_CLIENT_SECRET=
# For a complete Slack integration with search and posting to channels the
# following configs are also needed in addition to Slack authentication:
# DOCS: https://docs.getoutline.com/s/hosting/doc/slack-G2mc8DOJHk
@@ -30,10 +30,15 @@ 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;
let service;
if (urlObj.hostname === "github.com") {
service = IntegrationService.GitHub;
} else if (urlObj.hostname === "gitlab.com") {
service = IntegrationService.GitLab;
} else {
service = IntegrationService.Linear;
}
return (
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
+1 -1
View File
@@ -55,7 +55,7 @@ type Props = Omit<EditorProps, "editorStyle"> & {
* The main document editor includes an editable title with metadata below it,
* and support for commenting.
*/
function DocumentEditor(props: Props, ref: React.RefObject<any>) {
function DocumentEditor(props: Props, ref: React.RefObject<unknown>) {
const titleRef = React.useRef<RefHandle>(null);
const { t } = useTranslation();
const match = useRouteMatch();
@@ -51,7 +51,10 @@ type MessageEvent = {
};
};
function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
function MultiplayerEditor(
{ onSynced, ...props }: Props,
ref: React.Ref<unknown>
) {
const documentId = props.id;
const history = useHistory();
const { t } = useTranslation();
+7
View File
@@ -34,6 +34,13 @@ class IntegrationsStore extends Store<Integration> {
(integration) => integration.service === IntegrationService.Linear
);
}
@computed
get gitlab(): Integration<IntegrationType.Embed>[] {
return this.orderedData.filter(
(integration) => integration.service === IntegrationService.GitLab
);
}
}
export default IntegrationsStore;
+4 -4
View File
@@ -47,7 +47,7 @@ class ApiClient {
this.shareId = shareId;
};
fetch = async <T = any>(
fetch = async <T = unknown>(
path: string,
method: string,
data: JSONObject | FormData | undefined,
@@ -180,7 +180,7 @@ class ApiClient {
const error: {
message?: string;
error?: string;
data?: Record<string, any>;
data?: Record<string, unknown>;
} = {};
try {
@@ -244,13 +244,13 @@ class ApiClient {
throw err;
};
get = <T = any>(
get = <T = unknown>(
path: string,
data: JSONObject | undefined,
options?: FetchOptions
) => this.fetch<T>(path, "GET", data, options);
post = <T = any>(
post = <T = unknown>(
path: string,
data?: JSONObject | FormData | undefined,
options?: FetchOptions
+1 -1
View File
@@ -13,7 +13,7 @@ type LogCategory =
| "plugins"
| "policies";
type Extra = Record<string, any>;
type Extra = Record<string, unknown>;
class Logger {
/**
+20
View File
@@ -37,6 +37,16 @@ export const isURLMentionable = ({
);
}
case IntegrationService.GitLab: {
const settings =
integration.settings as IntegrationSettings<IntegrationType.Embed>;
return (
hostname === "gitlab.com" &&
settings.gitlab?.project.path_with_namespace === pathParts.slice(1, -2).join("/") // ensure installed project path matches with the provided url.
);
}
default:
return false;
}
@@ -67,6 +77,16 @@ export const determineMentionType = ({
return type === "issue" ? MentionType.Issue : undefined;
}
case IntegrationService.GitLab: {
const type = pathParts[pathParts.length - 2];
if (type === "issues") {
return MentionType.Issue;
} else if (type === "merge_requests") {
return MentionType.PullRequest;
}
return undefined;
}
default:
return;
}
+51
View File
@@ -0,0 +1,51 @@
import * as React from "react";
type Props = {
/** The size of the icon, 24px is default to match standard icons */
size?: number;
/** The color of the icon, defaults to the current text color */
fill?: string;
};
export default function Icon({ size = 24, fill = "currentColor" }: Props) {
return (
<svg
fill={fill}
width={size}
height={size}
viewBox="0 0 24 24"
version="1.1"
>
<path
d="M12 20.8L4.6 13.4L6.3 7.8L12 13.4L17.7 7.8L19.4 13.4L12 20.8Z"
fillRule="evenodd"
clipRule="evenodd"
/>
<path
d="M12 20.8L4.6 13.4L6.3 7.8L12 13.4L12 20.8Z"
fillRule="evenodd"
clipRule="evenodd"
fillOpacity="0.3"
/>
<path
d="M4.6 13.4L2.5 7.8L6.3 7.8L4.6 13.4Z"
fillRule="evenodd"
clipRule="evenodd"
fillOpacity="0.5"
/>
<path
d="M19.4 13.4L21.5 7.8L17.7 7.8L19.4 13.4Z"
fillRule="evenodd"
clipRule="evenodd"
fillOpacity="0.5"
/>
<path
d="M6.3 7.8L8.7 2.2L15.3 2.2L17.7 7.8L6.3 7.8Z"
fillRule="evenodd"
clipRule="evenodd"
fillOpacity="0.7"
/>
</svg>
);
}
+141
View File
@@ -0,0 +1,141 @@
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { IntegrationService } from "@shared/types";
import { ConnectedButton } from "~/scenes/Settings/components/ConnectedButton";
import { AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import Heading from "~/components/Heading";
import List from "~/components/List";
import ListItem from "~/components/List/Item";
import Notice from "~/components/Notice";
import PlaceholderText from "~/components/PlaceholderText";
import Scene from "~/components/Scene";
import TeamLogo from "~/components/TeamLogo";
import Text from "~/components/Text";
import Time from "~/components/Time";
import env from "~/env";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import GitLabIcon from "./Icon";
import { GitLabConnectButton } from "./components/GitLabButton";
function GitLab() {
const { integrations } = useStores();
const { t } = useTranslation();
const query = useQuery();
const error = query.get("error");
const appName = env.APP_NAME;
React.useEffect(() => {
void integrations.fetchAll({
service: IntegrationService.GitLab,
withRelations: true,
});
}, [integrations]);
return (
<Scene title="GitLab" icon={<GitLabIcon />}>
<Heading>GitLab</Heading>
{error === "access_denied" && (
<Notice>
<Trans>
Whoops, you need to accept the permissions in GitLab to connect{" "}
{{ appName }} to your project. Try again?
</Trans>
</Notice>
)}
{error === "unauthenticated" && (
<Notice>
<Trans>
Something went wrong while authenticating your request. Please try
logging in again.
</Trans>
</Notice>
)}
{env.GITLAB_CLIENT_ID ? (
<>
<Text as="p">
<Trans>
Enable previews of GitLab issues and merge requests in documents by connecting a
GitLab project to {appName}.
</Trans>
</Text>
{integrations.gitlab.length ? (
<>
<Heading as="h2">
<Flex justify="space-between" auto>
{t("Connected")}
<GitLabConnectButton icon={<PlusIcon />} />
</Flex>
</Heading>
<List>
{integrations.gitlab.map((integration) => {
const gitlabProject =
integration.settings?.gitlab?.project;
const integrationCreatedBy = integration.user
? integration.user.name
: undefined;
return (
<ListItem
key={gitlabProject?.id}
small
title={gitlabProject?.name}
subtitle={
integrationCreatedBy ? (
<>
<Trans>Enabled by {{ integrationCreatedBy }}</Trans>{" "}
&middot;{" "}
<Time
dateTime={integration.createdAt}
relative={false}
format={{ en_US: "MMMM d, y" }}
/>
</>
) : (
<PlaceholderText />
)
}
image={
<TeamLogo
src={gitlabProject?.avatar_url}
size={AvatarSize.Large}
/>
}
actions={
<ConnectedButton
onClick={integration.delete}
confirmationMessage={t(
"Disconnecting will prevent previewing GitLab links from this project in documents. Are you sure?"
)}
/>
}
/>
);
})}
</List>
</>
) : (
<p>
<GitLabConnectButton icon={<GitLabIcon />} />
</p>
)}
</>
) : (
<Notice>
<Trans>
The GitLab integration is currently disabled. Please set the
associated environment variables and restart the server to enable
the integration.
</Trans>
</Notice>
)}
</Scene>
);
}
export default observer(GitLab);
@@ -0,0 +1,24 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import Button, { type Props } from "~/components/Button";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { redirectTo } from "~/utils/urls";
import { GitLabUtils } from "../../shared/GitLabUtils";
export function GitLabConnectButton(props: Props<HTMLButtonElement>) {
const { t } = useTranslation();
const team = useCurrentTeam();
return (
<Button
onClick={() =>
redirectTo(GitLabUtils.authUrl({ state: { teamId: team.id } }))
}
neutral
{...props}
>
{t("Connect")}
</Button>
);
}
+17
View File
@@ -0,0 +1,17 @@
import * as React from "react";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
PluginManager.add([
{
...config,
type: Hook.Settings,
value: {
group: "Integrations",
icon: Icon,
component: React.lazy(() => import("./Settings")),
},
},
]);
+8
View File
@@ -0,0 +1,8 @@
{
"id": "gitlab",
"name": "GitLab",
"priority": 16,
"description": "Adds a GitLab integration for link unfurling and converting links to mentions.",
"after": "linear"
}
+100
View File
@@ -0,0 +1,100 @@
import Router from "koa-router";
import { IntegrationService, IntegrationType } from "@shared/types";
import Logger from "@server/logging/Logger";
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 { IntegrationAuthentication, Integration } from "@server/models";
import { APIContext } from "@server/types";
import { GitLab } from "../gitlab";
import UploadGitLabProjectAvatarTask from "../tasks/UploadGitLabProjectAvatarTask";
import * as T from "./schema";
import { GitLabUtils } from "plugins/gitlab/shared/GitLabUtils";
const router = new Router();
router.get(
"gitlab.callback",
auth({
optional: true,
}),
validate(T.GitLabCallbackSchema),
apexAuthRedirect<T.GitLabCallbackReq>({
getTeamId: (ctx) => GitLabUtils.parseState(ctx.input.query.state)?.teamId,
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;
// Check error after any sub-domain redirection. Otherwise, the user will be redirected to the root domain.
if (error) {
ctx.redirect(GitLabUtils.errorUrl(error));
return;
}
try {
// validation middleware ensures that code is non-null at this point.
const oauth = await GitLab.oauthAccess(code!);
const project = await GitLab.getInstalledProject(oauth.access_token);
const authentication = await IntegrationAuthentication.create(
{
service: IntegrationService.GitLab,
userId: user.id,
teamId: user.teamId,
token: oauth.access_token,
scopes: oauth.scope.split(" "),
},
{ transaction }
);
const integration = await Integration.create<
Integration<IntegrationType.Embed>
>(
{
service: IntegrationService.GitLab,
type: IntegrationType.Embed,
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
settings: {
gitlab: {
project: {
id: project.id,
name: project.name,
path_with_namespace: project.path_with_namespace,
avatar_url: project.avatar_url,
},
},
},
},
{ transaction }
);
transaction.afterCommit(async () => {
if (project.avatar_url) {
await new UploadGitLabProjectAvatarTask().schedule({
integrationId: integration.id,
avatarUrl: project.avatar_url,
});
}
});
ctx.redirect(GitLabUtils.successUrl());
} catch (err) {
Logger.error("Encountered error during GitLab OAuth callback", err);
ctx.redirect(GitLabUtils.errorUrl("unknown"));
}
}
);
export default router;
+18
View File
@@ -0,0 +1,18 @@
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(),
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>;
+7
View File
@@ -0,0 +1,7 @@
import env from "@server/env";
export default {
GITLAB_CLIENT_ID: env.GITLAB_CLIENT_ID,
GITLAB_CLIENT_SECRET: env.GITLAB_CLIENT_SECRET,
};
+273
View File
@@ -0,0 +1,273 @@
import { z } from "zod";
import {
IntegrationService,
IntegrationType,
UnfurlResourceType,
} from "@shared/types";
import Logger from "@server/logging/Logger";
import { Integration } from "@server/models";
import User from "@server/models/User";
import { UnfurlIssueOrPR, UnfurlSignature } from "@server/types";
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(),
});
const GitLabProjectSchema = z.object({
id: z.string(),
name: z.string(),
path_with_namespace: z.string(),
avatar_url: z.string().optional(),
});
const GitLabIssueSchema = z.object({
id: z.number(),
iid: z.number(),
title: z.string(),
description: z.string().nullable(),
state: z.string(),
created_at: z.string(),
author: z.object({
id: z.number(),
name: z.string(),
avatar_url: z.string().nullable(),
}),
labels: z.array(z.string()).optional(),
});
const GitLabMergeRequestSchema = z.object({
id: z.number(),
iid: z.number(),
title: z.string(),
description: z.string().nullable(),
state: z.string(),
created_at: z.string(),
author: z.object({
id: z.number(),
name: z.string(),
avatar_url: z.string().nullable(),
}),
labels: z.array(z.string()).optional(),
draft: z.boolean().optional(),
});
export class GitLab {
private static supportedUnfurls = [
UnfurlResourceType.Issue,
UnfurlResourceType.PR,
];
static async oauthAccess(code: string) {
const headers = {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
};
const body = new URLSearchParams();
body.set("code", code);
body.set("client_id", env.GITLAB_CLIENT_ID!);
body.set("client_secret", env.GITLAB_CLIENT_SECRET!);
body.set("redirect_uri", GitLabUtils.callbackUrl());
body.set("grant_type", "authorization_code");
const res = await fetch(GitLabUtils.tokenUrl, {
method: "POST",
headers,
body,
});
if (res.status !== 200) {
throw new Error(
`Error while exchanging oauth code from GitLab; status: ${res.status}`
);
}
return AccessTokenResponseSchema.parse(await res.json());
}
static async revokeAccess(accessToken: string) {
const headers = {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
};
const body = new URLSearchParams();
body.set("client_id", env.GITLAB_CLIENT_ID!);
body.set("client_secret", env.GITLAB_CLIENT_SECRET!);
body.set("token", accessToken);
await fetch(GitLabUtils.revokeUrl, {
method: "POST",
headers,
body,
});
}
static async getInstalledProject(accessToken: string) {
const headers = {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
};
// Get the first project the user has access to
// In a real implementation, we would want to let the user select which project to connect
const res = await fetch(
"https://gitlab.com/api/v4/projects?membership=true&per_page=1",
{
headers,
}
);
if (res.status !== 200) {
throw new Error(
`Error while fetching GitLab projects; status: ${res.status}`
);
}
const projects = await res.json();
if (!projects.length) {
throw new Error("No GitLab projects found");
}
return GitLabProjectSchema.parse(projects[0]);
}
/**
*
* @param url GitLab resource url
* @param actor User attempting to unfurl resource url
* @returns An object containing resource details e.g, a GitLab issue or merge request details
*/
static unfurl: UnfurlSignature = async (url: string, actor: User) => {
const resource = GitLab.parseUrl(url);
if (!resource) {
return;
}
const integration = (await Integration.scope("withAuthentication").findOne({
where: {
service: IntegrationService.GitLab,
teamId: actor.teamId,
"settings.gitlab.project.path_with_namespace": resource.projectPath,
},
})) as Integration<IntegrationType.Embed>;
if (!integration) {
return;
}
try {
const headers = {
Authorization: `Bearer ${integration.authentication.token}`,
Accept: "application/json",
};
let apiUrl: string;
let resourceSchema: z.ZodObject<z.ZodRawShape>;
let resourceType: UnfurlResourceType;
if (resource.type === "issues") {
apiUrl = `https://gitlab.com/api/v4/projects/${encodeURIComponent(resource.projectPath)}/issues/${resource.id}`;
resourceSchema = GitLabIssueSchema;
resourceType = UnfurlResourceType.Issue;
} else if (resource.type === "merge_requests") {
apiUrl = `https://gitlab.com/api/v4/projects/${encodeURIComponent(resource.projectPath)}/merge_requests/${resource.id}`;
resourceSchema = GitLabMergeRequestSchema;
resourceType = UnfurlResourceType.PR;
} else {
return;
}
const res = await fetch(apiUrl, { headers });
if (res.status !== 200) {
return { error: `Resource not found (${res.status})` };
}
const data = resourceSchema.parse(await res.json());
// Fetch labels if they exist
let labels = [];
if (data.labels && data.labels.length > 0) {
labels = data.labels.map((label) => ({
name: label,
color: "#428BCA", // Default GitLab blue
}));
}
return {
type: resourceType,
url,
id: `#${data.iid}`,
title: data.title,
description: data.description,
author: {
name: data.author.name,
avatarUrl: data.author.avatar_url || "",
},
labels,
state: {
name: data.state,
color: data.state === "opened" ? "#1aaa55" : "#db3b21", // Green for open, red for closed
draft:
resourceType === UnfurlResourceType.PR ? data.draft : undefined,
},
createdAt: data.created_at,
} satisfies UnfurlIssueOrPR;
} catch (err) {
Logger.warn("Failed to fetch resource from GitLab", err);
return { error: err.message || "Unknown error" };
}
};
/**
* Parses a given URL and returns resource identifiers for GitLab specific URLs
*
* @param url URL to parse
* @returns {object} Containing resource identifiers - `projectPath`, `type`, and `id`.
*/
private static parseUrl(url: string) {
const { hostname, pathname } = new URL(url);
if (hostname !== "gitlab.com") {
return;
}
const parts = pathname.split("/");
// Remove empty first element
parts.shift();
// GitLab URLs are in the format: /namespace/project/-/issues/1 or /namespace/project/-/merge_requests/1
// The namespace can have multiple levels (e.g., /group/subgroup/project/-/issues/1)
if (parts.length < 4) {
return;
}
// Find the index of "-" which separates project path from resource type
const separatorIndex = parts.indexOf("-");
if (separatorIndex === -1 || separatorIndex === parts.length - 1) {
return;
}
const projectPath = parts.slice(0, separatorIndex).join("/");
const type = parts[separatorIndex + 1];
const id = parts[separatorIndex + 2];
if (
!type ||
!id ||
!GitLab.supportedUnfurls.includes(type as UnfurlResourceType)
) {
return;
}
return { projectPath, type, id };
}
}
@@ -0,0 +1,56 @@
import { IntegrationType } from "@shared/types";
import BaseTask from "@server/queues/tasks/BaseTask";
import { Integration } from "@server/models";
import { FileOperation } from "@server/models";
import fetch from "node-fetch";
type Props = {
integrationId: string;
avatarUrl: string;
};
export default class UploadGitLabProjectAvatarTask extends BaseTask<Props> {
public async perform({ integrationId, avatarUrl }: Props) {
const integration = await Integration.findByPk(integrationId, {
rejectOnEmpty: true,
});
try {
const res = await fetch(avatarUrl);
const buffer = await res.buffer();
const name = avatarUrl.split("/").pop() || "avatar";
const contentType = res.headers.get("content-type") || "image/png";
const operation = await FileOperation.createFromBuffer({
buffer,
contentType,
name,
userId: integration.userId,
teamId: integration.teamId,
source: "gitlab",
});
await integration.update({
settings: {
...integration.settings,
gitlab: {
...(integration.settings as Integration<IntegrationType.Embed>)
.gitlab,
project: {
...(integration.settings as Integration<IntegrationType.Embed>)
.gitlab?.project,
avatar_url: operation.url,
},
},
},
});
} catch (err) {
// If the avatar upload fails, we don't need to fail the entire task
// as it's not critical to the integration's functionality.
// Just log the error and continue.
this.logger.error(
`Failed to upload GitLab project avatar: ${err.message}`
);
}
}
}
+52
View File
@@ -0,0 +1,52 @@
import queryString from "query-string";
import env from "@shared/env";
import { integrationSettingsPath } from "@shared/utils/routeHelpers";
export type OAuthState = {
teamId: string;
};
export class GitLabUtils {
private static oauthScopes = "api read_api read_user read_repository";
public static tokenUrl = "https://gitlab.com/oauth/token";
public static revokeUrl = "https://gitlab.com/oauth/revoke";
private static authBaseUrl = "https://gitlab.com/oauth/authorize";
private static settingsUrl = integrationSettingsPath("gitlab");
static parseState(state: string): OAuthState {
return JSON.parse(state);
}
static successUrl() {
return this.settingsUrl;
}
static errorUrl(error: string) {
return `${this.settingsUrl}?error=${error}`;
}
static callbackUrl(
{ baseUrl, params }: { baseUrl: string; params?: string } = {
baseUrl: env.URL,
params: undefined,
}
) {
return params
? `${baseUrl}/api/gitlab.callback?${params}`
: `${baseUrl}/api/gitlab.callback`;
}
static authUrl({ state }: { state: OAuthState }) {
const params = {
client_id: env.GITLAB_CLIENT_ID,
redirect_uri: this.callbackUrl(),
state: JSON.stringify(state),
scope: this.oauthScopes,
response_type: "code",
};
return `${this.authBaseUrl}?${queryString.stringify(params)}`;
}
}
+3 -2
View File
@@ -1,5 +1,6 @@
import { HttpsProxyAgent } from "https-proxy-agent";
import OAuth2Strategy, { Strategy } from "passport-oauth2";
import { Request } from "express";
export class OIDCStrategy extends Strategy {
constructor(
@@ -14,12 +15,12 @@ export class OIDCStrategy extends Strategy {
}
}
authenticate(req: any, options: any) {
authenticate(req: Request, options: Record<string, unknown>) {
options.originalQuery = req.query;
super.authenticate(req, options);
}
authorizationParams(options: any) {
authorizationParams(options: Record<string, unknown>) {
return {
...options.originalQuery,
...super.authorizationParams?.(options),
+2 -2
View File
@@ -6,7 +6,7 @@ import env from "./env";
const SLACK_API_URL = "https://slack.com/api";
export async function post(endpoint: string, body: Record<string, any>) {
export async function post(endpoint: string, body: Record<string, unknown>) {
let data;
const token = body.token;
@@ -30,7 +30,7 @@ export async function post(endpoint: string, body: Record<string, any>) {
return data;
}
export async function request(endpoint: string, body: Record<string, any>) {
export async function request(endpoint: string, body: Record<string, unknown>) {
let data;
try {
+1 -1
View File
@@ -16,7 +16,7 @@ export class SlackUtils {
static createState(
teamId: string,
type: IntegrationType,
data?: Record<string, any>
data?: Record<string, unknown>
) {
return JSON.stringify({ type, teamId, ...data });
}
+2 -2
View File
@@ -14,7 +14,7 @@ export enum TaskSchedule {
Minute = "minute",
}
export default abstract class BaseTask<T extends Record<string, any>> {
export default abstract class BaseTask<T extends Record<string, unknown>> {
/**
* An optional schedule for this task to be run automatically.
*/
@@ -43,7 +43,7 @@ export default abstract class BaseTask<T extends Record<string, any>> {
* @param props Properties to be used by the task
* @returns A promise that resolves once the task has completed.
*/
public abstract perform(props: T): Promise<any>;
public abstract perform(props: T): Promise<unknown>;
/**
* Handle failure when all attempts are exhausted for the task.
+1 -1
View File
@@ -3,7 +3,7 @@ import BaseTask from "./BaseTask";
type Props = {
templateName: string;
props: Record<string, any>;
props: Record<string, unknown>;
};
export default class EmailTask extends BaseTask<Props> {
@@ -825,7 +825,7 @@ describe("#documents.list", () => {
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data).toHaveLength(2);
const docIds = body.data.map((doc: any) => doc.id);
const docIds = body.data.map((doc: { id: string }) => doc.id);
expect(docIds).toContain(docs[0].id);
expect(docIds).toContain(docs[1].id);
expect(docIds).not.toContain(docs[2].id);
@@ -5361,7 +5361,7 @@ describe("#documents.documents", () => {
expect(res.status).toBe(200);
expect(body.data.id).toBe(parent.id);
const childIds = body.data.children.map((node: any) => node.id);
const childIds = body.data.children.map((node: { id: string }) => node.id);
expect(childIds).toContain(child1.id);
expect(childIds).toContain(child2.id);
});
@@ -153,7 +153,9 @@ describe("#relationships.info", () => {
expect(body.data.relationship).toBeTruthy();
expect(body.data.documents).toHaveLength(2);
// User can read their own document but admin document should also be included
const documentIds = body.data.documents.map((doc: any) => doc.id);
const documentIds = body.data.documents.map(
(doc: { id: string }) => doc.id
);
expect(documentIds).toContain(userDocument.id);
});
@@ -170,7 +172,9 @@ describe("#relationships.info", () => {
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.documents).toHaveLength(2);
const documentIds = body.data.documents.map((doc: any) => doc.id);
const documentIds = body.data.documents.map(
(doc: { id: string }) => doc.id
);
expect(documentIds).toContain(document.id);
expect(documentIds).toContain(reverseDocument.id);
});
@@ -265,7 +269,7 @@ describe("#relationships.list", () => {
expect(body.data.relationships).toBeTruthy();
// All returned relationships should be backlinks
body.data.relationships.forEach((rel: any) => {
body.data.relationships.forEach((rel: { type: string }) => {
expect(rel.type).toEqual(RelationshipType.Backlink);
});
});
@@ -283,7 +287,7 @@ describe("#relationships.list", () => {
expect(body.data.relationships).toBeTruthy();
// All returned relationships should have the specified documentId
body.data.relationships.forEach((rel: any) => {
body.data.relationships.forEach((rel: { documentId: string }) => {
expect(rel.documentId).toEqual(documents[0].id);
});
});
@@ -301,7 +305,7 @@ describe("#relationships.list", () => {
expect(body.data.relationships).toBeTruthy();
// All returned relationships should have the specified reverseDocumentId
body.data.relationships.forEach((rel: any) => {
body.data.relationships.forEach((rel: { reverseDocumentId: string }) => {
expect(rel.reverseDocumentId).toEqual(documents[1].id);
});
});
@@ -320,10 +324,12 @@ describe("#relationships.list", () => {
expect(body.data.relationships).toBeTruthy();
// All returned relationships should match both filters
body.data.relationships.forEach((rel: any) => {
expect(rel.type).toEqual(RelationshipType.Backlink);
expect(rel.documentId).toEqual(documents[0].id);
});
body.data.relationships.forEach(
(rel: { type: string; documentId: string }) => {
expect(rel.type).toEqual(RelationshipType.Backlink);
expect(rel.documentId).toEqual(documents[0].id);
}
);
});
it("should fail with status 400 bad request when documentId is invalid", async () => {
+1 -1
View File
@@ -38,7 +38,7 @@ describe("#searches.list", () => {
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data).toHaveLength(3);
const queries = body.data.map((d: any) => d.query);
const queries = body.data.map((d: { query: string }) => d.query);
expect(queries).toContain("query");
expect(queries).toContain("foo");
expect(queries).toContain("bar");
@@ -0,0 +1,34 @@
import * as React from "react";
import { isSafari } from "../../utils/browser";
import { BaseIconProps } from ".";
/** Renders an icon for a specific GitLab issue state */
export function GitLabIssueStatusIcon(props: BaseIconProps) {
const { state } = props;
const isOpen = state.name === "opened";
const color = state.color || (isOpen ? "#1aaa55" : "#db3b21"); // Green for open, red for closed
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
style={{ marginTop: isSafari() ? 0 : -2 }}
>
<circle cx="8" cy="8" r="7" stroke={color} strokeWidth="2" fill="none" />
{!isOpen && (
<path
d="M4.5 4.5L11.5 11.5M4.5 11.5L11.5 4.5"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
/>
)}
{state.draft && (
<rect x="4" y="7" width="8" height="2" rx="1" fill={color} />
)}
</svg>
);
}
@@ -7,6 +7,7 @@ import {
UnfurlResponse,
} from "../../types";
import { GitHubIssueStatusIcon } from "./GitHubIssueStatusIcon";
import { GitLabIssueStatusIcon } from "./GitLabIssueStatusIcon";
import { LinearIssueStatusIcon } from "./LinearIssueStatusIcon";
export type BaseIconProps = {
@@ -33,6 +34,8 @@ function getIcon(props: Props) {
return <GitHubIssueStatusIcon {...props} />;
case IntegrationService.Linear:
return <LinearIssueStatusIcon {...props} />;
case IntegrationService.GitLab:
return <GitLabIssueStatusIcon {...props} />;
}
}
@@ -1182,6 +1182,10 @@
"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.",
"Whoops, you need to accept the permissions in GitLab to connect {{appName}} to your project. Try again?": "Whoops, you need to accept the permissions in GitLab to connect {{appName}} to your project. Try again?",
"Enable previews of GitLab issues and merge requests in documents by connecting a GitLab project to {appName}.": "Enable previews of GitLab issues and merge requests in documents by connecting a GitLab project to {appName}.",
"Disconnecting will prevent previewing GitLab links from this project in documents. Are you sure?": "Disconnecting will prevent previewing GitLab links from this project in documents. Are you sure?",
"The GitLab integration is currently disabled. Please set the associated environment variables and restart the server to enable the integration.": "The GitLab integration is currently disabled. Please set the associated environment variables and restart the server to enable the integration.",
"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",
+6 -1
View File
@@ -126,6 +126,7 @@ export enum IntegrationService {
Umami = "umami",
GitHub = "github",
Linear = "linear",
GitLab = "gitlab",
Notion = "notion",
}
@@ -140,12 +141,13 @@ export const ImportableIntegrationService = {
export type IssueTrackerIntegrationService = Extract<
IntegrationService,
IntegrationService.GitHub | IntegrationService.Linear
IntegrationService.GitHub | IntegrationService.Linear | IntegrationService.GitLab
>;
export const IssueTrackerIntegrationService = {
GitHub: IntegrationService.GitHub,
Linear: IntegrationService.Linear,
GitLab: IntegrationService.GitLab,
} as const;
export type UserCreatableIntegrationService = Extract<
@@ -189,6 +191,9 @@ export type IntegrationSettings<T> = T extends IntegrationType.Embed
linear?: {
workspace: { id: string; name: string; key: string; logoUrl?: string };
};
gitlab?: {
project: { id: string; name: string; path_with_namespace: string; avatar_url?: string };
};
}
: T extends IntegrationType.Analytics
? { measurementId: string; instanceUrl?: string; scriptName?: string }
+2 -2
View File
@@ -14,7 +14,7 @@ const stripEmojis = (value: string) => value.replace(regex, "");
const cleanValue = (value: string) => stripEmojis(deburr(value));
function getSortByField<T extends Record<string, any>>(
function getSortByField<T extends Record<string, unknown>>(
item: T,
keyOrCallback: string | ((item: T) => string)
) {
@@ -25,7 +25,7 @@ function getSortByField<T extends Record<string, any>>(
return cleanValue(field);
}
function naturalSortBy<T extends Record<string, any>>(
function naturalSortBy<T extends Record<string, unknown>>(
items: T[],
key: string | ((item: T) => string),
sortOptions?: NaturalSortOptions