mirror of
https://github.com/docmost/docmost.git
synced 2026-08-22 22:02:12 +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 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 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.",
|
"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 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 create and edit pages in space.": "Can create and edit pages in space.",
|
||||||
"Can edit": "Can edit",
|
"Can edit": "Can edit",
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
"Can view": "Can view",
|
"Can view": "Can view",
|
||||||
"Can view pages in space but not edit.": "Can view pages in space but not edit.",
|
"Can view pages in space but not edit.": "Can view pages in space but not edit.",
|
||||||
"Cancel": "Cancel",
|
"Cancel": "Cancel",
|
||||||
|
"Card": "Card",
|
||||||
"Change email": "Change email",
|
"Change email": "Change email",
|
||||||
"Change password": "Change password",
|
"Change password": "Change password",
|
||||||
"Change photo": "Change photo",
|
"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.",
|
"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",
|
"Toggle full page width": "Toggle full page width",
|
||||||
"Unable to import pages. Please try again.": "Unable to import pages. Please try again.",
|
"Unable to import pages. Please try again.": "Unable to import pages. Please try again.",
|
||||||
|
"Unassigned": "Unassigned",
|
||||||
"untitled": "untitled",
|
"untitled": "untitled",
|
||||||
"Untitled": "Untitled",
|
"Untitled": "Untitled",
|
||||||
"Updated successfully": "Updated successfully",
|
"Updated successfully": "Updated successfully",
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
|
|||||||
import { Editor } from "@tiptap/core";
|
import { Editor } from "@tiptap/core";
|
||||||
import { matchIntegrationLink } from "@docmost/editor-ext";
|
import { matchIntegrationLink } from "@docmost/editor-ext";
|
||||||
import { integrationPasteMenuKey } from "@/features/editor/extensions/integration-paste-menu";
|
import { integrationPasteMenuKey } from "@/features/editor/extensions/integration-paste-menu";
|
||||||
|
import { queryClient } from "@/main.tsx";
|
||||||
|
import { Integration } from "@/features/integration/types/integration.types";
|
||||||
import {
|
import {
|
||||||
getAttachmentInfo,
|
getAttachmentInfo,
|
||||||
uploadFile,
|
uploadFile,
|
||||||
@@ -24,6 +26,17 @@ const ATTACHMENT_NODE_TYPES = [
|
|||||||
|
|
||||||
const ATTACHMENT_URL_RE = /\/api\/files\/([0-9a-f-]+)\//;
|
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 = (
|
export const handlePaste = (
|
||||||
editor: Editor,
|
editor: Editor,
|
||||||
event: ClipboardEvent,
|
event: ClipboardEvent,
|
||||||
@@ -33,7 +46,11 @@ export const handlePaste = (
|
|||||||
const clipboardData = event.clipboardData.getData("text/plain");
|
const clipboardData = event.clipboardData.getData("text/plain");
|
||||||
|
|
||||||
const integrationMatch = matchIntegrationLink(clipboardData.trim());
|
const integrationMatch = matchIntegrationLink(clipboardData.trim());
|
||||||
if (integrationMatch && editor.state.selection.empty) {
|
if (
|
||||||
|
integrationMatch &&
|
||||||
|
editor.state.selection.empty &&
|
||||||
|
isIntegrationInstalled(integrationMatch.provider)
|
||||||
|
) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const pastedUrl = clipboardData.trim();
|
const pastedUrl = clipboardData.trim();
|
||||||
editor
|
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 {
|
export function toBadgeColor(raw?: string): string {
|
||||||
if (!raw) return "gray";
|
if (!raw) return "gray";
|
||||||
const hex = raw.toLowerCase().replace("#", "");
|
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 { getOAuthAuthorizeUrl } from "@/features/integration/services/integration-service";
|
||||||
import { timeAgo } from "@/lib/time";
|
import { timeAgo } from "@/lib/time";
|
||||||
import { useUnfurl } from "./use-unfurl";
|
import { useUnfurl } from "./use-unfurl";
|
||||||
import { toBadgeColor } from "./badge-color";
|
import { badgeTextColor, toBadgeColor } from "./badge-color";
|
||||||
import classes from "./integration-link-view.module.css";
|
import classes from "./integration-link-view.module.css";
|
||||||
|
|
||||||
const SLACK_TEXT_CLAMP_LINES = 4;
|
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) {
|
function IntegrationLinkView(props: any) {
|
||||||
const { node, updateAttributes, editor } = props;
|
const { node, updateAttributes, editor } = props;
|
||||||
const { url, provider, unfurlData, status } = node.attrs;
|
const { url, provider, unfurlData, status } = node.attrs;
|
||||||
@@ -231,6 +321,10 @@ function IntegrationLinkView(props: any) {
|
|||||||
return <SlackMessageCard url={url} unfurlData={unfurlData} />;
|
return <SlackMessageCard url={url} unfurlData={unfurlData} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (provider === "jira" && unfurlData.metadata?.issueKey) {
|
||||||
|
return <JiraIssueCard url={url} unfurlData={unfurlData} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper data-drag-handle="">
|
<NodeViewWrapper data-drag-handle="">
|
||||||
<Card
|
<Card
|
||||||
@@ -261,6 +355,7 @@ function IntegrationLinkView(props: any) {
|
|||||||
size="xs"
|
size="xs"
|
||||||
variant="light"
|
variant="light"
|
||||||
color={toBadgeColor(unfurlData.statusColor)}
|
color={toBadgeColor(unfurlData.statusColor)}
|
||||||
|
c={badgeTextColor(toBadgeColor(unfurlData.statusColor))}
|
||||||
style={{ flexShrink: 0 }}
|
style={{ flexShrink: 0 }}
|
||||||
>
|
>
|
||||||
{unfurlData.status}
|
{unfurlData.status}
|
||||||
|
|||||||
+26
-1
@@ -4,7 +4,7 @@ import { memo } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { getIntegrationIcon } from "@/features/integration/components/integration-icons";
|
import { getIntegrationIcon } from "@/features/integration/components/integration-icons";
|
||||||
import { useUnfurl } from "./use-unfurl";
|
import { useUnfurl } from "./use-unfurl";
|
||||||
import { toBadgeColor } from "./badge-color";
|
import { badgeTextColor, toBadgeColor } from "./badge-color";
|
||||||
import classes from "./integration-link-view.module.css";
|
import classes from "./integration-link-view.module.css";
|
||||||
|
|
||||||
function shortUrl(url: string): string {
|
function shortUrl(url: string): string {
|
||||||
@@ -40,6 +40,7 @@ function IntegrationMentionView(props: any) {
|
|||||||
size="xs"
|
size="xs"
|
||||||
variant="light"
|
variant="light"
|
||||||
color={toBadgeColor(data.statusColor)}
|
color={toBadgeColor(data.statusColor)}
|
||||||
|
c={badgeTextColor(toBadgeColor(data.statusColor))}
|
||||||
className={classes.mentionIcon}
|
className={classes.mentionIcon}
|
||||||
>
|
>
|
||||||
{data.status}
|
{data.status}
|
||||||
@@ -80,6 +81,7 @@ function IntegrationMentionView(props: any) {
|
|||||||
size="xs"
|
size="xs"
|
||||||
variant="light"
|
variant="light"
|
||||||
color="gray"
|
color="gray"
|
||||||
|
c={badgeTextColor("gray")}
|
||||||
tt="none"
|
tt="none"
|
||||||
className={classes.mentionIcon}
|
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) {
|
} else if (issueNumber) {
|
||||||
content = (
|
content = (
|
||||||
<>
|
<>
|
||||||
|
|||||||
+32
-36
@@ -62,7 +62,7 @@ export function IntegrationPasteMenu({ editor }: EditorMenuProps) {
|
|||||||
}, [menuState, dismiss]);
|
}, [menuState, dismiss]);
|
||||||
|
|
||||||
const convert = useCallback(
|
const convert = useCallback(
|
||||||
(target: "preview" | "mention" | "url") => {
|
(target: "card" | "mention" | "url") => {
|
||||||
const found = findTarget();
|
const found = findTarget();
|
||||||
if (!found) {
|
if (!found) {
|
||||||
dismiss();
|
dismiss();
|
||||||
@@ -74,45 +74,37 @@ export function IntegrationPasteMenu({ editor }: EditorMenuProps) {
|
|||||||
const to = pos + node.nodeSize;
|
const to = pos + node.nodeSize;
|
||||||
const isBlock = node.type.name === "integrationLink";
|
const isBlock = node.type.name === "integrationLink";
|
||||||
|
|
||||||
if (target === "preview" && !isBlock) {
|
// Always replace, even when the node is already in the requested form:
|
||||||
editor
|
// the menu closes via the doc change, and BubbleMenu never re-evaluates
|
||||||
.chain()
|
// on meta-only transactions, so a bare dismiss would leave it stuck.
|
||||||
.focus(undefined, { scrollIntoView: false })
|
let content: Record<string, any>;
|
||||||
.deleteRange({ from, to })
|
if (target === "card") {
|
||||||
.insertContentAt(from, { type: "integrationLink", attrs })
|
content = { type: "integrationLink", attrs };
|
||||||
.run();
|
} else if (target === "mention") {
|
||||||
} else if (target === "mention" && isBlock) {
|
const mention = { type: "integrationMention", attrs };
|
||||||
editor
|
content = isBlock
|
||||||
.chain()
|
? {
|
||||||
.focus(undefined, { scrollIntoView: false })
|
type: "paragraph",
|
||||||
.deleteRange({ from, to })
|
content: [mention, { type: "text", text: " " }],
|
||||||
.insertContentAt(from, {
|
}
|
||||||
type: "paragraph",
|
: mention;
|
||||||
content: [
|
} else {
|
||||||
{ type: "integrationMention", attrs },
|
|
||||||
{ type: "text", text: " " },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
.run();
|
|
||||||
} else if (target === "url") {
|
|
||||||
const linkText = {
|
const linkText = {
|
||||||
type: "text",
|
type: "text",
|
||||||
text: attrs.url,
|
text: attrs.url,
|
||||||
marks: [{ type: "link", attrs: { href: attrs.url } }],
|
marks: [{ type: "link", attrs: { href: attrs.url } }],
|
||||||
};
|
};
|
||||||
editor
|
content = isBlock
|
||||||
.chain()
|
? { type: "paragraph", content: [linkText] }
|
||||||
.focus(undefined, { scrollIntoView: false })
|
: linkText;
|
||||||
.deleteRange({ from, to })
|
|
||||||
.insertContentAt(
|
|
||||||
from,
|
|
||||||
isBlock ? { type: "paragraph", content: [linkText] } : linkText,
|
|
||||||
)
|
|
||||||
.run();
|
|
||||||
} else {
|
|
||||||
// already in the requested form
|
|
||||||
dismiss();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus(undefined, { scrollIntoView: false })
|
||||||
|
.deleteRange({ from, to })
|
||||||
|
.insertContentAt(from, content)
|
||||||
|
.run();
|
||||||
},
|
},
|
||||||
[editor, findTarget, dismiss],
|
[editor, findTarget, dismiss],
|
||||||
);
|
);
|
||||||
@@ -126,6 +118,9 @@ export function IntegrationPasteMenu({ editor }: EditorMenuProps) {
|
|||||||
options={{ placement: "bottom-start", flip: true }}
|
options={{ placement: "bottom-start", flip: true }}
|
||||||
shouldShow={shouldShow}
|
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}>
|
<Paper shadow="md" radius="md" withBorder p={4} miw={140}>
|
||||||
<Text size="xs" c="dimmed" px={8} py={4}>
|
<Text size="xs" c="dimmed" px={8} py={4}>
|
||||||
{t("Paste as")}
|
{t("Paste as")}
|
||||||
@@ -137,9 +132,9 @@ export function IntegrationPasteMenu({ editor }: EditorMenuProps) {
|
|||||||
size="compact-sm"
|
size="compact-sm"
|
||||||
fullWidth
|
fullWidth
|
||||||
justify="flex-start"
|
justify="flex-start"
|
||||||
onClick={() => convert("preview")}
|
onClick={() => convert("card")}
|
||||||
>
|
>
|
||||||
{t("Preview")}
|
{t("Card")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
@@ -163,6 +158,7 @@ export function IntegrationPasteMenu({ editor }: EditorMenuProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
) : null}
|
||||||
</BaseBubbleMenu>
|
</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 { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
|
||||||
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
|
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
|
||||||
import { IntegrationPasteMenu } from "@/features/editor/components/integration-link/integration-paste-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 { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
@@ -326,6 +327,15 @@ function CollabPageEditor({
|
|||||||
[pageId, editable, extensions],
|
[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(() => {
|
useLayoutEffect(() => {
|
||||||
if (editor && !editor.isDestroyed) {
|
if (editor && !editor.isDestroyed) {
|
||||||
// @ts-ignore
|
// @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;
|
installation?: Integration;
|
||||||
onInstall: (type: string) => void;
|
onInstall: (type: string) => void;
|
||||||
onUninstall: (integrationId: string) => void;
|
onUninstall: (integrationId: string) => void;
|
||||||
onConfigure: (integration: Integration) => void;
|
|
||||||
onToggle: (integration: Integration, enabled: boolean) => void;
|
onToggle: (integration: Integration, enabled: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -20,7 +19,6 @@ export default function IntegrationRow({
|
|||||||
installation,
|
installation,
|
||||||
onInstall,
|
onInstall,
|
||||||
onUninstall,
|
onUninstall,
|
||||||
onConfigure,
|
|
||||||
onToggle,
|
onToggle,
|
||||||
}: IntegrationRowProps) {
|
}: IntegrationRowProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -64,13 +62,6 @@ export default function IntegrationRow({
|
|||||||
}
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="light"
|
|
||||||
onClick={() => onConfigure(installation)}
|
|
||||||
>
|
|
||||||
{t("Configure")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="subtle"
|
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 { Text, Alert, Stack } from "@mantine/core";
|
||||||
import { Helmet } from "react-helmet-async";
|
import { Helmet } from "react-helmet-async";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useState, useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { getAppName } from "@/lib/config";
|
import { getAppName } from "@/lib/config";
|
||||||
import SettingsTitle from "@/components/settings/settings-title";
|
import SettingsTitle from "@/components/settings/settings-title";
|
||||||
import IntegrationRow from "../components/integration-row";
|
import IntegrationRow from "../components/integration-row";
|
||||||
import IntegrationListSkeleton from "../components/integration-list-skeleton";
|
import IntegrationListSkeleton from "../components/integration-list-skeleton";
|
||||||
import IntegrationSettingsModal from "../components/integration-settings-modal";
|
|
||||||
import {
|
import {
|
||||||
useAvailableIntegrations,
|
useAvailableIntegrations,
|
||||||
useInstalledIntegrations,
|
useInstalledIntegrations,
|
||||||
@@ -31,8 +30,6 @@ export default function Integrations() {
|
|||||||
const uninstallMutation = useUninstallIntegration();
|
const uninstallMutation = useUninstallIntegration();
|
||||||
const updateMutation = useUpdateIntegrationSettings();
|
const updateMutation = useUpdateIntegrationSettings();
|
||||||
|
|
||||||
const [configuring, setConfiguring] = useState<Integration | null>(null);
|
|
||||||
|
|
||||||
const handleInstall = useCallback(
|
const handleInstall = useCallback(
|
||||||
async (type: string) => {
|
async (type: string) => {
|
||||||
const definition = available?.find((d) => d.type === type);
|
const definition = available?.find((d) => d.type === type);
|
||||||
@@ -68,10 +65,6 @@ export default function Integrations() {
|
|||||||
[uninstallMutation],
|
[uninstallMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleConfigure = useCallback((integration: Integration) => {
|
|
||||||
setConfiguring(integration);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleToggle = useCallback(
|
const handleToggle = useCallback(
|
||||||
(integration: Integration, enabled: boolean) => {
|
(integration: Integration, enabled: boolean) => {
|
||||||
updateMutation.mutate({
|
updateMutation.mutate({
|
||||||
@@ -122,19 +115,12 @@ export default function Integrations() {
|
|||||||
installation={installation}
|
installation={installation}
|
||||||
onInstall={handleInstall}
|
onInstall={handleInstall}
|
||||||
onUninstall={handleUninstall}
|
onUninstall={handleUninstall}
|
||||||
onConfigure={handleConfigure}
|
|
||||||
onToggle={handleToggle}
|
onToggle={handleToggle}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Stack>
|
</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 { NotificationModule } from './notification/notification.module';
|
||||||
import { WatcherModule } from './watcher/watcher.module';
|
import { WatcherModule } from './watcher/watcher.module';
|
||||||
import { IntegrationModule } from './integration/integration.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 { FavoriteModule } from './favorite/favorite.module';
|
||||||
import { SessionModule } from './session/session.module';
|
import { SessionModule } from './session/session.module';
|
||||||
import { ClsMiddleware } from 'nestjs-cls';
|
import { ClsMiddleware } from 'nestjs-cls';
|
||||||
@@ -45,6 +47,8 @@ import { ClsMiddleware } from 'nestjs-cls';
|
|||||||
NotificationModule,
|
NotificationModule,
|
||||||
WatcherModule,
|
WatcherModule,
|
||||||
IntegrationModule,
|
IntegrationModule,
|
||||||
|
GitHubModule,
|
||||||
|
GitLabModule,
|
||||||
SessionModule,
|
SessionModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,31 +2,20 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
export const githubSettingsSchema = z.object({
|
export const githubSettingsSchema = z.object({
|
||||||
baseUrl: z.string().url().optional(),
|
baseUrl: z.string().url().optional(),
|
||||||
org: z.string().optional(),
|
|
||||||
defaultRepo: z.string().optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const gitlabSettingsSchema = z.object({
|
export const gitlabSettingsSchema = z.object({
|
||||||
baseUrl: z.string().url().optional(),
|
baseUrl: z.string().url().optional(),
|
||||||
group: z.string().optional(),
|
|
||||||
defaultProject: z.string().optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const jiraSettingsSchema = z.object({
|
export const jiraSettingsSchema = z.object({
|
||||||
baseUrl: z.string().url().optional(),
|
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> = {
|
const integrationSettingsSchemas: Record<string, z.ZodType> = {
|
||||||
github: githubSettingsSchema,
|
github: githubSettingsSchema,
|
||||||
gitlab: gitlabSettingsSchema,
|
gitlab: gitlabSettingsSchema,
|
||||||
jira: jiraSettingsSchema,
|
jira: jiraSettingsSchema,
|
||||||
linear: linearSettingsSchema,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function validateIntegrationSettings(
|
export function validateIntegrationSettings(
|
||||||
@@ -51,8 +40,3 @@ export function validateIntegrationSettings(
|
|||||||
|
|
||||||
return { success: true, data: result.data };
|
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 { OnEvent } from '@nestjs/event-emitter';
|
||||||
import { InjectQueue } from '@nestjs/bullmq';
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
import { Queue } from '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';
|
import { EventName } from '../../common/events/event.contants';
|
||||||
|
|
||||||
const TOKEN_REFRESH_SCHEDULER_ID = 'integration-token-refresh-scheduler';
|
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:
|
regex:
|
||||||
/^https?:\/\/[a-z0-9-]+\.slack\.com\/archives\/([a-zA-Z0-9-]+)\/?$/,
|
/^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)
|
// GitHub PR commit (must be before generic PR pattern)
|
||||||
{
|
{
|
||||||
provider: "github",
|
provider: "github",
|
||||||
@@ -156,11 +175,6 @@ export const integrationLinkPatterns: IntegrationLinkPattern[] = [
|
|||||||
regex:
|
regex:
|
||||||
/^https?:\/\/([\w.-]+\.)?figma\.com\/(file|proto|board|design)\/([0-9a-zA-Z]{22,128})/,
|
/^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)?
|
// Linear issue: /team/issue/KEY-123(/:title-slug)?
|
||||||
{
|
{
|
||||||
provider: "linear",
|
provider: "linear",
|
||||||
|
|||||||
Reference in New Issue
Block a user