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
1066 lines
35 KiB
TypeScript
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
|
|
},
|
|
}
|