This commit is contained in:
Philipinho
2026-08-09 15:01:52 +01:00
parent 21665fc0e7
commit 16f7139e6f
24 changed files with 1303 additions and 223 deletions
@@ -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("#", "");
@@ -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}
@@ -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 = (
<>
@@ -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)}
/>
</>
);
}
+4
View File
@@ -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",