feat: integrate multi-provider adapters into skill sync runner

This commit is contained in:
Malte Polley 2026-06-27 22:23:15 +02:00
parent 9fef1ec3b6
commit 5a7fe0bb86
7 changed files with 1405 additions and 27 deletions

View file

@ -0,0 +1,396 @@
import { Types } from 'mongoose';
import type {
ISkill,
ISkillSyncStatus,
CreateSkillInput,
CreateSkillResult,
SkillSyncStatusInput,
} from '@librechat/data-schemas';
import type { SkillSyncConfig } from 'librechat-data-provider';
import type { GitHubSkillSyncDeps } from './github';
import { createGitHubSkillSyncRunner } from './github';
function makeSkill(input: CreateSkillInput): ISkill & { _id: Types.ObjectId } {
return {
_id: new Types.ObjectId(),
name: input.name,
description: input.description ?? '',
body: input.body ?? '',
source: input.source ?? 'inline',
sourceMetadata: input.sourceMetadata,
author: input.author ?? new Types.ObjectId(),
authorName: input.authorName ?? '',
version: 1,
fileCount: 0,
alwaysApply: input.alwaysApply ?? false,
tenantId: input.tenantId,
} as ISkill & { _id: Types.ObjectId };
}
/**
* Builds a mock fetch that emulates a GitLab-like API:
* - GET /api/v4/projects/:id/repository/commits/:ref commit
* - GET /api/v4/projects/:id/repository/tree tree listing
* - GET /api/v4/projects/:id/repository/blobs/:sha/raw blob content
*/
function gitlabFetch(skillMd: string): typeof fetch {
return jest.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url.includes('/repository/commits/')) {
return { ok: true, status: 200, json: async () => ({ id: 'gl-commit-sha', parent_ids: [] }) } as Response;
}
if (url.includes('/repository/tree')) {
return {
ok: true,
status: 200,
headers: { get: () => null },
json: async () => [
{ id: 'gl-skill-sha', name: 'SKILL.md', type: 'blob', path: 'skills/research/SKILL.md', mode: '100644' },
],
} as unknown as Response;
}
if (url.includes('/blobs/') && url.includes('/raw')) {
const buf = Buffer.from(skillMd);
return {
ok: true,
status: 200,
arrayBuffer: async () => buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength),
} as unknown as Response;
}
return { ok: false, status: 404, json: async () => ({}) } as unknown as Response;
}) as unknown as typeof fetch;
}
/**
* Builds a mock fetch that emulates a Bitbucket-like API:
* - GET /repositories/:ws/:repo/commit/:ref commit
* - GET /repositories/:ws/:repo/src/:ref/ tree listing
* - GET /repositories/:ws/:repo/src/:ref/:path blob content
*/
function bitbucketFetch(skillMd: string): typeof fetch {
return jest.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url.includes('/commit/')) {
return { ok: true, status: 200, json: async () => ({ hash: 'bb-commit-sha' }) } as Response;
}
if (url.match(/\/src\/[^/]+\/\?/) || url.match(/\/src\/[^/]+\?/)) {
return {
ok: true,
status: 200,
json: async () => ({
values: [
{ path: 'skills/writer/SKILL.md', type: 'commit_file', size: skillMd.length, commit: { hash: 'bb-commit-sha' } },
],
next: undefined,
}),
} as unknown as Response;
}
if (url.includes('/src/')) {
const buf = Buffer.from(skillMd);
return {
ok: true,
status: 200,
arrayBuffer: async () => buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength),
} as unknown as Response;
}
return { ok: false, status: 404, json: async () => ({}) } as unknown as Response;
}) as unknown as typeof fetch;
}
/**
* Builds a mock fetch that emulates Azure DevOps API:
* - GET /_apis/git/repositories/:repo/refs refs
* - GET /_apis/git/repositories/:repo/commits/:id commit
* - GET /_apis/git/repositories/:repo/trees/:sha tree listing
* - GET /_apis/git/repositories/:repo/blobs/:sha blob content
*/
function azureFetch(skillMd: string): typeof fetch {
return jest.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url.includes('/refs?')) {
return { ok: true, status: 200, json: async () => ({ value: [{ name: 'refs/heads/main', objectId: 'az-commit-sha' }] }) } as Response;
}
if (url.includes('/commits/')) {
return { ok: true, status: 200, json: async () => ({ commitId: 'az-commit-sha', treeId: 'az-tree-sha' }) } as Response;
}
if (url.includes('/trees/')) {
return {
ok: true,
status: 200,
json: async () => ({
treeEntries: [
{ objectId: 'az-blob-sha', relativePath: 'skills/coder/SKILL.md', gitObjectType: 'blob', size: skillMd.length, mode: '100644' },
],
}),
} as unknown as Response;
}
if (url.includes('/blobs/')) {
const buf = Buffer.from(skillMd);
return {
ok: true,
status: 200,
arrayBuffer: async () => buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength),
} as unknown as Response;
}
return { ok: false, status: 404, json: async () => ({}) } as unknown as Response;
}) as unknown as typeof fetch;
}
function createMultiProviderConfig(): SkillSyncConfig {
return {
github: {
enabled: false,
intervalMinutes: 60,
runOnStartup: false,
sources: [],
},
gitlab: {
enabled: true,
intervalMinutes: 30,
runOnStartup: false,
sources: [
{
id: 'gl-skills',
projectId: '12345',
ref: 'main',
paths: ['skills'],
token: '${GITLAB_SKILLS_TOKEN}',
},
],
},
bitbucket: {
enabled: true,
intervalMinutes: 45,
runOnStartup: false,
sources: [
{
id: 'bb-skills',
workspace: 'myteam',
repository: 'skills-repo',
ref: 'main',
paths: ['skills'],
token: '${BITBUCKET_SKILLS_TOKEN}',
},
],
},
azuredevops: {
enabled: true,
intervalMinutes: 60,
runOnStartup: false,
sources: [
{
id: 'az-skills',
organization: 'myorg',
project: 'myproj',
repository: 'skills-repo',
ref: 'main',
paths: ['skills'],
token: '${AZUREDEVOPS_SKILLS_TOKEN}',
},
],
},
};
}
function createDeps(overrides: Partial<GitHubSkillSyncDeps> = {}): GitHubSkillSyncDeps {
const statuses: ISkillSyncStatus[] = [];
return {
getConfig: async () => createMultiProviderConfig(),
getCredentialToken: jest.fn(async () => null),
getCredentialSummary: jest.fn(async () => null),
listCredentials: jest.fn(async () => []),
listStatuses: jest.fn(async () => statuses),
upsertStatus: jest.fn(async (input: SkillSyncStatusInput) => {
const status = {
provider: input.provider,
sourceId: input.sourceId,
tenantId: input.tenantId,
status: input.status,
credentialKey: input.credentialKey,
ref: input.ref,
paths: input.paths,
startedAt: input.startedAt,
finishedAt: input.finishedAt,
lastSuccessAt: input.status === 'succeeded' ? input.finishedAt : undefined,
lastFailureAt: input.status === 'failed' ? input.finishedAt : undefined,
errorCode: input.errorCode,
errorMessage: input.errorMessage,
syncedSkillCount: input.syncedSkillCount ?? 0,
syncedFileCount: input.syncedFileCount ?? 0,
deletedSkillCount: input.deletedSkillCount ?? 0,
deletedFileCount: input.deletedFileCount ?? 0,
} as ISkillSyncStatus;
statuses.push(status);
return status;
}),
tryAcquireLock: jest.fn(async () => true),
refreshLock: jest.fn(async () => true),
releaseLock: jest.fn(async () => undefined),
createSkill: jest.fn(async (input: CreateSkillInput): Promise<CreateSkillResult> => {
return { skill: makeSkill(input), warnings: [] };
}),
updateSkill: jest.fn(),
getSkillById: jest.fn(),
findSkillBySourceIdentity: jest.fn(async () => null),
listSkillsBySource: jest.fn(async () => []),
listSkillFiles: jest.fn(async () => []),
getSkillFileByPath: jest.fn(async () => null),
upsertSkillFile: jest.fn(async () => ({
_id: new Types.ObjectId(),
skillId: new Types.ObjectId(),
relativePath: 'file',
file_id: 'fid',
filename: 'file',
filepath: '/uploads/file',
source: 'local',
mimeType: 'text/plain',
bytes: 1,
category: 'file',
isExecutable: false,
author: new Types.ObjectId(),
})),
deleteSkillFile: jest.fn(async () => ({ deleted: true })),
deleteSkill: jest.fn(async () => ({ deleted: true })),
saveBuffer: jest.fn(async () => ({ filepath: '/uploads/file', source: 'local' })),
deleteFile: jest.fn(async () => undefined),
grantPermission: jest.fn(async () => undefined),
fetchFn: jest.fn(async () => ({ ok: false, status: 404 })) as unknown as typeof fetch,
...overrides,
};
}
describe('Multi-provider skill sync integration wiring', () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = {
...originalEnv,
GITLAB_SKILLS_TOKEN: 'glpat-test-token',
BITBUCKET_SKILLS_TOKEN: 'bb-test-token',
AZUREDEVOPS_SKILLS_TOKEN: 'az-test-token',
};
});
afterEach(() => {
process.env = originalEnv;
});
it('syncs a GitLab source when GitHub is disabled', async () => {
const glFetch = gitlabFetch('---\nname: research\ndescription: From GitLab\n---\nGL body');
const config = createMultiProviderConfig();
// Only enable GitLab
config.bitbucket!.enabled = false;
config.azuredevops!.enabled = false;
const deps = createDeps({
fetchFn: glFetch,
getConfig: async () => config,
});
const runner = createGitHubSkillSyncRunner(deps);
const result = await runner.runOnce();
expect(result.status).toBe('completed');
expect(deps.createSkill).toHaveBeenCalledWith(
expect.objectContaining({
name: 'research',
description: 'From GitLab',
source: 'gitlab',
sourceMetadata: expect.objectContaining({
provider: 'gitlab',
sourceId: 'gl-skills',
}),
}),
);
});
it('syncs a Bitbucket source when GitHub is disabled', async () => {
const bbFetch = bitbucketFetch('---\nname: writer\ndescription: From Bitbucket\n---\nBB body');
const config = createMultiProviderConfig();
// Only enable Bitbucket
config.gitlab!.enabled = false;
config.azuredevops!.enabled = false;
const deps = createDeps({
fetchFn: bbFetch,
getConfig: async () => config,
});
const runner = createGitHubSkillSyncRunner(deps);
const result = await runner.runOnce();
expect(result.status).toBe('completed');
expect(deps.createSkill).toHaveBeenCalledWith(
expect.objectContaining({
name: 'writer',
source: 'bitbucket',
sourceMetadata: expect.objectContaining({
provider: 'bitbucket',
sourceId: 'bb-skills',
}),
}),
);
});
it('syncs an Azure DevOps source when GitHub is disabled', async () => {
const azFetch = azureFetch('---\nname: coder\ndescription: From Azure\n---\nAZ body');
const config = createMultiProviderConfig();
// Only enable Azure DevOps
config.gitlab!.enabled = false;
config.bitbucket!.enabled = false;
const deps = createDeps({
fetchFn: azFetch,
getConfig: async () => config,
});
const runner = createGitHubSkillSyncRunner(deps);
const result = await runner.runOnce();
expect(result.status).toBe('completed');
expect(deps.createSkill).toHaveBeenCalledWith(
expect.objectContaining({
name: 'coder',
source: 'azuredevops',
sourceMetadata: expect.objectContaining({
provider: 'azuredevops',
sourceId: 'az-skills',
}),
}),
);
});
it('reports failed status when token env var is missing', async () => {
delete process.env.GITLAB_SKILLS_TOKEN;
const config = createMultiProviderConfig();
config.bitbucket!.enabled = false;
config.azuredevops!.enabled = false;
const deps = createDeps({ getConfig: async () => config });
const runner = createGitHubSkillSyncRunner(deps);
const result = await runner.runOnce();
expect(result.status).toBe('failed');
expect(result.sources).toEqual(
expect.arrayContaining([
expect.objectContaining({
provider: 'gitlab',
sourceId: 'gl-skills',
status: 'failed',
errorCode: 'MISSING_CREDENTIAL',
}),
]),
);
});
it('skips disabled providers and syncs only enabled ones', async () => {
const config = createMultiProviderConfig();
config.gitlab!.enabled = false;
config.bitbucket!.enabled = false;
config.azuredevops!.enabled = false;
const deps = createDeps({ getConfig: async () => config });
const runner = createGitHubSkillSyncRunner(deps);
const result = await runner.runOnce();
expect(result.status).toBe('skipped');
expect(result.sources).toHaveLength(0);
});
});

View file

@ -0,0 +1,859 @@
import path from 'path';
import crypto from 'crypto';
import { Types } from 'mongoose';
import { logger, tenantStorage } from '@librechat/data-schemas';
import {
ResourceType,
PrincipalType,
AccessRoleIds,
SKILL_SYNC_DEFAULT_DISCOVERY_DEPTH,
} from 'librechat-data-provider';
import type {
ISkill,
ISkillFile,
ISkillSyncStatus,
SkillSyncProvider,
SkillSyncStatusInput,
UpsertSkillFileInput,
} from '@librechat/data-schemas';
import type { GitRepoAdapter, GitTreeEntry } from './adapter';
import type { GitHubSkillSyncDeps } from './github';
import { DEFAULT_SKILL_IMPORT_LIMITS } from '../limits';
import { parseSkillMarkdown } from '../parse';
const SYSTEM_AUTHOR_ID = new Types.ObjectId('000000000000000000000000');
type AssertNotCancelled = () => void;
export type GenericSourceConfig = {
id: string;
ref: string;
paths: string[];
skillDiscoveryDepth?: number;
credentialKey?: string;
token?: string;
tenantId?: string;
};
type DiscoveredSkill = {
rootPath: string;
skillMd: GitTreeEntry;
files: GitTreeEntry[];
};
type SyncCounters = {
syncedSkillCount: number;
syncedFileCount: number;
deletedSkillCount: number;
deletedFileCount: number;
};
type SaveBufferResult = {
filepath: string;
source: string;
storageKey?: string;
storageRegion?: string;
};
type StoredSkillFileRef = {
filepath: string;
source: string;
storageKey?: string;
storageRegion?: string;
author?: Types.ObjectId | string;
tenantId?: string;
};
// --- Pure helpers (duplicated from github.ts to avoid modifying it) ---
function normalizeRepoPath(value: string): string {
const trimmed = value.trim().replace(/^\/+|\/+$/g, '');
return trimmed === '.' ? '' : trimmed;
}
function isSafeRelativePath(value: string): boolean {
if (!value || value.startsWith('/') || value.startsWith('\\')) {
return false;
}
if (!/^[a-zA-Z0-9._\-/]+$/.test(value)) {
return false;
}
return value.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..');
}
function toSkillName(value: string): string {
const normalized = value
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-{2,}/g, '-');
return normalized || 'synced-skill';
}
function getFilename(relativePath: string): string {
return path.posix.basename(relativePath);
}
function guessMimeType(filename: string): string {
const ext = path.extname(filename).toLowerCase();
const mimeMap: Record<string, string> = {
'.md': 'text/markdown',
'.txt': 'text/plain',
'.js': 'application/javascript',
'.ts': 'text/typescript',
'.json': 'application/json',
'.yaml': 'text/yaml',
'.yml': 'text/yaml',
'.py': 'text/x-python',
'.sh': 'application/x-sh',
'.css': 'text/css',
'.html': 'text/html',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.webp': 'image/webp',
'.pdf': 'application/pdf',
};
return mimeMap[ext] ?? 'application/octet-stream';
}
function toCleanFrontmatter(
frontmatter: Record<string, unknown> | undefined,
): Record<string, unknown> {
if (!frontmatter) {
return {};
}
const clean = { ...frontmatter };
delete clean.name;
delete clean.description;
if ('always-apply' in clean && typeof clean['always-apply'] !== 'boolean') {
delete clean['always-apply'];
}
if ('alwaysApply' in clean && typeof clean.alwaysApply !== 'boolean') {
delete clean.alwaysApply;
}
return clean;
}
function getProviderAuthorName(provider: SkillSyncProvider): string {
switch (provider) {
case 'gitlab':
return 'GitLab Sync';
case 'bitbucket':
return 'Bitbucket Sync';
case 'azuredevops':
return 'Azure DevOps Sync';
default:
return 'Skill Sync';
}
}
function makeUpstreamId(source: GenericSourceConfig, rootPath: string): string {
return `${source.id}:${rootPath}`;
}
function makeSourceAuthorId(
provider: SkillSyncProvider,
source: GenericSourceConfig,
): Types.ObjectId {
const seed = source.tenantId
? `${provider}:${source.id}:${source.tenantId}`
: `${provider}:${source.id}`;
const digest = crypto.createHash('sha256').update(seed).digest('hex').slice(0, 24);
return new Types.ObjectId(digest);
}
function getLimitMegabytes(bytes: number): number {
return Math.round(bytes / 1024 / 1024);
}
function assertBlobSize(entry: GitTreeEntry, relativePath: string): number {
if (typeof entry.size !== 'number' || !Number.isFinite(entry.size) || entry.size < 0) {
// If size not available from tree listing, skip pre-check (will check buffer after fetch)
return 0;
}
if (entry.size > DEFAULT_SKILL_IMPORT_LIMITS.maxSingleFileBytes) {
throw new GenericSyncError(
'BLOB_TOO_LARGE',
`File "${relativePath}" exceeds the ${getLimitMegabytes(DEFAULT_SKILL_IMPORT_LIMITS.maxSingleFileBytes)}MB per-file limit`,
);
}
return entry.size;
}
function assertBufferSize(buffer: Buffer, relativePath: string): void {
if (buffer.length > DEFAULT_SKILL_IMPORT_LIMITS.maxSingleFileBytes) {
throw new GenericSyncError(
'BLOB_TOO_LARGE',
`File "${relativePath}" exceeds the ${getLimitMegabytes(DEFAULT_SKILL_IMPORT_LIMITS.maxSingleFileBytes)}MB per-file limit`,
);
}
}
function assertCumulativeSize(totalBytes: number): void {
if (totalBytes > DEFAULT_SKILL_IMPORT_LIMITS.maxDecompressedBytes) {
throw new GenericSyncError(
'PACKAGE_TOO_LARGE',
`Skill files exceed the ${getLimitMegabytes(DEFAULT_SKILL_IMPORT_LIMITS.maxDecompressedBytes)}MB cumulative limit`,
);
}
}
function assertEntryCount(discovered: DiscoveredSkill): void {
if (discovered.files.length + 1 > DEFAULT_SKILL_IMPORT_LIMITS.maxEntries) {
throw new GenericSyncError(
'TOO_MANY_FILES',
`Skill "${discovered.rootPath}" exceeds the ${DEFAULT_SKILL_IMPORT_LIMITS.maxEntries} file limit`,
);
}
}
class GenericSyncError extends Error {
code: string;
constructor(code: string, message: string) {
super(message);
this.name = 'GenericSyncError';
this.code = code;
}
}
function sanitizeError(error: unknown): { code: string; message: string } {
if (error instanceof GenericSyncError) {
return { code: error.code, message: error.message };
}
if (error instanceof Error) {
return {
code: 'SYNC_FAILED',
message: error.message.replace(/Bearer\s+\S+/gi, 'Bearer [redacted]'),
};
}
return { code: 'SYNC_FAILED', message: 'Unknown skill sync failure' };
}
function getTokenEnvVarName(tokenReference: string | undefined): string | null {
const match = tokenReference?.trim().match(/^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/);
return match?.[1] ?? null;
}
function resolveToken(
deps: GitHubSkillSyncDeps,
provider: SkillSyncProvider,
source: GenericSourceConfig,
): Promise<string | null> {
if (deps.allowServerCredentials === false) {
return Promise.resolve(null);
}
const tokenEnvVar = getTokenEnvVarName(source.token);
if (tokenEnvVar) {
return Promise.resolve(process.env[tokenEnvVar]?.trim() || null);
}
if (!source.credentialKey) {
return Promise.resolve(null);
}
return deps.getCredentialToken(provider, source.credentialKey);
}
function isSkillRootWithinDiscoveryDepth(
rootPath: string,
basePath: string,
maxDepth: number,
): boolean {
if (rootPath === basePath) {
return true;
}
if (basePath && !rootPath.startsWith(`${basePath}/`)) {
return false;
}
const relative = basePath ? rootPath.slice(basePath.length).replace(/^\/+/, '') : rootPath;
if (!relative) {
return true;
}
return relative.split('/').length <= maxDepth;
}
function filterTreeByPaths(tree: GitTreeEntry[], paths: string[]): GitTreeEntry[] {
const normalizedPaths = paths.map(normalizeRepoPath);
return tree.filter((entry) => {
const entryPath = normalizeRepoPath(entry.path);
return normalizedPaths.some(
(basePath) =>
basePath === '' || entryPath === basePath || entryPath.startsWith(`${basePath}/`),
);
});
}
function discoverSkills(tree: GitTreeEntry[], source: GenericSourceConfig): DiscoveredSkill[] {
const basePaths = source.paths.map(normalizeRepoPath);
const skillDiscoveryDepth = source.skillDiscoveryDepth ?? SKILL_SYNC_DEFAULT_DISCOVERY_DEPTH;
const skillMdByRoot = new Map<string, GitTreeEntry>();
for (const entry of tree) {
if (entry.type !== 'blob') {
continue;
}
const normalized = normalizeRepoPath(entry.path);
const basename = path.posix.basename(normalized);
if (basename.toUpperCase() !== 'SKILL.MD') {
continue;
}
const parent = normalizeRepoPath(path.posix.dirname(normalized));
for (const basePath of basePaths) {
if (isSkillRootWithinDiscoveryDepth(parent, basePath, skillDiscoveryDepth)) {
skillMdByRoot.set(parent, entry);
}
}
}
const skillRoots = [...skillMdByRoot.keys()];
return [...skillMdByRoot.entries()].map(([rootPath, skillMd]) => {
const prefix = rootPath ? `${rootPath}/` : '';
const childSkillRoots = skillRoots.filter(
(c) => c && c !== rootPath && (rootPath ? c.startsWith(`${rootPath}/`) : true),
);
const files = tree.filter((entry) => {
if (entry.type !== 'blob') {
return false;
}
const normalized = normalizeRepoPath(entry.path);
if (!normalized.startsWith(prefix) || normalized === skillMd.path) {
return false;
}
if (childSkillRoots.some((childRoot) => normalized.startsWith(`${childRoot}/`))) {
return false;
}
const relativePath = prefix ? normalized.slice(prefix.length) : normalized;
return isSafeRelativePath(relativePath) && relativePath.toUpperCase() !== 'SKILL.MD';
});
return { rootPath, skillMd, files };
});
}
function getDiscoveredRelativePath(discovered: DiscoveredSkill, entry: GitTreeEntry): string {
const prefix = discovered.rootPath ? `${discovered.rootPath}/` : '';
const normalized = normalizeRepoPath(entry.path);
return prefix ? normalized.slice(prefix.length) : normalized;
}
function makeStatusInput(params: {
provider: SkillSyncProvider;
source: GenericSourceConfig;
status: SkillSyncStatusInput['status'];
startedAt?: Date;
finishedAt?: Date;
errorCode?: string;
errorMessage?: string;
counts?: Partial<SyncCounters>;
}): SkillSyncStatusInput {
return {
provider: params.provider,
sourceId: params.source.id,
tenantId: params.source.tenantId,
status: params.status,
credentialKey: params.source.credentialKey,
ref: params.source.ref,
paths: params.source.paths,
startedAt: params.startedAt,
finishedAt: params.finishedAt,
errorCode: params.errorCode,
errorMessage: params.errorMessage,
syncedSkillCount: params.counts?.syncedSkillCount ?? 0,
syncedFileCount: params.counts?.syncedFileCount ?? 0,
deletedSkillCount: params.counts?.deletedSkillCount ?? 0,
deletedFileCount: params.counts?.deletedFileCount ?? 0,
};
}
function getSourceMetadataString(
row: { sourceMetadata?: Record<string, unknown> },
key: string,
): string | undefined {
const metadata = row.sourceMetadata;
const value = metadata && typeof metadata === 'object' ? metadata[key] : undefined;
return typeof value === 'string' ? value : undefined;
}
/**
* Build the blob reference to pass to adapter.fetchBlob().
* Bitbucket requires "commitSha:filePath" format; others use the entry sha directly.
*/
function makeBlobRef(
provider: SkillSyncProvider,
entry: GitTreeEntry,
commitSha: string,
): string {
if (provider === 'bitbucket') {
return `${commitSha}:${entry.path}`;
}
return entry.sha;
}
async function ensurePublicViewer(
deps: GitHubSkillSyncDeps,
skillId: Types.ObjectId,
): Promise<void> {
await deps.grantPermission({
principalType: PrincipalType.PUBLIC,
principalId: null,
resourceType: ResourceType.SKILL,
resourceId: skillId,
accessRoleId: AccessRoleIds.SKILL_VIEWER,
grantedBy: SYSTEM_AUTHOR_ID,
});
}
export async function syncGenericSource(params: {
deps: GitHubSkillSyncDeps;
adapter: GitRepoAdapter;
provider: SkillSyncProvider;
source: GenericSourceConfig;
assertNotCancelled: AssertNotCancelled;
}): Promise<ISkillSyncStatus> {
const { deps, adapter, provider, source, assertNotCancelled } = params;
const startedAt = new Date();
await deps.upsertStatus(makeStatusInput({ provider, source, status: 'running', startedAt }));
try {
assertNotCancelled();
const token = await resolveToken(deps, provider, source);
assertNotCancelled();
if (!token) {
throw new GenericSyncError(
'MISSING_CREDENTIAL',
`Missing ${provider} credential for source "${source.id}"`,
);
}
const { commitSha, treeSha } = await adapter.getTreeSha(source.ref);
assertNotCancelled();
const fullTree = await adapter.listTree(treeSha, true);
assertNotCancelled();
const tree = filterTreeByPaths(fullTree, source.paths);
const discoveredSkills = discoverSkills(tree, source);
const authorId = makeSourceAuthorId(provider, source);
const authorName = getProviderAuthorName(provider);
const syncedAt = new Date();
const seenUpstreamIds = new Set<string>();
const counts: SyncCounters = {
syncedSkillCount: 0,
syncedFileCount: 0,
deletedSkillCount: 0,
deletedFileCount: 0,
};
for (const discovered of discoveredSkills) {
assertNotCancelled();
assertEntryCount(discovered);
const skillMdPath = discovered.rootPath
? `${discovered.rootPath}/SKILL.md`
: 'SKILL.md';
const blobRef = makeBlobRef(provider, discovered.skillMd, commitSha);
const skillMdBuffer = await adapter.fetchBlob(blobRef);
assertNotCancelled();
assertBufferSize(skillMdBuffer, skillMdPath);
const skillMdContent = skillMdBuffer.toString('utf-8');
const parsed = parseSkillMarkdown(skillMdContent);
if (parsed.parseError) {
throw new GenericSyncError(
'SKILL_PARSE_FAILED',
`${skillMdPath} contains invalid YAML frontmatter: ${parsed.parseError}`,
);
}
const upstreamId = makeUpstreamId(source, discovered.rootPath);
seenUpstreamIds.add(upstreamId);
const fallbackName = toSkillName(path.posix.basename(discovered.rootPath) || source.id);
const sourceMetadata = {
provider,
sourceId: source.id,
upstreamId,
ref: source.ref,
skillPath: discovered.rootPath,
commitSha,
skillBlobSha: discovered.skillMd.sha,
syncedAt: syncedAt.toISOString(),
syncStatus: 'synced',
};
const update = {
name: parsed.name || fallbackName,
description: parsed.description || parsed.name || fallbackName,
body: skillMdContent,
frontmatter: toCleanFrontmatter(parsed.frontmatter),
alwaysApply: parsed.alwaysApply,
source: provider,
sourceMetadata,
};
const sourceTenantId = source.tenantId ?? undefined;
const existing = await deps.findSkillBySourceIdentity({
source: provider,
upstreamId,
tenantId: sourceTenantId,
});
let skill: ISkill & { _id: Types.ObjectId };
if (existing && (existing.tenantId ?? undefined) === sourceTenantId) {
const result = await deps.updateSkill({
id: existing._id.toString(),
expectedVersion: existing.version,
update,
});
if (result.status === 'updated') {
skill = result.skill;
} else {
throw new GenericSyncError('SKILL_CONFLICT', `Skill "${existing.name}" conflict during sync`);
}
} else {
const created = await deps.createSkill({
...update,
name: update.name ?? fallbackName,
description: update.description ?? fallbackName,
author: authorId,
authorName,
source: provider,
tenantId: source.tenantId,
});
skill = created.skill;
}
await ensurePublicViewer(deps, skill._id);
// Sync files
const remotePaths = new Set<string>();
let totalFileBytes = 0;
for (const entry of discovered.files) {
assertNotCancelled();
const relativePath = getDiscoveredRelativePath(discovered, entry);
if (!isSafeRelativePath(relativePath) || relativePath.toUpperCase() === 'SKILL.MD') {
continue;
}
totalFileBytes += assertBlobSize(entry, relativePath);
assertCumulativeSize(totalFileBytes);
remotePaths.add(relativePath);
const existingFile = await deps.getSkillFileByPath(skill._id, relativePath);
if (existingFile && getSourceMetadataString(existingFile, 'blobSha') === entry.sha) {
continue;
}
const fileBlobRef = makeBlobRef(provider, entry, commitSha);
const buffer = await adapter.fetchBlob(fileBlobRef);
assertNotCancelled();
assertBufferSize(buffer, relativePath);
const fileId = crypto.randomUUID();
const filename = getFilename(relativePath);
const mimeType = guessMimeType(filename);
const saved = await deps.saveBuffer({
userId: skill.author.toString(),
buffer,
fileName: `${fileId}__${filename}`,
basePath: 'uploads',
isImage: mimeType.startsWith('image/'),
tenantId: skill.tenantId,
});
await deps.upsertSkillFile({
skillId: skill._id,
relativePath,
file_id: fileId,
filename,
filepath: saved.filepath,
storageKey: saved.storageKey,
storageRegion: saved.storageRegion,
source: saved.source,
sourceMetadata: {
provider,
sourceId: source.id,
upstreamId,
commitSha,
blobSha: entry.sha,
path: entry.path,
},
mimeType,
bytes: buffer.length,
isExecutable: false,
author: skill.author,
tenantId: skill.tenantId,
});
counts.syncedFileCount++;
}
// Delete files no longer in remote
const existingFiles = await deps.listSkillFiles(skill._id);
for (const file of existingFiles) {
if (remotePaths.has(file.relativePath)) {
continue;
}
const result = await deps.deleteSkillFile(skill._id, file.relativePath);
if (result.deleted) {
counts.deletedFileCount++;
if (deps.deleteFile) {
await deps.deleteFile({
filepath: file.filepath,
source: file.source,
storageKey: file.storageKey,
storageRegion: file.storageRegion,
user: file.author,
tenantId: file.tenantId,
}).catch((e) => logger.error(`[${provider}Sync] Failed to clean up file:`, e));
}
}
}
counts.syncedSkillCount++;
}
// Delete stale skills no longer in remote
const currentSyncedSkills = await deps.listSkillsBySource({
source: provider,
sourceId: source.id,
});
const sourceTenantId = source.tenantId ?? undefined;
for (const skill of currentSyncedSkills) {
assertNotCancelled();
if ((skill.tenantId ?? undefined) !== sourceTenantId) {
continue;
}
const upstreamId = getSourceMetadataString(skill, 'upstreamId') ?? '';
if (seenUpstreamIds.has(upstreamId)) {
continue;
}
// Delete skill and its files
const files = await deps.listSkillFiles(skill._id);
for (const file of files) {
if (deps.deleteFile) {
await deps.deleteFile({
filepath: file.filepath,
source: file.source,
storageKey: file.storageKey,
storageRegion: file.storageRegion,
user: file.author,
tenantId: file.tenantId,
}).catch((e) => logger.error(`[${provider}Sync] Failed to clean up stale file:`, e));
}
}
await deps.deleteSkill(skill._id.toString());
counts.deletedSkillCount++;
counts.deletedFileCount += files.length;
}
return deps.upsertStatus(
makeStatusInput({
provider,
source,
status: 'succeeded',
startedAt,
finishedAt: new Date(),
counts,
}),
);
} catch (error) {
const sanitized = sanitizeError(error);
logger.error(`[${provider}Sync] Source "${source.id}" failed: ${sanitized.message}`);
return deps.upsertStatus(
makeStatusInput({
provider,
source,
status: 'failed',
startedAt,
finishedAt: new Date(),
errorCode: sanitized.code,
errorMessage: sanitized.message,
}),
);
}
}
function syncGenericSourceInTenantContext(params: {
deps: GitHubSkillSyncDeps;
adapter: GitRepoAdapter;
provider: SkillSyncProvider;
source: GenericSourceConfig;
assertNotCancelled: AssertNotCancelled;
}): Promise<ISkillSyncStatus> {
if (!params.source.tenantId) {
return syncGenericSource(params);
}
return tenantStorage.run({ tenantId: params.source.tenantId }, async () =>
syncGenericSource(params),
);
}
export { syncGenericSourceInTenantContext };
/**
* Called from the main runner's runOnce() to sync all non-GitHub providers.
* Reads gitlab/bitbucket/azuredevops from the config and syncs each enabled source.
*/
export async function syncNonGitHubProviders(params: {
deps: GitHubSkillSyncDeps;
fetchFn: typeof fetch;
assertNotCancelled: AssertNotCancelled;
}): Promise<ISkillSyncStatus[]> {
const { deps, fetchFn, assertNotCancelled } = params;
const config = await deps.getConfig();
if (!config) {
return [];
}
const results: ISkillSyncStatus[] = [];
// GitLab
if (config.gitlab?.enabled && config.gitlab.sources.length > 0) {
for (const source of config.gitlab.sources) {
assertNotCancelled();
const token = await resolveToken(deps, 'gitlab', source);
if (!token) {
results.push(
await deps.upsertStatus(
makeStatusInput({
provider: 'gitlab',
source,
status: 'failed',
startedAt: new Date(),
finishedAt: new Date(),
errorCode: 'MISSING_CREDENTIAL',
errorMessage: `Missing gitlab credential for source "${source.id}"`,
}),
),
);
continue;
}
const { createRepoAdapter } = await import('./adapterFactory');
const adapter = createRepoAdapter({
provider: 'gitlab',
token,
projectId: source.projectId,
baseUrl: source.baseUrl,
fetchFn,
});
results.push(
await syncGenericSourceInTenantContext({
deps,
adapter,
provider: 'gitlab',
source: {
id: source.id,
ref: source.ref,
paths: source.paths,
skillDiscoveryDepth: source.skillDiscoveryDepth,
credentialKey: source.credentialKey,
token: source.token,
tenantId: source.tenantId,
},
assertNotCancelled,
}),
);
}
}
// Bitbucket
if (config.bitbucket?.enabled && config.bitbucket.sources.length > 0) {
for (const source of config.bitbucket.sources) {
assertNotCancelled();
const token = await resolveToken(deps, 'bitbucket', source);
if (!token) {
results.push(
await deps.upsertStatus(
makeStatusInput({
provider: 'bitbucket',
source,
status: 'failed',
startedAt: new Date(),
finishedAt: new Date(),
errorCode: 'MISSING_CREDENTIAL',
errorMessage: `Missing bitbucket credential for source "${source.id}"`,
}),
),
);
continue;
}
const { createRepoAdapter } = await import('./adapterFactory');
const adapter = createRepoAdapter({
provider: 'bitbucket',
token,
workspace: source.workspace,
repository: source.repository,
fetchFn,
});
results.push(
await syncGenericSourceInTenantContext({
deps,
adapter,
provider: 'bitbucket',
source: {
id: source.id,
ref: source.ref,
paths: source.paths,
skillDiscoveryDepth: source.skillDiscoveryDepth,
credentialKey: source.credentialKey,
token: source.token,
tenantId: source.tenantId,
},
assertNotCancelled,
}),
);
}
}
// Azure DevOps
if (config.azuredevops?.enabled && config.azuredevops.sources.length > 0) {
for (const source of config.azuredevops.sources) {
assertNotCancelled();
const token = await resolveToken(deps, 'azuredevops', source);
if (!token) {
results.push(
await deps.upsertStatus(
makeStatusInput({
provider: 'azuredevops',
source,
status: 'failed',
startedAt: new Date(),
finishedAt: new Date(),
errorCode: 'MISSING_CREDENTIAL',
errorMessage: `Missing azuredevops credential for source "${source.id}"`,
}),
),
);
continue;
}
const { createRepoAdapter } = await import('./adapterFactory');
const adapter = createRepoAdapter({
provider: 'azuredevops',
token,
organization: source.organization,
project: source.project,
repository: source.repository,
baseUrl: source.baseUrl,
fetchFn,
});
results.push(
await syncGenericSourceInTenantContext({
deps,
adapter,
provider: 'azuredevops',
source: {
id: source.id,
ref: source.ref,
paths: source.paths,
skillDiscoveryDepth: source.skillDiscoveryDepth,
credentialKey: source.credentialKey,
token: source.token,
tenantId: source.tenantId,
},
assertNotCancelled,
}),
);
}
}
return results;
}

View file

@ -24,6 +24,7 @@ import type {
import type { SkillSyncConfig, SkillSyncGitHubSourceConfig } from 'librechat-data-provider';
import { DEFAULT_SKILL_IMPORT_LIMITS } from '../limits';
import { parseSkillMarkdown } from '../parse';
import { syncNonGitHubProviders } from './genericSync';
const GITHUB_API_BASE = 'https://api.github.com';
const SYSTEM_AUTHOR_ID = new Types.ObjectId('000000000000000000000000');
@ -171,12 +172,12 @@ export type GitHubSkillSyncDeps = {
}) => Promise<UpdateSkillResult>;
getSkillById: (id: string | Types.ObjectId) => Promise<(ISkill & { _id: Types.ObjectId }) | null>;
findSkillBySourceIdentity: (params: {
source: 'github' | 'notion';
source: 'github' | 'gitlab' | 'bitbucket' | 'azuredevops' | 'notion';
upstreamId: string;
tenantId?: string;
}) => Promise<(ISkill & { _id: Types.ObjectId }) | null>;
listSkillsBySource: (params: {
source: 'github' | 'notion';
source: 'github' | 'gitlab' | 'bitbucket' | 'azuredevops' | 'notion';
sourceId: string;
}) => Promise<Array<ISkill & { _id: Types.ObjectId }>>;
listSkillFiles: (
@ -1790,8 +1791,16 @@ export function createGitHubSkillSyncRunner(deps: GitHubSkillSyncDeps): GitHubSk
async function runOnce(): Promise<GitHubSkillSyncRunResult> {
const github = getGithubConfig(await deps.getConfig());
if (!github.enabled || github.sources.length === 0) {
return { status: 'skipped', message: 'GitHub skill sync is disabled', sources: [] };
const githubEnabled = github.enabled && github.sources.length > 0;
if (!githubEnabled) {
// Even if GitHub is disabled, process other providers
const assertNotCancelled = () => {};
const genericSources = await syncNonGitHubProviders({ deps, fetchFn, assertNotCancelled });
if (genericSources.length === 0) {
return { status: 'skipped', message: 'Skill sync is disabled', sources: [] };
}
const failed = genericSources.some((s) => s.status === 'failed');
return { status: failed ? 'failed' : 'completed', sources: genericSources };
}
const allowServerCredentials = deps.allowServerCredentials !== false;
if (!allowServerCredentials) {
@ -1856,6 +1865,13 @@ export function createGitHubSkillSyncRunner(deps: GitHubSkillSyncDeps): GitHubSk
await syncSourceInTenantContext({ deps, source, fetchFn, assertNotCancelled }),
);
}
// Sync non-GitHub providers
if (!lockLost) {
const genericSources = await syncNonGitHubProviders({ deps, fetchFn, assertNotCancelled });
sources.push(...genericSources);
}
const failed = sources.some((source) => source.status === 'failed');
return {
status: failed || lockLost ? 'failed' : 'completed',

View file

@ -77,6 +77,25 @@ function hasGitHubConfig(config: SkillSyncConfig | undefined): config is SkillSy
return Boolean(config?.github);
}
function hasAnyEnabledProvider(config: SkillSyncConfig | undefined): boolean {
if (!config) {
return false;
}
if (config.github?.enabled && config.github.sources.length > 0) {
return true;
}
if (config.gitlab?.enabled && config.gitlab.sources.length > 0) {
return true;
}
if (config.bitbucket?.enabled && config.bitbucket.sources.length > 0) {
return true;
}
if (config.azuredevops?.enabled && config.azuredevops.sources.length > 0) {
return true;
}
return false;
}
function isSameSkillSyncConfig(
left: SkillSyncConfig | undefined,
right: SkillSyncConfig | undefined,
@ -153,13 +172,18 @@ function getRequestSkillSyncConfig(
logger: SkillSyncTriggerLogger,
): SkillSyncConfig | undefined {
const resolved = parseSkillSyncConfig(appConfig?.skillSync, logger);
if (
!hasGitHubConfig(resolved) ||
!resolved.github.enabled ||
resolved.github.sources.length === 0
) {
if (!hasAnyEnabledProvider(resolved)) {
return undefined;
}
if (!hasGitHubConfig(resolved)) {
// Non-GitHub providers are configured but no GitHub — pass through the
// full config so the runner can process them via syncNonGitHubProviders.
const base = parseSkillSyncConfig(appConfig?.config?.skillSync, logger);
if (isSameSkillSyncConfig(resolved, base)) {
return undefined;
}
return resolved;
}
const base = parseSkillSyncConfig(appConfig?.config?.skillSync, logger);
if (isSameSkillSyncConfig(resolved, base)) {
@ -268,11 +292,13 @@ export function createSkillSyncTriggerOrchestrator(
async function maybeRunForRequest(request: SkillSyncRequestLike): Promise<boolean> {
const config = getRequestSkillSyncConfig(request.config, request.user, deps.logger);
if (!hasGitHubConfig(config)) {
if (!config) {
return false;
}
const syncKey = getRequestSyncKey(config, request.user);
const syncKey = hasGitHubConfig(config)
? getRequestSyncKey(config, request.user)
: `nongithub:${JSON.stringify(request.user?.tenantId ?? '')}`;
if (inFlight.has(syncKey)) {
return false;
}
@ -282,9 +308,15 @@ export function createSkillSyncTriggerOrchestrator(
loadAppConfig: async () => request.config,
allowServerCredentials: Boolean(request.skillSyncAllowServerCredentials),
});
const status = await requestRunner.getStatus();
if (!shouldRunRequestSync(status, { minIntervalMs, staleRunningMs })) {
return false;
// For configs with GitHub sources, use the status-based interval check.
// For non-GitHub-only configs, run unconditionally (interval gating happens
// inside the generic sync via the per-source status timestamps).
if (hasGitHubConfig(config)) {
const status = await requestRunner.getStatus();
if (!shouldRunRequestSync(status, { minIntervalMs, staleRunningMs })) {
return false;
}
}
inFlight.add(syncKey);

View file

@ -28,6 +28,79 @@ function getSources(config: SkillSyncConfig | undefined) {
return Array.isArray(sources) ? sources : [];
}
function hasAnySources(config: SkillSyncConfig | undefined): boolean {
if (getSources(config).length > 0) {
return true;
}
if (config?.gitlab?.enabled && config.gitlab.sources.length > 0) {
return true;
}
if (config?.bitbucket?.enabled && config.bitbucket.sources.length > 0) {
return true;
}
if (config?.azuredevops?.enabled && config.azuredevops.sources.length > 0) {
return true;
}
return false;
}
function hasAnyEnabled(config: SkillSyncConfig | undefined): boolean {
if (config?.github?.enabled && getSources(config).length > 0) {
return true;
}
if (config?.gitlab?.enabled && config.gitlab.sources.length > 0) {
return true;
}
if (config?.bitbucket?.enabled && config.bitbucket.sources.length > 0) {
return true;
}
if (config?.azuredevops?.enabled && config.azuredevops.sources.length > 0) {
return true;
}
return false;
}
function getMinIntervalMinutes(config: SkillSyncConfig | undefined): number {
const intervals: number[] = [];
if (config?.github?.enabled) {
intervals.push(config.github.intervalMinutes ?? 60);
}
if (config?.gitlab?.enabled) {
intervals.push(config.gitlab.intervalMinutes ?? 60);
}
if (config?.bitbucket?.enabled) {
intervals.push(config.bitbucket.intervalMinutes ?? 60);
}
if (config?.azuredevops?.enabled) {
intervals.push(config.azuredevops.intervalMinutes ?? 60);
}
return intervals.length > 0 ? Math.min(...intervals) : 60;
}
function shouldRunOnStartup(config: SkillSyncConfig | undefined): boolean {
if (config?.github?.enabled && config.github.runOnStartup && getSources(config).length > 0) {
return true;
}
if (config?.gitlab?.enabled && config.gitlab.runOnStartup && config.gitlab.sources.length > 0) {
return true;
}
if (
config?.bitbucket?.enabled &&
config.bitbucket.runOnStartup &&
config.bitbucket.sources.length > 0
) {
return true;
}
if (
config?.azuredevops?.enabled &&
config.azuredevops.runOnStartup &&
config.azuredevops.sources.length > 0
) {
return true;
}
return false;
}
export function startGitHubSkillSyncScheduler(params: {
getConfig: () => MaybePromise<SkillSyncConfig | undefined>;
runner: GitHubSkillSyncRunner;
@ -48,21 +121,20 @@ export function startGitHubSkillSyncScheduler(params: {
if (stopped) {
return;
}
const delayMs = normalizeIntervalMinutes(config?.github?.intervalMinutes) * 60 * 1000;
const delayMs = normalizeIntervalMinutes(getMinIntervalMinutes(config)) * 60 * 1000;
timer = setTimeout(tick, delayMs);
timer.unref?.();
};
const runIfEnabled = async () => {
const config = await getConfig();
const github = config?.github;
if (!github?.enabled || getSources(config).length === 0) {
if (!hasAnyEnabled(config)) {
return config;
}
try {
await params.runner.runOnce();
} catch (error) {
logger.error('[GitHubSkillSync] Scheduled run failed:', error);
logger.error('[SkillSync] Scheduled run failed:', error);
}
return getConfig();
};
@ -87,9 +159,9 @@ export function startGitHubSkillSyncScheduler(params: {
void (async () => {
const config = await getConfig();
if (config?.github?.enabled && config.github.runOnStartup && getSources(config).length > 0) {
if (shouldRunOnStartup(config)) {
void params.runner.runOnce().catch((error) => {
logger.error('[GitHubSkillSync] Scheduled startup run failed:', error);
logger.error('[SkillSync] Scheduled startup run failed:', error);
});
}
scheduleNext(config);

View file

@ -480,7 +480,7 @@ export type CreateSkillInput = {
category?: string;
author: Types.ObjectId;
authorName: string;
source?: 'inline' | 'github' | 'notion';
source?: 'inline' | 'github' | 'gitlab' | 'bitbucket' | 'azuredevops' | 'notion';
sourceMetadata?: Record<string, unknown>;
/**
* When `true`, the skill is auto-primed into every turn. Callers pass this
@ -499,7 +499,7 @@ export type UpdateSkillInput = {
frontmatter?: Record<string, unknown>;
category?: string;
alwaysApply?: boolean;
source?: 'inline' | 'github' | 'notion';
source?: 'inline' | 'github' | 'gitlab' | 'bitbucket' | 'azuredevops' | 'notion';
sourceMetadata?: Record<string, unknown>;
};
@ -936,12 +936,12 @@ export function createSkillMethods(
deleteSkill: (id: string) => Promise<{ deleted: boolean }>;
deleteUserSkills: (userId: Types.ObjectId | string) => Promise<number>;
findSkillBySourceIdentity: (params: {
source: 'github' | 'notion';
source: 'github' | 'gitlab' | 'bitbucket' | 'azuredevops' | 'notion';
upstreamId: string;
tenantId?: string;
}) => Promise<(ISkill & { _id: Types.ObjectId }) | null>;
listSkillsBySource: (params: {
source: 'github' | 'notion';
source: 'github' | 'gitlab' | 'bitbucket' | 'azuredevops' | 'notion';
sourceId: string;
}) => Promise<Array<ISkill & { _id: Types.ObjectId }>>;
listSkillFiles: (
@ -1592,7 +1592,7 @@ export function createSkillMethods(
}
async function findSkillBySourceIdentity(params: {
source: 'github' | 'notion';
source: 'github' | 'gitlab' | 'bitbucket' | 'azuredevops' | 'notion';
upstreamId: string;
tenantId?: string;
}): Promise<(ISkill & { _id: Types.ObjectId }) | null> {
@ -1609,7 +1609,7 @@ export function createSkillMethods(
}
async function listSkillsBySource(params: {
source: 'github' | 'notion';
source: 'github' | 'gitlab' | 'bitbucket' | 'azuredevops' | 'notion';
sourceId: string;
}): Promise<Array<ISkill & { _id: Types.ObjectId }>> {
const Skill = mongoose.models.Skill as Model<ISkillDocument>;

View file

@ -72,9 +72,12 @@ export interface ISkill {
* Provenance of this skill's canonical definition.
* - `inline` authored inside LibreChat.
* - `github` mirrored from a configured GitHub skill sync source.
* - `gitlab` mirrored from a configured GitLab skill sync source.
* - `bitbucket` mirrored from a configured Bitbucket skill sync source.
* - `azuredevops` mirrored from a configured Azure DevOps skill sync source.
* - `notion` reserved for future external sync integrations.
*/
source: 'inline' | 'github' | 'notion';
source: 'inline' | 'github' | 'gitlab' | 'bitbucket' | 'azuredevops' | 'notion';
/**
* Provenance payload keyed by `source`, including upstream identifiers
* such as GitHub source id, path, and commit/blob SHAs.