repo-detail.ts 3.3 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192
  1. import { formatCount, type RepoMeta } from "@pipeline/shared";
  2. /**
  3. * Shared single-repo detail helpers — the fetch + format logic behind the
  4. * github-repo collector, parameterized by README truncation length so
  5. * github-daily-pick can ask for a deeper excerpt without duplicating code.
  6. */
  7. /** Format a repo-detail API payload as markdown (the LLM's fact source),
  8. * plus the typed RepoMeta the text module treats as authoritative. */
  9. export function formatRepoDetail(
  10. queryOwner: string,
  11. queryRepo: string,
  12. data: any,
  13. readmeLimit = 3000,
  14. ): { text: string; meta: RepoMeta | null } {
  15. // The API returns the REAL owner (transferred repos report their current
  16. // owner). Prefer data.fullName; fall back to the query params.
  17. const fullName = data.fullName || `${queryOwner}/${queryRepo}`;
  18. const [detailOwner, detailName] = fullName.split("/");
  19. const meta: RepoMeta = {
  20. owner: detailOwner || queryOwner,
  21. name: detailName || queryRepo,
  22. fullName,
  23. language: data.language ?? "",
  24. languageColor: data.languageColor ?? "",
  25. stars: data.stars,
  26. forks: data.forks,
  27. license: data.license ?? "",
  28. };
  29. const lines: string[] = [`# ${fullName}\n`];
  30. if (data.description) lines.push(`${data.description}\n`);
  31. // Structured metadata block — LLM copies verbatim into scene.github.repo.
  32. lines.push(`<!-- repo-meta: ${JSON.stringify(meta)} -->`);
  33. const meta2: string[] = [];
  34. if (data.language) meta2.push(`Language: ${data.language}`);
  35. if (data.stars != null) meta2.push(`Stars: ${formatCount(data.stars)}`);
  36. if (data.forks != null) meta2.push(`Forks: ${formatCount(data.forks)}`);
  37. if (data.openIssues != null) meta2.push(`Open Issues: ${formatCount(data.openIssues)}`);
  38. if (data.topics?.length) meta2.push(`Topics: ${data.topics.join(", ")}`);
  39. if (meta2.length) {
  40. lines.push("");
  41. lines.push(meta2.map((m) => `- ${m}`).join("\n"));
  42. }
  43. if (data.readme) {
  44. const readme = data.readme.length > readmeLimit
  45. ? data.readme.slice(0, readmeLimit) + "\n..."
  46. : data.readme;
  47. lines.push("\n## README\n", readme);
  48. }
  49. return { text: lines.join("\n"), meta };
  50. }
  51. /** Fetch one repo's detail payload and format it. Returns null on any failure
  52. * so callers can decide how to degrade. Retries transient upstream errors
  53. * (5xx / network) up to 2 times with a short backoff — a blip shouldn't burn
  54. * the daily pick's topic. */
  55. export async function fetchRepoDetail(
  56. repoUrlTemplate: string,
  57. owner: string,
  58. repo: string,
  59. readmeLimit = 3000,
  60. ): Promise<{ text: string; meta: RepoMeta | null } | null> {
  61. if (!repoUrlTemplate) return null;
  62. const url = repoUrlTemplate
  63. .replace(":owner", encodeURIComponent(owner))
  64. .replace(":repo", encodeURIComponent(repo));
  65. const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
  66. for (let attempt = 0; attempt < 3; attempt++) {
  67. try {
  68. const response = await fetch(url);
  69. if (response.ok) {
  70. const raw = (await response.json()) as Record<string, any>;
  71. const data = (raw.data ?? raw) as Record<string, any>;
  72. return formatRepoDetail(owner, repo, data, readmeLimit);
  73. }
  74. // 4xx is a real answer (repo gone etc.) — do not retry.
  75. if (response.status < 500) return null;
  76. } catch {
  77. // network error — retry
  78. }
  79. if (attempt < 2) await sleep(1000 * (attempt + 1));
  80. }
  81. return null;
  82. }