mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-08-30 16:53:21 +08:00
feat(datasource): 支持 GitLab 数据源文档同步 (#2656)
* 支持gitlab数据源 * 添加单元测试 && 修复gitlab数据源配置多个的UI展示问题 * 补充韩语、俄语文案 * gitlab数据源同步:新增文件类型过滤 && 改为streaming实现 * fix: remove duplicate RSS locale keys --------- Co-authored-by: liguowei <liguowei@xinye.com>
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "knowledage-base",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "knowledage-base",
|
||||
"version": "0.7.1",
|
||||
"version": "0.7.2",
|
||||
"dependencies": {
|
||||
"@microsoft/fetch-event-source": "^2.0.1",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 439 B |
@@ -929,6 +929,7 @@ const channelLabelMap: Record<string, string> = {
|
||||
wechat: 'knowledgeBase.channelWechat',
|
||||
wecom: 'knowledgeBase.channelWecom',
|
||||
feishu: 'knowledgeBase.channelFeishu',
|
||||
gitlab: 'knowledgeBase.channelGitLab',
|
||||
// Drive (云盘) connectors get their own channel so Drive docs show
|
||||
// "飞书云盘" / "Lark 云盘", distinct from the wiki connector's "飞书".
|
||||
feishu_drive: 'knowledgeBase.channelFeishuDrive',
|
||||
|
||||
@@ -466,6 +466,7 @@ export default {
|
||||
channelIm: 'IM Channel',
|
||||
channelNotion: 'Notion',
|
||||
channelYuque: 'Yuque',
|
||||
channelGitLab: 'GitLab',
|
||||
channelIma: 'Tencent IMA',
|
||||
channelUpload: 'Upload',
|
||||
channelManual: 'Manual',
|
||||
@@ -5415,6 +5416,14 @@ export default {
|
||||
connectionFailed: 'Connection failed',
|
||||
isRequired: 'is required',
|
||||
credentialsLabel: 'credentials',
|
||||
gitlab: {
|
||||
baseUrl: 'GitLab URL', accessToken: 'Personal access token', projects: 'GitLab projects',
|
||||
projectsHint: 'Enter a project ID (or URL-encoded project path), with optional branch and directories.',
|
||||
project: 'Project', projectId: 'Project ID', projectIdPlaceholder: 'For example: 12345 or group%2Fproject',
|
||||
ref: 'Branch', refPlaceholder: 'Leave empty to use the default branch',
|
||||
paths: 'Directories', pathsPlaceholder: 'One directory per line; leave empty to sync the whole project',
|
||||
addProject: 'Add project', projectRequired: 'Add at least one GitLab project',
|
||||
},
|
||||
resourceHint: 'Select the spaces or folders to sync',
|
||||
untitled: 'Untitled',
|
||||
resourceLoadFailed: 'Failed to load resources',
|
||||
@@ -5482,8 +5491,9 @@ export default {
|
||||
lark_drive: 'Lark Drive',
|
||||
notion: 'Notion',
|
||||
yuque: 'Yuque',
|
||||
rss: 'RSS / Atom Feed',
|
||||
ima: 'Tencent IMA',
|
||||
rss: 'RSS / Atom Feed'
|
||||
gitlab: 'GitLab'
|
||||
},
|
||||
connectorDesc: {
|
||||
feishu: 'Sync documents, spreadsheets and files from Feishu Wiki',
|
||||
@@ -5492,8 +5502,9 @@ export default {
|
||||
lark_drive: 'Sync documents, spreadsheets and files from a Lark Drive folder (Feishu international)',
|
||||
notion: 'Sync pages and databases from Notion',
|
||||
yuque: 'Sync documents from Yuque knowledge bases',
|
||||
rss: 'Sync articles from RSS / Atom feeds',
|
||||
ima: 'Sync documents, notes and files from Tencent IMA knowledge bases (AI sessions and video parses are not supported)',
|
||||
rss: 'Sync articles from RSS / Atom feeds'
|
||||
gitlab: 'Sync files from GitLab projects'
|
||||
},
|
||||
drive: {
|
||||
folderTokenLabel: 'Drive folder token',
|
||||
|
||||
@@ -583,6 +583,14 @@ export default {
|
||||
connectionFailed: '연결 실패',
|
||||
isRequired: '은(는) 필수입니다',
|
||||
credentialsLabel: '자격 증명',
|
||||
gitlab: {
|
||||
baseUrl: 'GitLab URL', accessToken: '개인 액세스 토큰', projects: 'GitLab 프로젝트',
|
||||
projectsHint: '프로젝트 ID(또는 URL 인코딩된 프로젝트 경로)를 입력하고, 필요에 따라 브랜치와 디렉터리를 지정하세요.',
|
||||
project: '프로젝트', projectId: '프로젝트 ID', projectIdPlaceholder: '예: 12345 또는 group%2Fproject',
|
||||
ref: '브랜치', refPlaceholder: '비워 두면 기본 브랜치를 사용합니다',
|
||||
paths: '디렉터리', pathsPlaceholder: '한 줄에 하나씩 입력하세요. 비워 두면 전체 프로젝트를 동기화합니다',
|
||||
addProject: '프로젝트 추가', projectRequired: 'GitLab 프로젝트를 하나 이상 추가하세요',
|
||||
},
|
||||
resourceHint: '동기화할 공간/폴더를 선택하세요',
|
||||
untitled: '제목 없음',
|
||||
resourceLoadFailed: '리소스 목록 로드 실패',
|
||||
@@ -705,7 +713,8 @@ export default {
|
||||
notion: 'Notion에서 페이지 및 데이터베이스 동기화',
|
||||
yuque: '위큐 지식베이스에서 문서 동기화',
|
||||
ima: 'Tencent IMA 지식베이스에서 문서, 노트 및 파일 동기화 (AI 세션과 동영상 분석은 지원되지 않음)',
|
||||
rss: 'RSS / Atom 피드에서 글 동기화'
|
||||
rss: 'RSS / Atom 피드에서 글 동기화',
|
||||
gitlab: 'GitLab 프로젝트의 파일 동기화'
|
||||
},
|
||||
connector: {
|
||||
feishu: '페이슈 (Feishu)',
|
||||
@@ -715,7 +724,8 @@ export default {
|
||||
notion: 'Notion',
|
||||
yuque: '위큐 (Yuque)',
|
||||
ima: 'Tencent IMA',
|
||||
rss: 'RSS / Atom 피드'
|
||||
rss: 'RSS / Atom 피드',
|
||||
gitlab: 'GitLab'
|
||||
},
|
||||
logDetail: {
|
||||
startTime: '시작 시간',
|
||||
@@ -5606,6 +5616,7 @@ export default {
|
||||
channelIm: 'IM 채널',
|
||||
channelNotion: 'Notion',
|
||||
channelYuque: 'Yuque',
|
||||
channelGitLab: 'GitLab',
|
||||
channelIma: 'Tencent IMA',
|
||||
channelUpload: '업로드',
|
||||
channelManual: '수동',
|
||||
|
||||
@@ -583,6 +583,14 @@ export default {
|
||||
connectionFailed: 'Подключение не удалось',
|
||||
isRequired: 'обязательно для заполнения',
|
||||
credentialsLabel: 'учётные данные',
|
||||
gitlab: {
|
||||
baseUrl: 'URL GitLab', accessToken: 'Персональный токен доступа', projects: 'Проекты GitLab',
|
||||
projectsHint: 'Укажите ID проекта (или URL-кодированный путь к проекту), а также при необходимости ветку и каталоги.',
|
||||
project: 'Проект', projectId: 'ID проекта', projectIdPlaceholder: 'Например: 12345 или group%2Fproject',
|
||||
ref: 'Ветка', refPlaceholder: 'Оставьте пустым, чтобы использовать ветку по умолчанию',
|
||||
paths: 'Каталоги', pathsPlaceholder: 'По одному каталогу в строке; оставьте пустым для синхронизации всего проекта',
|
||||
addProject: 'Добавить проект', projectRequired: 'Добавьте хотя бы один проект GitLab',
|
||||
},
|
||||
resourceHint: 'Выберите пространства или папки для синхронизации',
|
||||
untitled: 'Без названия',
|
||||
resourceLoadFailed: 'Не удалось загрузить список ресурсов',
|
||||
@@ -705,7 +713,8 @@ export default {
|
||||
notion: 'Синхронизация страниц и баз данных из Notion',
|
||||
yuque: 'Синхронизация документов из баз знаний Yuque',
|
||||
ima: 'Синхронизация документов, заметок и файлов из баз знаний Tencent IMA (ИИ-сессии и разбор видео не поддерживаются)',
|
||||
rss: 'Синхронизация статей из лент RSS / Atom'
|
||||
rss: 'Синхронизация статей из лент RSS / Atom',
|
||||
gitlab: 'Синхронизация файлов из проектов GitLab'
|
||||
},
|
||||
connector: {
|
||||
feishu: 'Feishu (Фэйшу)',
|
||||
@@ -715,7 +724,8 @@ export default {
|
||||
notion: 'Notion',
|
||||
yuque: 'Yuque (Юйцюэ)',
|
||||
ima: 'Tencent IMA',
|
||||
rss: 'RSS / Atom лента'
|
||||
rss: 'RSS / Atom лента',
|
||||
gitlab: 'GitLab'
|
||||
},
|
||||
logDetail: {
|
||||
startTime: 'Время начала',
|
||||
@@ -5606,6 +5616,7 @@ export default {
|
||||
channelIm: 'IM канал',
|
||||
channelNotion: 'Notion',
|
||||
channelYuque: 'Yuque',
|
||||
channelGitLab: 'GitLab',
|
||||
channelIma: 'Tencent IMA',
|
||||
channelUpload: 'Загрузка',
|
||||
channelManual: 'Вручную',
|
||||
|
||||
@@ -583,6 +583,16 @@ export default {
|
||||
connectionFailed: '连接失败',
|
||||
isRequired: '为必填项',
|
||||
credentialsLabel: '凭证',
|
||||
gitlab: {
|
||||
baseUrl: 'GitLab 地址',
|
||||
accessToken: '个人访问令牌',
|
||||
projects: 'GitLab 项目',
|
||||
projectsHint: '填写项目 ID(或 URL 编码后的项目路径),可选指定分支和目录。',
|
||||
project: '项目', projectId: '项目 ID', projectIdPlaceholder: '例如:12345 或 group%2Fproject',
|
||||
ref: '分支', refPlaceholder: '留空时使用默认分支',
|
||||
paths: '同步目录', pathsPlaceholder: '每行一个目录;留空同步整个项目',
|
||||
addProject: '添加项目', projectRequired: '请至少添加一个 GitLab 项目',
|
||||
},
|
||||
resourceHint: '选择要同步的内容空间/文件夹',
|
||||
untitled: '无标题',
|
||||
resourceLoadFailed: '加载资源列表失败',
|
||||
@@ -705,7 +715,8 @@ export default {
|
||||
notion: '同步 Notion 中的页面和数据库',
|
||||
yuque: '同步语雀知识库中的文档',
|
||||
ima: '同步腾讯 IMA 知识库中的文档、笔记与文件(暂不支持 AI 会话与视频解析)',
|
||||
rss: '同步 RSS / Atom 订阅源中的文章'
|
||||
rss: '同步 RSS / Atom 订阅源中的文章',
|
||||
gitlab: '同步 GitLab 项目中的文件'
|
||||
},
|
||||
connector: {
|
||||
feishu: '飞书',
|
||||
@@ -715,7 +726,8 @@ export default {
|
||||
notion: 'Notion',
|
||||
yuque: '语雀',
|
||||
ima: '腾讯 IMA',
|
||||
rss: 'RSS / Atom 订阅'
|
||||
rss: 'RSS / Atom 订阅',
|
||||
gitlab: 'GitLab'
|
||||
},
|
||||
logDetail: {
|
||||
startTime: '开始时间',
|
||||
@@ -5606,6 +5618,7 @@ export default {
|
||||
channelIm: 'IM 渠道',
|
||||
channelNotion: 'Notion',
|
||||
channelYuque: '语雀',
|
||||
channelGitLab: 'GitLab',
|
||||
channelIma: '腾讯 IMA',
|
||||
channelUpload: '上传',
|
||||
channelManual: '手动',
|
||||
|
||||
@@ -108,6 +108,7 @@ const channelLabelMap: Record<string, string> = {
|
||||
wechat: 'knowledgeBase.channelWechat',
|
||||
wecom: 'knowledgeBase.channelWecom',
|
||||
feishu: 'knowledgeBase.channelFeishu',
|
||||
gitlab: 'knowledgeBase.channelGitLab',
|
||||
dingtalk: 'knowledgeBase.channelDingtalk',
|
||||
slack: 'knowledgeBase.channelSlack',
|
||||
im: 'knowledgeBase.channelIm',
|
||||
|
||||
@@ -599,6 +599,7 @@ const sourceOptions = computed(() => [
|
||||
{ label: t('knowledgeBase.channelFeishuDrive'), value: 'feishu_drive' },
|
||||
{ label: t('knowledgeBase.channelNotion'), value: 'notion' },
|
||||
{ label: t('knowledgeBase.channelYuque'), value: 'yuque' },
|
||||
{ label: t('knowledgeBase.channelGitLab'), value: 'gitlab' },
|
||||
{ label: t('knowledgeBase.channelIma'), value: 'ima' },
|
||||
{ label: t('knowledgeBase.channelWechat'), value: 'wechat' },
|
||||
{ label: t('knowledgeBase.channelWecom'), value: 'wecom' },
|
||||
|
||||
@@ -154,6 +154,7 @@ const channelLabelMap: Record<string, string> = {
|
||||
wechat: 'knowledgeBase.channelWechat',
|
||||
wecom: 'knowledgeBase.channelWecom',
|
||||
feishu: 'knowledgeBase.channelFeishu',
|
||||
gitlab: 'knowledgeBase.channelGitLab',
|
||||
dingtalk: 'knowledgeBase.channelDingtalk',
|
||||
slack: 'knowledgeBase.channelSlack',
|
||||
im: 'knowledgeBase.channelIm',
|
||||
|
||||
@@ -114,6 +114,7 @@ const getSourceInfo = (item: KnowledgeItem): { icon: string; label: string } =>
|
||||
if (ch === 'lark_drive') return { icon: 'cloud-download', label: t('knowledgeBase.channelLarkDrive') };
|
||||
if (ch === 'notion') return { icon: 'cloud-download', label: t('knowledgeBase.channelNotion') };
|
||||
if (ch === 'yuque') return { icon: 'cloud-download', label: t('knowledgeBase.channelYuque') };
|
||||
if (ch === 'gitlab') return { icon: 'cloud-download', label: t('knowledgeBase.channelGitLab') };
|
||||
if (ch === 'ima') return { icon: 'cloud-download', label: t('knowledgeBase.channelIma') };
|
||||
if (ch === 'wechat') return { icon: 'cloud-download', label: t('knowledgeBase.channelWechat') };
|
||||
if (ch === 'wecom') return { icon: 'cloud-download', label: t('knowledgeBase.channelWecom') };
|
||||
|
||||
@@ -206,6 +206,21 @@ const driveFolderToken = ref('')
|
||||
const driveFolderTokenError = ref('')
|
||||
const driveRootLoaded = ref(false)
|
||||
const isDriveConnector = (type: string) => type === 'feishu_drive' || type === 'lark_drive'
|
||||
const isGitLabConnector = (type: string) => type === 'gitlab'
|
||||
|
||||
interface GitLabProjectInput { project_id: string; ref: string; pathsText: string }
|
||||
const gitlabProjects = ref<GitLabProjectInput[]>([])
|
||||
function syncGitLabProjectsToSettings() {
|
||||
if (!isGitLabConnector(form.value.type)) return
|
||||
form.value.config.settings.projects = gitlabProjects.value
|
||||
.filter(project => project.project_id.trim())
|
||||
.map(project => ({
|
||||
project_id: project.project_id.trim(), ref: project.ref.trim(),
|
||||
paths: project.pathsText.split(/[\n,]/).map(path => path.trim()).filter(Boolean),
|
||||
}))
|
||||
}
|
||||
function addGitLabProject() { gitlabProjects.value.push({ project_id: '', ref: '', pathsText: '' }) }
|
||||
function removeGitLabProject(index: number) { gitlabProjects.value.splice(index, 1); syncGitLabProjectsToSettings() }
|
||||
|
||||
// extractDriveFolderToken accepts either a bare folder_token or a Drive folder
|
||||
// URL (https://xxx.feishu.cn/drive/folder/<token> or the Lark equivalent
|
||||
@@ -611,6 +626,13 @@ const connectorDefs = computed<ConnectorDef[]>(() => [
|
||||
{ key: 'auth_headers', labelKey: 'datasource.field.authHeaders', placeholder: '', optional: true, hintKey: 'datasource.field.authHeadersHint', fieldType: 'custom_headers' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'gitlab', available: true, docUrl: '', permissionDocUrl: '', permissionPageUrl: '', requiredPermissions: [],
|
||||
fields: [
|
||||
{ key: 'base_url', labelKey: 'datasource.gitlab.baseUrl', placeholder: 'https://gitlab.example.com' },
|
||||
{ key: 'access_token', labelKey: 'datasource.gitlab.accessToken', placeholder: '', secret: true },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
|
||||
@@ -645,6 +667,7 @@ watch(visible, async (v) => {
|
||||
driveFolderTokenError.value = ''
|
||||
driveRootLoaded.value = false
|
||||
rssAuthHeaders.value = []
|
||||
gitlabProjects.value = []
|
||||
|
||||
if (isEdit.value && props.dataSource) {
|
||||
// Reset edit/replace toggle every open so an aborted replace doesn't
|
||||
@@ -671,6 +694,13 @@ watch(visible, async (v) => {
|
||||
sync_deletions: props.dataSource.sync_deletions,
|
||||
}
|
||||
selectedResourceIds.value = form.value.config?.resource_ids || []
|
||||
if (isGitLabConnector(form.value.type)) {
|
||||
const savedProjects = Array.isArray(form.value.config.settings.projects) ? form.value.config.settings.projects : []
|
||||
gitlabProjects.value = savedProjects.map((project: any) => ({
|
||||
project_id: String(project.project_id || ''), ref: String(project.ref || ''),
|
||||
pathsText: Array.isArray(project.paths) ? project.paths.join('\n') : '',
|
||||
}))
|
||||
}
|
||||
// Pre-fill the Drive root folder_token from the saved resource_ids so the
|
||||
// user sees what they previously entered. driveRootLoaded stays false: the
|
||||
// tree has not been listed yet, and clicking "load" triggers listResources
|
||||
@@ -737,6 +767,7 @@ function selectType(def: ConnectorDef) {
|
||||
form.value.type = def.type
|
||||
form.value.name = t(`datasource.connector.${def.type}`)
|
||||
form.value.config.credentials = {}
|
||||
if (isGitLabConnector(def.type)) addGitLabProject()
|
||||
rssAuthHeaders.value = []
|
||||
step.value = 1
|
||||
}
|
||||
@@ -970,6 +1001,13 @@ async function nextStep() {
|
||||
}
|
||||
driveFolderTokenError.value = ''
|
||||
}
|
||||
if (step.value === 2 && isGitLabConnector(form.value.type)) {
|
||||
syncGitLabProjectsToSettings()
|
||||
if (!gitlabProjects.value.some(project => project.project_id.trim())) {
|
||||
MessagePlugin.warning(t('datasource.gitlab.projectRequired'))
|
||||
return
|
||||
}
|
||||
}
|
||||
step.value++
|
||||
if (step.value === 2) {
|
||||
// Drive connectors need a user-supplied folder_token before listing.
|
||||
@@ -982,6 +1020,7 @@ async function nextStep() {
|
||||
}
|
||||
return
|
||||
}
|
||||
if (isGitLabConnector(form.value.type)) return
|
||||
loadResources()
|
||||
}
|
||||
}
|
||||
@@ -1000,6 +1039,7 @@ function prevStep() {
|
||||
// commitCredentialsIfNeeded). Sending an empty map keeps the backend
|
||||
// validator happy.
|
||||
function buildConfigPayload(): Record<string, unknown> {
|
||||
syncGitLabProjectsToSettings()
|
||||
return {
|
||||
credentials: isEdit.value ? {} : { ...form.value.config.credentials },
|
||||
resource_ids: form.value.config.resource_ids,
|
||||
@@ -1183,7 +1223,7 @@ const drawerConfirmText = computed(() => {
|
||||
v-model:visible="visible"
|
||||
:title="drawerTitle"
|
||||
:description="drawerDescription"
|
||||
:class="[form.type ? `datasource-editor-drawer datasource-editor-drawer--${form.type}` : 'datasource-editor-drawer', { 'ds-fixed-step': step === 2 }]"
|
||||
:class="[form.type ? `datasource-editor-drawer datasource-editor-drawer--${form.type}` : 'datasource-editor-drawer', { 'ds-fixed-step': step === 2 && !isGitLabConnector(form.type) }]"
|
||||
:hide-footer="step === 0"
|
||||
:confirm-text="drawerConfirmText"
|
||||
:confirm-loading="submitting || (step === 1 && testing)"
|
||||
@@ -1519,6 +1559,26 @@ const drawerConfirmText = computed(() => {
|
||||
|
||||
<!-- Step 2: Select resources -->
|
||||
<section v-if="step === 2" class="setting-drawer__section ds-resource-section">
|
||||
<template v-if="isGitLabConnector(form.type)">
|
||||
<h4 class="setting-drawer__section-title">{{ t('datasource.gitlab.projects') }}</h4>
|
||||
<p class="ds-resource-hint">{{ t('datasource.gitlab.projectsHint') }}</p>
|
||||
<div class="gitlab-project-list">
|
||||
<div v-for="(project, index) in gitlabProjects" :key="index" class="gitlab-project-row">
|
||||
<div class="gitlab-project-row__header">
|
||||
<strong>{{ t('datasource.gitlab.project') }} {{ index + 1 }}</strong>
|
||||
<t-button variant="text" size="small" theme="danger" @click="removeGitLabProject(index)"><t-icon name="delete" /></t-button>
|
||||
</div>
|
||||
<label class="form-label required">{{ t('datasource.gitlab.projectId') }}</label>
|
||||
<t-input v-model="project.project_id" :placeholder="t('datasource.gitlab.projectIdPlaceholder')" />
|
||||
<label class="form-label">{{ t('datasource.gitlab.ref') }}</label>
|
||||
<t-input v-model="project.ref" :placeholder="t('datasource.gitlab.refPlaceholder')" />
|
||||
<label class="form-label">{{ t('datasource.gitlab.paths') }}</label>
|
||||
<t-textarea v-model="project.pathsText" :placeholder="t('datasource.gitlab.pathsPlaceholder')" :autosize="{ minRows: 2, maxRows: 5 }" />
|
||||
</div>
|
||||
<t-button variant="outline" @click="addGitLabProject"><template #icon><t-icon name="add" /></template>{{ t('datasource.gitlab.addProject') }}</t-button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<h4 class="setting-drawer__section-title">{{ t('datasource.step.resources') }}</h4>
|
||||
<p class="ds-resource-hint">{{ t('datasource.resourceHint') }}</p>
|
||||
|
||||
@@ -1680,6 +1740,7 @@ const drawerConfirmText = computed(() => {
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<!-- Step 3: Sync strategy -->
|
||||
@@ -2605,6 +2666,27 @@ const drawerConfirmText = computed(() => {
|
||||
color: var(--td-text-color-primary);
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.gitlab-project-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.gitlab-project-row {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--td-component-stroke);
|
||||
border-radius: 6px;
|
||||
background: var(--td-bg-color-container);
|
||||
}
|
||||
|
||||
.gitlab-project-row__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!--
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import feishuIcon from '@/assets/img/datasource-feishu.ico'
|
||||
import gitlabIcon from '@/assets/img/datasource-gitlab.png'
|
||||
import larkIcon from '@/assets/img/datasource-lark.svg'
|
||||
import notionIcon from '@/assets/img/datasource-notion.ico'
|
||||
import yuqueIcon from '@/assets/img/datasource-yuque.ico'
|
||||
@@ -14,6 +15,7 @@ export const datasourceIconMap: Record<string, string> = {
|
||||
notion: notionIcon,
|
||||
yuque: yuqueIcon,
|
||||
rss: rssIcon,
|
||||
gitlab: gitlabIcon,
|
||||
ima: imaIcon,
|
||||
}
|
||||
|
||||
|
||||
@@ -1124,12 +1124,10 @@ func (s *DataSourceService) ValidateCredentials(ctx context.Context, connectorTy
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
config := &types.DataSourceConfig{
|
||||
Type: connectorType,
|
||||
Credentials: credentials,
|
||||
}
|
||||
|
||||
if err := connector.Validate(ctx, config); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ import (
|
||||
"github.com/Tencent/WeKnora/internal/datasource/connector/feishu/core"
|
||||
"github.com/Tencent/WeKnora/internal/datasource/connector/feishu/drive"
|
||||
"github.com/Tencent/WeKnora/internal/datasource/connector/feishu/wiki"
|
||||
gitlabConnector "github.com/Tencent/WeKnora/internal/datasource/connector/gitlab"
|
||||
imaConnector "github.com/Tencent/WeKnora/internal/datasource/connector/ima"
|
||||
notionConnector "github.com/Tencent/WeKnora/internal/datasource/connector/notion"
|
||||
rssConnector "github.com/Tencent/WeKnora/internal/datasource/connector/rss"
|
||||
@@ -1660,6 +1661,9 @@ func initConnectorRegistry() (*datasource.ConnectorRegistry, error) {
|
||||
if err := registry.Register(rssConnector.NewConnector()); err != nil {
|
||||
errs = errors.Join(errs, fmt.Errorf("register rss connector: %w", err))
|
||||
}
|
||||
if err := registry.Register(gitlabConnector.NewConnector()); err != nil {
|
||||
errs = errors.Join(errs, fmt.Errorf("register gitlab connector: %w", err))
|
||||
}
|
||||
|
||||
// Future connectors will be registered here:
|
||||
// if err := registry.Register(confluenceConnector.NewConnector()); err != nil { ... }
|
||||
|
||||
@@ -277,6 +277,14 @@ var ConnectorMetadataRegistry = map[string]ConnectorMetadata{
|
||||
AuthType: "custom",
|
||||
Capabilities: []string{"incremental"},
|
||||
},
|
||||
types.ConnectorTypeGitLab: {
|
||||
Type: types.ConnectorTypeGitLab,
|
||||
Name: "GitLab",
|
||||
Description: "Sync files from GitLab projects",
|
||||
Priority: 8,
|
||||
AuthType: "token",
|
||||
Capabilities: []string{"incremental", "hierarchical"},
|
||||
},
|
||||
}
|
||||
|
||||
// ListAvailableConnectors returns all available connector metadata
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
package gitlab
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/datasource"
|
||||
)
|
||||
|
||||
type client struct {
|
||||
baseURL, token string
|
||||
http *http.Client
|
||||
}
|
||||
type apiError struct {
|
||||
endpoint string
|
||||
status int
|
||||
}
|
||||
|
||||
func (e *apiError) Error() string {
|
||||
return fmt.Sprintf("gitlab API %s: status %d", e.endpoint, e.status)
|
||||
}
|
||||
|
||||
type project struct {
|
||||
ID int64 `json:"id"`
|
||||
PathWithNamespace string `json:"path_with_namespace"`
|
||||
Name string `json:"name"`
|
||||
WebURL string `json:"web_url"`
|
||||
DefaultBranch string `json:"default_branch"`
|
||||
Namespace struct {
|
||||
ID int64 `json:"id"`
|
||||
} `json:"namespace"`
|
||||
}
|
||||
type treeEntry struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
type member struct {
|
||||
Username string `json:"username"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
type comparison struct {
|
||||
Diffs []struct {
|
||||
OldPath string `json:"old_path"`
|
||||
NewPath string `json:"new_path"`
|
||||
NewFile bool `json:"new_file"`
|
||||
DeletedFile bool `json:"deleted_file"`
|
||||
RenamedFile bool `json:"renamed_file"`
|
||||
} `json:"diffs"`
|
||||
CompareTimeout bool `json:"compare_timeout"`
|
||||
CompareSameRef bool `json:"compare_same_ref"`
|
||||
}
|
||||
|
||||
func newClient(baseURL, token string) (*client, error) {
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
if baseURL == "" || strings.TrimSpace(token) == "" {
|
||||
return nil, fmt.Errorf("GitLab platform configuration is missing")
|
||||
}
|
||||
if err := datasource.ValidateConnectorBaseURL(baseURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !strings.Contains(baseURL, "://") {
|
||||
baseURL = "https://" + baseURL
|
||||
}
|
||||
if !strings.HasSuffix(baseURL, "/api/v4") {
|
||||
baseURL += "/api/v4"
|
||||
}
|
||||
return &client{baseURL: baseURL, token: token, http: datasource.NewConnectorHTTPClient(30 * time.Second)}, nil
|
||||
}
|
||||
func (c *client) get(ctx context.Context, endpoint string, out interface{}) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+endpoint, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("PRIVATE-TOKEN", c.token)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return &apiError{endpoint: endpoint, status: resp.StatusCode}
|
||||
}
|
||||
return json.Unmarshal(body, out)
|
||||
}
|
||||
func (c *client) getRaw(ctx context.Context, endpoint string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("PRIVATE-TOKEN", c.token)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, &apiError{endpoint: endpoint, status: resp.StatusCode}
|
||||
}
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
func projectPath(id string) string { return url.PathEscape(id) }
|
||||
func (c *client) project(ctx context.Context, id string) (*project, error) {
|
||||
var p project
|
||||
err := c.get(ctx, "/projects/"+projectPath(id), &p)
|
||||
return &p, err
|
||||
}
|
||||
func (c *client) projects(ctx context.Context) ([]project, error) {
|
||||
var p []project
|
||||
err := c.get(ctx, "/projects?membership=true&per_page=100&order_by=path_with_namespace&sort=asc", &p)
|
||||
return p, err
|
||||
}
|
||||
|
||||
// ping verifies that the supplied private token is accepted by this GitLab
|
||||
// instance. It avoids project-list ordering parameters that older GitLab
|
||||
// deployments may reject even when the token is valid.
|
||||
func (c *client) ping(ctx context.Context) error {
|
||||
var user struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
return c.get(ctx, "/user", &user)
|
||||
}
|
||||
func (c *client) members(ctx context.Context, id string) ([]member, error) {
|
||||
var m []member
|
||||
allEndpoint := "/projects/" + projectPath(id) + "/members/all?per_page=100"
|
||||
if err := c.get(ctx, allEndpoint, &m); err == nil {
|
||||
return m, nil
|
||||
} else {
|
||||
// Some GitLab deployments do not expose the inherited-members endpoint.
|
||||
// The standard members endpoint still verifies direct project membership.
|
||||
allErr := err
|
||||
m = nil
|
||||
membersEndpoint := "/projects/" + projectPath(id) + "/members?per_page=100"
|
||||
if err := c.get(ctx, membersEndpoint, &m); err == nil {
|
||||
return m, nil
|
||||
} else {
|
||||
return nil, fmt.Errorf("members query failed (%v); fallback failed (%w)", allErr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
func (c *client) groupMembers(ctx context.Context, id int64) ([]member, error) {
|
||||
var m []member
|
||||
err := c.get(ctx, fmt.Sprintf("/groups/%d/members?per_page=100", id), &m)
|
||||
return m, err
|
||||
}
|
||||
func (c *client) commitSHA(ctx context.Context, id, ref string) (string, error) {
|
||||
var v struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
err := c.get(ctx, "/projects/"+projectPath(id)+"/repository/commits/"+url.PathEscape(ref), &v)
|
||||
return v.ID, err
|
||||
}
|
||||
func (c *client) tree(ctx context.Context, id, ref, dir string) ([]treeEntry, error) {
|
||||
q := url.Values{"ref": {ref}, "per_page": {"100"}, "page": {"1"}}
|
||||
if dir != "" {
|
||||
q.Set("path", dir)
|
||||
}
|
||||
endpoint := "/projects/" + projectPath(id) + "/repository/tree"
|
||||
var all []treeEntry
|
||||
for {
|
||||
var page []treeEntry
|
||||
nextPage, err := c.getTreePage(ctx, endpoint, q, &page)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all = append(all, page...)
|
||||
if nextPage == "" {
|
||||
return all, nil
|
||||
}
|
||||
q.Set("page", nextPage)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *client) getTreePage(ctx context.Context, endpoint string, query url.Values, out interface{}) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+endpoint+"?"+query.Encode(), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("PRIVATE-TOKEN", c.token)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", &apiError{endpoint: endpoint, status: resp.StatusCode}
|
||||
}
|
||||
if err := json.Unmarshal(body, out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resp.Header.Get("X-Next-Page"), nil
|
||||
}
|
||||
func (c *client) raw(ctx context.Context, id, ref, file string) ([]byte, error) {
|
||||
q := url.Values{"ref": {ref}}
|
||||
encodedFile := gitlabFilePathEscape(file)
|
||||
rawEndpoint := "/projects/" + projectPath(id) + "/repository/files/" + encodedFile + "/raw?" + q.Encode()
|
||||
content, err := c.getRaw(ctx, rawEndpoint)
|
||||
if err == nil {
|
||||
return content, nil
|
||||
}
|
||||
var apiErr *apiError
|
||||
if !errors.As(err, &apiErr) || apiErr.status != http.StatusNotFound {
|
||||
return nil, fmt.Errorf("gitlab raw file: %w", err)
|
||||
}
|
||||
|
||||
// Some GitLab deployments expose the file detail endpoint but return 404
|
||||
// for the otherwise standard /raw route. The detail response contains the
|
||||
// same content as base64 and provides a compatible fallback.
|
||||
var detail struct {
|
||||
Encoding string `json:"encoding"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
detailEndpoint := "/projects/" + projectPath(id) + "/repository/files/" + encodedFile + "?" + q.Encode()
|
||||
if err := c.get(ctx, detailEndpoint, &detail); err != nil {
|
||||
return nil, fmt.Errorf("gitlab file content: %w", err)
|
||||
}
|
||||
if detail.Encoding != "base64" {
|
||||
return nil, fmt.Errorf("gitlab file content: unsupported encoding %q", detail.Encoding)
|
||||
}
|
||||
content, err = base64.StdEncoding.DecodeString(detail.Content)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gitlab file content: decode base64: %w", err)
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// gitlabFilePathEscape mirrors the company GitLab raw-file route: only ASCII
|
||||
// letters, digits, hyphen and underscore remain literal. In particular dots,
|
||||
// path separators and UTF-8 bytes must be percent-encoded.
|
||||
func gitlabFilePathEscape(file string) string {
|
||||
const hex = "0123456789ABCDEF"
|
||||
var b strings.Builder
|
||||
b.Grow(len(file) * 3)
|
||||
for _, c := range []byte(file) {
|
||||
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' {
|
||||
b.WriteByte(c)
|
||||
continue
|
||||
}
|
||||
b.WriteByte('%')
|
||||
b.WriteByte(hex[c>>4])
|
||||
b.WriteByte(hex[c&0x0f])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
func (c *client) compare(ctx context.Context, id, from, to string) (*comparison, error) {
|
||||
q := url.Values{"from": {from}, "to": {to}}
|
||||
var v comparison
|
||||
err := c.get(ctx, "/projects/"+projectPath(id)+"/repository/compare?"+q.Encode(), &v)
|
||||
return &v, err
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
package gitlab
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
secutils "github.com/Tencent/WeKnora/internal/utils"
|
||||
)
|
||||
|
||||
func allowLocalGitLabServer(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("SSRF_WHITELIST", "127.0.0.1,::1,localhost")
|
||||
secutils.ResetSSRFWhitelistForTest()
|
||||
t.Cleanup(secutils.ResetSSRFWhitelistForTest)
|
||||
}
|
||||
|
||||
func TestConnectorValidateUsesDataSourceCredentials(t *testing.T) {
|
||||
allowLocalGitLabServer(t)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v4/user" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if got := r.Header.Get("PRIVATE-TOKEN"); got != "per-source-token" {
|
||||
t.Fatalf("PRIVATE-TOKEN = %q", got)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"id": 42}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ds := &types.DataSourceConfig{
|
||||
Credentials: map[string]interface{}{
|
||||
"base_url": server.URL,
|
||||
"access_token": "per-source-token",
|
||||
},
|
||||
}
|
||||
|
||||
if err := NewConnector().Validate(context.Background(), ds); err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectorValidateReturnsGitLabAPIError(t *testing.T) {
|
||||
allowLocalGitLabServer(t)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v4/user" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, "invalid token", http.StatusUnauthorized)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := NewConnector().Validate(context.Background(), &types.DataSourceConfig{
|
||||
Credentials: map[string]interface{}{
|
||||
"base_url": server.URL,
|
||||
"access_token": "invalid-token",
|
||||
},
|
||||
})
|
||||
var apiErr *apiError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("Validate() error = %v, want GitLab API error", err)
|
||||
}
|
||||
if apiErr.endpoint != "/user" || apiErr.status != http.StatusUnauthorized {
|
||||
t.Fatalf("apiError = %#v", apiErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectorValidateRejectsMissingCredentials(t *testing.T) {
|
||||
err := NewConnector().Validate(context.Background(), &types.DataSourceConfig{
|
||||
Credentials: map[string]interface{}{"base_url": "https://gitlab.example.com"},
|
||||
})
|
||||
if err == nil || err.Error() != "GitLab platform configuration is missing" {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClientNormalizesAPIBaseURL(t *testing.T) {
|
||||
allowLocalGitLabServer(t)
|
||||
|
||||
server := httptest.NewServer(http.NotFoundHandler())
|
||||
defer server.Close()
|
||||
|
||||
c, err := newClient(server.URL+"/", "token")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := c.baseURL, server.URL+"/api/v4"; got != want {
|
||||
t.Fatalf("baseURL = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchIncrementalSyncsMultipleProjects(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/projects/1":
|
||||
_, _ = w.Write([]byte(`{"id":1,"name":"project-one","path_with_namespace":"group/project-one","web_url":"https://gitlab.test/group/project-one","default_branch":"master"}`))
|
||||
case "/projects/2":
|
||||
_, _ = w.Write([]byte(`{"id":2,"name":"project-two","path_with_namespace":"group/project-two","web_url":"https://gitlab.test/group/project-two","default_branch":"master"}`))
|
||||
case "/projects/1/repository/commits/master":
|
||||
_, _ = w.Write([]byte(`{"id":"commit-1"}`))
|
||||
case "/projects/2/repository/commits/master":
|
||||
_, _ = w.Write([]byte(`{"id":"commit-2"}`))
|
||||
case "/projects/1/repository/tree":
|
||||
_, _ = w.Write([]byte(`[{"name":"one.md","type":"blob","path":"one.md"}]`))
|
||||
case "/projects/2/repository/tree":
|
||||
_, _ = w.Write([]byte(`[{"name":"two.md","type":"blob","path":"two.md"}]`))
|
||||
default:
|
||||
if strings.HasPrefix(r.URL.EscapedPath(), "/projects/1/repository/files/one%2Emd/raw") {
|
||||
_, _ = w.Write([]byte("one"))
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(r.URL.EscapedPath(), "/projects/2/repository/files/two%2Emd/raw") {
|
||||
_, _ = w.Write([]byte("two"))
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
connector := &Connector{
|
||||
client: &client{baseURL: server.URL, http: server.Client()},
|
||||
canonicalBase: server.URL,
|
||||
}
|
||||
config := &types.DataSourceConfig{Settings: map[string]interface{}{
|
||||
"projects": []interface{}{
|
||||
map[string]interface{}{"project_id": "1", "ref": "master", "paths": []interface{}{}},
|
||||
map[string]interface{}{"project_id": "2", "ref": "master", "paths": []interface{}{}},
|
||||
},
|
||||
}}
|
||||
|
||||
items, cursor, err := connector.FetchIncremental(context.Background(), config, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("first sync: %v", err)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("first sync items = %d, want 2", len(items))
|
||||
}
|
||||
if cursor == nil || fmt.Sprint(cursor.ConnectorCursor["projects"]) == "" {
|
||||
t.Fatal("first sync did not return per-project cursor state")
|
||||
}
|
||||
|
||||
items, _, err = connector.FetchIncremental(context.Background(), config, cursor)
|
||||
if err != nil {
|
||||
t.Fatalf("second sync: %v", err)
|
||||
}
|
||||
if len(items) != 0 {
|
||||
t.Fatalf("second sync items = %d, want 0", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
type gitLabStreamRecorder struct {
|
||||
items []types.FetchedItem
|
||||
checkpoints []*types.SyncCursor
|
||||
}
|
||||
|
||||
func (h *gitLabStreamRecorder) Emit(_ context.Context, item types.FetchedItem) error {
|
||||
h.items = append(h.items, item)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *gitLabStreamRecorder) Checkpoint(_ context.Context, cursor *types.SyncCursor) error {
|
||||
h.checkpoints = append(h.checkpoints, cursor)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestFetchStreamFiltersUnsupportedFilesAndCheckpointsProjects(t *testing.T) {
|
||||
var rawRequests []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/projects/1":
|
||||
_, _ = w.Write([]byte(`{"id":1,"name":"docs","path_with_namespace":"group/docs","web_url":"https://gitlab.test/group/docs","default_branch":"main"}`))
|
||||
case "/projects/1/repository/commits/main":
|
||||
_, _ = w.Write([]byte(`{"id":"commit-1"}`))
|
||||
case "/projects/1/repository/tree":
|
||||
_, _ = w.Write([]byte(`[
|
||||
{"name":"README.md","type":"blob","path":"README.md"},
|
||||
{"name":"server.go","type":"blob","path":"server.go"},
|
||||
{"name":"payload.exe","type":"blob","path":"payload.exe"}
|
||||
]`))
|
||||
default:
|
||||
if strings.Contains(r.URL.EscapedPath(), "/repository/files/") {
|
||||
rawRequests = append(rawRequests, r.URL.EscapedPath())
|
||||
_, _ = w.Write([]byte("# readme"))
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
connector := &Connector{client: &client{baseURL: server.URL, http: server.Client()}, canonicalBase: server.URL}
|
||||
config := &types.DataSourceConfig{Settings: map[string]interface{}{
|
||||
"projects": []interface{}{map[string]interface{}{"project_id": "1", "paths": []interface{}{}}},
|
||||
}}
|
||||
handler := &gitLabStreamRecorder{}
|
||||
next, err := connector.FetchStream(context.Background(), config, nil, handler)
|
||||
if err != nil {
|
||||
t.Fatalf("FetchStream() error = %v", err)
|
||||
}
|
||||
if len(handler.items) != 1 || handler.items[0].FileName != "docs-main/README.md" {
|
||||
t.Fatalf("emitted items = %#v", handler.items)
|
||||
}
|
||||
if len(rawRequests) != 1 || !strings.Contains(rawRequests[0], "README%2Emd") {
|
||||
t.Fatalf("raw requests = %v, want only README.md", rawRequests)
|
||||
}
|
||||
if len(handler.checkpoints) != 1 || next == nil {
|
||||
t.Fatalf("checkpoints = %d, next = %#v", len(handler.checkpoints), next)
|
||||
}
|
||||
projects, _ := next.ConnectorCursor["projects"].(map[string]string)
|
||||
if projects["1"] != "commit-1" {
|
||||
t.Fatalf("cursor projects = %#v", next.ConnectorCursor["projects"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSupportedFile(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
file string
|
||||
want bool
|
||||
}{
|
||||
{file: "docs/guide.MD", want: true},
|
||||
{file: "report.pdf", want: true},
|
||||
{file: "src/main.go", want: false},
|
||||
{file: "archive.tar.gz", want: false},
|
||||
{file: "LICENSE", want: false},
|
||||
} {
|
||||
if got := isSupportedFile(tc.file); got != tc.want {
|
||||
t.Errorf("isSupportedFile(%q) = %v, want %v", tc.file, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTreeFollowsGitLabPagination(t *testing.T) {
|
||||
allowLocalGitLabServer(t)
|
||||
var requestedPages []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v4/projects/1/repository/tree" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
requestedPages = append(requestedPages, r.URL.Query().Get("page"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Query().Get("page") {
|
||||
case "1":
|
||||
w.Header().Set("X-Next-Page", "2")
|
||||
_, _ = w.Write([]byte(`[{"name":"one.md","type":"blob","path":"one.md"}]`))
|
||||
case "2":
|
||||
_, _ = w.Write([]byte(`[{"name":"two.md","type":"blob","path":"two.md"}]`))
|
||||
default:
|
||||
http.Error(w, "unexpected page", http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c, err := newClient(server.URL, "token")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries, err := c.tree(context.Background(), "1", "main", "")
|
||||
if err != nil {
|
||||
t.Fatalf("tree() error = %v", err)
|
||||
}
|
||||
if len(entries) != 2 || entries[1].Path != "two.md" {
|
||||
t.Fatalf("entries = %#v", entries)
|
||||
}
|
||||
if got, want := strings.Join(requestedPages, ","), "1,2"; got != want {
|
||||
t.Fatalf("requested pages = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectoryExistsListsTheTargetPath(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/projects/18296/repository/tree" || r.URL.Query().Get("path") != "docs" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[{"name":"quickstart.mdx","type":"blob","path":"docs/quickstart.mdx"}]`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
connector := &Connector{client: &client{baseURL: server.URL, http: server.Client()}}
|
||||
exists, err := connector.directoryExists(context.Background(), "18296", "master", "docs")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !exists {
|
||||
t.Fatal("expected docs directory to exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMembersFallsBackWhenInheritedMembersEndpointIsUnavailable(t *testing.T) {
|
||||
t.Setenv("SSRF_WHITELIST", "127.0.0.1,::1,localhost")
|
||||
secutils.ResetSSRFWhitelistForTest()
|
||||
t.Cleanup(secutils.ResetSSRFWhitelistForTest)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v4/projects/18724/members/all":
|
||||
http.NotFound(w, r)
|
||||
case "/api/v4/projects/18724/members":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[{"username":"alice","state":"active"}]`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c, err := newClient(server.URL, "token")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
members, err := c.members(context.Background(), "18724")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(members) != 1 || members[0].Username != "alice" {
|
||||
t.Fatalf("members = %#v", members)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawFallsBackToBase64FileDetail(t *testing.T) {
|
||||
t.Setenv("SSRF_WHITELIST", "127.0.0.1,::1,localhost")
|
||||
secutils.ResetSSRFWhitelistForTest()
|
||||
t.Cleanup(secutils.ResetSSRFWhitelistForTest)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.EscapedPath() {
|
||||
case "/api/v4/projects/18724/repository/files/docs%2Finternal%2Freadme%2Emd/raw":
|
||||
http.NotFound(w, r)
|
||||
case "/api/v4/projects/18724/repository/files/docs%2Finternal%2Freadme%2Emd":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"encoding":"base64","content":"SGVsbG8sIEdpdExhYiE="}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c, err := newClient(server.URL, "token")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content, err := c.raw(context.Background(), "18724", "master", "docs/internal/readme.md")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(content) != "Hello, GitLab!" {
|
||||
t.Fatalf("content = %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitlabFilePathEscape(t *testing.T) {
|
||||
got := gitlabFilePathEscape("docs/internal/中文-file.md")
|
||||
want := "docs%2Finternal%2F%E4%B8%AD%E6%96%87-file%2Emd"
|
||||
if got != want {
|
||||
t.Fatalf("gitlabFilePathEscape() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
package gitlab
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/datasource"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
var _ datasource.StreamingConnector = (*Connector)(nil)
|
||||
|
||||
type Connector struct {
|
||||
client *client
|
||||
canonicalBase string
|
||||
}
|
||||
|
||||
// NewConnector creates a stateless connector. Each data source provides its
|
||||
// own GitLab URL and access token in its encrypted credentials.
|
||||
func NewConnector() *Connector { return &Connector{} }
|
||||
func (c *Connector) Type() string { return types.ConnectorTypeGitLab }
|
||||
|
||||
func (c *Connector) configured(ds *types.DataSourceConfig) (*Connector, error) {
|
||||
if c.client != nil {
|
||||
return c, nil
|
||||
}
|
||||
if ds == nil {
|
||||
return nil, datasource.ErrInvalidConfig
|
||||
}
|
||||
baseURL, _ := ds.Credentials["base_url"].(string)
|
||||
token, _ := ds.Credentials["access_token"].(string)
|
||||
client, err := newClient(baseURL, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Connector{client: client, canonicalBase: client.baseURL}, nil
|
||||
}
|
||||
|
||||
func (c *Connector) Validate(ctx context.Context, ds *types.DataSourceConfig) error {
|
||||
var err error
|
||||
if c, err = c.configured(ds); err != nil {
|
||||
return err
|
||||
}
|
||||
// Connection testing deliberately validates only the GitLab URL and token.
|
||||
return c.client.ping(ctx)
|
||||
}
|
||||
|
||||
func (c *Connector) directoryExists(ctx context.Context, projectID, ref, dir string) (bool, error) {
|
||||
entries, err := c.client.tree(ctx, projectID, ref, dir)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
// Git does not store empty directories. A successful non-empty listing of
|
||||
// the target path is therefore a reliable directory existence check and
|
||||
// avoids deployments that treat path="." as an invalid root path.
|
||||
return len(entries) > 0, nil
|
||||
}
|
||||
|
||||
func hasActiveMember(members []member, username string) bool {
|
||||
for _, m := range members {
|
||||
if m.Username == username && m.State == "active" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func (c *Connector) ListResources(ctx context.Context, ds *types.DataSourceConfig, parent string) ([]types.Resource, error) {
|
||||
var err error
|
||||
if c, err = c.configured(ds); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := parseConfig(ds); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if parent == "" {
|
||||
ps, err := c.client.projects(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]types.Resource, 0, len(ps))
|
||||
for _, p := range ps {
|
||||
out = append(out, types.Resource{ExternalID: fmt.Sprint(p.ID), Name: p.PathWithNamespace, Type: "project", URL: p.WebURL, HasChildren: true})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
id, dir := splitResourceID(parent)
|
||||
p, err := c.client.project(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ref := p.DefaultBranch
|
||||
entries, err := c.client.tree(ctx, id, ref, dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]types.Resource, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.Type == "tree" {
|
||||
out = append(out, types.Resource{ExternalID: id + ":" + e.Path, Name: e.Name, Type: "directory", ParentID: parent, HasChildren: true})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (c *Connector) ResolveResourceAncestors(context.Context, *types.DataSourceConfig, []string) ([]string, error) {
|
||||
return []string{}, nil
|
||||
}
|
||||
func (c *Connector) FetchAll(ctx context.Context, ds *types.DataSourceConfig, _ []string) ([]types.FetchedItem, error) {
|
||||
var err error
|
||||
if c, err = c.configured(ds); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg, err := parseConfig(ds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []types.FetchedItem
|
||||
for _, s := range cfg.Projects {
|
||||
p, err := c.client.project(ctx, s.ProjectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ref := s.Ref
|
||||
if ref == "" {
|
||||
ref = p.DefaultBranch
|
||||
}
|
||||
files, err := c.files(ctx, s.ProjectID, ref, s.Paths)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, file := range files {
|
||||
item, err := c.item(ctx, p, ref, file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type cursor struct {
|
||||
Projects map[string]string `json:"projects"`
|
||||
}
|
||||
|
||||
func (c *Connector) FetchIncremental(ctx context.Context, ds *types.DataSourceConfig, old *types.SyncCursor) ([]types.FetchedItem, *types.SyncCursor, error) {
|
||||
var err error
|
||||
if c, err = c.configured(ds); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
cfg, err := parseConfig(ds)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
prev := cursor{Projects: map[string]string{}}
|
||||
if old != nil {
|
||||
b, _ := json.Marshal(old.ConnectorCursor)
|
||||
_ = json.Unmarshal(b, &prev)
|
||||
if prev.Projects == nil {
|
||||
prev.Projects = map[string]string{}
|
||||
}
|
||||
}
|
||||
var out []types.FetchedItem
|
||||
next := cursor{Projects: map[string]string{}}
|
||||
for _, s := range cfg.Projects {
|
||||
p, err := c.client.project(ctx, s.ProjectID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
ref := s.Ref
|
||||
if ref == "" {
|
||||
ref = p.DefaultBranch
|
||||
}
|
||||
head, err := c.client.commitSHA(ctx, s.ProjectID, ref)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
previous := prev.Projects[s.ProjectID]
|
||||
if previous == "" {
|
||||
files, err := c.files(ctx, s.ProjectID, ref, s.Paths)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, f := range files {
|
||||
item, err := c.item(ctx, p, ref, f)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
} else if previous != head {
|
||||
diff, err := c.client.compare(ctx, s.ProjectID, previous, head)
|
||||
if err != nil || diff.CompareTimeout {
|
||||
// A compare may be unavailable after history rewrites or truncated by
|
||||
// the server. Re-enumerate the configured scope to preserve updates.
|
||||
files, listErr := c.files(ctx, s.ProjectID, ref, s.Paths)
|
||||
if listErr != nil {
|
||||
return nil, nil, fmt.Errorf("gitlab compare %s: %w", s.ProjectID, err)
|
||||
}
|
||||
for _, f := range files {
|
||||
item, itemErr := c.item(ctx, p, ref, f)
|
||||
if itemErr != nil {
|
||||
return nil, nil, itemErr
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
} else {
|
||||
for _, d := range diff.Diffs {
|
||||
if d.DeletedFile {
|
||||
if c.inScope(d.OldPath, s.Paths) {
|
||||
out = append(out, c.deleted(p, ref, d.OldPath))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if d.RenamedFile && c.inScope(d.OldPath, s.Paths) {
|
||||
out = append(out, c.deleted(p, ref, d.OldPath))
|
||||
}
|
||||
if c.inScope(d.NewPath, s.Paths) {
|
||||
item, err := c.item(ctx, p, ref, d.NewPath)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
next.Projects[s.ProjectID] = head
|
||||
}
|
||||
raw, _ := json.Marshal(next)
|
||||
return out, &types.SyncCursor{LastSyncTime: time.Now().UTC(), ConnectorCursor: map[string]interface{}{"projects": next.Projects, "raw": string(raw)}}, nil
|
||||
}
|
||||
|
||||
// FetchStream is the production sync path. It emits each supported repository
|
||||
// file as soon as it is read, so large GitLab projects do not accumulate their
|
||||
// contents in memory. The service prefers this method for StreamingConnector
|
||||
// implementations; FetchAll and FetchIncremental remain compatibility methods
|
||||
// required by the base Connector interface.
|
||||
func (c *Connector) FetchStream(
|
||||
ctx context.Context, ds *types.DataSourceConfig, old *types.SyncCursor, h datasource.StreamHandler,
|
||||
) (*types.SyncCursor, error) {
|
||||
var err error
|
||||
if c, err = c.configured(ds); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg, err := parseConfig(ds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prev := cursor{Projects: map[string]string{}}
|
||||
if old != nil {
|
||||
b, _ := json.Marshal(old.ConnectorCursor)
|
||||
_ = json.Unmarshal(b, &prev)
|
||||
if prev.Projects == nil {
|
||||
prev.Projects = map[string]string{}
|
||||
}
|
||||
}
|
||||
next := cursor{Projects: make(map[string]string, len(cfg.Projects))}
|
||||
for projectID, commit := range prev.Projects {
|
||||
next.Projects[projectID] = commit
|
||||
}
|
||||
|
||||
for _, selection := range cfg.Projects {
|
||||
project, err := c.client.project(ctx, selection.ProjectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ref := selection.Ref
|
||||
if ref == "" {
|
||||
ref = project.DefaultBranch
|
||||
}
|
||||
head, err := c.client.commitSHA(ctx, selection.ProjectID, ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
previous := prev.Projects[selection.ProjectID]
|
||||
switch {
|
||||
case previous == "":
|
||||
err = c.streamFiles(ctx, project, ref, selection.Paths, h)
|
||||
case previous != head:
|
||||
err = c.streamChanges(ctx, project, ref, previous, head, selection.Paths, h)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
next.Projects[selection.ProjectID] = head
|
||||
checkpoint := gitLabCursor(next)
|
||||
if err := h.Checkpoint(ctx, checkpoint); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return gitLabCursor(next), nil
|
||||
}
|
||||
|
||||
func gitLabCursor(value cursor) *types.SyncCursor {
|
||||
raw, _ := json.Marshal(value)
|
||||
return &types.SyncCursor{
|
||||
LastSyncTime: time.Now().UTC(),
|
||||
ConnectorCursor: map[string]interface{}{"projects": value.Projects, "raw": string(raw)},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Connector) streamChanges(
|
||||
ctx context.Context, project *project, ref, from, to string, roots []string, h datasource.StreamHandler,
|
||||
) error {
|
||||
diff, err := c.client.compare(ctx, fmt.Sprint(project.ID), from, to)
|
||||
if err != nil || diff.CompareTimeout {
|
||||
// A compare can be unavailable after history rewrites or be truncated by
|
||||
// GitLab. Re-enumerating the configured scope preserves file updates.
|
||||
return c.streamFiles(ctx, project, ref, roots, h)
|
||||
}
|
||||
for _, change := range diff.Diffs {
|
||||
if change.DeletedFile {
|
||||
if c.inScope(change.OldPath, roots) && isSupportedFile(change.OldPath) {
|
||||
if err := h.Emit(ctx, c.deleted(project, ref, change.OldPath)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if change.RenamedFile && c.inScope(change.OldPath, roots) && isSupportedFile(change.OldPath) {
|
||||
if err := h.Emit(ctx, c.deleted(project, ref, change.OldPath)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if c.inScope(change.NewPath, roots) && isSupportedFile(change.NewPath) {
|
||||
item, err := c.item(ctx, project, ref, change.NewPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.Emit(ctx, item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Connector) streamFiles(
|
||||
ctx context.Context, project *project, ref string, roots []string, h datasource.StreamHandler,
|
||||
) error {
|
||||
return c.walkFiles(ctx, fmt.Sprint(project.ID), ref, roots, func(file string) error {
|
||||
item, err := c.item(ctx, project, ref, file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return h.Emit(ctx, item)
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Connector) files(ctx context.Context, id, ref string, roots []string) ([]string, error) {
|
||||
var out []string
|
||||
err := c.walkFiles(ctx, id, ref, roots, func(file string) error {
|
||||
out = append(out, file)
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// walkFiles visits supported blobs while traversing the selected directories.
|
||||
// It deliberately does not collect paths, keeping streaming sync memory bounded
|
||||
// by the traversal depth plus the current file body.
|
||||
func (c *Connector) walkFiles(ctx context.Context, id, ref string, roots []string, visit func(string) error) error {
|
||||
if len(roots) == 0 {
|
||||
roots = []string{""}
|
||||
}
|
||||
var walk func(string) error
|
||||
walk = func(dir string) error {
|
||||
entries, err := c.client.tree(ctx, id, ref, dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.Type == "tree" {
|
||||
if err := walk(e.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if e.Type == "blob" && isSupportedFile(e.Path) {
|
||||
if err := visit(e.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, r := range roots {
|
||||
if err := walk(r); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isSupportedFile(file string) bool {
|
||||
_, ok := gitLabSupportedFileExtensions[strings.ToLower(path.Ext(file))]
|
||||
return ok
|
||||
}
|
||||
|
||||
// gitLabSupportedFileExtensions limits repository sync to formats the current
|
||||
// knowledge import pipeline can process. This is intentionally connector-local:
|
||||
// GitLab exposes arbitrary repository blobs, unlike document-centric sources.
|
||||
var gitLabSupportedFileExtensions = map[string]struct{}{
|
||||
".pdf": {}, ".txt": {}, ".docx": {}, ".doc": {}, ".epub": {},
|
||||
".html": {}, ".htm": {}, ".mhtml": {}, ".md": {}, ".markdown": {},
|
||||
".png": {}, ".jpg": {}, ".jpeg": {}, ".gif": {},
|
||||
".csv": {}, ".xlsx": {}, ".xls": {}, ".pptx": {}, ".ppt": {}, ".json": {},
|
||||
".mp3": {}, ".wav": {}, ".m4a": {}, ".flac": {}, ".ogg": {},
|
||||
}
|
||||
|
||||
func (c *Connector) item(ctx context.Context, p *project, ref, file string) (types.FetchedItem, error) {
|
||||
body, err := c.client.raw(ctx, fmt.Sprint(p.ID), ref, file)
|
||||
if err != nil {
|
||||
return types.FetchedItem{}, err
|
||||
}
|
||||
id := fmt.Sprintf("gitlab:%s:%d:%s:%s", c.canonicalBase, p.ID, ref, file)
|
||||
return types.FetchedItem{ExternalID: id, Title: p.PathWithNamespace + "/" + file, FileName: knowledgeRelativePath(p.Name, ref, file), Content: body, ContentType: "text/plain", UpdatedAt: time.Now().UTC(), SourceResourceID: fmt.Sprint(p.ID), Metadata: map[string]string{"channel": types.ConnectorTypeGitLab, "source_type": "gitlab", "gitlab_project_id": fmt.Sprint(p.ID), "gitlab_ref": ref, "gitlab_path": file, "gitlab_url": p.WebURL + "/-/blob/" + ref + "/" + file}}, nil
|
||||
}
|
||||
|
||||
// knowledgeRelativePath maps a repository file to the KB folder convention:
|
||||
// <GitLab project name>-<branch>/<repository-relative file path>.
|
||||
func knowledgeRelativePath(projectName, ref, file string) string {
|
||||
root := strings.TrimSpace(projectName) + "-" + strings.ReplaceAll(strings.TrimSpace(ref), "/", "-")
|
||||
return path.Join(root, file)
|
||||
}
|
||||
func (c *Connector) deleted(p *project, ref, file string) types.FetchedItem {
|
||||
return types.FetchedItem{ExternalID: fmt.Sprintf("gitlab:%s:%d:%s:%s", c.canonicalBase, p.ID, ref, file), IsDeleted: true, Metadata: map[string]string{"channel": types.ConnectorTypeGitLab, "gitlab_path": file}}
|
||||
}
|
||||
func (c *Connector) inScope(file string, roots []string) bool {
|
||||
if len(roots) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, r := range roots {
|
||||
if file == r || strings.HasPrefix(file, r+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func splitResourceID(value string) (string, string) {
|
||||
parts := strings.SplitN(value, ":", 2)
|
||||
if len(parts) == 1 {
|
||||
return value, ""
|
||||
}
|
||||
return parts[0], parts[1]
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package gitlab
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/datasource"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
type projectSelection struct {
|
||||
ProjectID string `json:"project_id"`
|
||||
Ref string `json:"ref"`
|
||||
Paths []string `json:"paths"`
|
||||
}
|
||||
|
||||
type config struct {
|
||||
Projects []projectSelection `json:"projects"`
|
||||
}
|
||||
|
||||
func parseConfig(ds *types.DataSourceConfig) (*config, error) {
|
||||
if ds == nil {
|
||||
return nil, datasource.ErrInvalidConfig
|
||||
}
|
||||
raw, ok := ds.Settings["projects"]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: settings.projects is required", datasource.ErrInvalidConfig)
|
||||
}
|
||||
items, ok := raw.([]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: settings.projects must be an array", datasource.ErrInvalidConfig)
|
||||
}
|
||||
out := &config{Projects: make([]projectSelection, 0, len(items))}
|
||||
seen := map[string]bool{}
|
||||
for _, rawProject := range items {
|
||||
m, ok := rawProject.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: invalid project selection", datasource.ErrInvalidConfig)
|
||||
}
|
||||
id, _ := m["project_id"].(string)
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || seen[id] {
|
||||
return nil, fmt.Errorf("%w: project_id must be unique and non-empty", datasource.ErrInvalidConfig)
|
||||
}
|
||||
seen[id] = true
|
||||
ref, _ := m["ref"].(string)
|
||||
p := projectSelection{ProjectID: id, Ref: strings.TrimSpace(ref)}
|
||||
if rawPaths, exists := m["paths"]; exists {
|
||||
values, ok := rawPaths.([]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: paths must be an array", datasource.ErrInvalidConfig)
|
||||
}
|
||||
for _, value := range values {
|
||||
s, ok := value.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: path must be a string", datasource.ErrInvalidConfig)
|
||||
}
|
||||
normalized, err := normalizePath(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Paths = append(p.Paths, normalized)
|
||||
}
|
||||
}
|
||||
p.Paths = collapsePaths(p.Paths)
|
||||
out.Projects = append(out.Projects, p)
|
||||
}
|
||||
if len(out.Projects) == 0 {
|
||||
return nil, fmt.Errorf("%w: at least one project is required", datasource.ErrInvalidConfig)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func normalizePath(value string) (string, error) {
|
||||
v := strings.Trim(strings.TrimSpace(value), "/")
|
||||
if v == "" {
|
||||
return "", nil
|
||||
}
|
||||
if strings.Contains(v, "\\") {
|
||||
return "", fmt.Errorf("%w: path must use forward slashes", datasource.ErrInvalidConfig)
|
||||
}
|
||||
if path.Clean(v) != v || v == "." || strings.HasPrefix(v, "../") || strings.Contains(v, "/../") {
|
||||
return "", fmt.Errorf("%w: invalid repository path", datasource.ErrInvalidConfig)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func collapsePaths(paths []string) []string {
|
||||
if len(paths) == 0 {
|
||||
return nil
|
||||
}
|
||||
sort.Strings(paths)
|
||||
for _, p := range paths {
|
||||
if p == "" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(paths))
|
||||
for _, p := range paths {
|
||||
if len(out) > 0 && (p == out[len(out)-1] || strings.HasPrefix(p, out[len(out)-1]+"/")) {
|
||||
continue
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package gitlab
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
func TestParseConfigCollapsesDirectories(t *testing.T) {
|
||||
cfg, err := parseConfig(&types.DataSourceConfig{Settings: map[string]interface{}{
|
||||
"projects": []interface{}{map[string]interface{}{
|
||||
"project_id": "team/docs", "ref": "main",
|
||||
"paths": []interface{}{"docs/guide", "docs", "docs"},
|
||||
}},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cfg.Projects) != 1 || len(cfg.Projects[0].Paths) != 1 || cfg.Projects[0].Paths[0] != "docs" {
|
||||
t.Fatalf("unexpected config: %#v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigRootMeansWholeProject(t *testing.T) {
|
||||
cfg, err := parseConfig(&types.DataSourceConfig{Settings: map[string]interface{}{
|
||||
"projects": []interface{}{map[string]interface{}{"project_id": "42", "paths": []interface{}{"/"}}},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cfg.Projects[0].Paths) != 0 {
|
||||
t.Fatalf("root path must collapse to whole project: %#v", cfg.Projects[0].Paths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePathRejectsTraversal(t *testing.T) {
|
||||
if _, err := normalizePath("docs/../secrets"); err == nil {
|
||||
t.Fatal("expected traversal rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnowledgeRelativePathPreservesRepositoryTreeBelowProjectAndBranch(t *testing.T) {
|
||||
got := knowledgeRelativePath("knowledge", "feature/login", "docs/guide/install.md")
|
||||
if got != "knowledge-feature-login/docs/guide/install.md" {
|
||||
t.Fatalf("knowledge relative path = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasActiveMember(t *testing.T) {
|
||||
members := []member{{Username: "liguowei", State: "active"}, {Username: "disabled", State: "blocked"}}
|
||||
if !hasActiveMember(members, "liguowei") {
|
||||
t.Fatal("expected active member to match")
|
||||
}
|
||||
if hasActiveMember(members, "disabled") {
|
||||
t.Fatal("blocked member must not match")
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ const (
|
||||
ConnectorTypeSlack = "slack"
|
||||
ConnectorTypeIMAP = "imap"
|
||||
ConnectorTypeRSS = "rss"
|
||||
ConnectorTypeGitLab = "gitlab"
|
||||
ConnectorTypeIMA = "ima"
|
||||
|
||||
// Sync modes
|
||||
|
||||
Reference in New Issue
Block a user