mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff01724a11 | |||
| c02bc22cce | |||
| 7ea308a52c | |||
| e4d1a38367 | |||
| 98d54da0de | |||
| 4b638ae346 | |||
| abb849e1f6 | |||
| 2ec65e3dfc | |||
| 4e493972e5 | |||
| 4a8b8d5fa7 | |||
| 391fc5fdee | |||
| cbcf7d6a8e | |||
| 94eb1aa07d | |||
| ca66a6b2fa | |||
| 404a5991b3 |
@@ -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
|
||||
|
||||
@@ -125,8 +125,8 @@ function Collaborators(props: Props) {
|
||||
|
||||
return (
|
||||
<AvatarWithPresence
|
||||
{...rest}
|
||||
key={collaborator.id}
|
||||
{...rest}
|
||||
user={collaborator}
|
||||
isPresent={isPresent}
|
||||
isEditing={isEditing}
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
import * as React from "react";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
|
||||
export interface LazyComponent<T extends React.ComponentType<any>> {
|
||||
export interface LazyComponent<T extends React.ComponentType<unknown>> {
|
||||
Component: React.LazyExoticComponent<T>;
|
||||
preload: () => Promise<{ default: T }>;
|
||||
}
|
||||
@@ -34,7 +34,7 @@ interface LazyLoadOptions {
|
||||
* MyComponent.preload();
|
||||
* ```
|
||||
*/
|
||||
export function createLazyComponent<T extends React.ComponentType<any>>(
|
||||
export function createLazyComponent<T extends React.ComponentType<unknown>>(
|
||||
factory: () => Promise<{ default: T }>,
|
||||
options: LazyLoadOptions = {}
|
||||
): LazyComponent<T> {
|
||||
|
||||
@@ -14,7 +14,7 @@ describe("PaginatedList", () => {
|
||||
i18n,
|
||||
tReady: true,
|
||||
t: ((key: string) => key) as TFunction,
|
||||
} as any;
|
||||
} as unknown;
|
||||
|
||||
it("with no items renders nothing", () => {
|
||||
const result = render(
|
||||
|
||||
@@ -34,11 +34,11 @@ interface Props<T extends PaginatedItem>
|
||||
* @param options Pagination and other query options
|
||||
*/
|
||||
fetch?: (
|
||||
options: Record<string, any> | undefined
|
||||
options: Record<string, unknown> | undefined
|
||||
) => Promise<unknown[] | undefined> | undefined;
|
||||
|
||||
/** Additional options to pass to the fetch function */
|
||||
options?: Record<string, any>;
|
||||
options?: Record<string, unknown>;
|
||||
|
||||
/** Optional header content to display above the list */
|
||||
heading?: React.ReactNode;
|
||||
@@ -77,7 +77,9 @@ interface Props<T extends PaginatedItem>
|
||||
* Function to render section headings (typically date-based)
|
||||
* @param name The heading text or element to render
|
||||
*/
|
||||
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
|
||||
renderHeading?: (
|
||||
name: React.ReactElement<unknown> | string
|
||||
) => React.ReactNode;
|
||||
|
||||
/**
|
||||
* Handler for escape key press
|
||||
@@ -206,7 +208,7 @@ const PaginatedList = <T extends PaginatedItem>({
|
||||
if (fetch) {
|
||||
void fetchResults();
|
||||
}
|
||||
}, [fetch]);
|
||||
}, [fetch, fetchResults]);
|
||||
|
||||
// Handle updates to fetch or options
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -9,7 +9,7 @@ function Toasts() {
|
||||
|
||||
return (
|
||||
<StyledToaster
|
||||
theme={ui.resolvedTheme as any}
|
||||
theme={ui.resolvedTheme as unknown}
|
||||
closeButton
|
||||
toastOptions={{
|
||||
duration: 5000,
|
||||
|
||||
@@ -70,7 +70,7 @@ const LinkEditor: React.FC<Props> = ({
|
||||
React.useCallback(async () => {
|
||||
const res = await client.post("/suggestions.mention", { query });
|
||||
res.data.documents.map(documents.add);
|
||||
}, [query])
|
||||
}, [query, documents.add])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -79,6 +79,22 @@ const LinkEditor: React.FC<Props> = ({
|
||||
}
|
||||
}, [trimmedQuery, request]);
|
||||
|
||||
const save = React.useCallback(
|
||||
(href: string, title?: string) => {
|
||||
href = href.trim();
|
||||
|
||||
if (href.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
discardRef.current = true;
|
||||
href = sanitizeUrl(href) ?? "";
|
||||
|
||||
onSelectLink({ href, title, from, to });
|
||||
},
|
||||
[onSelectLink, from, to]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleGlobalKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "k" && event.metaKey) {
|
||||
@@ -107,20 +123,7 @@ const LinkEditor: React.FC<Props> = ({
|
||||
|
||||
save(trimmedQuery, trimmedQuery);
|
||||
};
|
||||
}, [trimmedQuery, initialValue]);
|
||||
|
||||
const save = (href: string, title?: string) => {
|
||||
href = href.trim();
|
||||
|
||||
if (href.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
discardRef.current = true;
|
||||
href = sanitizeUrl(href) ?? "";
|
||||
|
||||
onSelectLink({ href, title, from, to });
|
||||
};
|
||||
}, [trimmedQuery, initialValue, handleRemoveLink, save]);
|
||||
|
||||
const moveSelectionToEnd = () => {
|
||||
const { state, dispatch } = view;
|
||||
@@ -195,7 +198,7 @@ const LinkEditor: React.FC<Props> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveLink = () => {
|
||||
const handleRemoveLink = React.useCallback(() => {
|
||||
discardRef.current = true;
|
||||
|
||||
const { state, dispatch } = view;
|
||||
@@ -203,9 +206,12 @@ const LinkEditor: React.FC<Props> = ({
|
||||
dispatch(state.tr.removeMark(from, to, mark));
|
||||
}
|
||||
|
||||
onRemoveLink?.();
|
||||
if (onRemoveLink) {
|
||||
onRemoveLink();
|
||||
}
|
||||
|
||||
view.focus();
|
||||
};
|
||||
}, [view, mark, from, to, onRemoveLink]);
|
||||
|
||||
const isInternal = isInternalUrl(query);
|
||||
const hasResults = !!results.length;
|
||||
|
||||
@@ -184,7 +184,16 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
setItems(items);
|
||||
setLoaded(true);
|
||||
}
|
||||
}, [t, actorId, loading, search, users, documents, maxResultsInSection]);
|
||||
}, [
|
||||
t,
|
||||
actorId,
|
||||
loading,
|
||||
search,
|
||||
users,
|
||||
documents,
|
||||
maxResultsInSection,
|
||||
collections,
|
||||
]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (item: MentionItem) => {
|
||||
|
||||
@@ -87,7 +87,7 @@ function useIsActive(state: EditorState) {
|
||||
|
||||
const slice = selection.content();
|
||||
const fragment = slice.content;
|
||||
const nodes = (fragment as any).content;
|
||||
const nodes = (fragment as unknown).content;
|
||||
|
||||
return some(nodes, (n) => n.content.size);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
+2
-2
@@ -125,7 +125,7 @@ export type Action = {
|
||||
* Perform the action – note this should generally not be called directly, use `performAction`
|
||||
* instead. Errors will be caught and displayed to the user as a toast message.
|
||||
*/
|
||||
perform?: (context: ActionContext) => any;
|
||||
perform?: (context: ActionContext) => unknown;
|
||||
to?: string | { url: string; target?: string };
|
||||
children?: ((context: ActionContext) => Action[]) | Action[];
|
||||
};
|
||||
@@ -154,7 +154,7 @@ export type ActionV2 = BaseActionV2 & {
|
||||
tooltip?:
|
||||
| ((context: ActionContext) => React.ReactChild | undefined)
|
||||
| React.ReactChild;
|
||||
perform: (context: ActionContext) => any;
|
||||
perform: (context: ActionContext) => unknown;
|
||||
};
|
||||
|
||||
export type InternalLinkActionV2 = BaseActionV2 & {
|
||||
|
||||
@@ -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
@@ -13,7 +13,7 @@ type LogCategory =
|
||||
| "plugins"
|
||||
| "policies";
|
||||
|
||||
type Extra = Record<string, any>;
|
||||
type Extra = Record<string, unknown>;
|
||||
|
||||
class Logger {
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
|
||||
type ComponentPromise<T extends React.ComponentType<any>> = Promise<{
|
||||
type ComponentPromise<T extends React.ComponentType<unknown>> = Promise<{
|
||||
default: T;
|
||||
}>;
|
||||
|
||||
@@ -12,7 +12,7 @@ type ComponentPromise<T extends React.ComponentType<any>> = Promise<{
|
||||
* @param interval The interval between retries in milliseconds, defaults to 1000.
|
||||
* @returns A lazy component.
|
||||
*/
|
||||
export default function lazyWithRetry<T extends React.ComponentType<any>>(
|
||||
export default function lazyWithRetry<T extends React.ComponentType<unknown>>(
|
||||
component: () => ComponentPromise<T>,
|
||||
retries?: number,
|
||||
interval?: number
|
||||
@@ -20,7 +20,7 @@ export default function lazyWithRetry<T extends React.ComponentType<any>>(
|
||||
return React.lazy(() => retry(component, retries, interval));
|
||||
}
|
||||
|
||||
function retry<T extends React.ComponentType<any>>(
|
||||
function retry<T extends React.ComponentType<unknown>>(
|
||||
fn: () => ComponentPromise<T>,
|
||||
retriesLeft = 3,
|
||||
interval = 1000
|
||||
|
||||
@@ -37,6 +37,17 @@ 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 +78,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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
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>{" "}
|
||||
·{" "}
|
||||
<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,23 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
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")),
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"id": "gitlab",
|
||||
"name": "GitLab",
|
||||
"priority": 16,
|
||||
"description": "Adds a GitLab integration for link unfurling and converting links to mentions.",
|
||||
"after": "linear"
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
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;
|
||||
@@ -0,0 +1,17 @@
|
||||
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>;
|
||||
@@ -0,0 +1,6 @@
|
||||
import env from "@server/env";
|
||||
|
||||
export default {
|
||||
GITLAB_CLIENT_ID: env.GITLAB_CLIENT_ID,
|
||||
GITLAB_CLIENT_SECRET: env.GITLAB_CLIENT_SECRET,
|
||||
};
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import * as 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)}`;
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,15 @@ export const Notion = observer(() => {
|
||||
onClose: clearQueryParams,
|
||||
});
|
||||
}
|
||||
}, [t, dialogs, oauthSuccess, service, clearQueryParams]);
|
||||
}, [
|
||||
t,
|
||||
dialogs,
|
||||
oauthSuccess,
|
||||
service,
|
||||
clearQueryParams,
|
||||
handleSubmit,
|
||||
integrationId,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!oauthError) {
|
||||
|
||||
@@ -52,7 +52,15 @@ export function ImportDialog({ integrationId, onSubmit }: Props) {
|
||||
toast.error(err.message);
|
||||
resetSubmitting();
|
||||
}
|
||||
}, [permission, onSubmit]);
|
||||
}, [
|
||||
permission,
|
||||
onSubmit,
|
||||
integrationId,
|
||||
t,
|
||||
imports,
|
||||
resetSubmitting,
|
||||
setSubmitting,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Flex column gap={12}>
|
||||
|
||||
@@ -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,7 +15,8 @@ export class OIDCStrategy extends Strategy {
|
||||
}
|
||||
}
|
||||
|
||||
authenticate(req: any, options: any) {
|
||||
authenticate(req: Request, options?: any) {
|
||||
options = options || {};
|
||||
options.originalQuery = req.query;
|
||||
super.authenticate(req, options);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -573,7 +573,7 @@ router.post(
|
||||
});
|
||||
|
||||
let document: Document | null;
|
||||
let serializedDocument: Record<string, any> | undefined;
|
||||
let serializedDocument: Record<string, unknown> | undefined;
|
||||
let isPublic = false;
|
||||
|
||||
if (shareId) {
|
||||
|
||||
@@ -227,20 +227,22 @@ describe("#groups.list", () => {
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.groups.length).toEqual(2);
|
||||
expect(body.data.groups[0].id).toEqual(anotherGroup.id);
|
||||
expect(body.data.groups[1].id).toEqual(group.id);
|
||||
|
||||
expect(body.data.groupMemberships.length).toEqual(2);
|
||||
expect(body.data.groupMemberships[0].groupId).toEqual(group.id);
|
||||
expect(body.data.groupMemberships[1].groupId).toEqual(group.id);
|
||||
expect(
|
||||
body.data.groupMemberships.map((u: any) => u.user.id).includes(user.id)
|
||||
body.data.groupMemberships
|
||||
.map((u: { user: { id: string }; groupId: string }) => u.user.id)
|
||||
.includes(user.id)
|
||||
).toBe(true);
|
||||
expect(
|
||||
body.data.groupMemberships
|
||||
.map((u: any) => u.user.id)
|
||||
.map((u: { user: { id: string }; groupId: string }) => u.user.id)
|
||||
.includes(anotherUser.id)
|
||||
).toBe(true);
|
||||
expect(body.policies.length).toEqual(2);
|
||||
@@ -259,11 +261,13 @@ describe("#groups.list", () => {
|
||||
expect(anotherBody.data.groupMemberships[0].groupId).toEqual(group.id);
|
||||
expect(anotherBody.data.groupMemberships[1].groupId).toEqual(group.id);
|
||||
expect(
|
||||
body.data.groupMemberships.map((u: any) => u.user.id).includes(user.id)
|
||||
body.data.groupMemberships
|
||||
.map((u: { user: { id: string }; groupId: string }) => u.user.id)
|
||||
.includes(user.id)
|
||||
).toBe(true);
|
||||
expect(
|
||||
body.data.groupMemberships
|
||||
.map((u: any) => u.user.id)
|
||||
.map((u: { user: { id: string }; groupId: string }) => u.user.id)
|
||||
.includes(anotherUser.id)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -14,5 +14,5 @@ export class MutexLock {
|
||||
};
|
||||
}
|
||||
|
||||
private static redlock: any;
|
||||
private static redlock: unknown;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
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) {
|
||||
// No theme needed for this component
|
||||
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} />;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,10 +10,10 @@ export type CommandFactory = (attrs?: Record<string, Primitive>) => Command;
|
||||
export type WidgetProps = { rtl: boolean; readOnly: boolean | undefined };
|
||||
|
||||
export default class Extension {
|
||||
options: any;
|
||||
options: Record<string, unknown>;
|
||||
editor: Editor;
|
||||
|
||||
constructor(options: Record<string, any> = {}) {
|
||||
constructor(options: Record<string, unknown> = {}) {
|
||||
this.options = {
|
||||
...this.defaultOptions,
|
||||
...options,
|
||||
|
||||
@@ -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",
|
||||
|
||||
+13
-1
@@ -126,6 +126,7 @@ export enum IntegrationService {
|
||||
Umami = "umami",
|
||||
GitHub = "github",
|
||||
Linear = "linear",
|
||||
GitLab = "gitlab",
|
||||
Notion = "notion",
|
||||
}
|
||||
|
||||
@@ -140,12 +141,15 @@ 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 +193,14 @@ 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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user