From 5f602ce3c67c8eae2a7faab4b059c5355f73e3ce Mon Sep 17 00:00:00 2001 From: Silberengel Date: Tue, 24 Feb 2026 17:15:14 +0100 Subject: [PATCH] fallback to API if registered clone unavailble Nostr-Signature: 4921a95aea13f6f72329ff8a278a8ff6321776973e8db327d59ea62b90d363cc 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 0efffc826cad23849bd311be582a70cb0a42f3958c742470e8488803c5882955184b9241bf77fcf65fa5ea38feef8bc82de4965de1c783adf53ed05e461dc5de --- nostr/commit-signatures.jsonl | 1 + src/lib/services/git/api-repo-fetcher.ts | 75 +++++++++++- src/lib/styles/repo.css | 2 +- src/lib/utils/api-repo-helper.ts | 4 +- .../repos/[npub]/[repo]/branches/+server.ts | 59 ++++++++- .../repos/[npub]/[repo]/commits/+server.ts | 43 +++++++ .../api/repos/[npub]/[repo]/file/+server.ts | 61 +++++++++- .../api/repos/[npub]/[repo]/tags/+server.ts | 97 ++++++++++++++- .../api/repos/[npub]/[repo]/tree/+server.ts | 113 ++++++++++++++++++ 9 files changed, 440 insertions(+), 15 deletions(-) diff --git a/nostr/commit-signatures.jsonl b/nostr/commit-signatures.jsonl index 79677e5..8b84cf9 100644 --- a/nostr/commit-signatures.jsonl +++ b/nostr/commit-signatures.jsonl @@ -67,3 +67,4 @@ {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771850840,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","rearrange repo pages"]],"content":"Signed commit: rearrange repo pages","id":"9f8b68f36189073807510a2dac268b466629ecbc6b8dca66ba809cbf3a36dab5","sig":"911debb546c23038bbf77a57bee089130c7cce3a51f2cfb385c3904ec39bc76b90dc9bef2e8e501824ecff13925523d802b6c916d07fef2718554f4f65e6f4d2"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771923126,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix new branch creation"]],"content":"Signed commit: fix new branch creation","id":"7802c9afbf005e2637282f9d06ac8130fe27bfe3a94cc67c211da51d2e9e8350","sig":"30978d6a71b4935c88ff9cd1412294d850a752977943e1aa65bcfc2290d2f2e8bbce809556849a14f0923da33b12cb53d3339741cdabab3ba949dfbb48e9cc4c"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771923236,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","clean up build warning"]],"content":"Signed commit: clean up build warning","id":"297f43968ae4bcfc8b054037b914a728eaec805770ba0c02e33aab3009c1c046","sig":"91177b6f9c4cd0d69455d5e1c109912588f05c2ddbf287d606a9687ec522ba259ed83750dfbb4b77f20e3cb82a266f251983a14405babc28c0d83eb19bf3da70"} +{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771924650,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","pass announcement"]],"content":"Signed commit: pass announcement","id":"57e1440848e4b322a9b10a6dff49973f29c8dd20b85f6cc75fd40d32eb04f0e4","sig":"3866152051a42592e83a1850bf9f3fd49af597f7dcdb523ef39374d528f6c46df6118682cac3202c29ce89a90fec8b4284c68a57101c6c590d8d1a184cac9731"} diff --git a/src/lib/services/git/api-repo-fetcher.ts b/src/lib/services/git/api-repo-fetcher.ts index 7728fb3..3434339 100644 --- a/src/lib/services/git/api-repo-fetcher.ts +++ b/src/lib/services/git/api-repo-fetcher.ts @@ -45,6 +45,7 @@ export interface ApiRepoInfo { branches: ApiBranch[]; commits: ApiCommit[]; files: ApiFile[]; + tags?: ApiTag[]; readme?: { path: string; content: string; @@ -54,6 +55,13 @@ export interface ApiRepoInfo { isCloned: boolean; // Whether repo exists locally } +export interface ApiTag { + name: string; + sha: string; + message?: string; + date?: string; +} + export interface ApiBranch { name: string; commit: { @@ -264,10 +272,10 @@ async function fetchFromGitHub(owner: string, repo: string): Promise { logger.debug({ error: err, owner, repo }, 'Failed to fetch branches from GitHub'); return null; @@ -276,6 +284,10 @@ async function fetchFromGitHub(owner: string, repo: string): Promise { + logger.debug({ error: err, owner, repo }, 'Failed to fetch tags from GitHub'); + return null; + }), fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/${treeRef}?recursive=1`, { headers }).catch((err) => { logger.debug({ error: err, owner, repo, treeRef }, 'Failed to fetch tree from GitHub'); return null; @@ -303,6 +315,15 @@ async function fetchFromGitHub(owner: string, repo: string): Promise ({ + name: t.name, + sha: t.commit?.sha || '', + message: t.commit?.commit?.message?.split('\n')[0], + date: t.commit?.commit?.author?.date + })) + : []; + let files: ApiFile[] = []; if (treeResponse?.ok) { try { @@ -364,6 +385,7 @@ async function fetchFromGitHub(owner: string, repo: string): Promise null), + fetch(getApiBaseUrl( + `projects/${projectPath}/repository/tags`, + baseUrl, + new URLSearchParams({ per_page: '100' }) )).catch(() => null) ]); @@ -449,6 +476,22 @@ async function fetchFromGitLab(owner: string, repo: string, baseUrl: string): Pr date: c.committed_date })); + let tagsData: any[] = []; + if (tagsResponse && tagsResponse.ok) { + tagsData = await tagsResponse.json(); + if (!Array.isArray(tagsData)) { + logger.warn({ owner, repo }, 'GitLab tags response is not an array'); + tagsData = []; + } + } + + const tags: ApiTag[] = tagsData.map((t: any) => ({ + name: t.name, + sha: t.commit?.id || '', + message: t.message, + date: t.commit?.created_at + })); + // Fetch file tree (simplified - GitLab tree API is more complex) let files: ApiFile[] = []; try { @@ -507,6 +550,7 @@ async function fetchFromGitLab(owner: string, repo: string, baseUrl: string): Pr branches, commits, files, + tags, readme, platform: 'gitlab' }; @@ -546,7 +590,7 @@ async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Pro const defaultBranch = repoData.default_branch || 'master'; - const [branchesResponse, commitsResponse] = await Promise.all([ + const [branchesResponse, commitsResponse, tagsResponse] = await Promise.all([ fetch(getApiBaseUrl( `repos/${encodedOwner}/${encodedRepo}/branches`, baseUrl, @@ -556,6 +600,11 @@ async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Pro `repos/${encodedOwner}/${encodedRepo}/commits`, baseUrl, new URLSearchParams({ limit: '10' }) + )).catch(() => null), + fetch(getApiBaseUrl( + `repos/${encodedOwner}/${encodedRepo}/tags`, + baseUrl, + new URLSearchParams({ limit: '100' }) )).catch(() => null) ]); @@ -582,6 +631,15 @@ async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Pro logger.warn({ status: commitsResponse?.status, owner, repo }, 'Gitea API error for commits'); } + let tagsData: any[] = []; + if (tagsResponse && tagsResponse.ok) { + tagsData = await tagsResponse.json(); + if (!Array.isArray(tagsData)) { + logger.warn({ owner, repo }, 'Gitea tags response is not an array'); + tagsData = []; + } + } + const branches: ApiBranch[] = branchesData.map((b: any) => { const commitObj = b.commit || {}; return { @@ -605,6 +663,13 @@ async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Pro }; }); + const tags: ApiTag[] = tagsData.map((t: any) => ({ + name: t.name || '', + sha: t.commit?.sha || t.sha || '', + message: t.message, + date: t.commit?.created || t.created + })); + // Fetch file tree - Gitea uses /git/trees API endpoint let files: ApiFile[] = []; const encodedBranch = encodeURIComponent(defaultBranch); diff --git a/src/lib/styles/repo.css b/src/lib/styles/repo.css index 5709464..1d66ae8 100644 --- a/src/lib/styles/repo.css +++ b/src/lib/styles/repo.css @@ -1678,7 +1678,7 @@ span.clone-more { .commit-button { width: 100%; - padding: 0; + padding: 0.75rem 1rem; text-align: left; background: none; border: none; diff --git a/src/lib/utils/api-repo-helper.ts b/src/lib/utils/api-repo-helper.ts index 7953077..07ffdc3 100644 --- a/src/lib/utils/api-repo-helper.ts +++ b/src/lib/utils/api-repo-helper.ts @@ -49,6 +49,7 @@ export async function tryApiFetch( defaultBranch: string; files?: Array<{ name: string; path: string; type: 'file' | 'dir'; size?: number }>; commits?: Array<{ sha: string; message: string; author: string; date: string }>; + tags?: Array<{ name: string; sha: string; message?: string; date?: string }>; } | null> { try { const cloneUrls = extractCloneUrls(announcementEvent); @@ -126,7 +127,8 @@ export async function tryApiFetch( branches: metadata.branches || [], defaultBranch: metadata.defaultBranch || 'main', files: metadata.files || [], - commits: metadata.commits || [] + commits: metadata.commits || [], + tags: metadata.tags || [] }; } else { logger.warn({ url, npub, repoName, attempt: i + 1, total: sortedUrls.length }, `[${i + 1}/${sortedUrls.length}] fetchRepoMetadata returned null, trying next URL`); diff --git a/src/routes/api/repos/[npub]/[repo]/branches/+server.ts b/src/routes/api/repos/[npub]/[repo]/branches/+server.ts index 30b8a5a..292c9dc 100644 --- a/src/routes/api/repos/[npub]/[repo]/branches/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/branches/+server.ts @@ -17,6 +17,7 @@ import { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_SEARCH_RELAYS } from '$lib/config.j import { NostrClient } from '$lib/services/nostr/nostr-client.js'; import { eventCache } from '$lib/services/nostr/event-cache.js'; import { fetchRepoAnnouncementsWithCache, findRepoAnnouncement } from '$lib/utils/nostr-utils.js'; +import { isGraspUrl } from '$lib/services/git/api-repo-fetcher.js'; import logger from '$lib/services/logger.js'; /** @@ -90,11 +91,26 @@ export const GET: RequestHandler = createRepoGetHandler( // API fetch failed - repo is not cloned and API fetch didn't work // Check if we have clone URLs to provide better error message const hasCloneUrls = cloneUrls.length > 0; - logger.warn({ npub: context.npub, repo: context.repo, hasCloneUrls, cloneUrlCount: cloneUrls.length }, 'API fallback failed for branches'); + logger.warn({ + npub: context.npub, + repo: context.repo, + hasCloneUrls, + cloneUrlCount: cloneUrls.length, + cloneUrls: cloneUrls.slice(0, 3) // Log first 3 URLs for debugging + }, 'API fallback failed for branches - repo not cloned and API fetch unsuccessful'); + + // Provide more detailed error message + const cloneUrlTypes = cloneUrls.map(url => { + if (url.includes('github.com')) return 'GitHub'; + if (url.includes('gitlab.com') || url.includes('gitlab')) return 'GitLab'; + if (url.includes('gitea')) return 'Gitea'; + if (isGraspUrl(url)) return 'GRASP'; + return 'Unknown'; + }); throw handleNotFoundError( hasCloneUrls - ? 'Repository is not cloned locally and could not be fetched via API from external clone URLs. Privileged users can clone this repository using the "Clone to Server" button.' + ? `Repository is not cloned locally and could not be fetched via API from external clone URLs (${cloneUrlTypes.join(', ')}). This may be due to API rate limits, network issues, or the repository being private. Privileged users can clone this repository using the "Clone to Server" button.` : 'Repository is not cloned locally and has no external clone URLs for API fallback. Privileged users can clone this repository using the "Clone to Server" button.', { operation: 'getBranches', npub: context.npub, repo: context.repo } ); @@ -136,6 +152,45 @@ export const GET: RequestHandler = createRepoGetHandler( try { const branches = await fileManager.getBranches(context.npub, context.repo); + + // If repo exists but has no branches (empty repo), try API fallback + if (branches.length === 0) { + logger.debug({ npub: context.npub, repo: context.repo }, 'Repo exists but is empty, attempting API fallback'); + + try { + // Fetch repository announcement for API fallback + let allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, context.repoOwnerPubkey, eventCache); + let announcement = findRepoAnnouncement(allEvents, context.repo); + + // If no events found in cache/default relays, try all relays (default + search) + if (!announcement) { + const allRelays = [...new Set([...DEFAULT_NOSTR_RELAYS, ...DEFAULT_NOSTR_SEARCH_RELAYS])]; + if (allRelays.length > DEFAULT_NOSTR_RELAYS.length) { + const allRelaysClient = new NostrClient(allRelays); + allEvents = await fetchRepoAnnouncementsWithCache(allRelaysClient, context.repoOwnerPubkey, eventCache); + announcement = findRepoAnnouncement(allEvents, context.repo); + } + } + + if (announcement) { + const { tryApiFetch } = await import('$lib/utils/api-repo-helper.js'); + const { extractCloneUrls } = await import('$lib/utils/nostr-utils.js'); + const cloneUrls = extractCloneUrls(announcement); + + logger.debug({ npub: context.npub, repo: context.repo, cloneUrlCount: cloneUrls.length }, 'Attempting API fallback for empty repo'); + + const apiData = await tryApiFetch(announcement, context.npub, context.repo); + + if (apiData && apiData.branches && apiData.branches.length > 0) { + logger.info({ npub: context.npub, repo: context.repo, branchCount: apiData.branches.length }, 'Successfully fetched branches via API fallback for empty repo'); + return json(apiData.branches); + } + } + } catch (apiErr) { + logger.debug({ error: apiErr, npub: context.npub, repo: context.repo }, 'API fallback failed for empty repo, returning empty branches'); + } + } + return json(branches); } catch (err) { // Log the actual error for debugging diff --git a/src/routes/api/repos/[npub]/[repo]/commits/+server.ts b/src/routes/api/repos/[npub]/[repo]/commits/+server.ts index 7b1ffec..a5a35cb 100644 --- a/src/routes/api/repos/[npub]/[repo]/commits/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/commits/+server.ts @@ -90,8 +90,51 @@ export const GET: RequestHandler = createRepoGetHandler( try { const commits = await fileManager.getCommitHistory(context.npub, context.repo, branch, limit, path); + + // If repo exists but has no commits (empty repo), try API fallback + if (commits.length === 0) { + logger.debug({ npub: context.npub, repo: context.repo, branch }, 'Repo exists but is empty, attempting API fallback for commits'); + + try { + const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, context.repoOwnerPubkey, eventCache); + const announcement = findRepoAnnouncement(allEvents, context.repo); + + if (announcement) { + const { tryApiFetch } = await import('$lib/utils/api-repo-helper.js'); + const apiData = await tryApiFetch(announcement, context.npub, context.repo); + + if (apiData && apiData.commits && apiData.commits.length > 0) { + logger.info({ npub: context.npub, repo: context.repo, commitCount: apiData.commits.length }, 'Successfully fetched commits via API fallback for empty repo'); + return json(apiData.commits.slice(0, limit)); + } + } + } catch (apiErr) { + logger.debug({ error: apiErr, npub: context.npub, repo: context.repo }, 'API fallback failed for empty repo, returning empty commits'); + } + } + return json(commits); } catch (err) { + // If error occurs, try API fallback before giving up + logger.debug({ error: err, npub: context.npub, repo: context.repo }, '[Commits] Error getting commit history, attempting API fallback'); + + try { + const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, context.repoOwnerPubkey, eventCache); + const announcement = findRepoAnnouncement(allEvents, context.repo); + + if (announcement) { + const { tryApiFetch } = await import('$lib/utils/api-repo-helper.js'); + const apiData = await tryApiFetch(announcement, context.npub, context.repo); + + if (apiData && apiData.commits && apiData.commits.length > 0) { + logger.info({ npub: context.npub, repo: context.repo, commitCount: apiData.commits.length }, 'Successfully fetched commits via API fallback after error'); + return json(apiData.commits.slice(0, limit)); + } + } + } catch (apiErr) { + logger.debug({ error: apiErr, npub: context.npub, repo: context.repo }, 'API fallback failed after error'); + } + // Log the actual error for debugging logger.error({ error: err, npub: context.npub, repo: context.repo }, '[Commits] Error getting commit history'); // Check if it's a "not found" error diff --git a/src/routes/api/repos/[npub]/[repo]/file/+server.ts b/src/routes/api/repos/[npub]/[repo]/file/+server.ts index a100d73..0fe6b8d 100644 --- a/src/routes/api/repos/[npub]/[repo]/file/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/file/+server.ts @@ -198,10 +198,69 @@ export const GET: RequestHandler = async (event) => { fileContent = await fileManager.getFileContent(npub, repo, filePath, 'HEAD'); ref = 'HEAD'; // Update ref for logging } catch (headErr) { - // If HEAD also fails, throw the original error + // If HEAD also fails, try API fallback before throwing + logger.debug({ error: headErr, npub, repo, filePath }, 'Failed to read file from local repo, attempting API fallback'); + + try { + const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, repoOwnerPubkey, eventCache); + const announcement = findRepoAnnouncement(allEvents, repo); + + if (announcement) { + const { tryApiFetchFile } = await import('$lib/utils/api-repo-helper.js'); + // Use the original ref, or 'main' as fallback + const apiRef = url.searchParams.get('ref') || 'main'; + const apiFileContent = await tryApiFetchFile(announcement, npub, repo, filePath, apiRef); + + if (apiFileContent && apiFileContent.content) { + logger.info({ npub, repo, filePath, ref: apiRef }, 'Successfully fetched file via API fallback for empty repo'); + auditLogger.logFileOperation( + userPubkeyHex || null, + requestContext.clientIp, + 'read', + `${npub}/${repo}`, + filePath, + 'success' + ); + return json(apiFileContent); + } + } + } catch (apiErr) { + logger.debug({ error: apiErr, npub, repo, filePath }, 'API fallback failed for file'); + } + + // If API fallback also fails, throw the original error throw firstErr; } } else { + // Try API fallback before throwing + logger.debug({ error: firstErr, npub, repo, filePath }, 'Failed to read file from local repo, attempting API fallback'); + + try { + const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, repoOwnerPubkey, eventCache); + const announcement = findRepoAnnouncement(allEvents, repo); + + if (announcement) { + const { tryApiFetchFile } = await import('$lib/utils/api-repo-helper.js'); + const apiRef = ref === 'HEAD' ? 'main' : ref; + const apiFileContent = await tryApiFetchFile(announcement, npub, repo, filePath, apiRef); + + if (apiFileContent && apiFileContent.content) { + logger.info({ npub, repo, filePath, ref: apiRef }, 'Successfully fetched file via API fallback for empty repo'); + auditLogger.logFileOperation( + userPubkeyHex || null, + requestContext.clientIp, + 'read', + `${npub}/${repo}`, + filePath, + 'success' + ); + return json(apiFileContent); + } + } + } catch (apiErr) { + logger.debug({ error: apiErr, npub, repo, filePath }, 'API fallback failed for file'); + } + throw firstErr; } } diff --git a/src/routes/api/repos/[npub]/[repo]/tags/+server.ts b/src/routes/api/repos/[npub]/[repo]/tags/+server.ts index a64acd6..32f0599 100644 --- a/src/routes/api/repos/[npub]/[repo]/tags/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/tags/+server.ts @@ -5,12 +5,15 @@ import { json } from '@sveltejs/kit'; // @ts-ignore - SvelteKit generates this type import type { RequestHandler } from './$types'; -import { fileManager } from '$lib/services/service-registry.js'; +import { fileManager, nostrClient } from '$lib/services/service-registry.js'; import { createRepoGetHandler, createRepoPostHandler } from '$lib/utils/api-handlers.js'; import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js'; import { handleValidationError, handleNotFoundError } from '$lib/utils/error-handler.js'; import { join } from 'path'; import { existsSync } from 'fs'; +import { eventCache } from '$lib/services/nostr/event-cache.js'; +import { fetchRepoAnnouncementsWithCache, findRepoAnnouncement } from '$lib/utils/nostr-utils.js'; +import logger from '$lib/services/logger.js'; const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT ? process.env.GIT_REPO_ROOT @@ -20,14 +23,98 @@ export const GET: RequestHandler = createRepoGetHandler( async (context: RepoRequestContext) => { const repoPath = join(repoRoot, context.npub, `${context.repo}.git`); - // If repo doesn't exist locally, return empty tags array - // Tags are only available for locally cloned repositories + // If repo doesn't exist locally, try API fallback if (!existsSync(repoPath)) { + try { + const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, context.repoOwnerPubkey, eventCache); + const announcement = findRepoAnnouncement(allEvents, context.repo); + + if (announcement) { + const { tryApiFetch } = await import('$lib/utils/api-repo-helper.js'); + const apiData = await tryApiFetch(announcement, context.npub, context.repo); + + if (apiData && apiData.tags && apiData.tags.length > 0) { + logger.debug({ npub: context.npub, repo: context.repo, tagCount: apiData.tags.length }, 'Successfully fetched tags via API fallback'); + // Convert API tags to FileManager.Tag format + const tags = apiData.tags.map(t => ({ + name: t.name, + hash: t.sha, + message: t.message + })); + return json(tags); + } + } + } catch (apiErr) { + logger.debug({ error: apiErr, npub: context.npub, repo: context.repo }, 'API fallback failed for tags'); + } + + // No tags found via API fallback, return empty array return json([]); } - const tags = await fileManager.getTags(context.npub, context.repo); - return json(tags); + try { + const tags = await fileManager.getTags(context.npub, context.repo); + + // If repo exists but has no tags (empty repo), try API fallback + if (tags.length === 0) { + logger.debug({ npub: context.npub, repo: context.repo }, 'Repo exists but is empty, attempting API fallback for tags'); + + try { + const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, context.repoOwnerPubkey, eventCache); + const announcement = findRepoAnnouncement(allEvents, context.repo); + + if (announcement) { + const { tryApiFetch } = await import('$lib/utils/api-repo-helper.js'); + const apiData = await tryApiFetch(announcement, context.npub, context.repo); + + if (apiData && apiData.tags && apiData.tags.length > 0) { + logger.info({ npub: context.npub, repo: context.repo, tagCount: apiData.tags.length }, 'Successfully fetched tags via API fallback for empty repo'); + // Convert API tags to FileManager.Tag format + const apiTags = apiData.tags.map(t => ({ + name: t.name, + hash: t.sha, + message: t.message + })); + return json(apiTags); + } + } + } catch (apiErr) { + logger.debug({ error: apiErr, npub: context.npub, repo: context.repo }, 'API fallback failed for empty repo, returning empty tags'); + } + } + + return json(tags); + } catch (err) { + // If error occurs, try API fallback before giving up + logger.debug({ error: err, npub: context.npub, repo: context.repo }, '[Tags] Error getting tags, attempting API fallback'); + + try { + const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, context.repoOwnerPubkey, eventCache); + const announcement = findRepoAnnouncement(allEvents, context.repo); + + if (announcement) { + const { tryApiFetch } = await import('$lib/utils/api-repo-helper.js'); + const apiData = await tryApiFetch(announcement, context.npub, context.repo); + + if (apiData && apiData.tags && apiData.tags.length > 0) { + logger.info({ npub: context.npub, repo: context.repo, tagCount: apiData.tags.length }, 'Successfully fetched tags via API fallback after error'); + // Convert API tags to FileManager.Tag format + const apiTags = apiData.tags.map(t => ({ + name: t.name, + hash: t.sha, + message: t.message + })); + return json(apiTags); + } + } + } catch (apiErr) { + logger.debug({ error: apiErr, npub: context.npub, repo: context.repo }, 'API fallback failed after error'); + } + + // If all else fails, return empty array + logger.warn({ error: err, npub: context.npub, repo: context.repo }, '[Tags] Error getting tags, returning empty array'); + return json([]); + } }, { operation: 'getTags', requireRepoExists: false, requireRepoAccess: true } // Handle on-demand fetching, but check access for private repos ); diff --git a/src/routes/api/repos/[npub]/[repo]/tree/+server.ts b/src/routes/api/repos/[npub]/[repo]/tree/+server.ts index e6b6e3b..a4a461e 100644 --- a/src/routes/api/repos/[npub]/[repo]/tree/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/tree/+server.ts @@ -159,6 +159,67 @@ export const GET: RequestHandler = createRepoGetHandler( try { const files = await fileManager.listFiles(context.npub, context.repo, ref, path); + + // If repo exists but has no files (empty repo), try API fallback + if (files.length === 0) { + logger.debug({ npub: context.npub, repo: context.repo, path, ref }, 'Repo exists but is empty, attempting API fallback for tree'); + + try { + // Fetch repository announcement for API fallback + const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, context.repoOwnerPubkey, eventCache); + const announcement = findRepoAnnouncement(allEvents, context.repo); + + if (announcement) { + const { tryApiFetch } = await import('$lib/utils/api-repo-helper.js'); + const apiData = await tryApiFetch(announcement, context.npub, context.repo); + + if (apiData && apiData.files && apiData.files.length > 0) { + logger.info({ npub: context.npub, repo: context.repo, fileCount: apiData.files.length }, 'Successfully fetched files via API fallback for empty repo'); + + // Filter files by path if specified (same logic as above) + let filteredFiles: typeof apiData.files; + if (path) { + const normalizedPath = path.endsWith('/') ? path : `${path}/`; + filteredFiles = apiData.files.filter(f => { + if (!f.path.startsWith(normalizedPath)) { + return false; + } + const relativePath = f.path.slice(normalizedPath.length); + if (!relativePath) { + return false; + } + const cleanRelativePath = relativePath.endsWith('/') ? relativePath.slice(0, -1) : relativePath; + return !cleanRelativePath.includes('/'); + }); + } else { + filteredFiles = apiData.files.filter(f => { + const cleanPath = f.path.endsWith('/') ? f.path.slice(0, -1) : f.path; + const pathParts = cleanPath.split('/'); + return pathParts.length === 1; + }); + } + + // Normalize type and name + const normalizedFiles = filteredFiles.map(f => { + const cleanPath = f.path.endsWith('/') ? f.path.slice(0, -1) : f.path; + const pathParts = cleanPath.split('/'); + const displayName = pathParts[pathParts.length - 1] || f.name; + return { + name: displayName, + path: f.path, + type: (f.type === 'dir' ? 'directory' : 'file') as 'file' | 'directory', + size: f.size + }; + }); + + return json(normalizedFiles); + } + } + } catch (apiErr) { + logger.debug({ error: apiErr, npub: context.npub, repo: context.repo }, 'API fallback failed for empty repo, returning empty files'); + } + } + // Debug logging to help diagnose missing files logger.debug({ npub: context.npub, @@ -170,6 +231,58 @@ export const GET: RequestHandler = createRepoGetHandler( }, '[Tree] Returning files from fileManager.listFiles'); return json(files); } catch (err) { + // If error occurs, try API fallback before giving up + logger.debug({ error: err, npub: context.npub, repo: context.repo }, '[Tree] Error listing files, attempting API fallback'); + + try { + const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, context.repoOwnerPubkey, eventCache); + const announcement = findRepoAnnouncement(allEvents, context.repo); + + if (announcement) { + const { tryApiFetch } = await import('$lib/utils/api-repo-helper.js'); + const apiData = await tryApiFetch(announcement, context.npub, context.repo); + + if (apiData && apiData.files && apiData.files.length > 0) { + logger.info({ npub: context.npub, repo: context.repo, fileCount: apiData.files.length }, 'Successfully fetched files via API fallback after error'); + + // Filter and normalize files (same logic as above) + const path = context.path || ''; + let filteredFiles: typeof apiData.files; + if (path) { + const normalizedPath = path.endsWith('/') ? path : `${path}/`; + filteredFiles = apiData.files.filter(f => { + if (!f.path.startsWith(normalizedPath)) return false; + const relativePath = f.path.slice(normalizedPath.length); + if (!relativePath) return false; + const cleanRelativePath = relativePath.endsWith('/') ? relativePath.slice(0, -1) : relativePath; + return !cleanRelativePath.includes('/'); + }); + } else { + filteredFiles = apiData.files.filter(f => { + const cleanPath = f.path.endsWith('/') ? f.path.slice(0, -1) : f.path; + return cleanPath.split('/').length === 1; + }); + } + + const normalizedFiles = filteredFiles.map(f => { + const cleanPath = f.path.endsWith('/') ? f.path.slice(0, -1) : f.path; + const pathParts = cleanPath.split('/'); + const displayName = pathParts[pathParts.length - 1] || f.name; + return { + name: displayName, + path: f.path, + type: (f.type === 'dir' ? 'directory' : 'file') as 'file' | 'directory', + size: f.size + }; + }); + + return json(normalizedFiles); + } + } + } catch (apiErr) { + logger.debug({ error: apiErr, npub: context.npub, repo: context.repo }, 'API fallback failed after error'); + } + // Log the actual error for debugging logger.error({ error: err, npub: context.npub, repo: context.repo }, '[Tree] Error listing files'); // Check if it's a "not found" error