mirror of
https://github.com/docmost/docmost.git
synced 2026-08-15 08:41:38 +10:00
wip
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.",
|
||||
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Are you sure you want to remove this user from the space? The user will lose all access to this space.",
|
||||
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Are you sure you want to restore this version? Any changes not versioned will be lost.",
|
||||
"Assigned to {{name}}": "Assigned to {{name}}",
|
||||
"Can become members of groups and spaces in workspace": "Can become members of groups and spaces in workspace",
|
||||
"Can create and edit pages in space.": "Can create and edit pages in space.",
|
||||
"Can edit": "Can edit",
|
||||
@@ -22,6 +23,7 @@
|
||||
"Can view": "Can view",
|
||||
"Can view pages in space but not edit.": "Can view pages in space but not edit.",
|
||||
"Cancel": "Cancel",
|
||||
"Card": "Card",
|
||||
"Change email": "Change email",
|
||||
"Change password": "Change password",
|
||||
"Change photo": "Change photo",
|
||||
@@ -216,6 +218,7 @@
|
||||
"To change your email, you have to enter your password and new email.": "To change your email, you have to enter your password and new email.",
|
||||
"Toggle full page width": "Toggle full page width",
|
||||
"Unable to import pages. Please try again.": "Unable to import pages. Please try again.",
|
||||
"Unassigned": "Unassigned",
|
||||
"untitled": "untitled",
|
||||
"Untitled": "Untitled",
|
||||
"Updated successfully": "Updated successfully",
|
||||
|
||||
@@ -7,6 +7,8 @@ import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { matchIntegrationLink } from "@docmost/editor-ext";
|
||||
import { integrationPasteMenuKey } from "@/features/editor/extensions/integration-paste-menu";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { Integration } from "@/features/integration/types/integration.types";
|
||||
import {
|
||||
getAttachmentInfo,
|
||||
uploadFile,
|
||||
@@ -24,6 +26,17 @@ const ATTACHMENT_NODE_TYPES = [
|
||||
|
||||
const ATTACHMENT_URL_RE = /\/api\/files\/([0-9a-f-]+)\//;
|
||||
|
||||
// Only installed + enabled providers get card treatment; anything else pastes
|
||||
// as an ordinary link. The cache is prefetched when the page editor mounts;
|
||||
// a cold cache also means ordinary link.
|
||||
function isIntegrationInstalled(provider: string): boolean {
|
||||
const installed = queryClient.getQueryData<Integration[]>([
|
||||
"installed-integrations",
|
||||
]);
|
||||
const integration = installed?.find((i) => i.type === provider);
|
||||
return Boolean(integration?.isEnabled);
|
||||
}
|
||||
|
||||
export const handlePaste = (
|
||||
editor: Editor,
|
||||
event: ClipboardEvent,
|
||||
@@ -33,7 +46,11 @@ export const handlePaste = (
|
||||
const clipboardData = event.clipboardData.getData("text/plain");
|
||||
|
||||
const integrationMatch = matchIntegrationLink(clipboardData.trim());
|
||||
if (integrationMatch && editor.state.selection.empty) {
|
||||
if (
|
||||
integrationMatch &&
|
||||
editor.state.selection.empty &&
|
||||
isIntegrationInstalled(integrationMatch.provider)
|
||||
) {
|
||||
event.preventDefault();
|
||||
const pastedUrl = clipboardData.trim();
|
||||
editor
|
||||
|
||||
@@ -1,3 +1,31 @@
|
||||
// Light-scheme text per hue, measured to pass 4.5:1 on the light-variant
|
||||
// badge background; hexes are darkened .9 shades for hues whose scale
|
||||
// never gets dark enough.
|
||||
const BADGE_TEXT_LIGHT: Record<string, string> = {
|
||||
dark: "var(--mantine-color-dark-9)",
|
||||
gray: "var(--mantine-color-gray-9)",
|
||||
red: "var(--mantine-color-red-9)",
|
||||
pink: "var(--mantine-color-pink-9)",
|
||||
grape: "var(--mantine-color-grape-9)",
|
||||
violet: "var(--mantine-color-violet-9)",
|
||||
indigo: "var(--mantine-color-indigo-9)",
|
||||
blue: "var(--mantine-color-blue-9)",
|
||||
cyan: "var(--mantine-color-cyan-9)",
|
||||
teal: "var(--mantine-color-teal-9)",
|
||||
green: "#277c38",
|
||||
lime: "#4e7e0b",
|
||||
yellow: "#ad5900",
|
||||
orange: "#c3410e",
|
||||
};
|
||||
|
||||
export function badgeTextColor(color?: string): string | undefined {
|
||||
if (!color) return undefined;
|
||||
const light = BADGE_TEXT_LIGHT[color];
|
||||
if (!light) return undefined;
|
||||
// Dark scheme keeps Mantine's own light-variant text.
|
||||
return `light-dark(${light}, var(--mantine-color-${color}-light-color))`;
|
||||
}
|
||||
|
||||
export function toBadgeColor(raw?: string): string {
|
||||
if (!raw) return "gray";
|
||||
const hex = raw.toLowerCase().replace("#", "");
|
||||
|
||||
+96
-1
@@ -17,7 +17,7 @@ import { getIntegrationIcon } from "@/features/integration/components/integratio
|
||||
import { getOAuthAuthorizeUrl } from "@/features/integration/services/integration-service";
|
||||
import { timeAgo } from "@/lib/time";
|
||||
import { useUnfurl } from "./use-unfurl";
|
||||
import { toBadgeColor } from "./badge-color";
|
||||
import { badgeTextColor, toBadgeColor } from "./badge-color";
|
||||
import classes from "./integration-link-view.module.css";
|
||||
|
||||
const SLACK_TEXT_CLAMP_LINES = 4;
|
||||
@@ -125,6 +125,96 @@ function SlackMessageCard({
|
||||
);
|
||||
}
|
||||
|
||||
function JiraIssueCard({
|
||||
url,
|
||||
unfurlData,
|
||||
}: {
|
||||
url: string;
|
||||
unfurlData: Record<string, any>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const meta = unfurlData.metadata ?? {};
|
||||
|
||||
const infoLine = [
|
||||
meta.issueKey,
|
||||
unfurlData.author
|
||||
? t("Assigned to {{name}}", { name: unfurlData.author })
|
||||
: t("Unassigned"),
|
||||
meta.updatedAt
|
||||
? t("Updated {{time}}", { time: timeAgo(new Date(meta.updatedAt)) })
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" • ");
|
||||
|
||||
return (
|
||||
<NodeViewWrapper data-drag-handle="">
|
||||
<Card
|
||||
className={classes.card}
|
||||
withBorder
|
||||
padding="sm"
|
||||
radius="sm"
|
||||
component="a"
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
style={{ textDecoration: "none", color: "inherit" }}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
{unfurlData.authorAvatarUrl ? (
|
||||
<Avatar
|
||||
src={unfurlData.authorAvatarUrl}
|
||||
size={28}
|
||||
radius="xl"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ flexShrink: 0 }}>{getIntegrationIcon("jira", 28)}</div>
|
||||
)}
|
||||
|
||||
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{unfurlData.title}
|
||||
</Text>
|
||||
{unfurlData.status && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={toBadgeColor(unfurlData.statusColor)}
|
||||
c={badgeTextColor(toBadgeColor(unfurlData.statusColor))}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{unfurlData.status}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{meta.issueTypeIconUrl && (
|
||||
<img
|
||||
src={meta.issueTypeIconUrl}
|
||||
width={14}
|
||||
height={14}
|
||||
alt=""
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{infoLine}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<div style={{ flexShrink: 0, alignSelf: "center" }}>
|
||||
{getIntegrationIcon("jira", 18)}
|
||||
</div>
|
||||
</Group>
|
||||
</Card>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
function IntegrationLinkView(props: any) {
|
||||
const { node, updateAttributes, editor } = props;
|
||||
const { url, provider, unfurlData, status } = node.attrs;
|
||||
@@ -231,6 +321,10 @@ function IntegrationLinkView(props: any) {
|
||||
return <SlackMessageCard url={url} unfurlData={unfurlData} />;
|
||||
}
|
||||
|
||||
if (provider === "jira" && unfurlData.metadata?.issueKey) {
|
||||
return <JiraIssueCard url={url} unfurlData={unfurlData} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper data-drag-handle="">
|
||||
<Card
|
||||
@@ -261,6 +355,7 @@ function IntegrationLinkView(props: any) {
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={toBadgeColor(unfurlData.statusColor)}
|
||||
c={badgeTextColor(toBadgeColor(unfurlData.statusColor))}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{unfurlData.status}
|
||||
|
||||
+26
-1
@@ -4,7 +4,7 @@ import { memo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getIntegrationIcon } from "@/features/integration/components/integration-icons";
|
||||
import { useUnfurl } from "./use-unfurl";
|
||||
import { toBadgeColor } from "./badge-color";
|
||||
import { badgeTextColor, toBadgeColor } from "./badge-color";
|
||||
import classes from "./integration-link-view.module.css";
|
||||
|
||||
function shortUrl(url: string): string {
|
||||
@@ -40,6 +40,7 @@ function IntegrationMentionView(props: any) {
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={toBadgeColor(data.statusColor)}
|
||||
c={badgeTextColor(toBadgeColor(data.statusColor))}
|
||||
className={classes.mentionIcon}
|
||||
>
|
||||
{data.status}
|
||||
@@ -80,6 +81,7 @@ function IntegrationMentionView(props: any) {
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="gray"
|
||||
c={badgeTextColor("gray")}
|
||||
tt="none"
|
||||
className={classes.mentionIcon}
|
||||
>
|
||||
@@ -88,6 +90,29 @@ function IntegrationMentionView(props: any) {
|
||||
)}
|
||||
</>
|
||||
);
|
||||
} else if (meta.issueKey) {
|
||||
// Jira: type icon leads, provider icon trails.
|
||||
content = (
|
||||
<>
|
||||
{meta.issueTypeIconUrl ? (
|
||||
<img
|
||||
src={meta.issueTypeIconUrl}
|
||||
width={14}
|
||||
height={14}
|
||||
alt=""
|
||||
className={classes.mentionIcon}
|
||||
/>
|
||||
) : (
|
||||
getIntegrationIcon(provider, 14)
|
||||
)}
|
||||
<Text component="span" size="sm" c="dimmed">
|
||||
{meta.issueKey}
|
||||
</Text>
|
||||
<span className={classes.mentionText}>{data.title}</span>
|
||||
{statusBadge}
|
||||
{meta.issueTypeIconUrl && getIntegrationIcon(provider, 14)}
|
||||
</>
|
||||
);
|
||||
} else if (issueNumber) {
|
||||
content = (
|
||||
<>
|
||||
|
||||
+32
-36
@@ -62,7 +62,7 @@ export function IntegrationPasteMenu({ editor }: EditorMenuProps) {
|
||||
}, [menuState, dismiss]);
|
||||
|
||||
const convert = useCallback(
|
||||
(target: "preview" | "mention" | "url") => {
|
||||
(target: "card" | "mention" | "url") => {
|
||||
const found = findTarget();
|
||||
if (!found) {
|
||||
dismiss();
|
||||
@@ -74,45 +74,37 @@ export function IntegrationPasteMenu({ editor }: EditorMenuProps) {
|
||||
const to = pos + node.nodeSize;
|
||||
const isBlock = node.type.name === "integrationLink";
|
||||
|
||||
if (target === "preview" && !isBlock) {
|
||||
editor
|
||||
.chain()
|
||||
.focus(undefined, { scrollIntoView: false })
|
||||
.deleteRange({ from, to })
|
||||
.insertContentAt(from, { type: "integrationLink", attrs })
|
||||
.run();
|
||||
} else if (target === "mention" && isBlock) {
|
||||
editor
|
||||
.chain()
|
||||
.focus(undefined, { scrollIntoView: false })
|
||||
.deleteRange({ from, to })
|
||||
.insertContentAt(from, {
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "integrationMention", attrs },
|
||||
{ type: "text", text: " " },
|
||||
],
|
||||
})
|
||||
.run();
|
||||
} else if (target === "url") {
|
||||
// Always replace, even when the node is already in the requested form:
|
||||
// the menu closes via the doc change, and BubbleMenu never re-evaluates
|
||||
// on meta-only transactions, so a bare dismiss would leave it stuck.
|
||||
let content: Record<string, any>;
|
||||
if (target === "card") {
|
||||
content = { type: "integrationLink", attrs };
|
||||
} else if (target === "mention") {
|
||||
const mention = { type: "integrationMention", attrs };
|
||||
content = isBlock
|
||||
? {
|
||||
type: "paragraph",
|
||||
content: [mention, { type: "text", text: " " }],
|
||||
}
|
||||
: mention;
|
||||
} else {
|
||||
const linkText = {
|
||||
type: "text",
|
||||
text: attrs.url,
|
||||
marks: [{ type: "link", attrs: { href: attrs.url } }],
|
||||
};
|
||||
editor
|
||||
.chain()
|
||||
.focus(undefined, { scrollIntoView: false })
|
||||
.deleteRange({ from, to })
|
||||
.insertContentAt(
|
||||
from,
|
||||
isBlock ? { type: "paragraph", content: [linkText] } : linkText,
|
||||
)
|
||||
.run();
|
||||
} else {
|
||||
// already in the requested form
|
||||
dismiss();
|
||||
content = isBlock
|
||||
? { type: "paragraph", content: [linkText] }
|
||||
: linkText;
|
||||
}
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.focus(undefined, { scrollIntoView: false })
|
||||
.deleteRange({ from, to })
|
||||
.insertContentAt(from, content)
|
||||
.run();
|
||||
},
|
||||
[editor, findTarget, dismiss],
|
||||
);
|
||||
@@ -126,6 +118,9 @@ export function IntegrationPasteMenu({ editor }: EditorMenuProps) {
|
||||
options={{ placement: "bottom-start", flip: true }}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
{/* Content is gated on the plugin state too: meta-only dismissals
|
||||
(Escape) are invisible to BubbleMenu's update cycle. */}
|
||||
{menuState ? (
|
||||
<Paper shadow="md" radius="md" withBorder p={4} miw={140}>
|
||||
<Text size="xs" c="dimmed" px={8} py={4}>
|
||||
{t("Paste as")}
|
||||
@@ -137,9 +132,9 @@ export function IntegrationPasteMenu({ editor }: EditorMenuProps) {
|
||||
size="compact-sm"
|
||||
fullWidth
|
||||
justify="flex-start"
|
||||
onClick={() => convert("preview")}
|
||||
onClick={() => convert("card")}
|
||||
>
|
||||
{t("Preview")}
|
||||
{t("Card")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
@@ -163,6 +158,7 @@ export function IntegrationPasteMenu({ editor }: EditorMenuProps) {
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : null}
|
||||
</BaseBubbleMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu";
|
||||
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
|
||||
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
|
||||
import { IntegrationPasteMenu } from "@/features/editor/components/integration-link/integration-paste-menu.tsx";
|
||||
import { getInstalledIntegrations } from "@/features/integration/services/integration-service";
|
||||
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -326,6 +327,15 @@ function CollabPageEditor({
|
||||
[pageId, editable, extensions],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Warm the cache the paste handler reads to decide whether a pasted
|
||||
// integration url becomes a card or stays an ordinary link.
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["installed-integrations"],
|
||||
queryFn: getInstalledIntegrations,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (editor && !editor.isDestroyed) {
|
||||
// @ts-ignore
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { Card, Group, Text, Badge, Button, Stack, Switch } from "@mantine/core";
|
||||
import {
|
||||
IconBrandGithub,
|
||||
IconBrandSlack,
|
||||
IconBrandGitlab,
|
||||
IconPuzzle,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
IntegrationDefinition,
|
||||
Integration,
|
||||
} from "../types/integration.types";
|
||||
|
||||
const iconMap: Record<string, React.ElementType> = {
|
||||
github: IconBrandGithub,
|
||||
slack: IconBrandSlack,
|
||||
gitlab: IconBrandGitlab,
|
||||
};
|
||||
|
||||
type IntegrationCardProps = {
|
||||
definition: IntegrationDefinition;
|
||||
installation?: Integration;
|
||||
onInstall: (type: string) => void;
|
||||
onUninstall: (integrationId: string) => void;
|
||||
onConfigure: (integration: Integration) => void;
|
||||
onToggle: (integration: Integration, enabled: boolean) => void;
|
||||
};
|
||||
|
||||
export default function IntegrationCard({
|
||||
definition,
|
||||
installation,
|
||||
onInstall,
|
||||
onUninstall,
|
||||
onConfigure,
|
||||
onToggle,
|
||||
}: IntegrationCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const Icon = iconMap[definition.icon] ?? IconPuzzle;
|
||||
const isInstalled = !!installation;
|
||||
|
||||
return (
|
||||
<Card withBorder padding="lg" radius="md">
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Group gap="sm">
|
||||
<Icon size={28} stroke={1.5} />
|
||||
<div>
|
||||
<Text fw={600} size="sm">
|
||||
{definition.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{definition.description}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" mb="md">
|
||||
{definition.capabilities.map((cap) => (
|
||||
<Badge key={cap} size="xs" variant="light">
|
||||
{cap}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
|
||||
{isInstalled ? (
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Switch
|
||||
label={t("Enabled")}
|
||||
checked={installation.isEnabled}
|
||||
onChange={(e) => onToggle(installation, e.currentTarget.checked)}
|
||||
size="sm"
|
||||
/>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={() => onConfigure(installation)}
|
||||
>
|
||||
{t("Configure")}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => onUninstall(installation.id)}
|
||||
>
|
||||
{t("Uninstall")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={() => onInstall(definition.type)}
|
||||
>
|
||||
{t("Install")}
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,6 @@ type IntegrationRowProps = {
|
||||
installation?: Integration;
|
||||
onInstall: (type: string) => void;
|
||||
onUninstall: (integrationId: string) => void;
|
||||
onConfigure: (integration: Integration) => void;
|
||||
onToggle: (integration: Integration, enabled: boolean) => void;
|
||||
};
|
||||
|
||||
@@ -20,7 +19,6 @@ export default function IntegrationRow({
|
||||
installation,
|
||||
onInstall,
|
||||
onUninstall,
|
||||
onConfigure,
|
||||
onToggle,
|
||||
}: IntegrationRowProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -64,13 +62,6 @@ export default function IntegrationRow({
|
||||
}
|
||||
size="sm"
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={() => onConfigure(installation)}
|
||||
>
|
||||
{t("Configure")}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Modal, Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Integration } from "../types/integration.types";
|
||||
|
||||
type IntegrationSettingsModalProps = {
|
||||
integration: Integration | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function IntegrationSettingsModal({
|
||||
integration,
|
||||
opened,
|
||||
onClose,
|
||||
}: IntegrationSettingsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!integration) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={`${integration.type.charAt(0).toUpperCase() + integration.type.slice(1)} ${t("Settings")}`}
|
||||
size="md"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("Integration settings will appear here.")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
import { Text, Alert, Stack } from "@mantine/core";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState, useCallback } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { getAppName } from "@/lib/config";
|
||||
import SettingsTitle from "@/components/settings/settings-title";
|
||||
import IntegrationRow from "../components/integration-row";
|
||||
import IntegrationListSkeleton from "../components/integration-list-skeleton";
|
||||
import IntegrationSettingsModal from "../components/integration-settings-modal";
|
||||
import {
|
||||
useAvailableIntegrations,
|
||||
useInstalledIntegrations,
|
||||
@@ -31,8 +30,6 @@ export default function Integrations() {
|
||||
const uninstallMutation = useUninstallIntegration();
|
||||
const updateMutation = useUpdateIntegrationSettings();
|
||||
|
||||
const [configuring, setConfiguring] = useState<Integration | null>(null);
|
||||
|
||||
const handleInstall = useCallback(
|
||||
async (type: string) => {
|
||||
const definition = available?.find((d) => d.type === type);
|
||||
@@ -68,10 +65,6 @@ export default function Integrations() {
|
||||
[uninstallMutation],
|
||||
);
|
||||
|
||||
const handleConfigure = useCallback((integration: Integration) => {
|
||||
setConfiguring(integration);
|
||||
}, []);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(integration: Integration, enabled: boolean) => {
|
||||
updateMutation.mutate({
|
||||
@@ -122,19 +115,12 @@ export default function Integrations() {
|
||||
installation={installation}
|
||||
onInstall={handleInstall}
|
||||
onUninstall={handleUninstall}
|
||||
onConfigure={handleConfigure}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<IntegrationSettingsModal
|
||||
integration={configuring}
|
||||
opened={!!configuring}
|
||||
onClose={() => setConfiguring(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ import { LabelModule } from './label/label.module';
|
||||
import { NotificationModule } from './notification/notification.module';
|
||||
import { WatcherModule } from './watcher/watcher.module';
|
||||
import { IntegrationModule } from './integration/integration.module';
|
||||
import { GitHubModule } from './integration/providers/github/github.module';
|
||||
import { GitLabModule } from './integration/providers/gitlab/gitlab.module';
|
||||
import { FavoriteModule } from './favorite/favorite.module';
|
||||
import { SessionModule } from './session/session.module';
|
||||
import { ClsMiddleware } from 'nestjs-cls';
|
||||
@@ -45,6 +47,8 @@ import { ClsMiddleware } from 'nestjs-cls';
|
||||
NotificationModule,
|
||||
WatcherModule,
|
||||
IntegrationModule,
|
||||
GitHubModule,
|
||||
GitLabModule,
|
||||
SessionModule,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -2,31 +2,20 @@ import { z } from 'zod';
|
||||
|
||||
export const githubSettingsSchema = z.object({
|
||||
baseUrl: z.string().url().optional(),
|
||||
org: z.string().optional(),
|
||||
defaultRepo: z.string().optional(),
|
||||
});
|
||||
|
||||
export const gitlabSettingsSchema = z.object({
|
||||
baseUrl: z.string().url().optional(),
|
||||
group: z.string().optional(),
|
||||
defaultProject: z.string().optional(),
|
||||
});
|
||||
|
||||
export const jiraSettingsSchema = z.object({
|
||||
baseUrl: z.string().url().optional(),
|
||||
cloudId: z.string().optional(),
|
||||
siteName: z.string().optional(),
|
||||
});
|
||||
|
||||
export const linearSettingsSchema = z.object({
|
||||
teamId: z.string().optional(),
|
||||
});
|
||||
|
||||
const integrationSettingsSchemas: Record<string, z.ZodType> = {
|
||||
github: githubSettingsSchema,
|
||||
gitlab: gitlabSettingsSchema,
|
||||
jira: jiraSettingsSchema,
|
||||
linear: linearSettingsSchema,
|
||||
};
|
||||
|
||||
export function validateIntegrationSettings(
|
||||
@@ -51,8 +40,3 @@ export function validateIntegrationSettings(
|
||||
|
||||
return { success: true, data: result.data };
|
||||
}
|
||||
|
||||
export type GithubSettings = z.infer<typeof githubSettingsSchema>;
|
||||
export type GitlabSettings = z.infer<typeof gitlabSettingsSchema>;
|
||||
export type JiraSettings = z.infer<typeof jiraSettingsSchema>;
|
||||
export type LinearSettings = z.infer<typeof linearSettingsSchema>;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import { QueueJob, QueueName } from '../../integrations/queue/constants/queue.constants';
|
||||
import { QueueJob, QueueName } from '../../integrations/queue/constants';
|
||||
import { EventName } from '../../common/events/event.contants';
|
||||
|
||||
const TOKEN_REFRESH_SCHEDULER_ID = 'integration-token-refresh-scheduler';
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { UnfurlPattern } from '../../registry/integration-provider.interface';
|
||||
|
||||
function escapeForRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export function buildGitHubPatterns(baseUrl: string): UnfurlPattern[] {
|
||||
const escaped = escapeForRegex(baseUrl);
|
||||
return [
|
||||
// Commit within a PR: /:owner/:repo/pull/:num/commits/:sha
|
||||
{
|
||||
regex: new RegExp(
|
||||
`^${escaped}\\/([^\\/]+)\\/([^\\/]+)\\/pull\\/(\\d+)\\/commits\\/([a-f0-9]+)`,
|
||||
),
|
||||
type: 'github-pr-commit',
|
||||
},
|
||||
// PR sub-pages: /:owner/:repo/pull/:num(/checks|/commits|/files)?
|
||||
{
|
||||
regex: new RegExp(`^${escaped}\\/([^\\/]+)\\/([^\\/]+)\\/pull\\/(\\d+)`),
|
||||
type: 'github-pr',
|
||||
},
|
||||
// Single issue: /:owner/:repo/issues/:num
|
||||
{
|
||||
regex: new RegExp(
|
||||
`^${escaped}\\/([^\\/]+)\\/([^\\/]+)\\/issues\\/(\\d+)`,
|
||||
),
|
||||
type: 'github-issue',
|
||||
},
|
||||
// Commit: /:owner/:repo/commit(s)/:sha
|
||||
{
|
||||
regex: new RegExp(
|
||||
`^${escaped}\\/([^\\/]+)\\/([^\\/]+)\\/commits?\\/([a-f0-9]+)`,
|
||||
),
|
||||
type: 'github-commit',
|
||||
},
|
||||
// File/blob: /:owner/:repo/blob/:ref/:path(#L:start(-L:end))?
|
||||
{
|
||||
regex: new RegExp(
|
||||
`^${escaped}\\/([^\\/]+)\\/([^\\/]+)\\/blob\\/([^\\/]+)\\/(.+?)(?:#L(\\d+)(?:-L(\\d+))?)?$`,
|
||||
),
|
||||
type: 'github-file',
|
||||
},
|
||||
// Pulls list: /:owner/:repo/pulls
|
||||
{
|
||||
regex: new RegExp(
|
||||
`^${escaped}\\/([^\\/]+)\\/([^\\/]+)\\/pulls(?:\\/.*)?(?:\\?.*)?$`,
|
||||
),
|
||||
type: 'github-pulls-list',
|
||||
},
|
||||
// Issues list: /:owner/:repo/issues(/created_by/...|/assigned/...)?
|
||||
{
|
||||
regex: new RegExp(
|
||||
`^${escaped}\\/([^\\/]+)\\/([^\\/]+)\\/issues(?:\\/(?:created_by|assigned)\\/[\\w.\\/-]+)?\\/?(?:\\?.*)?$`,
|
||||
),
|
||||
type: 'github-issues-list',
|
||||
},
|
||||
// Releases: /:owner/:repo/releases
|
||||
{
|
||||
regex: new RegExp(
|
||||
`^${escaped}\\/([^\\/]+)\\/([^\\/]+)\\/releases(?:\\/.*)?(?:\\?.*)?$`,
|
||||
),
|
||||
type: 'github-releases-list',
|
||||
},
|
||||
// Repo: /:owner/:repo
|
||||
{
|
||||
regex: new RegExp(
|
||||
`^${escaped}\\/([a-zA-Z0-9\\-_.]+)\\/([a-zA-Z0-9\\-_.]+)\\/?$`,
|
||||
),
|
||||
type: 'github-repo',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Module, OnModuleInit } from '@nestjs/common';
|
||||
import { GitHubProvider } from './github.provider';
|
||||
import { GitHubService } from './github.service';
|
||||
import { IntegrationRegistry } from '../../registry/integration-registry';
|
||||
import { IntegrationModule } from '../../integration.module';
|
||||
|
||||
@Module({
|
||||
imports: [IntegrationModule],
|
||||
providers: [GitHubProvider, GitHubService],
|
||||
exports: [GitHubProvider],
|
||||
})
|
||||
export class GitHubModule implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly registry: IntegrationRegistry,
|
||||
private readonly githubProvider: GitHubProvider,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
this.registry.register(this.githubProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
IntegrationProvider,
|
||||
IntegrationDefinition,
|
||||
LinkDescription,
|
||||
OAuthConfig,
|
||||
UnfurlPattern,
|
||||
UnfurlOpts,
|
||||
UnfurlResult,
|
||||
} from '../../registry/integration-provider.interface';
|
||||
import { GitHubService } from './github.service';
|
||||
import { buildGitHubPatterns } from './github-patterns';
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://github.com';
|
||||
|
||||
@Injectable()
|
||||
export class GitHubProvider extends IntegrationProvider {
|
||||
definition: IntegrationDefinition = {
|
||||
type: 'github',
|
||||
name: 'GitHub',
|
||||
description: 'Link previews for repos, pull requests, issues, commits, and files',
|
||||
icon: 'github',
|
||||
capabilities: ['oauth', 'unfurl'],
|
||||
oauth: {
|
||||
authUrl: 'https://github.com/login/oauth/authorize',
|
||||
tokenUrl: 'https://github.com/login/oauth/access_token',
|
||||
scopes: ['repo', 'read:user'],
|
||||
},
|
||||
unfurlPatterns: buildGitHubPatterns('https://github.com'),
|
||||
};
|
||||
|
||||
constructor(private readonly githubService: GitHubService) {
|
||||
super();
|
||||
}
|
||||
|
||||
getOAuthConfig(settings: Record<string, any>): OAuthConfig {
|
||||
const baseUrl = this.resolveBaseUrl(settings);
|
||||
return {
|
||||
authUrl: `${baseUrl}/login/oauth/authorize`,
|
||||
tokenUrl: `${baseUrl}/login/oauth/access_token`,
|
||||
scopes: ['repo', 'read:user'],
|
||||
};
|
||||
}
|
||||
|
||||
getUnfurlPatterns(settings: Record<string, any>): UnfurlPattern[] {
|
||||
const baseUrl = this.resolveBaseUrl(settings);
|
||||
if (baseUrl === DEFAULT_BASE_URL) return [];
|
||||
return buildGitHubPatterns(baseUrl);
|
||||
}
|
||||
|
||||
async unfurl(opts: UnfurlOpts): Promise<UnfurlResult> {
|
||||
const { match, patternType, accessToken, url } = opts;
|
||||
const apiBaseUrl = this.resolveApiBaseUrl(url);
|
||||
const owner = match[1];
|
||||
const repo = match[2];
|
||||
|
||||
switch (patternType) {
|
||||
case 'github-pr': {
|
||||
const number = parseInt(match[3], 10);
|
||||
return this.githubService.unfurlPullRequest(
|
||||
accessToken, apiBaseUrl, owner, repo, number, url,
|
||||
);
|
||||
}
|
||||
|
||||
case 'github-issue': {
|
||||
const number = parseInt(match[3], 10);
|
||||
return this.githubService.unfurlIssue(
|
||||
accessToken, apiBaseUrl, owner, repo, number, url,
|
||||
);
|
||||
}
|
||||
|
||||
case 'github-repo':
|
||||
return this.githubService.unfurlRepo(
|
||||
accessToken, apiBaseUrl, owner, repo, url,
|
||||
);
|
||||
|
||||
case 'github-commit': {
|
||||
const sha = match[3];
|
||||
return this.githubService.unfurlCommit(
|
||||
accessToken, apiBaseUrl, owner, repo, sha, url,
|
||||
);
|
||||
}
|
||||
|
||||
case 'github-pr-commit': {
|
||||
const sha = match[4];
|
||||
return this.githubService.unfurlCommit(
|
||||
accessToken, apiBaseUrl, owner, repo, sha, url,
|
||||
);
|
||||
}
|
||||
|
||||
case 'github-file': {
|
||||
const ref = match[3];
|
||||
const path = match[4];
|
||||
const startLine = match[5] ? parseInt(match[5], 10) : undefined;
|
||||
const endLine = match[6] ? parseInt(match[6], 10) : undefined;
|
||||
return this.githubService.unfurlFile(
|
||||
owner, repo, ref, path, startLine, endLine, url,
|
||||
);
|
||||
}
|
||||
|
||||
case 'github-pulls-list':
|
||||
case 'github-issues-list':
|
||||
case 'github-releases-list':
|
||||
return this.githubService.unfurlCollectionPage(
|
||||
accessToken, apiBaseUrl, owner, repo, patternType.replace('github-', ''), url,
|
||||
);
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown GitHub pattern type: ${patternType}`);
|
||||
}
|
||||
}
|
||||
|
||||
describeLink(
|
||||
patternType: string,
|
||||
match: RegExpMatchArray,
|
||||
): LinkDescription | null {
|
||||
const repo = `${match[1]}/${match[2]}`;
|
||||
switch (patternType) {
|
||||
case 'github-pr':
|
||||
return { title: `Pull Request #${match[3]}`, description: repo };
|
||||
case 'github-pr-commit':
|
||||
return { title: `Commit ${match[4].slice(0, 7)}`, description: repo };
|
||||
case 'github-issue':
|
||||
return { title: `Issue #${match[3]}`, description: repo };
|
||||
case 'github-commit':
|
||||
return { title: `Commit ${match[3].slice(0, 7)}`, description: repo };
|
||||
case 'github-file':
|
||||
return { title: match[4], description: repo };
|
||||
case 'github-pulls-list':
|
||||
return { title: 'Pull Requests', description: repo };
|
||||
case 'github-issues-list':
|
||||
return { title: 'Issues', description: repo };
|
||||
case 'github-releases-list':
|
||||
return { title: 'Releases', description: repo };
|
||||
case 'github-repo':
|
||||
return { title: repo };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private resolveBaseUrl(settings: Record<string, any>): string {
|
||||
// env wins: the OAuth app credentials in env are registered on that instance
|
||||
const baseUrl =
|
||||
process.env.INTEGRATION_GITHUB_BASE_URL ||
|
||||
(settings?.baseUrl as string | undefined);
|
||||
return baseUrl ? baseUrl.replace(/\/+$/, '') : DEFAULT_BASE_URL;
|
||||
}
|
||||
|
||||
private resolveApiBaseUrl(url: string): string {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.hostname === 'github.com') {
|
||||
return 'https://api.github.com';
|
||||
}
|
||||
return `${parsed.origin}/api/v3`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { UnfurlResult } from '../../registry/integration-provider.interface';
|
||||
import { relativeTime } from '../../utils/relative-time';
|
||||
|
||||
@Injectable()
|
||||
export class GitHubService {
|
||||
private readonly logger = new Logger(GitHubService.name);
|
||||
|
||||
async unfurlPullRequest(
|
||||
accessToken: string,
|
||||
apiBaseUrl: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
number: number,
|
||||
url: string,
|
||||
): Promise<UnfurlResult> {
|
||||
const data = await this.apiGet(
|
||||
accessToken,
|
||||
apiBaseUrl,
|
||||
`/repos/${owner}/${repo}/pulls/${number}`,
|
||||
);
|
||||
|
||||
const prAuthor = data.user?.login;
|
||||
const prDesc = [
|
||||
`#${data.number}`,
|
||||
relativeTime(data.updated_at ?? data.created_at),
|
||||
prAuthor,
|
||||
].filter(Boolean).join(' · ');
|
||||
|
||||
return {
|
||||
title: data.title,
|
||||
description: prDesc,
|
||||
url,
|
||||
provider: 'github',
|
||||
providerIcon: 'github',
|
||||
status: this.formatPrStatus(data),
|
||||
statusColor: this.getPrStatusColor(data),
|
||||
author: prAuthor,
|
||||
authorAvatarUrl: data.user?.avatar_url,
|
||||
metadata: {
|
||||
type: 'pr',
|
||||
number: data.number,
|
||||
repo: `${owner}/${repo}`,
|
||||
labels: data.labels?.map((l: any) => l.name) ?? [],
|
||||
draft: data.draft,
|
||||
additions: data.additions,
|
||||
deletions: data.deletions,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async unfurlIssue(
|
||||
accessToken: string,
|
||||
apiBaseUrl: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
number: number,
|
||||
url: string,
|
||||
): Promise<UnfurlResult> {
|
||||
const data = await this.apiGet(
|
||||
accessToken,
|
||||
apiBaseUrl,
|
||||
`/repos/${owner}/${repo}/issues/${number}`,
|
||||
);
|
||||
|
||||
const issueAuthor = data.user?.login;
|
||||
const issueDesc = [
|
||||
`#${data.number}`,
|
||||
relativeTime(data.updated_at ?? data.created_at),
|
||||
issueAuthor,
|
||||
].filter(Boolean).join(' · ');
|
||||
|
||||
return {
|
||||
title: data.title,
|
||||
description: issueDesc,
|
||||
url,
|
||||
provider: 'github',
|
||||
providerIcon: 'github',
|
||||
status: data.state,
|
||||
statusColor: data.state === 'open' ? 'green' : 'purple',
|
||||
author: issueAuthor,
|
||||
authorAvatarUrl: data.user?.avatar_url,
|
||||
metadata: {
|
||||
type: 'issue',
|
||||
number: data.number,
|
||||
repo: `${owner}/${repo}`,
|
||||
labels: data.labels?.map((l: any) => l.name) ?? [],
|
||||
assignees: data.assignees?.map((a: any) => a.login) ?? [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async unfurlRepo(
|
||||
accessToken: string,
|
||||
apiBaseUrl: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
url: string,
|
||||
): Promise<UnfurlResult> {
|
||||
const data = await this.apiGet(
|
||||
accessToken,
|
||||
apiBaseUrl,
|
||||
`/repos/${owner}/${repo}`,
|
||||
);
|
||||
|
||||
const visibility = data.private ? 'Private' : 'Public';
|
||||
|
||||
return {
|
||||
title: data.full_name,
|
||||
description: data.description?.slice(0, 200) ?? undefined,
|
||||
url,
|
||||
provider: 'github',
|
||||
providerIcon: 'github',
|
||||
status: visibility,
|
||||
statusColor: data.private ? 'gray' : 'green',
|
||||
author: data.owner?.login,
|
||||
authorAvatarUrl: data.owner?.avatar_url,
|
||||
metadata: {
|
||||
type: 'repo',
|
||||
repo: `${owner}/${repo}`,
|
||||
stars: data.stargazers_count,
|
||||
forks: data.forks_count,
|
||||
language: data.language,
|
||||
defaultBranch: data.default_branch,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async unfurlCommit(
|
||||
accessToken: string,
|
||||
apiBaseUrl: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
sha: string,
|
||||
url: string,
|
||||
): Promise<UnfurlResult> {
|
||||
const data = await this.apiGet(
|
||||
accessToken,
|
||||
apiBaseUrl,
|
||||
`/repos/${owner}/${repo}/commits/${sha}`,
|
||||
);
|
||||
|
||||
const shortSha = data.sha?.slice(0, 7);
|
||||
|
||||
const commitAuthor = data.author?.login ?? data.commit?.author?.name;
|
||||
const commitDesc = [
|
||||
shortSha,
|
||||
relativeTime(data.commit?.author?.date ?? data.commit?.committer?.date),
|
||||
commitAuthor,
|
||||
].filter(Boolean).join(' · ');
|
||||
|
||||
return {
|
||||
title: data.commit?.message?.split('\n')[0] ?? shortSha,
|
||||
description: commitDesc,
|
||||
url,
|
||||
provider: 'github',
|
||||
providerIcon: 'github',
|
||||
author: commitAuthor,
|
||||
authorAvatarUrl: data.author?.avatar_url,
|
||||
metadata: {
|
||||
type: 'commit',
|
||||
sha: data.sha,
|
||||
shortSha,
|
||||
repo: `${owner}/${repo}`,
|
||||
stats: data.stats,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
unfurlFile(
|
||||
owner: string,
|
||||
repo: string,
|
||||
ref: string,
|
||||
path: string,
|
||||
startLine: number | undefined,
|
||||
endLine: number | undefined,
|
||||
url: string,
|
||||
): UnfurlResult {
|
||||
const fileName = path.split('/').pop() ?? path;
|
||||
const lineRange = startLine
|
||||
? endLine
|
||||
? `L${startLine}-L${endLine}`
|
||||
: `L${startLine}`
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
title: lineRange ? `${fileName}#${lineRange}` : fileName,
|
||||
description: `${owner}/${repo} · ${ref.slice(0, 7)}`,
|
||||
url,
|
||||
provider: 'github',
|
||||
providerIcon: 'github',
|
||||
metadata: {
|
||||
type: 'file',
|
||||
repo: `${owner}/${repo}`,
|
||||
ref,
|
||||
path,
|
||||
startLine,
|
||||
endLine,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async unfurlCollectionPage(
|
||||
accessToken: string,
|
||||
apiBaseUrl: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
collectionType: string,
|
||||
url: string,
|
||||
): Promise<UnfurlResult> {
|
||||
const data = await this.apiGet(
|
||||
accessToken,
|
||||
apiBaseUrl,
|
||||
`/repos/${owner}/${repo}`,
|
||||
);
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
'pulls-list': 'Pull Requests',
|
||||
'issues-list': 'Issues',
|
||||
'releases-list': 'Releases',
|
||||
};
|
||||
|
||||
return {
|
||||
title: `${labels[collectionType] ?? collectionType} · ${data.full_name}`,
|
||||
description: `${owner}/${repo}`,
|
||||
url,
|
||||
provider: 'github',
|
||||
providerIcon: 'github',
|
||||
author: data.owner?.login,
|
||||
authorAvatarUrl: data.owner?.avatar_url,
|
||||
metadata: {
|
||||
type: collectionType,
|
||||
repo: `${owner}/${repo}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private formatPrStatus(pr: any): string {
|
||||
if (pr.merged) return 'merged';
|
||||
if (pr.draft) return 'draft';
|
||||
return pr.state;
|
||||
}
|
||||
|
||||
private getPrStatusColor(pr: any): string {
|
||||
if (pr.merged) return 'purple';
|
||||
if (pr.draft) return 'gray';
|
||||
if (pr.state === 'open') return 'green';
|
||||
return 'red';
|
||||
}
|
||||
|
||||
private async apiGet(
|
||||
accessToken: string,
|
||||
apiBaseUrl: string,
|
||||
path: string,
|
||||
): Promise<any> {
|
||||
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
'User-Agent': 'Docmost',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`GitHub API error: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { UnfurlPattern } from '../../registry/integration-provider.interface';
|
||||
|
||||
function escapeForRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export function buildGitLabPatterns(baseUrl: string): UnfurlPattern[] {
|
||||
const escaped = escapeForRegex(baseUrl);
|
||||
return [
|
||||
{
|
||||
regex: new RegExp(
|
||||
`^${escaped}\\/(.+)\\/-\\/merge_requests\\/(\\d+)\\/diffs\\?.*commit_id=([a-f0-9]+)`,
|
||||
),
|
||||
type: 'gitlab-commit-in-mr',
|
||||
},
|
||||
{
|
||||
regex: new RegExp(
|
||||
`^${escaped}\\/(.+)\\/-\\/merge_requests\\/(\\d+)`,
|
||||
),
|
||||
type: 'gitlab-mr',
|
||||
},
|
||||
{
|
||||
regex: new RegExp(
|
||||
`^${escaped}\\/(.+)\\/-\\/issues\\/(\\d+)`,
|
||||
),
|
||||
type: 'gitlab-issue',
|
||||
},
|
||||
// Issues renamed to work items; same iid, resolved via the issues API.
|
||||
{
|
||||
regex: new RegExp(
|
||||
`^${escaped}\\/(.+)\\/-\\/work_items\\/(\\d+)`,
|
||||
),
|
||||
type: 'gitlab-issue',
|
||||
},
|
||||
// Work item opened as a drawer over the list; the target is base64 JSON
|
||||
// in the show param, decoded by the provider.
|
||||
{
|
||||
regex: new RegExp(
|
||||
`^${escaped}\\/(.+)\\/-\\/work_items\\/?\\?(?:.*&)?show=`,
|
||||
),
|
||||
type: 'gitlab-work-item-drawer',
|
||||
},
|
||||
{
|
||||
regex: new RegExp(
|
||||
`^${escaped}\\/(.+)\\/-\\/commits?\\/([a-f0-9]+)`,
|
||||
),
|
||||
type: 'gitlab-commit',
|
||||
},
|
||||
{
|
||||
regex: new RegExp(
|
||||
`^${escaped}\\/(.+)\\/-\\/issues(?:\\/)?(?:\\?.*)?$`,
|
||||
),
|
||||
type: 'gitlab-issues-list',
|
||||
},
|
||||
{
|
||||
regex: new RegExp(
|
||||
`^${escaped}\\/(.+)\\/-\\/merge_requests(?:\\/)?(?:\\?.*)?$`,
|
||||
),
|
||||
type: 'gitlab-merges-list',
|
||||
},
|
||||
{
|
||||
regex: new RegExp(
|
||||
`^${escaped}\\/([a-zA-Z0-9\\-_.]+)\\/([a-zA-Z0-9\\-_]+)\\/?$`,
|
||||
),
|
||||
type: 'gitlab-project',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Module, OnModuleInit } from '@nestjs/common';
|
||||
import { GitLabProvider } from './gitlab.provider';
|
||||
import { GitLabService } from './gitlab.service';
|
||||
import { IntegrationRegistry } from '../../registry/integration-registry';
|
||||
import { IntegrationModule } from '../../integration.module';
|
||||
|
||||
@Module({
|
||||
imports: [IntegrationModule],
|
||||
providers: [GitLabProvider, GitLabService],
|
||||
exports: [GitLabProvider],
|
||||
})
|
||||
export class GitLabModule implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly registry: IntegrationRegistry,
|
||||
private readonly gitlabProvider: GitLabProvider,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
this.registry.register(this.gitlabProvider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
IntegrationProvider,
|
||||
IntegrationDefinition,
|
||||
LinkDescription,
|
||||
OAuthConfig,
|
||||
UnfurlPattern,
|
||||
UnfurlOpts,
|
||||
UnfurlResult,
|
||||
} from '../../registry/integration-provider.interface';
|
||||
import { GitLabService } from './gitlab.service';
|
||||
import { buildGitLabPatterns } from './gitlab-patterns';
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://gitlab.com';
|
||||
|
||||
@Injectable()
|
||||
export class GitLabProvider extends IntegrationProvider {
|
||||
definition: IntegrationDefinition = {
|
||||
type: 'gitlab',
|
||||
name: 'GitLab',
|
||||
description: 'Link previews for projects, merge requests, issues, and commits',
|
||||
icon: 'gitlab',
|
||||
capabilities: ['oauth', 'unfurl'],
|
||||
oauth: {
|
||||
authUrl: 'https://gitlab.com/oauth/authorize',
|
||||
tokenUrl: 'https://gitlab.com/oauth/token',
|
||||
scopes: ['read_api', 'read_user'],
|
||||
},
|
||||
unfurlPatterns: buildGitLabPatterns('https://gitlab.com'),
|
||||
};
|
||||
|
||||
constructor(private readonly gitlabService: GitLabService) {
|
||||
super();
|
||||
}
|
||||
|
||||
getOAuthConfig(settings: Record<string, any>): OAuthConfig {
|
||||
const baseUrl = this.resolveBaseUrl(settings);
|
||||
return {
|
||||
authUrl: `${baseUrl}/oauth/authorize`,
|
||||
tokenUrl: `${baseUrl}/oauth/token`,
|
||||
scopes: ['read_api', 'read_user'],
|
||||
};
|
||||
}
|
||||
|
||||
getUnfurlPatterns(settings: Record<string, any>): UnfurlPattern[] {
|
||||
const baseUrl = this.resolveBaseUrl(settings);
|
||||
if (baseUrl === DEFAULT_BASE_URL) return [];
|
||||
return buildGitLabPatterns(baseUrl);
|
||||
}
|
||||
|
||||
async unfurl(opts: UnfurlOpts): Promise<UnfurlResult> {
|
||||
const { match, patternType, accessToken, url } = opts;
|
||||
const apiBaseUrl = this.resolveApiBaseUrl(url);
|
||||
|
||||
switch (patternType) {
|
||||
case 'gitlab-mr': {
|
||||
const projectPath = match[1];
|
||||
const iid = parseInt(match[2], 10);
|
||||
return this.gitlabService.unfurlMergeRequest(
|
||||
accessToken, apiBaseUrl, projectPath, iid, url,
|
||||
);
|
||||
}
|
||||
|
||||
case 'gitlab-issue': {
|
||||
const projectPath = match[1];
|
||||
const iid = parseInt(match[2], 10);
|
||||
return this.gitlabService.unfurlIssue(
|
||||
accessToken, apiBaseUrl, projectPath, iid, url,
|
||||
);
|
||||
}
|
||||
|
||||
case 'gitlab-project': {
|
||||
const projectPath = `${match[1]}/${match[2]}`;
|
||||
return this.gitlabService.unfurlProject(
|
||||
accessToken, apiBaseUrl, projectPath, url,
|
||||
);
|
||||
}
|
||||
|
||||
case 'gitlab-commit': {
|
||||
const projectPath = match[1];
|
||||
const commitSha = match[2];
|
||||
return this.gitlabService.unfurlCommit(
|
||||
accessToken, apiBaseUrl, projectPath, commitSha, url,
|
||||
);
|
||||
}
|
||||
|
||||
case 'gitlab-commit-in-mr': {
|
||||
const projectPath = match[1];
|
||||
const commitSha = match[3];
|
||||
return this.gitlabService.unfurlCommit(
|
||||
accessToken, apiBaseUrl, projectPath, commitSha, url,
|
||||
);
|
||||
}
|
||||
|
||||
case 'gitlab-work-item-drawer': {
|
||||
const target = this.decodeWorkItemShowParam(url);
|
||||
if (!target) {
|
||||
throw new Error('Could not decode work item show param');
|
||||
}
|
||||
return this.gitlabService.unfurlIssue(
|
||||
accessToken, apiBaseUrl, target.fullPath, target.iid, url,
|
||||
);
|
||||
}
|
||||
|
||||
case 'gitlab-issues-list': {
|
||||
const projectPath = match[1];
|
||||
return this.gitlabService.unfurlIssuesList(
|
||||
accessToken, apiBaseUrl, projectPath, url,
|
||||
);
|
||||
}
|
||||
|
||||
case 'gitlab-merges-list': {
|
||||
const projectPath = match[1];
|
||||
return this.gitlabService.unfurlMergesList(
|
||||
accessToken, apiBaseUrl, projectPath, url,
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown GitLab pattern type: ${patternType}`);
|
||||
}
|
||||
}
|
||||
|
||||
describeLink(
|
||||
patternType: string,
|
||||
match: RegExpMatchArray,
|
||||
url: string,
|
||||
): LinkDescription | null {
|
||||
const projectPath = match[1];
|
||||
switch (patternType) {
|
||||
case 'gitlab-mr':
|
||||
return { title: `Merge Request !${match[2]}`, description: projectPath };
|
||||
case 'gitlab-issue':
|
||||
return { title: `Issue #${match[2]}`, description: projectPath };
|
||||
case 'gitlab-work-item-drawer': {
|
||||
const target = this.decodeWorkItemShowParam(url);
|
||||
return target
|
||||
? { title: `Issue #${target.iid}`, description: target.fullPath }
|
||||
: { title: 'Work item', description: projectPath };
|
||||
}
|
||||
case 'gitlab-commit':
|
||||
return { title: `Commit ${match[2].slice(0, 8)}`, description: projectPath };
|
||||
case 'gitlab-commit-in-mr':
|
||||
return { title: `Commit ${match[3].slice(0, 8)}`, description: projectPath };
|
||||
case 'gitlab-issues-list':
|
||||
return { title: 'Issues', description: projectPath };
|
||||
case 'gitlab-merges-list':
|
||||
return { title: 'Merge Requests', description: projectPath };
|
||||
case 'gitlab-project':
|
||||
return { title: `${match[1]}/${match[2]}` };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// The work items list opens an item as a drawer and encodes it in the URL
|
||||
// as ?show=base64({ iid, full_path, id }). full_path beats the URL path:
|
||||
// a drawer opened from a group-level list still names the actual project.
|
||||
private decodeWorkItemShowParam(
|
||||
url: string,
|
||||
): { fullPath: string; iid: number } | null {
|
||||
try {
|
||||
const show = new URL(url).searchParams.get('show');
|
||||
if (!show) return null;
|
||||
const base64 = show.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const payload = JSON.parse(
|
||||
Buffer.from(base64, 'base64').toString('utf8'),
|
||||
);
|
||||
const iid = parseInt(payload.iid, 10);
|
||||
if (typeof payload.full_path !== 'string' || Number.isNaN(iid)) {
|
||||
return null;
|
||||
}
|
||||
return { fullPath: payload.full_path, iid };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private resolveBaseUrl(settings: Record<string, any>): string {
|
||||
// env wins: the OAuth app credentials in env are registered on that instance
|
||||
const baseUrl =
|
||||
process.env.INTEGRATION_GITLAB_BASE_URL ||
|
||||
(settings?.baseUrl as string | undefined);
|
||||
return baseUrl ? baseUrl.replace(/\/+$/, '') : DEFAULT_BASE_URL;
|
||||
}
|
||||
|
||||
private resolveApiBaseUrl(url: string): string {
|
||||
const parsed = new URL(url);
|
||||
return `${parsed.origin}/api/v4`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { UnfurlResult } from '../../registry/integration-provider.interface';
|
||||
import { relativeTime } from '../../utils/relative-time';
|
||||
|
||||
@Injectable()
|
||||
export class GitLabService {
|
||||
private readonly logger = new Logger(GitLabService.name);
|
||||
|
||||
async unfurlMergeRequest(
|
||||
accessToken: string,
|
||||
apiBaseUrl: string,
|
||||
projectPath: string,
|
||||
iid: number,
|
||||
url: string,
|
||||
): Promise<UnfurlResult> {
|
||||
const encodedProject = encodeURIComponent(projectPath);
|
||||
const data = await this.apiGet(
|
||||
accessToken,
|
||||
apiBaseUrl,
|
||||
`/projects/${encodedProject}/merge_requests/${iid}`,
|
||||
);
|
||||
|
||||
const authorName = data.author?.name ?? data.author?.username;
|
||||
const desc = [
|
||||
`!${data.iid}`,
|
||||
relativeTime(data.updated_at ?? data.created_at),
|
||||
authorName,
|
||||
].filter(Boolean).join(' · ');
|
||||
|
||||
return {
|
||||
title: data.title,
|
||||
description: desc,
|
||||
url,
|
||||
provider: 'gitlab',
|
||||
providerIcon: 'gitlab',
|
||||
status: this.formatMrStatus(data),
|
||||
statusColor: this.getMrStatusColor(data),
|
||||
author: authorName,
|
||||
authorAvatarUrl: data.author?.avatar_url,
|
||||
metadata: {
|
||||
type: 'mr',
|
||||
iid: data.iid,
|
||||
project: projectPath,
|
||||
labels: data.labels ?? [],
|
||||
draft: data.draft ?? data.work_in_progress,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async unfurlIssue(
|
||||
accessToken: string,
|
||||
apiBaseUrl: string,
|
||||
projectPath: string,
|
||||
iid: number,
|
||||
url: string,
|
||||
): Promise<UnfurlResult> {
|
||||
const encodedProject = encodeURIComponent(projectPath);
|
||||
const data = await this.apiGet(
|
||||
accessToken,
|
||||
apiBaseUrl,
|
||||
`/projects/${encodedProject}/issues/${iid}`,
|
||||
);
|
||||
|
||||
const issueAuthor = data.author?.name ?? data.author?.username;
|
||||
const issueDesc = [
|
||||
`#${data.iid}`,
|
||||
relativeTime(data.updated_at ?? data.created_at),
|
||||
issueAuthor,
|
||||
].filter(Boolean).join(' · ');
|
||||
|
||||
return {
|
||||
title: data.title,
|
||||
description: issueDesc,
|
||||
url,
|
||||
provider: 'gitlab',
|
||||
providerIcon: 'gitlab',
|
||||
status: data.state,
|
||||
statusColor: data.state === 'opened' ? 'green' : 'blue',
|
||||
author: issueAuthor,
|
||||
authorAvatarUrl: data.author?.avatar_url,
|
||||
metadata: {
|
||||
type: 'issue',
|
||||
iid: data.iid,
|
||||
project: projectPath,
|
||||
labels: data.labels ?? [],
|
||||
assignees:
|
||||
data.assignees?.map((a: any) => a.name ?? a.username) ?? [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async unfurlProject(
|
||||
accessToken: string,
|
||||
apiBaseUrl: string,
|
||||
projectPath: string,
|
||||
url: string,
|
||||
): Promise<UnfurlResult> {
|
||||
const encodedProject = encodeURIComponent(projectPath);
|
||||
const data = await this.apiGet(
|
||||
accessToken,
|
||||
apiBaseUrl,
|
||||
`/projects/${encodedProject}`,
|
||||
);
|
||||
|
||||
const visibility = data.visibility === 'public' ? 'Public' : data.visibility === 'internal' ? 'Internal' : 'Private';
|
||||
|
||||
return {
|
||||
title: data.name,
|
||||
description: data.description?.slice(0, 200) ?? undefined,
|
||||
url,
|
||||
provider: 'gitlab',
|
||||
providerIcon: 'gitlab',
|
||||
status: visibility,
|
||||
statusColor: data.visibility === 'public' ? 'green' : 'gray',
|
||||
author: data.namespace?.name,
|
||||
authorAvatarUrl: data.avatar_url ?? data.namespace?.avatar_url,
|
||||
metadata: {
|
||||
type: 'project',
|
||||
project: projectPath,
|
||||
stars: data.star_count,
|
||||
forks: data.forks_count,
|
||||
defaultBranch: data.default_branch,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async unfurlCommit(
|
||||
accessToken: string,
|
||||
apiBaseUrl: string,
|
||||
projectPath: string,
|
||||
commitSha: string,
|
||||
url: string,
|
||||
): Promise<UnfurlResult> {
|
||||
const encodedProject = encodeURIComponent(projectPath);
|
||||
const data = await this.apiGet(
|
||||
accessToken,
|
||||
apiBaseUrl,
|
||||
`/projects/${encodedProject}/repository/commits/${commitSha}`,
|
||||
);
|
||||
|
||||
const shortSha = data.short_id ?? data.id?.slice(0, 8);
|
||||
|
||||
const commitDesc = [
|
||||
shortSha,
|
||||
relativeTime(data.committed_date ?? data.created_at),
|
||||
data.author_name,
|
||||
].filter(Boolean).join(' · ');
|
||||
|
||||
return {
|
||||
title: data.title ?? data.message?.split('\n')[0],
|
||||
description: commitDesc,
|
||||
url,
|
||||
provider: 'gitlab',
|
||||
providerIcon: 'gitlab',
|
||||
author: data.author_name,
|
||||
authorAvatarUrl: undefined,
|
||||
metadata: {
|
||||
type: 'commit',
|
||||
sha: data.id,
|
||||
shortSha,
|
||||
project: projectPath,
|
||||
stats: data.stats,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async unfurlIssuesList(
|
||||
accessToken: string,
|
||||
apiBaseUrl: string,
|
||||
projectPath: string,
|
||||
url: string,
|
||||
): Promise<UnfurlResult> {
|
||||
const encodedProject = encodeURIComponent(projectPath);
|
||||
const data = await this.apiGet(
|
||||
accessToken,
|
||||
apiBaseUrl,
|
||||
`/projects/${encodedProject}?statistics=false`,
|
||||
);
|
||||
|
||||
return {
|
||||
title: `Issues · ${data.name}`,
|
||||
description: projectPath,
|
||||
url,
|
||||
provider: 'gitlab',
|
||||
providerIcon: 'gitlab',
|
||||
author: data.namespace?.name,
|
||||
authorAvatarUrl: data.avatar_url ?? data.namespace?.avatar_url,
|
||||
metadata: {
|
||||
type: 'issues-list',
|
||||
project: projectPath,
|
||||
openIssuesCount: data.open_issues_count,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async unfurlMergesList(
|
||||
accessToken: string,
|
||||
apiBaseUrl: string,
|
||||
projectPath: string,
|
||||
url: string,
|
||||
): Promise<UnfurlResult> {
|
||||
const encodedProject = encodeURIComponent(projectPath);
|
||||
const data = await this.apiGet(
|
||||
accessToken,
|
||||
apiBaseUrl,
|
||||
`/projects/${encodedProject}?statistics=false`,
|
||||
);
|
||||
|
||||
return {
|
||||
title: `Merge Requests · ${data.name}`,
|
||||
description: projectPath,
|
||||
url,
|
||||
provider: 'gitlab',
|
||||
providerIcon: 'gitlab',
|
||||
author: data.namespace?.name,
|
||||
authorAvatarUrl: data.avatar_url ?? data.namespace?.avatar_url,
|
||||
metadata: {
|
||||
type: 'merges-list',
|
||||
project: projectPath,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private formatMrStatus(mr: any): string {
|
||||
if (mr.state === 'merged') return 'merged';
|
||||
if (mr.draft || mr.work_in_progress) return 'draft';
|
||||
return mr.state;
|
||||
}
|
||||
|
||||
private getMrStatusColor(mr: any): string {
|
||||
if (mr.state === 'merged') return 'purple';
|
||||
if (mr.draft || mr.work_in_progress) return 'gray';
|
||||
if (mr.state === 'opened') return 'green';
|
||||
if (mr.state === 'closed') return 'red';
|
||||
return 'gray';
|
||||
}
|
||||
|
||||
private async apiGet(
|
||||
accessToken: string,
|
||||
apiBaseUrl: string,
|
||||
path: string,
|
||||
): Promise<any> {
|
||||
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`GitLab API error: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { formatDistanceStrict } from 'date-fns';
|
||||
|
||||
export function relativeTime(iso: string): string {
|
||||
return formatDistanceStrict(new Date(iso), new Date(), { addSuffix: true });
|
||||
}
|
||||
@@ -17,6 +17,25 @@ export const integrationLinkPatterns: IntegrationLinkPattern[] = [
|
||||
regex:
|
||||
/^https?:\/\/[a-z0-9-]+\.slack\.com\/archives\/([a-zA-Z0-9-]+)\/?$/,
|
||||
},
|
||||
// Jira issue (cloud + self-hosted): /browse/KEY-123, tolerating ?atlOrigin=…
|
||||
// Must precede the GitHub repo pattern, which would swallow the two-segment
|
||||
// /browse/KEY path on any host.
|
||||
{
|
||||
provider: "jira",
|
||||
regex: /^https?:\/\/[^\/]+\/browse\/([A-Za-z0-9]+-\d+)/,
|
||||
},
|
||||
// Jira legacy board (cloud + self-hosted): RapidBoard.jspa?…selectedIssue=KEY
|
||||
{
|
||||
provider: "jira",
|
||||
regex:
|
||||
/^https?:\/\/[^\/]+\/secure\/RapidBoard\.jspa\?(?:.*&)?selectedIssue=([A-Za-z0-9]+-\d+)/,
|
||||
},
|
||||
// Jira cloud board/backlog with a selected issue
|
||||
{
|
||||
provider: "jira",
|
||||
regex:
|
||||
/^https?:\/\/[a-z0-9-]+\.atlassian\.net\/jira\/software(?:\/c)?\/projects\/[\w-]+\/boards\/\d+(?:\/\w+)?\?(?:.*&)?selectedIssue=([A-Za-z0-9]+-\d+)/,
|
||||
},
|
||||
// GitHub PR commit (must be before generic PR pattern)
|
||||
{
|
||||
provider: "github",
|
||||
@@ -156,11 +175,6 @@ export const integrationLinkPatterns: IntegrationLinkPattern[] = [
|
||||
regex:
|
||||
/^https?:\/\/([\w.-]+\.)?figma\.com\/(file|proto|board|design)\/([0-9a-zA-Z]{22,128})/,
|
||||
},
|
||||
// Jira (cloud + server): /browse/KEY-123
|
||||
{
|
||||
provider: "jira",
|
||||
regex: /^https?:\/\/[^\/]+\/browse\/([A-Z][A-Z0-9]+-\d+)/,
|
||||
},
|
||||
// Linear issue: /team/issue/KEY-123(/:title-slug)?
|
||||
{
|
||||
provider: "linear",
|
||||
|
||||
Reference in New Issue
Block a user