You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
692 lines
24 KiB
692 lines
24 KiB
/** |
|
* 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<GitRepoInfo | null> { |
|
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<string, { message: string; author: string; date: string }>(); |
|
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<GitRepoInfo | null> { |
|
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<GitRepoInfo | null> { |
|
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<GitRepoInfo | null> { |
|
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 |
|
}
|
|
|