Files
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

1066 lines
35 KiB
TypeScript

import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import type { SecureFetchResponse } from '@/lib/core/security/input-validation.server'
import { isSameOrigin } from '@/lib/core/utils/validation'
import { secureFetchWithRetry } from '@/lib/knowledge/documents/secure-fetch.server'
import { VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
import { gitlabConnectorMeta } from '@/connectors/gitlab/meta'
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
import {
CONNECTOR_MAX_FILE_BYTES,
computeContentHash,
joinTagArray,
markSkipped,
parseTagDate,
sizeLimitSkipReason,
} from '@/connectors/utils'
import { normalizeGitLabHost, UnsafeGitLabHostError } from '@/tools/gitlab/utils'
const logger = createLogger('GitLabConnector')
const PAGE_SIZE = 100
/** Max repository file size to index. Larger blobs are skipped. */
const MAX_FILE_SIZE = CONNECTOR_MAX_FILE_BYTES
/** 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, 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. `repo` = repository files (code/docs),
* `all` = repo + wiki + issues. `both` is retained for backward compatibility and
* means wiki + issues (no repository files).
*/
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
title?: string
format?: string
content?: string
encoding?: string
}
interface GitLabUser {
username?: string
name?: string
}
interface GitLabMilestone {
title?: string
}
interface GitLabIssue {
iid: number
title?: string
description?: string | null
state?: string
labels?: string[]
author?: GitLabUser | null
assignees?: GitLabUser[] | null
milestone?: GitLabMilestone | null
updated_at?: string
created_at?: string
web_url?: string
}
interface GitLabProject {
id: number
path_with_namespace?: string
web_url?: string
default_branch?: string
wiki_access_level?: string
wiki_enabled?: boolean
}
/**
* Normalizes the host config value via the shared GitLab host normalizer:
* trims, strips any protocol prefix and trailing slashes, rejects structurally
* unsafe hosts (userinfo, whitespace, embedded path), and falls back to
* gitlab.com when empty. Shared with the GitLab tools and webhook provider so
* every surface resolves and validates hosts identically.
*
* @throws {UnsafeGitLabHostError} when a non-empty host is structurally unsafe.
*/
function normalizeHost(rawHost: unknown): string {
return normalizeGitLabHost(rawHost)
}
/**
* Builds the REST API v4 base URL for the configured host.
*/
function buildApiBase(host: string): string {
return `https://${host}/api/v4`
}
/**
* Returns the encoded project identifier (numeric ID or URL-encoded path).
* GitLab accepts a numeric ID or the URL-encoded `group/project` path.
*/
function encodeProjectId(project: unknown): string {
return encodeURIComponent(String(project ?? '').trim())
}
/**
* Reads the parsed content-type choice from sourceConfig (defaults to 'both').
*/
function getContentTypeChoice(sourceConfig: Record<string, unknown>): ContentTypeChoice {
const value = typeof sourceConfig.contentTypes === 'string' ? sourceConfig.contentTypes : 'both'
if (
value === 'repo' ||
value === 'wiki' ||
value === 'issues' ||
value === 'both' ||
value === 'all'
) {
return value
}
return 'both'
}
/**
* Standard request headers carrying the Personal Access Token.
*/
function authHeaders(accessToken: string): Record<string, string> {
return {
'PRIVATE-TOKEN': accessToken,
Accept: 'application/json',
}
}
/**
* Builds the change-detection hash for a wiki page.
*
* GitLab wiki pages expose no version number or `updated_at` timestamp in the
* REST API, so there is no metadata field that reliably changes when a page is
* edited. As a last resort we hash the page content itself. To keep the hash
* identical between the listing stub and getDocument, both paths request the
* page content (the list endpoint supports `with_content=1`) and feed it through
* this same function.
*/
async function buildWikiContentHash(
projectId: string,
slug: string,
content: string
): Promise<string> {
const contentDigest = await computeContentHash(content)
return `gitlab:wiki:${projectId}:${slug}:${contentDigest}`
}
/**
* Builds the change-detection hash for an issue. Issues expose `updated_at`,
* which increments on every edit, comment, or state change — an ideal metadata
* indicator that requires no content fetch.
*/
function buildIssueContentHash(projectId: string, iid: number, updatedAt: string): 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
const title = path.split('/').pop() || path
const skippedForSize = (size: number): ExternalDocument => {
logger.info('Skipping oversized GitLab file', { path, size })
return markSkipped(
{
externalId: `${FILE_PREFIX}${path}`,
title,
content: '',
mimeType: 'text/plain',
sourceUrl: buildFileSourceUrl(apiBase, encodedProject, host, projectPath, ref, path),
contentHash: buildFileContentHash(encodedProject, path, blobSha),
metadata: { contentType: 'file', title, path, size },
},
sizeLimitSkipReason(MAX_FILE_SIZE)
)
}
if (typeof file.size === 'number' && file.size > MAX_FILE_SIZE) {
return skippedForSize(file.size)
}
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) {
return skippedForSize(buffer.byteLength)
}
const content = buffer.toString('utf8')
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>".
*/
function composeBody(title: string, content: string): string {
const trimmedTitle = title.trim()
const trimmedContent = content.trim()
if (!trimmedTitle) return trimmedContent
if (!trimmedContent) return trimmedTitle
return `${trimmedTitle}\n\n${trimmedContent}`
}
/**
* Builds a wiki page document (full content) from a fetched page.
*/
async function wikiPageToDocument(
apiBase: string,
encodedProject: string,
host: string,
projectPath: string,
page: GitLabWikiPage
): Promise<ExternalDocument | null> {
const content = typeof page.content === 'string' ? page.content : ''
const title = page.title?.trim() || page.slug
const body = composeBody(title, content)
if (!body.trim()) return null
const contentHash = await buildWikiContentHash(encodedProject, page.slug, content)
return {
externalId: `${WIKI_PREFIX}${page.slug}`,
title,
content: body,
contentDeferred: false,
mimeType: 'text/plain',
sourceUrl: projectPath
? `https://${host}/${projectPath}/-/wikis/${page.slug}`
: `${apiBase}/projects/${encodedProject}/wikis/${page.slug}`,
contentHash,
metadata: {
contentType: 'wiki',
title,
slug: page.slug,
},
}
}
/**
* Builds an issue document from a fetched issue.
*/
function issueToDocument(
encodedProject: string,
host: string,
projectPath: string,
issue: GitLabIssue
): ExternalDocument | null {
const title = issue.title?.trim() || `Issue #${issue.iid}`
const description = typeof issue.description === 'string' ? issue.description : ''
const body = composeBody(title, description)
if (!body.trim()) return null
const updatedAt = issue.updated_at ?? issue.created_at ?? ''
const createdAt = issue.created_at ?? ''
const author = issue.author?.username?.trim() || issue.author?.name?.trim() || ''
const labels = Array.isArray(issue.labels) ? issue.labels : []
const milestone = issue.milestone?.title?.trim() || ''
const fallbackUrl = projectPath
? `https://${host}/${projectPath}/-/issues/${issue.iid}`
: undefined
return {
externalId: `${ISSUE_PREFIX}${issue.iid}`,
title,
content: body,
contentDeferred: false,
mimeType: 'text/plain',
sourceUrl: issue.web_url || fallbackUrl,
contentHash: buildIssueContentHash(encodedProject, issue.iid, updatedAt),
metadata: {
contentType: 'issue',
title,
iid: issue.iid,
state: issue.state,
author,
labels,
milestone,
createdAt,
updatedAt,
},
}
}
/**
* Fetches the project record, used to resolve the human-readable path for
* source URLs and to confirm access during validation.
*/
async function fetchProject(
apiBase: string,
encodedProject: string,
accessToken: string,
retryOptions?: typeof VALIDATE_RETRY_OPTIONS
): Promise<SecureFetchResponse> {
return secureFetchWithRetry(
`${apiBase}/projects/${encodedProject}`,
{ method: 'GET', headers: authHeaders(accessToken) },
retryOptions
)
}
/**
* Encodes the listing cursor. The cursor packs the resource phase (repo ➜ wiki ➜
* issues) and a per-phase continuation token so a single sync walks the phases in
* order. The repository-tree and issues phases both use GitLab keyset pagination
* and store the full `rel="next"` URL from the Link header to fetch verbatim.
*/
interface CursorState {
phase: SyncPhase
issuePage: number
/** Full `rel="next"` URL for the repository-tree keyset page to fetch next. */
fileNextUrl?: string
/** Full `rel="next"` URL for the issues keyset page to fetch next. */
issueNextUrl?: string
}
function encodeCursor(state: CursorState): string {
return Buffer.from(JSON.stringify(state), 'utf8').toString('base64url')
}
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: SyncPhase
issuePage: number
fileNextUrl: string
issueNextUrl: string
}>
const phase: SyncPhase =
parsed.phase === 'repo' || parsed.phase === 'issues' || parsed.phase === 'wiki'
? parsed.phase
: initialPhase
return {
phase,
issuePage: Number(parsed.issuePage) > 0 ? Number(parsed.issuePage) : 1,
fileNextUrl: typeof parsed.fileNextUrl === 'string' ? parsed.fileNextUrl : undefined,
issueNextUrl: typeof parsed.issueNextUrl === 'string' ? parsed.issueNextUrl : 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.
*/
function applyMaxItemsCap(
documents: ExternalDocument[],
maxItems: number,
syncContext: Record<string, unknown> | undefined
): { documents: ExternalDocument[]; capped: boolean } {
if (maxItems <= 0) return { documents, capped: false }
const prevTotal = (syncContext?.totalDocsFetched as number) ?? 0
const remaining = Math.max(0, maxItems - prevTotal)
const sliced = documents.length > remaining ? documents.slice(0, remaining) : documents
const newTotal = prevTotal + sliced.length
if (syncContext) syncContext.totalDocsFetched = newTotal
const capped = newTotal >= maxItems
if (capped && syncContext) syncContext.listingCapped = true
return { documents: sliced, capped }
}
export const gitlabConnector: ConnectorConfig = {
...gitlabConnectorMeta,
listDocuments: async (
accessToken: string,
sourceConfig: Record<string, unknown>,
cursor?: string,
syncContext?: Record<string, unknown>,
lastSyncAt?: Date
): Promise<ExternalDocumentList> => {
const host = normalizeHost(sourceConfig.host)
const apiBase = buildApiBase(host)
const encodedProject = encodeProjectId(sourceConfig.project)
const choice = getContentTypeChoice(sourceConfig)
const maxItems = sourceConfig.maxItems ? Number(sourceConfig.maxItems) : 0
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)
if (projectResponse.ok) {
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
}
}
}
let state = decodeCursor(cursor, phases[0])
if (!phases.includes(state.phase)) state = { phase: phases[0], issuePage: 1 }
/** 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',
})
if (state.fileNextUrl && !isSameOrigin(state.fileNextUrl, apiBase)) {
throw new Error('GitLab pagination cursor points to an unexpected host')
}
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 secureFetchWithRetry(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 })
const response = await secureFetchWithRetry(url, {
method: 'GET',
headers: authHeaders(accessToken),
})
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,
error: errorText.slice(0, 500),
})
throw new Error(`Failed to list GitLab wiki pages: ${response.status}`)
}
const pages = (await response.json()) as GitLabWikiPage[]
const documents: ExternalDocument[] = []
for (const page of pages) {
if (!page.slug) continue
const doc = await wikiPageToDocument(apiBase, encodedProject, host, projectPath, page)
if (doc) documents.push(doc)
}
const { documents: capped, capped: hitLimit } = applyMaxItemsCap(
documents,
maxItems,
syncContext
)
if (hitLimit) {
return { documents: capped, hasMore: false }
}
const adv = advance('wiki')
return { documents: capped, nextCursor: adv.nextCursor, hasMore: adv.hasMore }
}
if (state.phase === 'issues') {
const params = new URLSearchParams({
per_page: String(PAGE_SIZE),
order_by: 'updated_at',
sort: 'desc',
pagination: 'keyset',
})
if (lastSyncAt) params.set('updated_after', lastSyncAt.toISOString())
const issueState =
typeof sourceConfig.issueState === 'string' ? sourceConfig.issueState.trim() : ''
if (issueState && issueState !== 'all') params.set('state', issueState)
const issueLabels =
typeof sourceConfig.issueLabels === 'string' ? sourceConfig.issueLabels.trim() : ''
if (issueLabels) params.set('labels', issueLabels)
const issueMilestone =
typeof sourceConfig.issueMilestone === 'string' ? sourceConfig.issueMilestone.trim() : ''
if (issueMilestone) params.set('milestone', issueMilestone)
if (state.issueNextUrl && !isSameOrigin(state.issueNextUrl, apiBase)) {
throw new Error('GitLab pagination cursor points to an unexpected host')
}
const url =
state.issueNextUrl ?? `${apiBase}/projects/${encodedProject}/issues?${params.toString()}`
logger.info('Listing GitLab issues', {
host,
project: encodedProject,
continued: Boolean(state.issueNextUrl),
incremental: Boolean(lastSyncAt),
})
const response = await secureFetchWithRetry(url, {
method: 'GET',
headers: authHeaders(accessToken),
})
if (!response.ok) {
const errorText = await response.text().catch(() => '')
logger.error('Failed to list GitLab issues', {
status: response.status,
error: errorText.slice(0, 500),
})
throw new Error(`Failed to list GitLab issues: ${response.status}`)
}
const issues = (await response.json()) as GitLabIssue[]
const documents: ExternalDocument[] = []
for (const issue of issues) {
if (issue.iid == null) continue
const doc = issueToDocument(encodedProject, host, projectPath, issue)
if (doc) documents.push(doc)
}
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: 'issues', issuePage: 1, issueNextUrl: nextLink }),
hasMore: true,
}
}
return { documents: capped, hasMore: false }
}
return { documents: [], hasMore: false }
},
getDocument: async (
accessToken: string,
sourceConfig: Record<string, unknown>,
externalId: string,
syncContext?: Record<string, unknown>
): Promise<ExternalDocument | null> => {
const host = normalizeHost(sourceConfig.host)
const apiBase = buildApiBase(host)
const encodedProject = encodeProjectId(sourceConfig.project)
if (!encodedProject || !externalId) return null
const projectPath = (syncContext?.projectPath as string) ?? ''
try {
if (externalId.startsWith(WIKI_PREFIX)) {
const slug = externalId.slice(WIKI_PREFIX.length)
if (!slug) return null
const url = `${apiBase}/projects/${encodedProject}/wikis/${encodeURIComponent(slug)}?render_html=false`
const response = await secureFetchWithRetry(url, {
method: 'GET',
headers: authHeaders(accessToken),
})
if (!response.ok) {
if (response.status === 404) return null
throw new Error(`Failed to fetch GitLab wiki page: ${response.status}`)
}
const page = (await response.json()) as GitLabWikiPage
if (!page.slug) return null
return wikiPageToDocument(apiBase, encodedProject, host, projectPath, page)
}
if (externalId.startsWith(ISSUE_PREFIX)) {
const iidStr = externalId.slice(ISSUE_PREFIX.length)
const iid = Number(iidStr)
if (!iidStr || Number.isNaN(iid)) return null
const url = `${apiBase}/projects/${encodedProject}/issues/${iid}`
const response = await secureFetchWithRetry(url, {
method: 'GET',
headers: authHeaders(accessToken),
})
if (!response.ok) {
if (response.status === 404) return null
throw new Error(`Failed to fetch GitLab issue: ${response.status}`)
}
const issue = (await response.json()) as GitLabIssue
if (issue.iid == null) return null
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 secureFetchWithRetry(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}`, {
error: toError(error).message,
})
return null
}
},
validateConfig: async (
accessToken: string,
sourceConfig: Record<string, unknown>
): Promise<{ valid: boolean; error?: string }> => {
const project = (sourceConfig.project as string)?.trim()
if (!project) {
return { valid: false, error: 'Project is required' }
}
const maxItems = sourceConfig.maxItems as string | undefined
if (maxItems && (Number.isNaN(Number(maxItems)) || Number(maxItems) <= 0)) {
return { valid: false, error: 'Max items must be a positive number' }
}
let host: string
try {
host = normalizeHost(sourceConfig.host)
} catch (error) {
if (error instanceof UnsafeGitLabHostError) {
return {
valid: false,
error: 'Host must be a valid GitLab domain (e.g. gitlab.example.com)',
}
}
throw error
}
const apiBase = buildApiBase(host)
const encodedProject = encodeProjectId(project)
const choice = getContentTypeChoice(sourceConfig)
try {
const response = await fetchProject(
apiBase,
encodedProject,
accessToken,
VALIDATE_RETRY_OPTIONS
)
if (response.status === 404) {
return { valid: false, error: `Project "${project}" not found on ${host}` }
}
if (response.status === 401 || response.status === 403) {
return { valid: false, error: 'Invalid token or insufficient permissions' }
}
if (!response.ok) {
return { valid: false, error: `Cannot access project: ${response.status}` }
}
const projectRecord = (await response.json()) as GitLabProject
if (activePhases(choice).includes('wiki')) {
const accessLevel = projectRecord.wiki_access_level
const enabled =
accessLevel != null ? accessLevel !== 'disabled' : projectRecord.wiki_enabled !== false
if (!enabled) {
if (choice === 'wiki') {
return { valid: false, error: 'The wiki feature is disabled for this 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 secureFetchWithRetry(
`${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}`,
}
}
}
return { valid: true }
} catch (error) {
return { valid: false, error: getErrorMessage(error, 'Failed to validate configuration') }
}
},
/**
* 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> = {}
if (typeof metadata.contentType === 'string' && metadata.contentType.trim()) {
result.contentType = metadata.contentType
}
if (typeof metadata.title === 'string' && metadata.title.trim()) {
result.title = metadata.title
}
if (typeof metadata.state === 'string' && metadata.state.trim()) {
result.state = metadata.state
}
if (typeof metadata.author === 'string' && metadata.author.trim()) {
result.author = metadata.author
}
const labels = joinTagArray(metadata.labels)
if (labels) result.labels = labels
if (typeof metadata.milestone === 'string' && metadata.milestone.trim()) {
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
const updatedAt = parseTagDate(metadata.updatedAt)
if (updatedAt) result.updatedAt = updatedAt
return result
},
}