/** * Service for fetching git repository data from various hosting platforms * Supports GitHub, GitLab, Gitea, and other git hosting services */ import { fetchGitHubApi } from '../github-api.js'; export interface GitRepoInfo { name: string; description?: string; url: string; defaultBranch: string; branches: GitBranch[]; commits: GitCommit[]; files: GitFile[]; readme?: { path: string; content: string; format: 'markdown' | 'asciidoc'; }; usingGitHubToken?: boolean; // Indicates if GitHub API token was used } export interface GitBranch { name: string; commit: { sha: string; message: string; author: string; date: string; }; } export interface GitCommit { sha: string; message: string; author: string; date: string; } export interface GitFile { name: string; path: string; type: 'file' | 'dir'; size?: number; } /** * Parse git URL to extract platform, owner, and repo */ function parseGitUrl(url: string): { platform: string; owner: string; repo: string; baseUrl: string } | null { // GitHub const githubMatch = url.match(/github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?\/?$/); if (githubMatch) { return { platform: 'github', owner: githubMatch[1], repo: githubMatch[2].replace(/\.git$/, ''), baseUrl: 'https://api.github.com' }; } // 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[2], repo: gitlabMatch[3].replace(/\.git$/, ''), baseUrl }; } // 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', owner: giteaMatch[2], repo: giteaMatch[3].replace(/\.git$/, ''), baseUrl: `${giteaMatch[1]}/api/v1` }; } return null; } /** * Fetch repository data from GitHub */ async function fetchFromGitHub(owner: string, repo: string): Promise { try { const repoApiResult = await fetchGitHubApi(`https://api.github.com/repos/${owner}/${repo}`); if (!repoApiResult.response.ok) { console.warn(`GitHub API error for repo ${owner}/${repo}: ${repoApiResult.response.status} ${repoApiResult.response.statusText}`); return null; } const repoData = await repoApiResult.response.json(); // Track if any request used a token let usingToken = repoApiResult.usedToken; const defaultBranch = repoData.default_branch || 'main'; const [branchesResult, commitsResult, treeResult] = await Promise.all([ fetchGitHubApi(`https://api.github.com/repos/${owner}/${repo}/branches`), fetchGitHubApi(`https://api.github.com/repos/${owner}/${repo}/commits?per_page=10`), fetchGitHubApi(`https://api.github.com/repos/${owner}/${repo}/git/trees/${defaultBranch}?recursive=1`).catch(() => ({ response: null as any, usedToken: false })) ]); // Update token usage flag if any request used a token usingToken = usingToken || branchesResult.usedToken || commitsResult.usedToken || (treeResult?.usedToken ?? false); const branchesResponse = branchesResult.response; const commitsResponse = commitsResult.response; const treeResponse = treeResult?.response; // Check if responses are OK and parse JSON let branchesData: any[] = []; let commitsData: any[] = []; let treeData: any = null; if (branchesResponse && branchesResponse.ok) { branchesData = await branchesResponse.json(); if (!Array.isArray(branchesData)) { console.warn('GitHub branches response is not an array:', branchesData); branchesData = []; } } else { console.warn(`GitHub API error for branches: ${branchesResponse?.status || 'unknown'}`); } if (commitsResponse && commitsResponse.ok) { commitsData = await commitsResponse.json(); if (!Array.isArray(commitsData)) { console.warn('GitHub commits response is not an array:', commitsData); commitsData = []; } } else { console.warn(`GitHub API error for commits: ${commitsResponse?.status || 'unknown'}`); } if (treeResponse && treeResponse.ok) { treeData = await treeResponse.json(); } // Create a map of commit SHAs to commit details for lookup const commitMap = new Map(); for (const c of commitsData) { if (c.sha) { const commitObj = c.commit || {}; commitMap.set(c.sha, { message: commitObj.message ? commitObj.message.split('\n')[0] : '', author: commitObj.author?.name || commitObj.committer?.name || 'Unknown', date: commitObj.author?.date || commitObj.committer?.date || new Date().toISOString() }); } } const branches: GitBranch[] = branchesData.map((b: any) => { const commitSha = b.commit?.sha || ''; // Try to get commit details from the commit object first, then fall back to our commit map const commitObj = b.commit?.commit || {}; let commitMessage = commitObj.message ? commitObj.message.split('\n')[0] : ''; let commitAuthor = commitObj.author?.name || commitObj.committer?.name || ''; let commitDate = commitObj.author?.date || commitObj.committer?.date || ''; // If commit details are missing, try to find them in our commit map if (!commitMessage && commitSha) { const mappedCommit = commitMap.get(commitSha); if (mappedCommit) { commitMessage = mappedCommit.message; commitAuthor = mappedCommit.author; commitDate = mappedCommit.date; } } // Final fallbacks if (!commitMessage) commitMessage = 'No commit message'; if (!commitAuthor) commitAuthor = 'Unknown'; if (!commitDate) commitDate = new Date().toISOString(); return { name: b.name || '', commit: { sha: commitSha, message: commitMessage, author: commitAuthor, date: commitDate } }; }); const commits: GitCommit[] = commitsData.map((c: any) => { const commitObj = c.commit || {}; const message = commitObj.message ? commitObj.message.split('\n')[0] : ''; const author = commitObj.author?.name || commitObj.committer?.name || 'Unknown'; const date = commitObj.author?.date || commitObj.committer?.date || new Date().toISOString(); return { sha: c.sha || '', message: message, author: author, date: date }; }); const files: GitFile[] = treeData?.tree?.filter((item: any) => item.type === 'blob' || item.type === 'tree').map((item: any) => ({ name: item.path.split('/').pop(), path: item.path, type: item.type === 'tree' ? 'dir' : 'file', size: item.size })) || []; // Try to fetch README (prioritize .adoc over .md) // First try root directory (most common case) let readme: { path: string; content: string; format: 'markdown' | 'asciidoc' } | undefined; const readmeFiles = ['README.adoc', 'README.md', 'README.rst', 'README.txt']; for (const readmeFile of readmeFiles) { try { const readmeResult = await fetchGitHubApi(`https://api.github.com/repos/${owner}/${repo}/contents/${readmeFile}?ref=${defaultBranch}`); if (readmeResult.usedToken) { usingToken = true; } if (!readmeResult.response.ok) throw new Error('Not found'); const readmeData = await readmeResult.response.json(); if (readmeData.content) { const content = atob(readmeData.content.replace(/\s/g, '')); readme = { path: readmeFile, content, format: readmeFile.toLowerCase().endsWith('.adoc') ? 'asciidoc' : 'markdown' }; break; // Found a README, stop searching } } catch { continue; // Try next file } } // If not found in root, search the file tree (case-insensitive) if (!readme && files.length > 0) { const readmePatterns = [/^readme\.adoc$/i, /^readme\.md$/i, /^readme\.rst$/i, /^readme\.txt$/i, /^readme$/i]; let readmePath: string | null = null; for (const file of files) { if (file.type === 'file') { const fileName = file.name; for (const pattern of readmePatterns) { if (pattern.test(fileName)) { readmePath = file.path; break; } } if (readmePath) break; } } // If found in tree, fetch it if (readmePath) { try { const readmeResult = await fetchGitHubApi(`https://api.github.com/repos/${owner}/${repo}/contents/${readmePath}?ref=${defaultBranch}`); if (readmeResult.usedToken) { usingToken = true; } if (!readmeResult.response.ok) throw new Error('Not found'); const readmeData = await readmeResult.response.json(); if (readmeData.content) { const content = atob(readmeData.content.replace(/\s/g, '')); const format = readmePath.toLowerCase().endsWith('.adoc') ? 'asciidoc' : 'markdown'; readme = { path: readmePath, content, format }; } } catch { // Failed to fetch from tree path } } } return { name: repoData.name, description: repoData.description, url: repoData.html_url, defaultBranch: repoData.default_branch, branches, commits, files, readme, usingGitHubToken: usingToken }; } catch (error) { console.error('Error fetching from GitHub:', error); return null; } } /** * Fetch repository data from GitLab */ async function fetchFromGitLab(owner: string, repo: string, baseUrl: string): Promise { try { const projectPath = `${owner}/${repo}`; const encodedPath = encodeURIComponent(projectPath); const [repoData, branchesData, commitsData] = await Promise.all([ fetch(`${baseUrl}/projects/${encodedPath}`).then(r => r.json()), fetch(`${baseUrl}/projects/${encodedPath}/repository/branches`).then(r => r.json()), fetch(`${baseUrl}/projects/${encodedPath}/repository/commits?per_page=10`).then(r => r.json()) ]); 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.committed_date } })); const commits: GitCommit[] = commitsData.map((c: any) => ({ sha: c.id, message: c.message.split('\n')[0], author: c.author_name, date: c.committed_date })); // Fetch file tree let files: GitFile[] = []; try { const treeData = await fetch(`${baseUrl}/projects/${encodedPath}/repository/tree?recursive=true&per_page=100`).then(r => r.json()); files = treeData.map((item: any) => ({ name: item.name, path: item.path, type: item.type === 'tree' ? 'dir' : 'file', size: item.size })); } catch { // Tree fetch failed } // Try to fetch README (prioritize .adoc over .md) // First try root directory (most common case) let readme: { path: string; content: string; format: 'markdown' | 'asciidoc' } | undefined; const readmeFiles = ['README.adoc', 'README.md', 'README.rst', 'README.txt']; for (const readmeFile of readmeFiles) { try { const fileData = await fetch(`${baseUrl}/projects/${encodedPath}/repository/files/${encodeURIComponent(readmeFile)}/raw?ref=${repoData.default_branch}`).then(r => { if (!r.ok) throw new Error('Not found'); return r.text(); }); readme = { path: readmeFile, content: fileData, format: readmeFile.toLowerCase().endsWith('.adoc') ? 'asciidoc' : 'markdown' }; break; // Found a README, stop searching } catch { continue; // Try next file } } // If not found in root, search the file tree (case-insensitive) if (!readme && files.length > 0) { const readmePatterns = [/^readme\.adoc$/i, /^readme\.md$/i, /^readme\.rst$/i, /^readme\.txt$/i, /^readme$/i]; let readmePath: string | null = null; for (const file of files) { if (file.type === 'file') { const fileName = file.name; for (const pattern of readmePatterns) { if (pattern.test(fileName)) { readmePath = file.path; break; } } if (readmePath) break; } } // If found in tree, fetch it if (readmePath) { try { const fileData = await fetch(`${baseUrl}/projects/${encodedPath}/repository/files/${encodeURIComponent(readmePath)}/raw?ref=${repoData.default_branch}`).then(r => { if (!r.ok) throw new Error('Not found'); return r.text(); }); const format = readmePath.toLowerCase().endsWith('.adoc') ? 'asciidoc' : 'markdown'; readme = { path: readmePath, content: fileData, format }; } catch { // Failed to fetch from tree path } } } return { name: repoData.name, description: repoData.description, url: repoData.web_url, defaultBranch: repoData.default_branch, branches, commits, files, readme }; } catch (error) { console.error('Error fetching from GitLab:', error); return null; } } /** * Fetch repository data from Gitea */ async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Promise { try { // Use proxy endpoint to avoid CORS issues const proxyBaseUrl = encodeURIComponent(baseUrl); const repoResponse = await fetch(`/api/gitea-proxy/${proxyBaseUrl}/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(`/api/gitea-proxy/${proxyBaseUrl}/repos/${owner}/${repo}/branches`).catch(() => null), fetch(`/api/gitea-proxy/${proxyBaseUrl}/repos/${owner}/${repo}/commits?limit=10`).catch(() => null) ]); 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 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() } }; }); 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 { // Try the git/trees endpoint first (more complete) const treeResponse = await fetch(`/api/gitea-proxy/${proxyBaseUrl}/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(`/api/gitea-proxy/${proxyBaseUrl}/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) // First try root directory (most common case) let readme: { path: string; content: string; format: 'markdown' | 'asciidoc' } | undefined; const readmeFiles = ['README.adoc', 'README.md', 'README.rst', 'README.txt']; for (const readmeFile of readmeFiles) { try { const fileResponse = await fetch(`/api/gitea-proxy/${proxyBaseUrl}/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, content, format: readmeFile.toLowerCase().endsWith('.adoc') ? 'asciidoc' : 'markdown' }; break; // Found a README, stop searching } } catch (error) { // Try next file continue; } } // If not found in root, search the file tree (case-insensitive) if (!readme && files.length > 0) { const readmePatterns = [/^readme\.adoc$/i, /^readme\.md$/i, /^readme\.rst$/i, /^readme\.txt$/i, /^readme$/i]; let readmePath: string | null = null; for (const file of files) { if (file.type === 'file') { const fileName = file.name; for (const pattern of readmePatterns) { if (pattern.test(fileName)) { readmePath = file.path; break; } } if (readmePath) break; } } // If found in tree, fetch it if (readmePath) { try { const fileResponse = await fetch(`/api/gitea-proxy/${proxyBaseUrl}/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 = { path: readmePath, content, format }; } } catch (error) { console.warn('Failed to fetch README from tree path:', error); } } } return { name: repoData.name || repoData.full_name?.split('/').pop() || repo, description: repoData.description, url: repoData.html_url || repoData.clone_url || `${baseUrl.replace('/api/v1', '')}/${owner}/${repo}`, defaultBranch: repoData.default_branch || defaultBranch, branches, commits, files, readme }; } catch (error) { console.error('Error fetching from Gitea:', error); return null; } } /** * Fetch repository data from a git URL */ export async function fetchGitRepo(url: string): Promise { const parsed = parseGitUrl(url); if (!parsed) { console.error('Unable to parse git URL:', url); return null; } const { platform, owner, repo, baseUrl } = parsed; switch (platform) { case 'github': return fetchFromGitHub(owner, repo); 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); return null; } } /** * Convert SSH git URL to HTTPS format */ function convertSshToHttps(url: string): string | null { // Handle git@host:user/repo.git format const sshMatch = url.match(/git@([^:]+):(.+?)(?:\.git)?$/); if (sshMatch) { const [, host, path] = sshMatch; return `https://${host}/${path}${path.endsWith('.git') ? '' : '.git'}`; } return null; } /** * Extract git URLs from a Nostr event */ export function extractGitUrls(event: { tags: string[][]; content: string }): string[] { const urls: string[] = []; // Check tags for git URLs (including 'clone' tag which is used in NIP-34) for (const tag of event.tags) { if (tag[0] === 'r' || tag[0] === 'url' || tag[0] === 'git' || tag[0] === 'clone') { const url = tag[1]; if (!url) continue; // Convert SSH URLs to HTTPS if (url.startsWith('git@')) { const httpsUrl = convertSshToHttps(url); if (httpsUrl) { urls.push(httpsUrl); continue; } } // Check if it's a git URL if (url.includes('github.com') || url.includes('gitlab.com') || url.includes('gitea') || url.includes('.git') || url.startsWith('http')) { urls.push(url); } } } // Check content for git URLs const urlRegex = /(https?:\/\/[^\s]+\.git|https?:\/\/(?:github|gitlab|gitea)[^\s]+)/gi; const matches = event.content.match(urlRegex); if (matches) { urls.push(...matches); } return [...new Set(urls)]; // Deduplicate }