/** * Service for fetching git repository data from various hosting platforms * Supports GitHub, GitLab, Gitea, and other git hosting services */ 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'; }; } 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 const gitlabMatch = url.match(/gitlab\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?\/?$/); if (gitlabMatch) { return { platform: 'gitlab', owner: gitlabMatch[1], repo: gitlabMatch[2].replace(/\.git$/, ''), baseUrl: 'https://gitlab.com/api/v4' }; } // Gitea (generic pattern) 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 repoResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}`); if (!repoResponse.ok) { console.warn(`GitHub API error for repo ${owner}/${repo}: ${repoResponse.status} ${repoResponse.statusText}`); return null; } const repoData = await repoResponse.json(); const defaultBranch = repoData.default_branch || 'main'; const [branchesResponse, commitsResponse, treeResponse] = await Promise.all([ fetch(`https://api.github.com/repos/${owner}/${repo}/branches`), fetch(`https://api.github.com/repos/${owner}/${repo}/commits?per_page=10`), fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/${defaultBranch}?recursive=1`).catch(() => null) ]); // 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) 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 readmeData = await fetch(`https://api.github.com/repos/${owner}/${repo}/contents/${readmeFile}`).then(r => { if (!r.ok) throw new Error('Not found'); return r.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 } } return { name: repoData.name, description: repoData.description, url: repoData.html_url, defaultBranch: repoData.default_branch, branches, commits, files, readme }; } 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) 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 } } 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 { 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 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 } })); 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 })); // Fetch file tree 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 to fetch README (prioritize .adoc over .md) 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}/repos/${owner}/${repo}/contents/${readmeFile}?ref=${repoData.default_branch}`).then(r => { if (!r.ok) throw new Error('Not found'); return r.json(); }); if (fileData.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 { continue; // Try next file } } return { name: repoData.name, description: repoData.description, url: repoData.html_url, defaultBranch: repoData.default_branch, 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': 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 }