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.
735 lines
23 KiB
735 lines
23 KiB
/** |
|
* API-based repository fetcher service |
|
* Fetches repository metadata from external platforms without cloning |
|
* Supports GitHub, GitLab, Gitea, GRASP, and other git hosting services |
|
* |
|
* This is used by default for displaying repos. Only privileged users |
|
* can explicitly clone repos to the server. |
|
*/ |
|
|
|
import logger from '../logger.js'; |
|
|
|
/** |
|
* Check if we're running on the server (Node.js) or client (browser) |
|
*/ |
|
function isServerSide(): boolean { |
|
return typeof process !== 'undefined' && process.versions?.node !== undefined; |
|
} |
|
|
|
/** |
|
* Get the base URL for API requests |
|
* On server-side, call APIs directly. On client-side, use proxy to avoid CORS. |
|
*/ |
|
function getApiBaseUrl(apiPath: string, baseUrl: string, searchParams: URLSearchParams): string { |
|
if (isServerSide()) { |
|
// Server-side: call API directly |
|
const cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; |
|
const cleanApiPath = apiPath.startsWith('/') ? apiPath : `/${apiPath}`; |
|
const queryString = searchParams.toString(); |
|
return `${cleanBaseUrl}${cleanApiPath}${queryString ? `?${queryString}` : ''}`; |
|
} else { |
|
// Client-side: use proxy to avoid CORS |
|
const queryString = new URLSearchParams({ |
|
baseUrl, |
|
...Object.fromEntries(searchParams.entries()) |
|
}).toString(); |
|
return `/api/gitea-proxy/${apiPath}?${queryString}`; |
|
} |
|
} |
|
|
|
export interface ApiRepoInfo { |
|
name: string; |
|
description?: string; |
|
url: string; |
|
defaultBranch: string; |
|
branches: ApiBranch[]; |
|
commits: ApiCommit[]; |
|
files: ApiFile[]; |
|
readme?: { |
|
path: string; |
|
content: string; |
|
format: 'markdown' | 'asciidoc'; |
|
}; |
|
platform: 'github' | 'gitlab' | 'gitea' | 'grasp' | 'unknown'; |
|
isCloned: boolean; // Whether repo exists locally |
|
} |
|
|
|
export interface ApiBranch { |
|
name: string; |
|
commit: { |
|
sha: string; |
|
message: string; |
|
author: string; |
|
date: string; |
|
}; |
|
} |
|
|
|
export interface ApiCommit { |
|
sha: string; |
|
message: string; |
|
author: string; |
|
date: string; |
|
} |
|
|
|
export interface ApiFile { |
|
name: string; |
|
path: string; |
|
type: 'file' | 'dir'; |
|
size?: number; |
|
} |
|
|
|
type GitPlatform = 'github' | 'gitlab' | 'gitea' | 'grasp' | 'unknown'; |
|
|
|
/** |
|
* Check if a URL is a GRASP (Git Repository Access via Secure Protocol) URL |
|
* GRASP URLs contain npub (Nostr public key) in the path: https://host/npub.../repo.git |
|
*/ |
|
export function isGraspUrl(url: string): boolean { |
|
return /\/npub1[a-z0-9]+/i.test(url); |
|
} |
|
|
|
/** |
|
* Parse git URL to extract platform, owner, and repo |
|
*/ |
|
export function parseGitUrl(url: string): { platform: GitPlatform; owner: string; repo: string; baseUrl: string } | null { |
|
// Handle GRASP URLs - they use Gitea-compatible API but with npub as owner |
|
if (isGraspUrl(url)) { |
|
const graspMatch = url.match(/(https?:\/\/[^/]+)\/(npub1[a-z0-9]+)\/([^/]+?)(?:\.git)?\/?$/i); |
|
if (graspMatch) { |
|
const [, baseHost, npub, repo] = graspMatch; |
|
return { |
|
platform: 'grasp', |
|
owner: npub, |
|
repo: repo.replace(/\.git$/, ''), |
|
baseUrl: `${baseHost}/api/v1` |
|
}; |
|
} |
|
return 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]; |
|
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 |
|
}; |
|
} |
|
|
|
// Gitea and other Git hosting services (generic pattern) |
|
const giteaMatch = url.match(/(https?:\/\/[^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/); |
|
if (giteaMatch) { |
|
// Double-check it's not a GRASP URL (npub in owner position) |
|
if (giteaMatch[2].startsWith('npub1')) { |
|
return null; |
|
} |
|
return { |
|
platform: 'gitea', |
|
owner: giteaMatch[2], |
|
repo: giteaMatch[3].replace(/\.git$/, ''), |
|
baseUrl: `${giteaMatch[1]}/api/v1` |
|
}; |
|
} |
|
|
|
return null; |
|
} |
|
|
|
/** |
|
* Check if a repository exists locally |
|
*/ |
|
async function checkLocalRepo(npub: string, repoName: string): Promise<boolean> { |
|
try { |
|
// Dynamic import to avoid bundling Node.js fs in browser |
|
const { existsSync } = await import('fs'); |
|
const { join } = await import('path'); |
|
const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; |
|
const repoPath = join(repoRoot, npub, `${repoName}.git`); |
|
return existsSync(repoPath); |
|
} catch { |
|
// If we can't check (e.g., in browser), assume not cloned |
|
return false; |
|
} |
|
} |
|
|
|
/** |
|
* Fetch repository metadata from GitHub API |
|
*/ |
|
async function fetchFromGitHub(owner: string, repo: string): Promise<Partial<ApiRepoInfo> | null> { |
|
try { |
|
const githubToken = process.env.GITHUB_TOKEN; |
|
const headers: Record<string, string> = { |
|
'Accept': 'application/vnd.github.v3+json', |
|
'User-Agent': 'GitRepublic' |
|
}; |
|
|
|
if (githubToken) { |
|
headers['Authorization'] = `Bearer ${githubToken}`; |
|
} |
|
|
|
const repoResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}`, { headers }); |
|
if (!repoResponse.ok) { |
|
if (repoResponse.status === 404) { |
|
return null; |
|
} |
|
logger.warn({ status: repoResponse.status, owner, repo }, 'GitHub API error'); |
|
return null; |
|
} |
|
|
|
const repoData = await repoResponse.json(); |
|
const defaultBranch = repoData.default_branch || 'main'; |
|
|
|
// Fetch branches, commits, and tree in parallel |
|
const [branchesResponse, commitsResponse, treeResponse] = await Promise.all([ |
|
fetch(`https://api.github.com/repos/${owner}/${repo}/branches`, { headers }).catch(() => null), |
|
fetch(`https://api.github.com/repos/${owner}/${repo}/commits?per_page=10`, { headers }).catch(() => null), |
|
fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/${defaultBranch}?recursive=1`, { headers }).catch(() => null) |
|
]); |
|
|
|
const branches: ApiBranch[] = branchesResponse?.ok |
|
? (await branchesResponse.json()).map((b: any) => ({ |
|
name: b.name, |
|
commit: { |
|
sha: b.commit.sha, |
|
message: b.commit.commit?.message?.split('\n')[0] || 'No commit message', |
|
author: b.commit.commit?.author?.name || 'Unknown', |
|
date: b.commit.commit?.author?.date || new Date().toISOString() |
|
} |
|
})) |
|
: []; |
|
|
|
const commits: ApiCommit[] = commitsResponse?.ok |
|
? (await commitsResponse.json()).map((c: any) => ({ |
|
sha: c.sha, |
|
message: c.commit?.message?.split('\n')[0] || 'No commit message', |
|
author: c.commit?.author?.name || 'Unknown', |
|
date: c.commit?.author?.date || new Date().toISOString() |
|
})) |
|
: []; |
|
|
|
const files: ApiFile[] = treeResponse?.ok |
|
? (await treeResponse.json()).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 |
|
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 readmeResponse = await fetch( |
|
`https://api.github.com/repos/${owner}/${repo}/contents/${readmeFile}?ref=${defaultBranch}`, |
|
{ headers } |
|
); |
|
if (readmeResponse.ok) { |
|
const readmeData = await readmeResponse.json(); |
|
if (readmeData.content) { |
|
const content = atob(readmeData.content.replace(/\s/g, '')); |
|
readme = { |
|
path: readmeFile, |
|
content, |
|
format: readmeFile.toLowerCase().endsWith('.adoc') ? 'asciidoc' : 'markdown' |
|
}; |
|
break; |
|
} |
|
} |
|
} catch { |
|
continue; |
|
} |
|
} |
|
|
|
return { |
|
name: repoData.name, |
|
description: repoData.description, |
|
url: repoData.html_url, |
|
defaultBranch, |
|
branches, |
|
commits, |
|
files, |
|
readme, |
|
platform: 'github' |
|
}; |
|
} catch (error) { |
|
logger.error({ error, owner, repo }, 'Error fetching from GitHub'); |
|
return null; |
|
} |
|
} |
|
|
|
/** |
|
* Fetch repository metadata from GitLab API |
|
*/ |
|
async function fetchFromGitLab(owner: string, repo: string, baseUrl: string): Promise<Partial<ApiRepoInfo> | null> { |
|
try { |
|
const projectPath = encodeURIComponent(`${owner}/${repo}`); |
|
|
|
// Use proxy endpoint on client-side, direct API on server-side |
|
const repoUrl = getApiBaseUrl( |
|
`projects/${projectPath}`, |
|
baseUrl, |
|
new URLSearchParams() |
|
); |
|
const repoResponse = await fetch(repoUrl); |
|
|
|
if (!repoResponse.ok) { |
|
if (repoResponse.status === 404) { |
|
return null; |
|
} |
|
logger.warn({ status: repoResponse.status, owner, repo }, 'GitLab API error'); |
|
return null; |
|
} |
|
|
|
const repoData = await repoResponse.json(); |
|
const defaultBranch = repoData.default_branch || 'master'; |
|
|
|
// Fetch branches and commits in parallel |
|
const [branchesResponse, commitsResponse] = await Promise.all([ |
|
fetch(getApiBaseUrl( |
|
`projects/${projectPath}/repository/branches`, |
|
baseUrl, |
|
new URLSearchParams() |
|
)).catch(() => null), |
|
fetch(getApiBaseUrl( |
|
`projects/${projectPath}/repository/commits`, |
|
baseUrl, |
|
new URLSearchParams({ per_page: '10' }) |
|
)).catch(() => null) |
|
]); |
|
|
|
let branchesData: any[] = []; |
|
let commitsData: any[] = []; |
|
|
|
if (branchesResponse && branchesResponse.ok) { |
|
branchesData = await branchesResponse.json(); |
|
if (!Array.isArray(branchesData)) { |
|
logger.warn({ owner, repo }, 'GitLab branches response is not an array'); |
|
branchesData = []; |
|
} |
|
} |
|
|
|
if (commitsResponse && commitsResponse.ok) { |
|
commitsData = await commitsResponse.json(); |
|
if (!Array.isArray(commitsData)) { |
|
logger.warn({ owner, repo }, 'GitLab commits response is not an array'); |
|
commitsData = []; |
|
} |
|
} |
|
|
|
const branches: ApiBranch[] = 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: ApiCommit[] = commitsData.map((c: any) => ({ |
|
sha: c.id, |
|
message: c.message.split('\n')[0], |
|
author: c.author_name, |
|
date: c.committed_date |
|
})); |
|
|
|
// Fetch file tree (simplified - GitLab tree API is more complex) |
|
let files: ApiFile[] = []; |
|
try { |
|
const treeResponse = await fetch(getApiBaseUrl( |
|
`projects/${projectPath}/repository/tree`, |
|
baseUrl, |
|
new URLSearchParams({ recursive: 'true', per_page: '100' }) |
|
)).catch(() => null); |
|
if (treeResponse && treeResponse.ok) { |
|
const treeData = await treeResponse.json(); |
|
if (Array.isArray(treeData)) { |
|
files = treeData.map((item: any) => ({ |
|
name: item.name, |
|
path: item.path, |
|
type: item.type === 'tree' ? 'dir' : 'file', |
|
size: item.size |
|
})); |
|
} |
|
} |
|
} catch (error) { |
|
logger.warn({ error, owner, repo }, 'Failed to fetch GitLab file tree'); |
|
} |
|
|
|
// Try to fetch README |
|
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 readmeUrl = getApiBaseUrl( |
|
`projects/${projectPath}/repository/files/${encodeURIComponent(readmeFile)}/raw`, |
|
baseUrl, |
|
new URLSearchParams({ ref: defaultBranch }) |
|
); |
|
const fileData = await fetch(readmeUrl).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 (error) { |
|
continue; // Try next file |
|
} |
|
} |
|
|
|
return { |
|
name: repoData.name, |
|
description: repoData.description, |
|
url: repoData.web_url, |
|
defaultBranch, |
|
branches, |
|
commits, |
|
files, |
|
readme, |
|
platform: 'gitlab' |
|
}; |
|
} catch (error) { |
|
logger.error({ error, owner, repo }, 'Error fetching from GitLab'); |
|
return null; |
|
} |
|
} |
|
|
|
/** |
|
* Fetch repository metadata from Gitea API |
|
*/ |
|
async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Promise<Partial<ApiRepoInfo> | null> { |
|
try { |
|
// URL-encode owner and repo to handle special characters |
|
const encodedOwner = encodeURIComponent(owner); |
|
const encodedRepo = encodeURIComponent(repo); |
|
|
|
// Use proxy endpoint on client-side, direct API on server-side |
|
const repoUrl = getApiBaseUrl( |
|
`repos/${encodedOwner}/${encodedRepo}`, |
|
baseUrl, |
|
new URLSearchParams() |
|
); |
|
const repoResponse = await fetch(repoUrl); |
|
if (!repoResponse.ok) { |
|
if (repoResponse.status === 404) { |
|
return null; |
|
} |
|
logger.warn({ status: repoResponse.status, owner, repo }, 'Gitea API error'); |
|
return null; |
|
} |
|
const repoData = await repoResponse.json(); |
|
|
|
const defaultBranch = repoData.default_branch || 'master'; |
|
|
|
const [branchesResponse, commitsResponse] = await Promise.all([ |
|
fetch(getApiBaseUrl( |
|
`repos/${encodedOwner}/${encodedRepo}/branches`, |
|
baseUrl, |
|
new URLSearchParams() |
|
)).catch(() => null), |
|
fetch(getApiBaseUrl( |
|
`repos/${encodedOwner}/${encodedRepo}/commits`, |
|
baseUrl, |
|
new URLSearchParams({ limit: '10' }) |
|
)).catch(() => null) |
|
]); |
|
|
|
let branchesData: any[] = []; |
|
let commitsData: any[] = []; |
|
|
|
if (branchesResponse && branchesResponse.ok) { |
|
branchesData = await branchesResponse.json(); |
|
if (!Array.isArray(branchesData)) { |
|
logger.warn({ owner, repo }, 'Gitea branches response is not an array'); |
|
branchesData = []; |
|
} |
|
} else { |
|
logger.warn({ status: branchesResponse?.status, owner, repo }, 'Gitea API error for branches'); |
|
} |
|
|
|
if (commitsResponse && commitsResponse.ok) { |
|
commitsData = await commitsResponse.json(); |
|
if (!Array.isArray(commitsData)) { |
|
logger.warn({ owner, repo }, 'Gitea commits response is not an array'); |
|
commitsData = []; |
|
} |
|
} else { |
|
logger.warn({ status: commitsResponse?.status, owner, repo }, 'Gitea API error for commits'); |
|
} |
|
|
|
const branches: ApiBranch[] = 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: ApiCommit[] = 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: ApiFile[] = []; |
|
const encodedBranch = encodeURIComponent(defaultBranch); |
|
try { |
|
// Try the git/trees endpoint first (more complete) |
|
const treeResponse = await fetch(getApiBaseUrl( |
|
`repos/${encodedOwner}/${encodedRepo}/git/trees/${encodedBranch}`, |
|
baseUrl, |
|
new URLSearchParams({ 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(getApiBaseUrl( |
|
`repos/${encodedOwner}/${encodedRepo}/contents`, |
|
baseUrl, |
|
new URLSearchParams({ ref: encodedBranch }) |
|
)).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) { |
|
logger.warn({ error, owner, repo }, 'Failed to fetch Gitea file tree'); |
|
} |
|
|
|
// 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 encodedReadmeFile = encodeURIComponent(readmeFile); |
|
const fileResponse = await fetch(getApiBaseUrl( |
|
`repos/${encodedOwner}/${encodedRepo}/contents/${encodedReadmeFile}`, |
|
baseUrl, |
|
new URLSearchParams({ 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 { |
|
// URL-encode the file path segments |
|
const encodedReadmePath = readmePath.split('/').map(segment => encodeURIComponent(segment)).join('/'); |
|
const fileResponse = await fetch(getApiBaseUrl( |
|
`repos/${encodedOwner}/${encodedRepo}/contents/${encodedReadmePath}`, |
|
baseUrl, |
|
new URLSearchParams({ ref: encodedBranch }) |
|
)); |
|
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) { |
|
logger.warn({ error, readmePath, owner, repo }, 'Failed to fetch README from tree path'); |
|
} |
|
} |
|
} |
|
|
|
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, |
|
platform: 'gitea' |
|
}; |
|
} catch (error) { |
|
logger.error({ error, owner, repo }, 'Error fetching from Gitea'); |
|
return null; |
|
} |
|
} |
|
|
|
/** |
|
* Fetch repository metadata from GRASP |
|
* GRASP repos use git protocol, so we can't easily fetch metadata via API |
|
* For now, return minimal info indicating it's a GRASP repo |
|
*/ |
|
async function fetchFromGrasp(npub: string, repo: string, baseUrl: string, originalUrl: string): Promise<Partial<ApiRepoInfo> | null> { |
|
// GRASP repos typically don't have REST APIs |
|
// Full implementation would use git protocol (info/refs, git-upload-pack) |
|
// For now, return basic structure |
|
return { |
|
name: repo, |
|
description: undefined, |
|
url: originalUrl, |
|
defaultBranch: 'main', |
|
branches: [], |
|
commits: [], |
|
files: [], |
|
platform: 'grasp' |
|
}; |
|
} |
|
|
|
/** |
|
* Fetch repository metadata from a git URL |
|
* This is the main entry point for API-based fetching |
|
*/ |
|
export async function fetchRepoMetadata( |
|
url: string, |
|
npub: string, |
|
repoName: string |
|
): Promise<ApiRepoInfo | null> { |
|
const parsed = parseGitUrl(url); |
|
if (!parsed) { |
|
logger.warn({ url }, 'Unable to parse git URL'); |
|
return null; |
|
} |
|
|
|
const { platform, owner, repo, baseUrl } = parsed; |
|
const isCloned = await checkLocalRepo(npub, repoName); |
|
|
|
let metadata: Partial<ApiRepoInfo> | null = null; |
|
|
|
switch (platform) { |
|
case 'github': |
|
metadata = await fetchFromGitHub(owner, repo); |
|
break; |
|
case 'gitlab': |
|
metadata = await fetchFromGitLab(owner, repo, baseUrl); |
|
break; |
|
case 'gitea': |
|
metadata = await fetchFromGitea(owner, repo, baseUrl); |
|
break; |
|
case 'grasp': |
|
metadata = await fetchFromGrasp(owner, repo, baseUrl, url); |
|
break; |
|
default: |
|
logger.warn({ platform, url }, 'Unsupported platform'); |
|
return null; |
|
} |
|
|
|
if (!metadata) { |
|
return null; |
|
} |
|
|
|
return { |
|
...metadata, |
|
isCloned, |
|
platform |
|
} as ApiRepoInfo; |
|
} |
|
|
|
/** |
|
* Extract git URLs from a Nostr repo announcement event |
|
*/ |
|
export function extractGitUrls(event: { tags: string[][] }): string[] { |
|
const urls: string[] = []; |
|
|
|
for (const tag of event.tags) { |
|
if (tag[0] === 'clone') { |
|
// Clone tags can have multiple URLs: ["clone", "url1", "url2", "url3"] |
|
for (let i = 1; i < tag.length; i++) { |
|
const url = tag[i]; |
|
if (url && typeof url === 'string' && (url.startsWith('http') || url.startsWith('git@'))) { |
|
urls.push(url); |
|
} |
|
} |
|
} |
|
} |
|
|
|
return [...new Set(urls)]; // Deduplicate |
|
}
|
|
|