/** * GitHub caching utilities for unified repos */ import type { GitHubRepoInfo } from "../types/index.ts"; import { initDb, schema, type DbInstance } from "../db/index.ts"; import { defaultGitHubService, type GitHubService } from "../services/github.ts"; import { errorToString } from "../utils/errors.ts"; import { debugError } from "../utils/debug.ts"; import { getTokenAsync } from "./auth.ts"; /** * Dependencies that can be injected for testing */ export interface CacheDeps { db: DbInstance; githubService: GitHubService; /** * Optional token provider used when no env token is set. * Defaults to getTokenAsync which will call `gh auth token`. */ tokenProvider: () => Promise; } /** * Check if GitHub repos cache is fresh based on TTL */ function isGitHubCacheFresh(lastFetched: Date | null, ttlSeconds: number): boolean { if (!lastFetched) return false; const now = Date.now(); const ttlMs = ttlSeconds * 1000; return now - lastFetched.getTime() < ttlMs; } /** * Save GitHub repos to cache * Returns true on success, false on failure */ export async function saveGitHubReposToCache( repos: GitHubRepoInfo[], deps?: { db?: DbInstance } ): Promise { try { const db = deps?.db ?? await initDb(); const now = new Date(); // Clear existing cache await db.delete(schema.githubRepos).run(); // Insert all repos for (const repo of repos) { await db.insert(schema.githubRepos).values({ name: repo.name, fullName: repo.fullName, owner: repo.owner, description: repo.description, htmlUrl: repo.htmlUrl, sshUrl: repo.sshUrl, cloneUrl: repo.cloneUrl, isPrivate: repo.isPrivate, isArchived: repo.isArchived, isFork: repo.isFork, pushedAt: repo.pushedAt, updatedAt: repo.updatedAt, defaultBranch: repo.defaultBranch, language: repo.language, size: repo.size, stargazersCount: repo.stargazersCount, forksCount: repo.forksCount, openIssuesCount: repo.openIssuesCount, watchersCount: repo.watchersCount, topics: JSON.stringify(repo.topics), license: repo.license, hasIssues: repo.hasIssues, hasWiki: repo.hasWiki, hasDiscussions: repo.hasDiscussions, lastFetched: now, }); } return true; } catch (error) { debugError("cache", "Failed to save GitHub repos to cache", error); return false; } } /** * Get the most recent cache write time across all GitHub repo rows. * Used to decide whether the cached snapshot is fresh per TTL. */ export async function getGitHubCacheLastFetched( deps?: { db?: DbInstance } ): Promise { try { const db = deps?.db ?? await initDb(); const rows = await db.select({ lastFetched: schema.githubRepos.lastFetched }).from(schema.githubRepos).all(); let latest: number | null = null; for (const row of rows) { const t = row.lastFetched?.getTime() ?? null; if (t !== null && (latest === null || t > latest)) latest = t; } return latest === null ? null : new Date(latest); } catch (error) { debugError("cache", "Failed to read GitHub cache lastFetched", error); return null; } } /** * Load GitHub repos from cache */ export async function loadGitHubReposFromCache( deps?: { db?: DbInstance } ): Promise { try { const db = deps?.db ?? await initDb(); const rows = await db.select().from(schema.githubRepos).all(); return rows.map(row => { // Get the actual values from the row const htmlUrl = row.htmlUrl; const sshUrl = row.sshUrl; const cloneUrl = row.cloneUrl; const isPrivate = row.isPrivate; const isArchived = row.isArchived; const isFork = row.isFork; const defaultBranch = row.defaultBranch; const size = row.size; const stargazersCount = row.stargazersCount; const forksCount = row.forksCount; const openIssuesCount = row.openIssuesCount; const watchersCount = row.watchersCount; const topics = row.topics; const license = row.license; const hasIssues = row.hasIssues; const hasWiki = row.hasWiki; const hasDiscussions = row.hasDiscussions; return { name: row.name, fullName: row.fullName, owner: row.owner, description: row.description, htmlUrl: htmlUrl ?? '', sshUrl: sshUrl ?? '', cloneUrl: cloneUrl ?? '', isPrivate: isPrivate ?? false, isArchived: isArchived ?? false, isFork: isFork ?? false, pushedAt: row.pushedAt, updatedAt: row.updatedAt, defaultBranch: defaultBranch ?? 'main', language: row.language, size: size ?? 0, stargazersCount: stargazersCount ?? 0, forksCount: forksCount ?? 0, openIssuesCount: openIssuesCount ?? 0, watchersCount: watchersCount ?? 0, topics: (() => { try { return topics ? JSON.parse(topics) : []; } catch (e) { console.warn(`Failed to parse topics JSON: "${topics}"`); return []; } })(), license: license, hasIssues: hasIssues ?? false, hasWiki: hasWiki ?? false, hasDiscussions: hasDiscussions ?? false, }; }); } catch (error) { debugError("cache", "Failed to load GitHub repos from cache", error); return []; } } /** * Fetch GitHub repos with caching support */ export async function fetchGitHubReposWithCache( options?: { includeOrgs?: boolean; includeArchived?: boolean; includeForks?: boolean; }, cacheTtlSeconds = 300, deps?: Partial ): Promise<{ repos: GitHubRepoInfo[]; fromCache: boolean; error?: string; }> { const githubService = deps?.githubService ?? defaultGitHubService; const tokenProvider = deps?.tokenProvider ?? getTokenAsync; // Attempt to populate env token from gh CLI if none is set if (!githubService.hasToken()) { const token = await tokenProvider(); if (!token && !githubService.hasToken()) { return { repos: [], fromCache: false, error: "GITHUB_TOKEN not set" }; } } // Try to load from cache first. Freshness is based on the cache's own // lastFetched timestamp (when we wrote the row), NOT the repo's updatedAt // — those represent very different things. try { const lastFetched = await getGitHubCacheLastFetched({ db: deps?.db }); if (lastFetched && isGitHubCacheFresh(lastFetched, cacheTtlSeconds)) { const cached = await loadGitHubReposFromCache({ db: deps?.db }); if (cached.length > 0) { return { repos: cached, fromCache: true }; } } } catch (error) { debugError("cache", "Failed to check GitHub cache", error); } // Fetch fresh data try { const repos = await githubService.getAllRepos(options); // Save to cache (don't await, let it happen in background) saveGitHubReposToCache(repos, { db: deps?.db }).catch(console.error); return { repos, fromCache: false }; } catch (error) { const errorString = errorToString(error); console.error("Failed to fetch GitHub repos:", errorString); // On error, try to return stale cache if available try { const cached = await loadGitHubReposFromCache({ db: deps?.db }); if (cached.length > 0) { console.log("Using stale cache due to fetch error"); return { repos: cached, fromCache: true, error: errorString }; } } catch { // No cache available } return { repos: [], fromCache: false, error: errorString }; } } /** * Clear all GitHub repos from cache * Returns true on success, false on failure */ export async function clearGitHubCache(): Promise { try { const db = await initDb(); await db.delete(schema.githubRepos).run(); return true; } catch (error) { debugError("cache", "Failed to clear GitHub cache", error); return false; } }