feat(gitlab): sync repository files (code/docs) (#4864)

* feat(gitlab): sync repository files (code/docs) alongside wiki and issues

* fix(gitlab): follow full keyset next-link for repo tree + skip disabled wiki gracefully in all/both

* fix(gitlab): error on bad user branch (tree 404), warn on resolveRef fallback, normalize pathPrefix to directory boundary

* fix(gitlab): preserve slashes in branch ref for file source URLs (GitFlow branches)

* fix(gitlab): never abort sync on repo-tree 404 (empty repo); validate user branch exists at setup instead

* fix(gitlab): validate ref via commits endpoint so tags and commit SHAs are accepted, not just branches

* fix(gitlab): skip repo phase on tree 403 (missing read_repository) so wiki/issues still sync under all

* fix(byok): add Fal icon and repair corrupted Ollama icon path

The Ollama BYOK icon rendered blank because its SVG path had spaces
stripped between arc-command flags (e.g. `a5.05 5.05 0 12.05-.636`),
producing invalid tokens. Replaced with the canonical Ollama path.

Also added a dedicated FalIcon (was falling back to the generic
ImageIcon) and wired it into the BYOK provider list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(icons): repair corrupted Fireworks icon arc command

The leftmost spark of the Fireworks icon never rendered because its
third subpath used a corrupted arc command (`a34.59 34.59 0 17.15 37.65`)
with collapsed flags, yielding an invalid sweep-flag of 7 that aborts
the path parse. Replaced with the canonical lobehub Fireworks source.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Waleed
2026-06-03 12:23:03 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent b329c36b1a
commit cd66774960
3 changed files with 499 additions and 40 deletions
@@ -20,13 +20,13 @@ import {
BasetenIcon,
BrandfetchIcon,
ExaAIIcon,
FalIcon,
FindymailIcon,
FirecrawlIcon,
FireworksIcon,
GeminiIcon,
GoogleIcon,
HunterIOIcon,
ImageIcon,
JinaAIIcon,
LinkupIcon,
MistralIcon,
@@ -118,7 +118,7 @@ const PROVIDERS: {
{
id: 'falai',
name: 'Fal.ai',
icon: ImageIcon,
icon: FalIcon,
description: 'Image and video generation',
placeholder: 'Enter your Fal.ai API key',
},
+17 -6
View File
@@ -3365,7 +3365,18 @@ export const OllamaIcon = (props: SVGProps<SVGSVGElement>) => (
xmlns='http://www.w3.org/2000/svg'
>
<title>Ollama</title>
<path d='M7.91 1.09c.216.09.411.23.588.41.3.306.54.744.73 1.26.191.52.315 1.1.36 1.68a5.05 5.05 0 12.05-.636l.051-.004c.87-.07 1.73.09 2.48.47.101.05.2.11.3.17.05-.569.17-1.13.36-1.64.19-.52.44-.957.73-1.26a1.67 1.67 0 1.59-.41c.257-.1.53-.118.8-.42.4.114.75.368 1.02.737.25.337.43.769.56 1.29.23.93.27 2.16.115 3.65l.053.4.03.019c.757.58 1.28 1.4 1.56 2.35.44 1.49.216 3.16-.534 4.09l-.18.02.2c.417.76.67 1.57.724 2.4l.002.03c.064 1.07-.2 2.14-.814 3.19l-.007.01.1.02c.472 1.16.62 2.32.438 3.49l-.6.04a.651.65 0 01-.747.54.648.65 0 01-.54-.742c.167-1.03.01-2.07-.48-3.12a.643.64 0 01.04-.617l.004-.006c.604-.924.85-1.83.8-2.72-.046-.779-.325-1.54-.8-2.27a.644.64 0 01.18-.886l.009-.006c.243-.159.47-.565.58-1.12a4.23 4.23 0 00-.095-1.97c-.205-.7-.58-1.28-1.1-1.68-.595-.454-1.38-.673-2.38-.61a.653.65 0 01-.632-.371c-.314-.665-.772-1.14-1.34-1.44a3.29 3.29 0 00-1.77-.332c-1.25.099-2.34.801-2.67 1.69a.652.65 0 01-.61.43c-1.07.002-1.89.252-2.5.703-.522.39-.878.94-1.07 1.59a4.07 4.07 0 00-.068 1.89c.112.56.331 1.2.58 1.27l.8.01c.212.21.257.53.11.785-.36.62-.629 1.55-.673 2.44-.05 1.02.186 1.9.719 2.54l.16.02a.643.64 0 1.1.69c-.576 1.24-.753 2.25-.562 3.05a.652.65 0 01-1.27.298c-.243-1.02-.078-2.18.473-3.5l.014-.035-.008-.012a4.34 4.34 0 01-.598-1.31l-.005-.019a5.76 5.76 0 01-.177-1.78c.044-.91.28-1.84.622-2.59l.012-.026-.002-.002c-.293-.418-.51-.953-.63-1.54l-.005-.024a5.35 5.35 0 1.09-2.49c.262-.915.78-1.7 1.54-2.27.06-.45.12-.9.19-.132-.159-1.49-.119-2.73.11-3.67.13-.518.31-.95.56-1.29.27-.368.61-.622 1.01-.737.27-.076.54-.59.8.042zm4.12 9.09c.936 0 1.8.31 2.45.855.63.53 1 1.24 1 1.94 0 .888-.406 1.58-1.13 2.02-.62.38-1.45.557-2.4.557-1.01 0-1.87-.259-2.49-.734-.617-.47-.963-1.13-.963-1.84 0-.707.4-1.42 1.06-1.95.668-.537 1.55-.849 2.49-.849zm0 .896a3.07 3.07 0 00-1.92.65c-.461.37-.722.84-.722 1.25 0 .428.21.83.61 1.13.455.35 1.12.548 1.94.548.8 0 1.47-.147 1.93-.426.46-.28.7-.686.7-1.26 0-.423-.246-.89-.683-1.26-.484-.405-1.14-.643-1.86-.643zm.662 1.21l.4c.12.15.095.37-.056.49l-.292.23v.446a.375.38 0 01-.376.37.375.38 0 01-.376-.373v-.46l-.271-.218a.347.35 0 01-.052-.49.35.353 0 1.49-.051l.215.17.22-.174a.353.35 0 01.49.05zm-5.04-1.92c.478 0 .867.39.87.871a.87.87 0 01-.868.87.87.87 0 01-.867-.87.87.87 0 1.87-.872zm8.71 0c.48 0 .868.39.87.871a.87.87 0 01-.868.87.87.87 0 01-.867-.87.87.87 0 1.87-.872zM7.44 2.3l-.3a.659.66 0 00-.285.24l-.5.01c-.138.19-.258.47-.348.83-.17.69-.216 1.63-.124 2.78.43-.128.9-.208 1.4-.237l.01-.1.02-.034c.046-.82.1-.161.15-.239.12-.771.02-1.69-.253-2.44-.134-.364-.297-.65-.453-.813a.628.63 0 00-.107-.09L7.44 2.3zm9.17.04l-.2a.628.63 0 00-.107.09c-.156.16-.32.45-.453.81-.29.79-.387 1.78-.23 2.57l.58.1.8.01h.03a5.18 5.18 0 11.47.212c.086-1.12.038-2.04-.128-2.72-.09-.365-.21-.643-.349-.832l-.004-.006a.659.66 0 00-.285-.239h-.004z' />
<path d='M7.905 1.09c.216.085.411.225.588.41.295.306.544.744.734 1.263.191.522.315 1.1.362 1.68a5.054 5.054 0 012.049-.636l.051-.004c.87-.07 1.73.087 2.48.474.101.053.2.11.297.17.05-.569.172-1.134.36-1.644.19-.52.439-.957.733-1.264a1.67 1.67 0 01.589-.41c.257-.1.53-.118.796-.042.401.114.745.368 1.016.737.248.337.434.769.561 1.287.23.934.27 2.163.115 3.645l.053.04.026.019c.757.576 1.284 1.397 1.563 2.35.435 1.487.216 3.155-.534 4.088l-.018.021.002.003c.417.762.67 1.567.724 2.4l.002.03c.064 1.065-.2 2.137-.814 3.19l-.007.01.01.024c.472 1.157.62 2.322.438 3.486l-.006.039a.651.651 0 01-.747.536.648.648 0 01-.54-.742c.167-1.033.01-2.069-.48-3.123a.643.643 0 01.04-.617l.004-.006c.604-.924.854-1.83.8-2.72-.046-.779-.325-1.544-.8-2.273a.644.644 0 01.18-.886l.009-.006c.243-.159.467-.565.58-1.12a4.229 4.229 0 00-.095-1.974c-.205-.7-.58-1.284-1.105-1.683-.595-.454-1.383-.673-2.38-.61a.653.653 0 01-.632-.371c-.314-.665-.772-1.141-1.343-1.436a3.288 3.288 0 00-1.772-.332c-1.245.099-2.343.801-2.67 1.686a.652.652 0 01-.61.425c-1.067.002-1.893.252-2.497.703-.522.39-.878.935-1.066 1.588a4.07 4.07 0 00-.068 1.886c.112.558.331 1.02.582 1.269l.008.007c.212.207.257.53.109.785-.36.622-.629 1.549-.673 2.44-.05 1.018.186 1.902.719 2.536l.016.019a.643.643 0 01.095.69c-.576 1.236-.753 2.252-.562 3.052a.652.652 0 01-1.269.298c-.243-1.018-.078-2.184.473-3.498l.014-.035-.008-.012a4.339 4.339 0 01-.598-1.309l-.005-.019a5.764 5.764 0 01-.177-1.785c.044-.91.278-1.842.622-2.59l.012-.026-.002-.002c-.293-.418-.51-.953-.63-1.545l-.005-.024a5.352 5.352 0 01.093-2.49c.262-.915.777-1.701 1.536-2.269.06-.045.123-.09.186-.132-.159-1.493-.119-2.73.112-3.67.127-.518.314-.95.562-1.287.27-.368.614-.622 1.015-.737.266-.076.54-.059.797.042zm4.116 9.09c.936 0 1.8.313 2.446.855.63.527 1.005 1.235 1.005 1.94 0 .888-.406 1.58-1.133 2.022-.62.375-1.451.557-2.403.557-1.009 0-1.871-.259-2.493-.734-.617-.47-.963-1.13-.963-1.845 0-.707.398-1.417 1.056-1.946.668-.537 1.55-.849 2.485-.849zm0 .896a3.07 3.07 0 00-1.916.65c-.461.37-.722.835-.722 1.25 0 .428.21.829.61 1.134.455.347 1.124.548 1.943.548.799 0 1.473-.147 1.932-.426.463-.28.7-.686.7-1.257 0-.423-.246-.89-.683-1.256-.484-.405-1.14-.643-1.864-.643zm.662 1.21l.004.004c.12.151.095.37-.056.49l-.292.23v.446a.375.375 0 01-.376.373.375.375 0 01-.376-.373v-.46l-.271-.218a.347.347 0 01-.052-.49.353.353 0 01.494-.051l.215.172.22-.174a.353.353 0 01.49.051zm-5.04-1.919c.478 0 .867.39.867.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zm8.706 0c.48 0 .868.39.868.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zM7.44 2.3l-.003.002a.659.659 0 00-.285.238l-.005.006c-.138.189-.258.467-.348.832-.17.692-.216 1.631-.124 2.782.43-.128.899-.208 1.404-.237l.01-.001.019-.034c.046-.082.095-.161.148-.239.123-.771.022-1.692-.253-2.444-.134-.364-.297-.65-.453-.813a.628.628 0 00-.107-.09L7.44 2.3zm9.174.04l-.002.001a.628.628 0 00-.107.09c-.156.163-.32.45-.453.814-.29.794-.387 1.776-.23 2.572l.058.097.008.014h.03a5.184 5.184 0 011.466.212c.086-1.124.038-2.043-.128-2.722-.09-.365-.21-.643-.349-.832l-.004-.006a.659.659 0 00-.285-.239h-.004z' />
</svg>
)
export const FalIcon = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} height='1em' viewBox='0 0 24 24' width='1em' xmlns='http://www.w3.org/2000/svg'>
<title>Fal</title>
<path
clipRule='evenodd'
d='M15.477 0c.415 0 .749.338.788.752a7.775 7.775 0 006.985 6.984c.413.04.752.373.752.788v6.952c0 .415-.338.748-.752.788a7.775 7.775 0 00-6.985 6.984c-.04.414-.373.752-.788.752H8.525c-.416 0-.749-.338-.789-.752a7.775 7.775 0 00-6.984-6.984c-.414-.04-.752-.373-.752-.788V8.524c0-.415.338-.748.752-.788A7.775 7.775 0 007.736.752C7.776.338 8.11 0 8.526 0h6.95zM4.819 11.98a7.226 7.226 0 007.223 7.23 7.226 7.226 0 007.223-7.23c0-3.994-3.234-7.23-7.223-7.23a7.227 7.227 0 00-7.223 7.23z'
fill='#EC0648'
fillRule='evenodd'
/>
</svg>
)
export function ShieldCheckIcon(props: SVGProps<SVGSVGElement>) {
@@ -3982,16 +3993,16 @@ export function FireworksIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
viewBox='0 0 512 512'
height='1em'
width='1em'
viewBox='0 0 24 24'
xmlns='http://www.w3.org/2000/svg'
fillRule='evenodd'
clipRule='evenodd'
strokeLinejoin='round'
strokeMiterlimit={2}
>
<path
d='M314.33 110.17L255.98 251.73l-58.42-141.56h-37.46l64 154.75c5.23 12.85 17.77 21.31 31.65 21.31s26.42-8.44 31.65-21.27l64.4-154.79h-37.46zm24.92 215.67L446 216.58l-14.56-34.77-116.58 119.56c-9.71 9.96-12.54 24.83-7.15 37.65 5.29 12.73 17.79 21.08 31.58 21.08l.42.06L506 359.75l-14.56-34.77-152.15.853h-.042zM66 216.5l14.56-34.77 116.58 119.56a34.59 34.59 0 17.15 37.65C199 351.67 186.5 360.02 172.71 360.02l-166.67-.375-.42.04 14.56-34.77 152.15.875L66 216.5z'
fill='#5019c5'
d='M14.8 5l-2.801 6.795L9.195 5H7.397l3.072 7.428a1.64 1.64 0 003.038.002L16.598 5H14.8zm1.196 10.352l5.124-5.244-.699-1.669-5.596 5.739a1.664 1.664 0 00-.343 1.807 1.642 1.642 0 001.516 1.012L16 17l8-.02-.699-1.669-7.303.041h-.002zM2.88 10.104l.699-1.669 5.596 5.739c.468.479.603 1.189.343 1.807a1.643 1.643 0 01-1.516 1.012l-8-.018-.002.002.699-1.669 7.303.042-5.122-5.246z'
fill='#5019C5'
/>
</svg>
)
+480 -32
View File
@@ -9,18 +9,120 @@ const logger = createLogger('GitLabConnector')
const DEFAULT_HOST = 'gitlab.com'
const PAGE_SIZE = 100
/** Max repository file size to index. Larger blobs are skipped. */
const MAX_FILE_SIZE = 10 * 1024 * 1024
/** Bytes sniffed for NUL when detecting binary files (matches git's heuristic). */
const BINARY_SNIFF_BYTES = 8000
/**
* Prefix encoded into each document's externalId so getDocument can route to the
* correct GitLab resource. Wiki pages are addressed by slug, issues by iid.
* correct GitLab resource. Wiki pages are addressed by slug, issues by iid, and
* repository files by their repo-relative path.
*/
const WIKI_PREFIX = 'wiki:'
const ISSUE_PREFIX = 'issue:'
const FILE_PREFIX = 'file:'
/**
* Selects which GitLab resources to sync.
* Selects which GitLab resources to sync. `repo` = repository files (code/docs),
* `all` = repo + wiki + issues. `both` is retained for backward compatibility and
* means wiki + issues (no repository files).
*/
type ContentTypeChoice = 'wiki' | 'issues' | 'both'
type ContentTypeChoice = 'repo' | 'wiki' | 'issues' | 'both' | 'all'
/** Listing phases, walked in order: repository files ➜ wiki ➜ issues. */
type SyncPhase = 'repo' | 'wiki' | 'issues'
interface GitLabTreeEntry {
id: string
name: string
type: 'blob' | 'tree'
path: string
mode?: string
}
interface GitLabFile {
file_path?: string
blob_id?: string
content?: string
encoding?: string
size?: number
}
/**
* Heuristic binary detection: a NUL byte in the first 8 KB marks the file as
* binary, matching `git diff` / `git grep` semantics.
*/
function isBinaryBuffer(buf: Buffer): boolean {
const len = Math.min(buf.length, BINARY_SNIFF_BYTES)
for (let i = 0; i < len; i++) {
if (buf[i] === 0) return true
}
return false
}
/**
* Parses a comma-separated extension filter into a normalized set (leading dot,
* lowercased). Returns null when no filter is configured (accept all files).
*/
function parseExtensions(raw: unknown): Set<string> | null {
const trimmed = typeof raw === 'string' ? raw.trim() : ''
if (!trimmed) return null
const exts = trimmed
.split(',')
.map((e) => e.trim().toLowerCase())
.filter(Boolean)
.map((e) => (e.startsWith('.') ? e : `.${e}`))
return exts.length > 0 ? new Set(exts) : null
}
/**
* Returns true when the file path matches the extension filter (or no filter set).
*/
function matchesExtension(filePath: string, extSet: Set<string> | null): boolean {
if (!extSet) return true
const lastDot = filePath.lastIndexOf('.')
if (lastDot === -1) return false
return extSet.has(filePath.slice(lastDot).toLowerCase())
}
/**
* Extracts the full `rel="next"` URL from a keyset-pagination `Link` response
* header. GitLab's guidance is to follow this link verbatim rather than rebuild
* the URL, so the connector stores and re-fetches it as-is — this is robust to
* whichever continuation parameter the endpoint uses (`page_token`, `cursor`,
* `id_after`, …). Returns undefined when there is no next page.
*/
function parseNextLink(linkHeader: string | null): string | undefined {
if (!linkHeader) return undefined
for (const part of linkHeader.split(',')) {
if (!/rel="?next"?/i.test(part)) continue
const urlMatch = part.match(/<([^>]+)>/)
if (urlMatch) return urlMatch[1]
}
return undefined
}
/**
* Returns the ordered list of active sync phases for a content-type choice.
*/
function activePhases(choice: ContentTypeChoice): SyncPhase[] {
const phases: SyncPhase[] = []
if (choice === 'repo' || choice === 'all') phases.push('repo')
if (choice === 'wiki' || choice === 'both' || choice === 'all') phases.push('wiki')
if (choice === 'issues' || choice === 'both' || choice === 'all') phases.push('issues')
return phases
}
/**
* Returns the phase following `current` for a choice, or undefined when `current`
* is the last active phase.
*/
function nextPhase(current: SyncPhase, choice: ContentTypeChoice): SyncPhase | undefined {
const phases = activePhases(choice)
const idx = phases.indexOf(current)
return idx >= 0 && idx + 1 < phases.length ? phases[idx + 1] : undefined
}
interface GitLabWikiPage {
slug: string
@@ -57,6 +159,7 @@ interface GitLabProject {
id: number
path_with_namespace?: string
web_url?: string
default_branch?: string
wiki_access_level?: string
wiki_enabled?: boolean
}
@@ -94,7 +197,15 @@ function encodeProjectId(project: unknown): string {
*/
function getContentTypeChoice(sourceConfig: Record<string, unknown>): ContentTypeChoice {
const value = typeof sourceConfig.contentTypes === 'string' ? sourceConfig.contentTypes : 'both'
if (value === 'wiki' || value === 'issues') return value
if (
value === 'repo' ||
value === 'wiki' ||
value === 'issues' ||
value === 'both' ||
value === 'all'
) {
return value
}
return 'both'
}
@@ -136,6 +247,118 @@ function buildIssueContentHash(projectId: string, iid: number, updatedAt: string
return `gitlab:issue:${projectId}:${iid}:${updatedAt}`
}
/**
* Builds the change-detection hash for a repository file. The git blob SHA is
* content-addressable, so it changes exactly when the file content changes — and
* it is available both on the tree listing (`tree entry.id`) and the file fetch
* (`blob_id`), so the stub and hydrated document hash identically without a
* content fetch during listing.
*/
function buildFileContentHash(projectId: string, path: string, blobSha: string): string {
return `gitlab:file:${projectId}:${path}:${blobSha}`
}
/**
* Builds the web UI URL for a repository file at a given ref.
*/
function buildFileSourceUrl(
apiBase: string,
encodedProject: string,
host: string,
projectPath: string,
ref: string,
path: string
): string {
const encodedPath = path.split('/').map(encodeURIComponent).join('/')
if (projectPath) {
const encodedRef = ref.split('/').map(encodeURIComponent).join('/')
return `https://${host}/${projectPath}/-/blob/${encodedRef}/${encodedPath}`
}
return `${apiBase}/projects/${encodedProject}/repository/files/${encodeURIComponent(path)}/raw?ref=${encodeURIComponent(ref)}`
}
/**
* Builds a deferred stub for a repository file from a tree entry. Content is empty
* and fetched lazily via getDocument for new/changed files only.
*/
function treeEntryToStub(
apiBase: string,
encodedProject: string,
host: string,
projectPath: string,
ref: string,
entry: GitLabTreeEntry
): ExternalDocument {
return {
externalId: `${FILE_PREFIX}${entry.path}`,
title: entry.name || entry.path,
content: '',
contentDeferred: true,
mimeType: 'text/plain',
sourceUrl: buildFileSourceUrl(apiBase, encodedProject, host, projectPath, ref, entry.path),
contentHash: buildFileContentHash(encodedProject, entry.path, entry.id),
metadata: {
contentType: 'file',
title: entry.name || entry.path,
path: entry.path,
},
}
}
/**
* Builds a repository-file document from a fetched (non-raw) file response. Returns
* null for binary, oversized, or empty files so they are not indexed.
*/
function fileToDocument(
apiBase: string,
encodedProject: string,
host: string,
projectPath: string,
ref: string,
path: string,
file: GitLabFile
): ExternalDocument | null {
const blobSha = file.blob_id?.trim()
if (!blobSha) return null
if (typeof file.size === 'number' && file.size > MAX_FILE_SIZE) {
logger.info('Skipping oversized GitLab file', { path, size: file.size })
return null
}
const raw = typeof file.content === 'string' ? file.content : ''
const buffer = file.encoding === 'base64' ? Buffer.from(raw, 'base64') : Buffer.from(raw, 'utf8')
if (isBinaryBuffer(buffer)) {
logger.info('Skipping binary GitLab file', { path })
return null
}
if (buffer.byteLength > MAX_FILE_SIZE) {
logger.info('Skipping oversized GitLab file', { path, size: buffer.byteLength })
return null
}
const content = buffer.toString('utf8')
const title = path.split('/').pop() || path
const body = composeBody(title, content)
if (!body.trim()) return null
return {
externalId: `${FILE_PREFIX}${path}`,
title,
content: body,
contentDeferred: false,
mimeType: 'text/plain',
sourceUrl: buildFileSourceUrl(apiBase, encodedProject, host, projectPath, ref, path),
contentHash: buildFileContentHash(encodedProject, path, blobSha),
metadata: {
contentType: 'file',
title,
path,
size: buffer.byteLength,
},
}
}
/**
* Composes the document body as "Title\n\n<content>".
*/
@@ -251,30 +474,73 @@ async function fetchProject(
* issues via the X-Next-Page header.
*/
interface CursorState {
phase: 'wiki' | 'issues'
phase: SyncPhase
issuePage: number
/** Full `rel="next"` URL for the repository-tree keyset page to fetch next. */
fileNextUrl?: string
}
function encodeCursor(state: CursorState): string {
return Buffer.from(JSON.stringify(state), 'utf8').toString('base64url')
}
function decodeCursor(cursor: string | undefined, initialPhase: 'wiki' | 'issues'): CursorState {
function decodeCursor(cursor: string | undefined, initialPhase: SyncPhase): CursorState {
if (!cursor) return { phase: initialPhase, issuePage: 1 }
try {
const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as Partial<{
phase: 'wiki' | 'issues'
phase: SyncPhase
issuePage: number
fileNextUrl: string
}>
const phase: SyncPhase =
parsed.phase === 'repo' || parsed.phase === 'issues' || parsed.phase === 'wiki'
? parsed.phase
: initialPhase
return {
phase: parsed.phase === 'issues' ? 'issues' : 'wiki',
phase,
issuePage: Number(parsed.issuePage) > 0 ? Number(parsed.issuePage) : 1,
fileNextUrl: typeof parsed.fileNextUrl === 'string' ? parsed.fileNextUrl : undefined,
}
} catch {
return { phase: initialPhase, issuePage: 1 }
}
}
/**
* Resolves the git ref (branch/tag) to sync repository files from. Uses the
* user-configured `ref` when set, otherwise the project's default branch, which
* is cached on syncContext to avoid repeat lookups across pages and getDocument.
*/
async function resolveRef(
sourceConfig: Record<string, unknown>,
syncContext: Record<string, unknown> | undefined,
apiBase: string,
encodedProject: string,
accessToken: string
): Promise<string> {
const configured = typeof sourceConfig.ref === 'string' ? sourceConfig.ref.trim() : ''
if (configured) return configured
const cached = syncContext?.defaultBranch as string | undefined
if (cached) return cached
const response = await fetchProject(apiBase, encodedProject, accessToken)
if (response.ok) {
const project = (await response.json()) as GitLabProject
const branch = project.default_branch?.trim() || 'main'
if (syncContext) {
syncContext.defaultBranch = branch
if (project.path_with_namespace) syncContext.projectPath = project.path_with_namespace
}
return branch
}
logger.warn('Failed to fetch GitLab project for default branch; falling back to "main"', {
project: encodedProject,
status: response.status,
})
return 'main'
}
/**
* Applies the optional maxItems cap to a batch, tracking the running total in
* syncContext and flagging `listingCapped` when the cap is hit.
@@ -298,7 +564,8 @@ function applyMaxItemsCap(
export const gitlabConnector: ConnectorConfig = {
id: 'gitlab',
name: 'GitLab',
description: 'Sync wiki pages and issues from a GitLab project into your knowledge base',
description:
'Sync repository files, wiki pages, and issues from a GitLab project into your knowledge base',
version: '1.0.0',
icon: GitLabIcon,
@@ -310,8 +577,9 @@ export const gitlabConnector: ConnectorConfig = {
/**
* Incremental sync applies to issues only (via the `updated_after` filter
* derived from lastSyncAt). Wikis lack a change timestamp, so they are always
* re-listed in full and reconciled by content hash.
* derived from lastSyncAt). Wikis and repository files lack a change timestamp
* on listing, so they are always re-listed in full and reconciled by content
* hash (wiki: content digest, file: git blob SHA) — unchanged docs are skipped.
*/
supportsIncrementalSync: true,
@@ -338,10 +606,42 @@ export const gitlabConnector: ConnectorConfig = {
type: 'dropdown',
required: false,
options: [
{ label: 'Code, Wiki & Issues', id: 'all' },
{ label: 'Code (repository files) only', id: 'repo' },
{ label: 'Wiki only', id: 'wiki' },
{ label: 'Issues only', id: 'issues' },
{ label: 'Both', id: 'both' },
{ label: 'Wiki & Issues', id: 'both' },
],
description: 'Which content to index. "Code" syncs repository files (READMEs, docs, source).',
},
{
id: 'ref',
title: 'Branch',
type: 'short-input',
required: false,
mode: 'advanced',
placeholder: 'Default branch',
description: 'Branch or tag to sync repository files from. Applies only when syncing Code.',
},
{
id: 'pathPrefix',
title: 'Path Filter',
type: 'short-input',
required: false,
mode: 'advanced',
placeholder: 'e.g. docs/',
description:
'Only sync repository files under this path prefix. Applies only when syncing Code.',
},
{
id: 'fileExtensions',
title: 'File Extensions',
type: 'short-input',
required: false,
mode: 'advanced',
placeholder: 'e.g. .md, .txt, .mdx',
description:
'Only sync repository files with these extensions (comma-separated). Leave blank for all text files. Applies only when syncing Code.',
},
{
id: 'issueState',
@@ -398,13 +698,13 @@ export const gitlabConnector: ConnectorConfig = {
const choice = getContentTypeChoice(sourceConfig)
const maxItems = sourceConfig.maxItems ? Number(sourceConfig.maxItems) : 0
const wantsWiki = choice === 'wiki' || choice === 'both'
const wantsIssues = choice === 'issues' || choice === 'both'
if (!encodedProject) {
throw new Error('Project is required')
}
const phases = activePhases(choice)
if (phases.length === 0) return { documents: [], hasMore: false }
let projectPath = (syncContext?.projectPath as string) ?? ''
if (!projectPath && syncContext) {
const projectResponse = await fetchProject(apiBase, encodedProject, accessToken)
@@ -412,13 +712,98 @@ export const gitlabConnector: ConnectorConfig = {
const project = (await projectResponse.json()) as GitLabProject
projectPath = project.path_with_namespace ?? ''
syncContext.projectPath = projectPath
if (project.default_branch && !syncContext.defaultBranch) {
syncContext.defaultBranch = project.default_branch
}
}
}
const initialPhase: 'wiki' | 'issues' = wantsWiki ? 'wiki' : 'issues'
const state = decodeCursor(cursor, initialPhase)
let state = decodeCursor(cursor, phases[0])
if (!phases.includes(state.phase)) state = { phase: phases[0], issuePage: 1 }
if (state.phase === 'wiki' && wantsWiki) {
/** Cursor that advances to the first page of the phase after `current`, if any. */
const advance = (current: SyncPhase): { nextCursor?: string; hasMore: boolean } => {
const next = nextPhase(current, choice)
if (!next) return { hasMore: false }
return { nextCursor: encodeCursor({ phase: next, issuePage: 1 }), hasMore: true }
}
if (state.phase === 'repo') {
const ref = await resolveRef(sourceConfig, syncContext, apiBase, encodedProject, accessToken)
const extSet = parseExtensions(sourceConfig.fileExtensions)
const rawPrefix =
typeof sourceConfig.pathPrefix === 'string' ? sourceConfig.pathPrefix.trim() : ''
const pathPrefix = rawPrefix && !rawPrefix.endsWith('/') ? `${rawPrefix}/` : rawPrefix
const treeParams = new URLSearchParams({
ref,
recursive: 'true',
per_page: String(PAGE_SIZE),
pagination: 'keyset',
})
const url =
state.fileNextUrl ??
`${apiBase}/projects/${encodedProject}/repository/tree?${treeParams.toString()}`
logger.info('Listing GitLab repository files', {
host,
project: encodedProject,
ref,
continued: Boolean(state.fileNextUrl),
})
const response = await fetchWithRetry(url, {
method: 'GET',
headers: authHeaders(accessToken),
})
if (!response.ok) {
if (response.status === 404 || response.status === 403) {
logger.warn('GitLab repository tree unavailable; skipping files', {
host,
project: encodedProject,
ref,
status: response.status,
})
const adv = advance('repo')
return { documents: [], nextCursor: adv.nextCursor, hasMore: adv.hasMore }
}
const errorText = await response.text().catch(() => '')
logger.error('Failed to list GitLab repository tree', {
status: response.status,
error: errorText.slice(0, 500),
})
throw new Error(`Failed to list GitLab repository tree: ${response.status}`)
}
const entries = (await response.json()) as GitLabTreeEntry[]
const documents: ExternalDocument[] = []
for (const entry of entries) {
if (entry.type !== 'blob' || !entry.path) continue
if (pathPrefix && !entry.path.startsWith(pathPrefix)) continue
if (!matchesExtension(entry.path, extSet)) continue
documents.push(treeEntryToStub(apiBase, encodedProject, host, projectPath, ref, entry))
}
const { documents: capped, capped: hitLimit } = applyMaxItemsCap(
documents,
maxItems,
syncContext
)
if (hitLimit) return { documents: capped, hasMore: false }
const nextLink = parseNextLink(response.headers.get('link'))
if (nextLink) {
return {
documents: capped,
nextCursor: encodeCursor({ phase: 'repo', issuePage: 1, fileNextUrl: nextLink }),
hasMore: true,
}
}
const adv = advance('repo')
return { documents: capped, nextCursor: adv.nextCursor, hasMore: adv.hasMore }
}
if (state.phase === 'wiki') {
const url = `${apiBase}/projects/${encodedProject}/wikis?with_content=1`
logger.info('Listing GitLab wiki pages', { host, project: encodedProject })
@@ -428,6 +813,15 @@ export const gitlabConnector: ConnectorConfig = {
})
if (!response.ok) {
if (response.status === 403 || response.status === 404) {
logger.warn('GitLab wiki unavailable; skipping wiki phase', {
host,
project: encodedProject,
status: response.status,
})
const adv = advance('wiki')
return { documents: [], nextCursor: adv.nextCursor, hasMore: adv.hasMore }
}
const errorText = await response.text().catch(() => '')
logger.error('Failed to list GitLab wiki pages', {
status: response.status,
@@ -450,18 +844,15 @@ export const gitlabConnector: ConnectorConfig = {
syncContext
)
if (hitLimit || !wantsIssues) {
if (hitLimit) {
return { documents: capped, hasMore: false }
}
return {
documents: capped,
nextCursor: encodeCursor({ phase: 'issues', issuePage: 1 }),
hasMore: true,
}
const adv = advance('wiki')
return { documents: capped, nextCursor: adv.nextCursor, hasMore: adv.hasMore }
}
if (wantsIssues) {
if (state.phase === 'issues') {
const params = new URLSearchParams({
per_page: String(PAGE_SIZE),
page: String(state.issuePage),
@@ -586,6 +977,32 @@ export const gitlabConnector: ConnectorConfig = {
return issueToDocument(encodedProject, host, projectPath, issue)
}
if (externalId.startsWith(FILE_PREFIX)) {
const path = externalId.slice(FILE_PREFIX.length)
if (!path) return null
const ref = await resolveRef(
sourceConfig,
syncContext,
apiBase,
encodedProject,
accessToken
)
const url = `${apiBase}/projects/${encodedProject}/repository/files/${encodeURIComponent(path)}?ref=${encodeURIComponent(ref)}`
const response = await fetchWithRetry(url, {
method: 'GET',
headers: authHeaders(accessToken),
})
if (!response.ok) {
if (response.status === 404) return null
throw new Error(`Failed to fetch GitLab file: ${response.status}`)
}
const file = (await response.json()) as GitLabFile
return fileToDocument(apiBase, encodedProject, host, projectPath, ref, path, file)
}
return null
} catch (error) {
logger.warn(`Failed to fetch GitLab document ${externalId}`, {
@@ -634,7 +1051,7 @@ export const gitlabConnector: ConnectorConfig = {
const projectRecord = (await response.json()) as GitLabProject
if (choice === 'wiki' || choice === 'both') {
if (activePhases(choice).includes('wiki')) {
const accessLevel = projectRecord.wiki_access_level
const enabled =
accessLevel != null ? accessLevel !== 'disabled' : projectRecord.wiki_enabled !== false
@@ -642,7 +1059,28 @@ export const gitlabConnector: ConnectorConfig = {
if (choice === 'wiki') {
return { valid: false, error: 'The wiki feature is disabled for this project' }
}
logger.warn('Wiki feature disabled; only issues will sync', { project })
logger.warn('Wiki feature disabled; it will be skipped', { project })
}
}
const userRef = typeof sourceConfig.ref === 'string' ? sourceConfig.ref.trim() : ''
if (userRef && activePhases(choice).includes('repo')) {
const refResponse = await fetchWithRetry(
`${apiBase}/projects/${encodedProject}/repository/commits/${encodeURIComponent(userRef)}`,
{ method: 'GET', headers: authHeaders(accessToken) },
VALIDATE_RETRY_OPTIONS
)
if (refResponse.status === 404) {
return {
valid: false,
error: `Branch, tag, or commit "${userRef}" not found in project "${project}"`,
}
}
if (!refResponse.ok) {
return {
valid: false,
error: `Cannot verify ref "${userRef}": ${refResponse.status}`,
}
}
}
@@ -659,16 +1097,17 @@ export const gitlabConnector: ConnectorConfig = {
{ id: 'author', displayName: 'Author', fieldType: 'text' },
{ id: 'labels', displayName: 'Labels', fieldType: 'text' },
{ id: 'milestone', displayName: 'Milestone', fieldType: 'text' },
{ id: 'path', displayName: 'File Path', fieldType: 'text' },
{ id: 'size', displayName: 'File Size (bytes)', fieldType: 'number' },
{ id: 'createdAt', displayName: 'Created At', fieldType: 'date' },
{ id: 'updatedAt', displayName: 'Updated At', fieldType: 'date' },
],
/**
* Maps document metadata to tag slots. The `contentType` and `title` tags
* apply to both wikis and issues. The remaining tags (state, author, labels,
* milestone, createdAt, updatedAt) are issue-only — wiki pages expose none of
* them in the REST API, so wiki documents leave those metadata fields empty
* and the type/empty guards below skip them.
* Maps document metadata to tag slots. `contentType` and `title` apply to every
* document type. `state`/`author`/`labels`/`milestone`/`createdAt`/`updatedAt`
* are issue-only and `path`/`size` are repository-file-only; each document type
* leaves the others' fields empty and the type/empty guards below skip them.
*/
mapTags: (metadata: Record<string, unknown>): Record<string, unknown> => {
const result: Record<string, unknown> = {}
@@ -693,6 +1132,15 @@ export const gitlabConnector: ConnectorConfig = {
result.milestone = metadata.milestone
}
if (typeof metadata.path === 'string' && metadata.path.trim()) {
result.path = metadata.path
}
if (metadata.size != null) {
const num = Number(metadata.size)
if (!Number.isNaN(num)) result.size = num
}
const createdAt = parseTagDate(metadata.createdAt)
if (createdAt) result.createdAt = createdAt