10 changed files with 782 additions and 36 deletions
@ -0,0 +1,412 @@ |
|||||||
|
/** |
||||||
|
* 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'; |
||||||
|
|
||||||
|
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 |
||||||
|
*/ |
||||||
|
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 |
||||||
|
* Note: This is a simplified version. For full implementation, see aitherboard's git-repo-fetcher.ts |
||||||
|
*/ |
||||||
|
async function fetchFromGitLab(owner: string, repo: string, baseUrl: string): Promise<Partial<ApiRepoInfo> | null> { |
||||||
|
try { |
||||||
|
const projectPath = encodeURIComponent(`${owner}/${repo}`); |
||||||
|
const repoResponse = await fetch(`${baseUrl}/projects/${projectPath}`); |
||||||
|
|
||||||
|
if (!repoResponse.ok) { |
||||||
|
if (repoResponse.status === 404) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
const repoData = await repoResponse.json(); |
||||||
|
const defaultBranch = repoData.default_branch || 'master'; |
||||||
|
|
||||||
|
// For now, return basic info. Full implementation would fetch branches, commits, files
|
||||||
|
return { |
||||||
|
name: repoData.name, |
||||||
|
description: repoData.description, |
||||||
|
url: repoData.web_url, |
||||||
|
defaultBranch, |
||||||
|
branches: [], |
||||||
|
commits: [], |
||||||
|
files: [], |
||||||
|
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 { |
||||||
|
const encodedOwner = encodeURIComponent(owner); |
||||||
|
const encodedRepo = encodeURIComponent(repo); |
||||||
|
const repoResponse = await fetch(`${baseUrl}/repos/${encodedOwner}/${encodedRepo}`); |
||||||
|
|
||||||
|
if (!repoResponse.ok) { |
||||||
|
if (repoResponse.status === 404) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
const repoData = await repoResponse.json(); |
||||||
|
const defaultBranch = repoData.default_branch || 'master'; |
||||||
|
|
||||||
|
return { |
||||||
|
name: repoData.name, |
||||||
|
description: repoData.description, |
||||||
|
url: repoData.html_url || repoData.clone_url, |
||||||
|
defaultBranch, |
||||||
|
branches: [], |
||||||
|
commits: [], |
||||||
|
files: [], |
||||||
|
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
|
||||||
|
} |
||||||
@ -0,0 +1,20 @@ |
|||||||
|
/** |
||||||
|
* Utility functions for checking user access levels |
||||||
|
*/ |
||||||
|
|
||||||
|
import type { UserLevel } from '../services/nostr/user-level-service.js'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if a user has unlimited/write access |
||||||
|
* Only unlimited users can clone repos and register new repos |
||||||
|
*/ |
||||||
|
export function hasUnlimitedAccess(userLevel: UserLevel | null | undefined): boolean { |
||||||
|
return userLevel === 'unlimited'; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if a user is logged in (has any access level) |
||||||
|
*/ |
||||||
|
export function isLoggedIn(userLevel: UserLevel | null | undefined): boolean { |
||||||
|
return userLevel !== null && userLevel !== undefined && userLevel !== 'strictly_rate_limited'; |
||||||
|
} |
||||||
@ -0,0 +1,124 @@ |
|||||||
|
/** |
||||||
|
* Clone repository endpoint |
||||||
|
* Only privileged users (unlimited access) can clone repos to the server |
||||||
|
*/ |
||||||
|
|
||||||
|
import { error, json } from '@sveltejs/kit'; |
||||||
|
import type { RequestHandler } from './$types'; |
||||||
|
import { RepoManager } from '$lib/services/git/repo-manager.js'; |
||||||
|
import { requireNpubHex } from '$lib/utils/npub-utils.js'; |
||||||
|
import { existsSync } from 'fs'; |
||||||
|
import { join } from 'path'; |
||||||
|
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; |
||||||
|
import { NostrClient } from '$lib/services/nostr/nostr-client.js'; |
||||||
|
import { KIND } from '$lib/types/nostr.js'; |
||||||
|
import { extractRequestContext } from '$lib/utils/api-context.js'; |
||||||
|
import { getCachedUserLevel } from '$lib/services/security/user-level-cache.js'; |
||||||
|
import logger from '$lib/services/logger.js'; |
||||||
|
import { handleApiError, handleValidationError } from '$lib/utils/error-handler.js'; |
||||||
|
|
||||||
|
const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; |
||||||
|
const repoManager = new RepoManager(repoRoot); |
||||||
|
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); |
||||||
|
|
||||||
|
export const POST: RequestHandler = async (event) => { |
||||||
|
const { npub, repo } = event.params; |
||||||
|
|
||||||
|
if (!npub || !repo) { |
||||||
|
throw handleValidationError('Missing npub or repo parameter', { operation: 'cloneRepo', npub, repo }); |
||||||
|
} |
||||||
|
|
||||||
|
// Extract user context
|
||||||
|
const requestContext = extractRequestContext(event); |
||||||
|
const userPubkeyHex = requestContext.userPubkeyHex; |
||||||
|
|
||||||
|
if (!userPubkeyHex) { |
||||||
|
throw error(401, 'Authentication required. Please log in to clone repositories.'); |
||||||
|
} |
||||||
|
|
||||||
|
// Check if user has unlimited access
|
||||||
|
const userLevel = getCachedUserLevel(userPubkeyHex); |
||||||
|
if (!userLevel || userLevel.level !== 'unlimited') { |
||||||
|
throw error(403, 'Only users with unlimited access can clone repositories to the server.'); |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
// Decode npub to get pubkey
|
||||||
|
const repoOwnerPubkey = requireNpubHex(npub); |
||||||
|
const repoPath = join(repoRoot, npub, `${repo}.git`); |
||||||
|
|
||||||
|
// Check if repo already exists
|
||||||
|
if (existsSync(repoPath)) { |
||||||
|
return json({
|
||||||
|
success: true,
|
||||||
|
message: 'Repository already exists locally', |
||||||
|
alreadyExists: true |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
// Fetch repository announcement
|
||||||
|
const events = await nostrClient.fetchEvents([ |
||||||
|
{ |
||||||
|
kinds: [KIND.REPO_ANNOUNCEMENT], |
||||||
|
authors: [repoOwnerPubkey], |
||||||
|
'#d': [repo], |
||||||
|
limit: 1 |
||||||
|
} |
||||||
|
]); |
||||||
|
|
||||||
|
if (events.length === 0) { |
||||||
|
throw handleValidationError( |
||||||
|
'Repository announcement not found in Nostr', |
||||||
|
{ operation: 'cloneRepo', npub, repo } |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
const announcementEvent = events[0]; |
||||||
|
|
||||||
|
// Attempt to clone the repository
|
||||||
|
const cloned = await repoManager.fetchRepoOnDemand(npub, repo, announcementEvent); |
||||||
|
|
||||||
|
if (!cloned) { |
||||||
|
throw handleApiError( |
||||||
|
new Error('Failed to clone repository from remote URLs'), |
||||||
|
{ operation: 'cloneRepo', npub, repo }, |
||||||
|
'Could not clone repository. Please check that the repository has valid clone URLs and is accessible.' |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
// Verify repo exists after cloning
|
||||||
|
if (!existsSync(repoPath)) { |
||||||
|
// Wait a moment for filesystem to sync
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500)); |
||||||
|
if (!existsSync(repoPath)) { |
||||||
|
throw handleApiError( |
||||||
|
new Error('Repository clone completed but repository is not accessible'), |
||||||
|
{ operation: 'cloneRepo', npub, repo }, |
||||||
|
'Repository clone completed but repository is not accessible' |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
logger.info({ npub, repo, userPubkeyHex: userPubkeyHex.slice(0, 16) + '...' }, 'Repository cloned successfully'); |
||||||
|
|
||||||
|
return json({ |
||||||
|
success: true, |
||||||
|
message: 'Repository cloned successfully', |
||||||
|
alreadyExists: false |
||||||
|
}); |
||||||
|
} catch (err) { |
||||||
|
logger.error({ error: err, npub, repo }, 'Error cloning repository'); |
||||||
|
|
||||||
|
// Re-throw auth errors as-is
|
||||||
|
if (err instanceof Error && (err.message.includes('401') || err.message.includes('403'))) { |
||||||
|
throw err; |
||||||
|
} |
||||||
|
|
||||||
|
const error = err instanceof Error ? err : new Error(String(err)); |
||||||
|
throw handleApiError( |
||||||
|
error, |
||||||
|
{ operation: 'cloneRepo', npub, repo }, |
||||||
|
'Failed to clone repository' |
||||||
|
); |
||||||
|
} |
||||||
|
}; |
||||||
Loading…
Reference in new issue