From 91a02f0aad7620d7f86396cca7598dc3a651acac Mon Sep 17 00:00:00 2001 From: Silberengel Date: Fri, 13 Feb 2026 20:23:06 +0100 Subject: [PATCH] add support for onedev, gitlab, and gitea --- .../components/content/FileExplorer.svelte | 31 ++- src/lib/services/content/git-repo-fetcher.ts | 181 +++++++++++++----- 2 files changed, 152 insertions(+), 60 deletions(-) diff --git a/src/lib/components/content/FileExplorer.svelte b/src/lib/components/content/FileExplorer.svelte index 6e0e475..0b10ffd 100644 --- a/src/lib/components/content/FileExplorer.svelte +++ b/src/lib/components/content/FileExplorer.svelte @@ -95,20 +95,33 @@ const [, owner, repo] = match; apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${file.path}?ref=${repoInfo.defaultBranch}`; } - } else if (url.includes('gitlab.com')) { - // GitLab API: GET /projects/{id}/repository/files/{path}/raw - const match = url.match(/gitlab\.com\/([^/]+)\/([^/]+)/); + } else if (url.includes('gitlab')) { + // GitLab API (both gitlab.com and self-hosted): GET /projects/{id}/repository/files/{path}/raw + const match = url.match(/(https?:\/\/[^/]*gitlab[^/]*)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/); if (match) { - const [, owner, repo] = match; - const projectPath = encodeURIComponent(`${owner}/${repo}`); - apiUrl = `https://gitlab.com/api/v4/projects/${projectPath}/repository/files/${encodeURIComponent(file.path)}/raw?ref=${repoInfo.defaultBranch}`; + const [, baseHost, owner, repo] = match; + const cleanRepo = repo.replace(/\.git$/, ''); + const projectPath = encodeURIComponent(`${owner}/${cleanRepo}`); + const apiBase = baseHost.includes('gitlab.com') + ? 'https://gitlab.com/api/v4' + : `${baseHost}/api/v4`; + apiUrl = `${apiBase}/projects/${projectPath}/repository/files/${encodeURIComponent(file.path)}/raw?ref=${repoInfo.defaultBranch}`; + } + } else if (url.includes('onedev')) { + // OneDev API: similar to Gitea, uses /api/v1/repos/{owner}/{repo}/contents/{path} + const match = url.match(/(https?:\/\/[^/]*onedev[^/]*)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/); + if (match) { + const [, baseUrl, owner, repo] = match; + const cleanRepo = repo.replace(/\.git$/, ''); + apiUrl = `${baseUrl}/api/v1/repos/${owner}/${cleanRepo}/contents/${file.path}?ref=${repoInfo.defaultBranch}`; } } else { - // Try Gitea pattern - const match = url.match(/(https?:\/\/[^/]+)\/([^/]+)\/([^/]+)/); + // Try Gitea/Forgejo pattern (handles .git suffix) - generic fallback + const match = url.match(/(https?:\/\/[^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/); if (match) { const [, baseUrl, owner, repo] = match; - apiUrl = `${baseUrl}/api/v1/repos/${owner}/${repo}/contents/${file.path}?ref=${repoInfo.defaultBranch}`; + const cleanRepo = repo.replace(/\.git$/, ''); + apiUrl = `${baseUrl}/api/v1/repos/${owner}/${cleanRepo}/contents/${file.path}?ref=${repoInfo.defaultBranch}`; } } diff --git a/src/lib/services/content/git-repo-fetcher.ts b/src/lib/services/content/git-repo-fetcher.ts index 8e78e84..607743a 100644 --- a/src/lib/services/content/git-repo-fetcher.ts +++ b/src/lib/services/content/git-repo-fetcher.ts @@ -60,19 +60,36 @@ function parseGitUrl(url: string): { platform: string; owner: string; repo: stri }; } - // GitLab - const gitlabMatch = url.match(/gitlab\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?\/?$/); + // GitLab (both gitlab.com and self-hosted instances) + const gitlabMatch = url.match(/(https?:\/\/[^/]*gitlab[^/]*)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/); if (gitlabMatch) { + const baseHost = gitlabMatch[1]; + // GitLab.com uses /api/v4, self-hosted might use /api/v4 or /api/v3 + const baseUrl = baseHost.includes('gitlab.com') + ? 'https://gitlab.com/api/v4' + : `${baseHost}/api/v4`; return { platform: 'gitlab', - owner: gitlabMatch[1], - repo: gitlabMatch[2].replace(/\.git$/, ''), - baseUrl: 'https://gitlab.com/api/v4' + owner: gitlabMatch[2], + repo: gitlabMatch[3].replace(/\.git$/, ''), + baseUrl }; } - // Gitea (generic pattern) - const giteaMatch = url.match(/(https?:\/\/[^/]+)[/:]([^/]+)\/([^/]+?)(?:\.git)?\/?$/); + // OneDev (detect by onedev in hostname) + const onedevMatch = url.match(/(https?:\/\/[^/]*onedev[^/]*)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/); + if (onedevMatch) { + return { + platform: 'onedev', + owner: onedevMatch[2], + repo: onedevMatch[3].replace(/\.git$/, ''), + baseUrl: `${onedevMatch[1]}/api/v1` // OneDev uses /api/v1 similar to Gitea + }; + } + + // Gitea and other Git hosting services (generic pattern) - matches https://host/owner/repo.git or https://host/owner/repo + // This is a catch-all for services that use Gitea-compatible API (like Gitea, Forgejo, etc.) + const giteaMatch = url.match(/(https?:\/\/[^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/); if (giteaMatch) { return { platform: 'gitea', @@ -418,41 +435,100 @@ async function fetchFromGitLab(owner: string, repo: string, baseUrl: string): Pr */ async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Promise { try { - const [repoData, branchesData, commitsData] = await Promise.all([ - fetch(`${baseUrl}/repos/${owner}/${repo}`).then(r => r.json()), - fetch(`${baseUrl}/repos/${owner}/${repo}/branches`).then(r => r.json()), - fetch(`${baseUrl}/repos/${owner}/${repo}/commits?limit=10`).then(r => r.json()) + const repoResponse = await fetch(`${baseUrl}/repos/${owner}/${repo}`); + if (!repoResponse.ok) { + console.warn(`Gitea API error for repo ${owner}/${repo}: ${repoResponse.status} ${repoResponse.statusText}`); + return null; + } + const repoData = await repoResponse.json(); + + const defaultBranch = repoData.default_branch || 'master'; + + const [branchesResponse, commitsResponse] = await Promise.all([ + fetch(`${baseUrl}/repos/${owner}/${repo}/branches`).catch(() => null), + fetch(`${baseUrl}/repos/${owner}/${repo}/commits?limit=10`).catch(() => null) ]); - - const branches: GitBranch[] = branchesData.map((b: any) => ({ - name: b.name, - commit: { - sha: b.commit.id, - message: b.commit.message.split('\n')[0], - author: b.commit.author.name, - date: b.commit.timestamp + + let branchesData: any[] = []; + let commitsData: any[] = []; + + if (branchesResponse && branchesResponse.ok) { + branchesData = await branchesResponse.json(); + if (!Array.isArray(branchesData)) { + console.warn('Gitea branches response is not an array:', branchesData); + branchesData = []; } - })); + } else { + console.warn(`Gitea API error for branches: ${branchesResponse?.status || 'unknown'}`); + } + + if (commitsResponse && commitsResponse.ok) { + commitsData = await commitsResponse.json(); + if (!Array.isArray(commitsData)) { + console.warn('Gitea commits response is not an array:', commitsData); + commitsData = []; + } + } else { + console.warn(`Gitea API error for commits: ${commitsResponse?.status || 'unknown'}`); + } - const commits: GitCommit[] = commitsData.map((c: any) => ({ - sha: c.sha, - message: c.commit.message.split('\n')[0], - author: c.commit.author.name, - date: c.commit.timestamp - })); + const branches: GitBranch[] = branchesData.map((b: any) => { + const commitObj = b.commit || {}; + return { + name: b.name || '', + commit: { + sha: commitObj.id || b.commit?.sha || '', + message: commitObj.message ? commitObj.message.split('\n')[0] : 'No commit message', + author: commitObj.author?.name || commitObj.author_name || 'Unknown', + date: commitObj.timestamp || commitObj.created || new Date().toISOString() + } + }; + }); - // Fetch file tree + const commits: GitCommit[] = commitsData.map((c: any) => { + const commitObj = c.commit || {}; + return { + sha: c.sha || c.id || '', + message: commitObj.message ? commitObj.message.split('\n')[0] : 'No commit message', + author: commitObj.author?.name || commitObj.author_name || 'Unknown', + date: commitObj.timestamp || commitObj.created || new Date().toISOString() + }; + }); + + // Fetch file tree - Gitea uses /git/trees API endpoint let files: GitFile[] = []; try { - const treeData = await fetch(`${baseUrl}/repos/${owner}/${repo}/contents?ref=${repoData.default_branch}`).then(r => r.json()); - files = treeData.map((item: any) => ({ - name: item.name, - path: item.path, - type: item.type === 'dir' ? 'dir' : 'file', - size: item.size - })); - } catch { - // Tree fetch failed + // Try the git/trees endpoint first (more complete) + const treeResponse = await fetch(`${baseUrl}/repos/${owner}/${repo}/git/trees/${defaultBranch}?recursive=1`).catch(() => null); + if (treeResponse && treeResponse.ok) { + const treeData = await treeResponse.json(); + if (treeData.tree && Array.isArray(treeData.tree)) { + files = treeData.tree + .filter((item: any) => item.type === 'blob' || item.type === 'tree') + .map((item: any) => ({ + name: item.path.split('/').pop() || item.path, + path: item.path, + type: item.type === 'tree' ? 'dir' : 'file', + size: item.size + })); + } + } else { + // Fallback to contents endpoint (only root directory) + const contentsResponse = await fetch(`${baseUrl}/repos/${owner}/${repo}/contents?ref=${defaultBranch}`).catch(() => null); + if (contentsResponse && contentsResponse.ok) { + const contentsData = await contentsResponse.json(); + if (Array.isArray(contentsData)) { + files = contentsData.map((item: any) => ({ + name: item.name, + path: item.path || item.name, + type: item.type === 'dir' ? 'dir' : 'file', + size: item.size + })); + } + } + } + } catch (error) { + console.warn('Failed to fetch Gitea file tree:', error); } // Try to fetch README (prioritize .adoc over .md) @@ -461,11 +537,11 @@ async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Pro const readmeFiles = ['README.adoc', 'README.md', 'README.rst', 'README.txt']; for (const readmeFile of readmeFiles) { try { - const fileData = await fetch(`${baseUrl}/repos/${owner}/${repo}/contents/${readmeFile}?ref=${repoData.default_branch}`).then(r => { - if (!r.ok) throw new Error('Not found'); - return r.json(); - }); + const fileResponse = await fetch(`${baseUrl}/repos/${owner}/${repo}/contents/${readmeFile}?ref=${defaultBranch}`); + if (!fileResponse.ok) throw new Error('Not found'); + const fileData = await fileResponse.json(); if (fileData.content) { + // Gitea returns base64 encoded content const content = atob(fileData.content.replace(/\s/g, '')); readme = { path: readmeFile, @@ -474,8 +550,9 @@ async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Pro }; break; // Found a README, stop searching } - } catch { - continue; // Try next file + } catch (error) { + // Try next file + continue; } } @@ -499,11 +576,11 @@ async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Pro // If found in tree, fetch it if (readmePath) { try { - const fileData = await fetch(`${baseUrl}/repos/${owner}/${repo}/contents/${readmePath}?ref=${repoData.default_branch}`).then(r => { - if (!r.ok) throw new Error('Not found'); - return r.json(); - }); + const fileResponse = await fetch(`${baseUrl}/repos/${owner}/${repo}/contents/${readmePath}?ref=${defaultBranch}`); + if (!fileResponse.ok) throw new Error('Not found'); + const fileData = await fileResponse.json(); if (fileData.content) { + // Gitea returns base64 encoded content const content = atob(fileData.content.replace(/\s/g, '')); const format = readmePath.toLowerCase().endsWith('.adoc') ? 'asciidoc' : 'markdown'; readme = { @@ -512,17 +589,17 @@ async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Pro format }; } - } catch { - // Failed to fetch from tree path + } catch (error) { + console.warn('Failed to fetch README from tree path:', error); } } } return { - name: repoData.name, + name: repoData.name || repoData.full_name?.split('/').pop() || repo, description: repoData.description, - url: repoData.html_url, - defaultBranch: repoData.default_branch, + url: repoData.html_url || repoData.clone_url || `${baseUrl.replace('/api/v1', '')}/${owner}/${repo}`, + defaultBranch: repoData.default_branch || defaultBranch, branches, commits, files, @@ -552,6 +629,8 @@ export async function fetchGitRepo(url: string): Promise { case 'gitlab': return fetchFromGitLab(owner, repo, baseUrl); case 'gitea': + case 'onedev': + // OneDev uses a similar API structure to Gitea, so we can use the same handler return fetchFromGitea(owner, repo, baseUrl); default: console.error('Unsupported platform:', platform);