feat: 添加 GitLab 仓库支持和认证配置

- 添加 GitLab Token 环境变量和运行时配置支持
- 实现 GitLab API v4 集成(仓库树和文件获取)
- 在系统配置界面添加 GitLab Token 配置
- 支持容器环境下访问私有 GitLab 仓库
- 优化错误提示和调试日志
- 更新文档说明 GitLab 使用方法
This commit is contained in:
lintsinghua 2025-10-27 15:42:02 +08:00
parent 7aaaa4a900
commit ab64fde709
8 changed files with 151 additions and 22 deletions

View File

@ -101,10 +101,23 @@ VITE_USE_LOCAL_DB=true
# VITE_SUPABASE_URL=https://your-project.supabase.co
# VITE_SUPABASE_ANON_KEY=your-anon-key-here
# ==================== GitHub 集成配置 (可选) ====================
# 用于仓库分析功能
# ==================== Git 仓库集成配置 (可选) ====================
# 用于访问私有仓库进行代码审计
# GitHub Token
# 获取Token: https://github.com/settings/tokens
# VITE_GITHUB_TOKEN=your_github_token_here
# 权限需求: repo (访问私有仓库)
# VITE_GITHUB_TOKEN=ghp_your_github_token_here
# GitLab Token
# 获取Token: https://gitlab.com/-/profile/personal_access_tokens
# 权限需求: read_api, read_repository
# VITE_GITLAB_TOKEN=glpat-your_gitlab_token_here
# 💡 提示:
# 1. 公开仓库无需配置 Token
# 2. 私有仓库或容器内访问需要配置相应的 Token
# 3. 支持自建 GitLab 服务器Token 格式相同)
# ==================== 应用配置 ====================
VITE_APP_ID=xcodereviewer

View File

@ -637,10 +637,11 @@ pnpm lint
> 💡 **提示**不配置Supabase时系统以演示模式运行数据不持久化
#### GitHub集成配置(可选)
#### Git仓库集成配置(可选)
| 变量名 | 必需 | 说明 |
|--------|------|------|
| `VITE_GITHUB_TOKEN` | ❌ | GitHub Personal Access Token用于仓库分析功能 |
| `VITE_GITHUB_TOKEN` | ❌ | GitHub Personal Access Token用于访问私有GitHub仓库 |
| `VITE_GITLAB_TOKEN` | ❌ | GitLab Personal Access Token用于访问私有GitLab仓库 |
#### 分析行为配置
| 变量名 | 默认值 | 说明 |

View File

@ -638,10 +638,11 @@ pnpm lint
> 💡 **Note**: Without Supabase config, system runs in demo mode without data persistence
#### GitHub Integration Configuration (Optional)
#### Git Repository Integration Configuration (Optional)
| Variable | Required | Description |
|----------|----------|-------------|
| `VITE_GITHUB_TOKEN` | ❌ | GitHub Personal Access Token (for repository analysis) |
| `VITE_GITHUB_TOKEN` | ❌ | GitHub Personal Access Token (for accessing private GitHub repositories) |
| `VITE_GITLAB_TOKEN` | ❌ | GitLab Personal Access Token (for accessing private GitLab repositories) |
#### Analysis Behavior Configuration
| Variable | Default | Description |

View File

@ -134,12 +134,27 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
} else {
// GitHub/GitLab等远程仓库
console.log('📡 调用 runRepositoryAudit...');
// 从运行时配置中获取 Token
const getRuntimeConfig = () => {
try {
const saved = localStorage.getItem('xcodereviewer_runtime_config');
return saved ? JSON.parse(saved) : null;
} catch {
return null;
}
};
const runtimeConfig = getRuntimeConfig();
const githubToken = runtimeConfig?.githubToken || (import.meta.env.VITE_GITHUB_TOKEN as string | undefined);
const gitlabToken = runtimeConfig?.gitlabToken || (import.meta.env.VITE_GITLAB_TOKEN as string | undefined);
taskId = await runRepositoryAudit({
projectId: project.id,
repoUrl: project.repository_url!,
branch: taskForm.branch_name || project.default_branch || 'main',
exclude: taskForm.exclude_patterns,
githubToken: undefined,
githubToken,
gitlabToken,
createdBy: 'local-user'
});
}

View File

@ -306,11 +306,11 @@ export default function TerminalProgressDialog({
addLog("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", "error");
addLog("可能的原因:", "error");
addLog(" • 网络连接问题", "error");
addLog(" • 仓库访问权限不足", "error");
addLog(" • GitHub API 限流", "error");
addLog(" • 仓库访问权限不足(私有仓库需配置 Token", "error");
addLog(" • GitHub/GitLab API 限流", "error");
addLog(" • 代码文件格式错误", "error");
addLog("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", "error");
addLog("💡 建议: 检查网络连接和仓库配置后重试", "warning");
addLog("💡 建议: 检查网络连接、仓库配置和 Token 设置后重试", "warning");
setIsFailed(true);
if (pollIntervalRef.current) {

View File

@ -80,6 +80,9 @@ interface SystemConfigData {
// GitHub 配置
githubToken: string;
// GitLab 配置
gitlabToken: string;
// 分析配置
maxAnalyzeFiles: number;
llmConcurrency: number;
@ -111,6 +114,7 @@ export function SystemConfig() {
doubaoApiKey: '',
ollamaBaseUrl: 'http://localhost:11434/v1',
githubToken: '',
gitlabToken: '',
maxAnalyzeFiles: 40,
llmConcurrency: 2,
llmGapMs: 500,
@ -170,6 +174,7 @@ export function SystemConfig() {
doubaoApiKey: import.meta.env.VITE_DOUBAO_API_KEY || '',
ollamaBaseUrl: import.meta.env.VITE_OLLAMA_BASE_URL || 'http://localhost:11434/v1',
githubToken: import.meta.env.VITE_GITHUB_TOKEN || '',
gitlabToken: import.meta.env.VITE_GITLAB_TOKEN || '',
maxAnalyzeFiles: Number(import.meta.env.VITE_MAX_ANALYZE_FILES) || 40,
llmConcurrency: Number(import.meta.env.VITE_LLM_CONCURRENCY) || 2,
llmGapMs: Number(import.meta.env.VITE_LLM_GAP_MS) || 500,
@ -611,6 +616,36 @@ export function SystemConfig() {
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>GitLab </CardTitle>
<CardDescription> GitLab Personal Access Token 访</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label>GitLab Token</Label>
<div className="flex gap-2">
<Input
type={showApiKeys['gitlab'] ? 'text' : 'password'}
value={config.gitlabToken}
onChange={(e) => updateConfig('gitlabToken', e.target.value)}
placeholder="glpat-xxxxxxxxxxxx"
/>
<Button
variant="outline"
size="icon"
onClick={() => toggleShowApiKey('gitlab')}
>
{showApiKeys['gitlab'] ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
<p className="text-xs text-muted-foreground">
https://gitlab.com/-/profile/personal_access_tokens
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>

View File

@ -28,12 +28,26 @@ async function githubApi<T>(url: string, token?: string): Promise<T> {
return res.json() as Promise<T>;
}
async function gitlabApi<T>(url: string, token?: string): Promise<T> {
const headers: Record<string, string> = { "Content-Type": "application/json" };
const t = token || (import.meta.env.VITE_GITLAB_TOKEN as string | undefined);
if (t) headers["PRIVATE-TOKEN"] = t;
const res = await fetch(url, { headers });
if (!res.ok) {
if (res.status === 401) throw new Error("GitLab API 401请配置 VITE_GITLAB_TOKEN 或确认仓库权限");
if (res.status === 403) throw new Error("GitLab API 403请确认仓库权限/频率限制");
throw new Error(`GitLab API ${res.status}: ${url}`);
}
return res.json() as Promise<T>;
}
export async function runRepositoryAudit(params: {
projectId: string;
repoUrl: string;
branch?: string;
exclude?: string[];
githubToken?: string;
gitlabToken?: string;
createdBy?: string;
}) {
const branch = params.branch || "main";
@ -54,7 +68,12 @@ export async function runRepositoryAudit(params: {
const taskId = (task as any).id as string;
console.log(`🚀 GitHub任务已创建: ${taskId},准备启动后台扫描...`);
// 检测仓库类型
const isGitHub = /github\.com/i.test(params.repoUrl);
const isGitLab = /gitlab\.com|gitlab\./i.test(params.repoUrl);
const repoType = isGitHub ? "GitHub" : isGitLab ? "GitLab" : "Git";
console.log(`🚀 ${repoType}任务已创建: ${taskId},准备启动后台扫描...`);
// 启动后台审计任务,不阻塞返回
(async () => {
@ -70,14 +89,47 @@ export async function runRepositoryAudit(params: {
} as any);
console.log(`✅ 任务 ${taskId}: 状态已更新为 running`);
let files: { path: string; url?: string }[] = [];
if (isGitHub) {
// GitHub 仓库处理
const m = params.repoUrl.match(/github\.com\/(.+?)\/(.+?)(?:\.git)?$/i);
if (!m) throw new Error("仅支持 GitHub 仓库 URL例如 https://github.com/owner/repo");
if (!m) throw new Error("GitHub 仓库 URL 格式错误,例如 https://github.com/owner/repo");
const owner = m[1];
const repo = m[2];
const treeUrl = `https://api.github.com/repos/${owner}/${repo}/git/trees/${encodeURIComponent(branch)}?recursive=1`;
const tree = await githubApi<{ tree: GithubTreeItem[] }>(treeUrl, params.githubToken);
let files = (tree.tree || []).filter(i => i.type === "blob" && isTextFile(i.path) && !matchExclude(i.path, excludes));
files = (tree.tree || [])
.filter(i => i.type === "blob" && isTextFile(i.path) && !matchExclude(i.path, excludes))
.map(i => ({ path: i.path, url: `https://raw.githubusercontent.com/${owner}/${repo}/${encodeURIComponent(branch)}/${i.path}` }));
} else if (isGitLab) {
// GitLab 仓库处理
const m = params.repoUrl.match(/gitlab\.com\/(.+?)\/(.+?)(?:\.git)?$/i);
if (!m) throw new Error("GitLab 仓库 URL 格式错误,例如 https://gitlab.com/owner/repo");
const projectPath = encodeURIComponent(`${m[1]}/${m[2]}`);
const treeUrl = `https://gitlab.com/api/v4/projects/${projectPath}/repository/tree?ref=${encodeURIComponent(branch)}&recursive=true&per_page=100`;
console.log(`📡 GitLab API: 获取仓库文件树 - ${treeUrl}`);
const tree = await gitlabApi<Array<{ path: string; type: string }>>(treeUrl, params.gitlabToken);
console.log(`✅ GitLab API: 获取到 ${tree.length} 个项目`);
files = tree
.filter(i => i.type === "blob" && isTextFile(i.path) && !matchExclude(i.path, excludes))
.map(i => ({
path: i.path,
// GitLab 文件 API 路径需要完整的 URL 编码(包括斜杠)
url: `https://gitlab.com/api/v4/projects/${projectPath}/repository/files/${encodeURIComponent(i.path)}/raw?ref=${encodeURIComponent(branch)}`
}));
console.log(`📝 GitLab: 过滤后可分析文件 ${files.length}`);
if (tree.length >= 100) {
console.warn(`⚠️ GitLab: 文件数量达到API限制(100),可能有文件未被扫描。建议使用排除模式减少文件数。`);
}
} else {
throw new Error("不支持的仓库类型,仅支持 GitHub 和 GitLab 仓库");
}
// 采样限制,优先分析较小文件与常见语言
files = files
.sort((a, b) => (a.path.length - b.path.length))
@ -107,8 +159,17 @@ export async function runRepositoryAudit(params: {
const f = files[current];
totalFiles++;
try {
const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${encodeURIComponent(branch)}/${f.path}`;
const contentRes = await fetch(rawUrl);
// 使用预先构建的 URL支持 GitHub 和 GitLab
const rawUrl = f.url!;
const headers: Record<string, string> = {};
// 为 GitLab 添加认证 Token
if (isGitLab) {
const token = params.gitlabToken || (import.meta.env.VITE_GITLAB_TOKEN as string | undefined);
if (token) {
headers["PRIVATE-TOKEN"] = token;
}
}
const contentRes = await fetch(rawUrl, { headers });
if (!contentRes.ok) { await new Promise(r=>setTimeout(r, LLM_GAP_MS)); continue; }
const content = await contentRes.text();
if (content.length > MAX_FILE_SIZE_BYTES) { await new Promise(r=>setTimeout(r, LLM_GAP_MS)); continue; }
@ -144,7 +205,7 @@ export async function runRepositoryAudit(params: {
}
// 每分析一个文件都更新进度,确保实时性
console.log(`📈 GitHub任务 ${taskId}: 进度 ${totalFiles}/${files.length} (${Math.round(totalFiles/files.length*100)}%)`);
console.log(`📈 ${repoType}任务 ${taskId}: 进度 ${totalFiles}/${files.length} (${Math.round(totalFiles/files.length*100)}%)`);
await api.updateAuditTask(taskId, {
status: "running",
total_files: files.length,

View File

@ -85,6 +85,9 @@ export const env = {
// ==================== GitHub 配置 ====================
GITHUB_TOKEN: runtimeConfig?.githubToken || import.meta.env.VITE_GITHUB_TOKEN || '',
// ==================== GitLab 配置 ====================
GITLAB_TOKEN: runtimeConfig?.gitlabToken || import.meta.env.VITE_GITLAB_TOKEN || '',
// ==================== 应用配置 ====================
APP_ID: import.meta.env.VITE_APP_ID || 'xcodereviewer',