import { formatCount, type RepoMeta } from "@pipeline/shared"; /** * Shared single-repo detail helpers — the fetch + format logic behind the * github-repo collector, parameterized by README truncation length so * github-daily-pick can ask for a deeper excerpt without duplicating code. */ /** Format a repo-detail API payload as markdown (the LLM's fact source), * plus the typed RepoMeta the text module treats as authoritative. */ export function formatRepoDetail( queryOwner: string, queryRepo: string, data: any, readmeLimit = 3000, ): { text: string; meta: RepoMeta | null } { // The API returns the REAL owner (transferred repos report their current // owner). Prefer data.fullName; fall back to the query params. const fullName = data.fullName || `${queryOwner}/${queryRepo}`; const [detailOwner, detailName] = fullName.split("/"); const meta: RepoMeta = { owner: detailOwner || queryOwner, name: detailName || queryRepo, fullName, language: data.language ?? "", languageColor: data.languageColor ?? "", stars: data.stars, forks: data.forks, license: data.license ?? "", }; const lines: string[] = [`# ${fullName}\n`]; if (data.description) lines.push(`${data.description}\n`); // Structured metadata block — LLM copies verbatim into scene.github.repo. lines.push(``); const meta2: string[] = []; if (data.language) meta2.push(`Language: ${data.language}`); if (data.stars != null) meta2.push(`Stars: ${formatCount(data.stars)}`); if (data.forks != null) meta2.push(`Forks: ${formatCount(data.forks)}`); if (data.openIssues != null) meta2.push(`Open Issues: ${formatCount(data.openIssues)}`); if (data.topics?.length) meta2.push(`Topics: ${data.topics.join(", ")}`); if (meta2.length) { lines.push(""); lines.push(meta2.map((m) => `- ${m}`).join("\n")); } if (data.readme) { const readme = data.readme.length > readmeLimit ? data.readme.slice(0, readmeLimit) + "\n..." : data.readme; lines.push("\n## README\n", readme); } return { text: lines.join("\n"), meta }; } /** Fetch one repo's detail payload and format it. Returns null on any failure * so callers can decide how to degrade. Retries transient upstream errors * (5xx / network) up to 2 times with a short backoff — a blip shouldn't burn * the daily pick's topic. */ export async function fetchRepoDetail( repoUrlTemplate: string, owner: string, repo: string, readmeLimit = 3000, ): Promise<{ text: string; meta: RepoMeta | null } | null> { if (!repoUrlTemplate) return null; const url = repoUrlTemplate .replace(":owner", encodeURIComponent(owner)) .replace(":repo", encodeURIComponent(repo)); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); for (let attempt = 0; attempt < 3; attempt++) { try { const response = await fetch(url); if (response.ok) { const raw = (await response.json()) as Record; const data = (raw.data ?? raw) as Record; return formatRepoDetail(owner, repo, data, readmeLimit); } // 4xx is a real answer (repo gone etc.) — do not retry. if (response.status < 500) return null; } catch { // network error — retry } if (attempt < 2) await sleep(1000 * (attempt + 1)); } return null; }