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
+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 });
}