// c14 — secondary I/O: HTTP clients to GitHub. Two concerns under one roof: // 1. OAuth flow for sign-in (used by /auth/github/start + callback). // 2. Raw-content fetch of `.tdd-md.json` from a public repo's default // branch, for project onboarding. // Both talk to GitHub; both are pure HTTP, no in-process state. import { PROJECT_CONFIG_PATH, parseProjectConfig, type ProjectConfig, } from "./a31_project_config.ts"; const CLIENT_ID = process.env.GITHUB_CLIENT_ID ?? ""; const CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET ?? ""; export interface GithubUser { login: string; id: number; email: string | null; avatar_url: string; name: string | null; } export interface GithubEmail { email: string; primary: boolean; verified: boolean; visibility: string | null; } export const isConfigured = (): boolean => CLIENT_ID !== "" && CLIENT_SECRET !== ""; export const authorizeUrl = (state: string, redirectUri: string): string => { const params = new URLSearchParams({ client_id: CLIENT_ID, redirect_uri: redirectUri, scope: "read:user user:email", state, allow_signup: "true", }); return `https://github.com/login/oauth/authorize?${params}`; }; export const exchangeCode = async (code: string, redirectUri: string): Promise => { const res = await fetch("https://github.com/login/oauth/access_token", { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify({ client_id: CLIENT_ID, client_secret: CLIENT_SECRET, code, redirect_uri: redirectUri, }), }); if (!res.ok) { throw new Error(`github token exchange failed: ${res.status}`); } const data = (await res.json()) as { access_token?: string; error?: string; error_description?: string }; if (!data.access_token) { throw new Error(`github token exchange returned no token: ${data.error_description ?? data.error ?? "unknown"}`); } return data.access_token; }; export const fetchUser = async (accessToken: string): Promise => { const res = await fetch("https://api.github.com/user", { headers: { Authorization: `token ${accessToken}`, Accept: "application/vnd.github+json", "User-Agent": "tdd.md", }, }); if (!res.ok) throw new Error(`github user fetch failed: ${res.status}`); return (await res.json()) as GithubUser; }; export const fetchPrimaryEmail = async (accessToken: string): Promise => { const res = await fetch("https://api.github.com/user/emails", { headers: { Authorization: `token ${accessToken}`, Accept: "application/vnd.github+json", "User-Agent": "tdd.md", }, }); if (!res.ok) return null; const emails = (await res.json()) as GithubEmail[]; const verified = emails.filter((e) => e.verified); return verified.find((e) => e.primary)?.email ?? verified[0]?.email ?? null; }; // Pulls .tdd-md.json from a public GitHub repo's default branch via the // raw-content host. No auth — public-repo only for now (private repos // land when we install a GitHub App, deferred to a later sliver). export const fetchProjectConfig = async ( repoOwner: string, repoName: string, ): Promise => { const url = `https://raw.githubusercontent.com/${encodeURIComponent(repoOwner)}/${encodeURIComponent(repoName)}/HEAD/${PROJECT_CONFIG_PATH}`; const res = await fetch(url, { headers: { Accept: "application/json", "User-Agent": "tdd.md" }, }); if (res.status === 404) { throw new Error( `${PROJECT_CONFIG_PATH} not found in ${repoOwner}/${repoName} on the default branch (or the repo is private; private repos aren't supported yet).`, ); } if (!res.ok) { throw new Error( `Couldn't fetch ${PROJECT_CONFIG_PATH} from ${repoOwner}/${repoName}: HTTP ${res.status}`, ); } let parsed: unknown; try { parsed = await res.json(); } catch { throw new Error(`${PROJECT_CONFIG_PATH} in ${repoOwner}/${repoName} isn't valid JSON`); } return parseProjectConfig(parsed); }; // --------------------------------------------------------------------- // Public commits API. Used to feed the live reports view from real // data. Public-repo only; unauthenticated calls are rate-limited to // 60/hour, so we cache aggressively. Single in-memory cache per // (owner, repo) with a 5-minute TTL — enough for casual page-loads, // not so long that pushed commits stay invisible. // --------------------------------------------------------------------- export interface GithubCommit { sha: string; commit: { author: { name: string; email: string; date: string }; message: string; }; author: { login: string } | null; } const COMMITS_TTL_MS = 5 * 60 * 1000; const commitsCache = new Map(); // Deploy-time snapshot: scripts/p620/snapshot-git-history.ts dumps the // local git log into content/git-history/__.json so the // container can serve /reports/live for a private repo without a // GitHub token. Bundle is preferred when present; we fall back to the // public API for any repo we don't bundle. const bundlePath = (repoOwner: string, repoName: string): string => `./content/git-history/${repoOwner}__${repoName}.json`; interface GitHistoryBundle { owner: string; name: string; fetchedAt: number; commits: GithubCommit[]; } const loadBundle = async ( repoOwner: string, repoName: string, ): Promise => { try { const file = Bun.file(bundlePath(repoOwner, repoName)); if (!(await file.exists())) return null; const data = (await file.json()) as GitHistoryBundle; return Array.isArray(data.commits) ? data.commits : null; } catch { return null; } }; export const fetchRepoCommits = async ( repoOwner: string, repoName: string, perPage = 100, ): Promise => { const key = `${repoOwner}/${repoName}#${perPage}`; const cached = commitsCache.get(key); if (cached && Date.now() - cached.fetchedAt < COMMITS_TTL_MS) { return cached.commits; } const bundle = await loadBundle(repoOwner, repoName); if (bundle) { const sliced = bundle.slice(0, perPage); commitsCache.set(key, { fetchedAt: Date.now(), commits: sliced }); return sliced; } const url = `https://api.github.com/repos/${encodeURIComponent(repoOwner)}/${encodeURIComponent(repoName)}/commits?per_page=${perPage}`; const res = await fetch(url, { headers: { Accept: "application/vnd.github+json", "User-Agent": "tdd.md", }, }); if (!res.ok) { // Honour the cache on transient failure rather than blanking the page — // GitHub's 60/hour anonymous rate limit is the most likely cause and // the cached data is still strictly better than no data. if (cached) return cached.commits; throw new Error(`GitHub commits API failed for ${repoOwner}/${repoName}: HTTP ${res.status}`); } const commits = (await res.json()) as GithubCommit[]; commitsCache.set(key, { fetchedAt: Date.now(), commits }); return commits; }; // --------------------------------------------------------------------- // Test-results bundle. Companion to the git-history bundle above — // scripts/p620/snapshot-tests.ts runs `bun test --reporter=junit` at // each deploy and appends the result to this JSON file. Lets the // container render /reports/live/tests against real data without // running tests at runtime. // --------------------------------------------------------------------- export interface TestRecord { name: string; file: string; status: "pass" | "fail"; durationMs: number; } export interface PlaceholderTest { name: string; file: string; reason: string; } export interface TestRunRecord { sha: string; branch: string; ranAt: number; total: number; passing: number; failing: number; durationMs: number; tests: TestRecord[]; // Optional for backwards-compat with bundles written before the // placeholder-detection sliver shipped. Treat missing as []. placeholderTests?: PlaceholderTest[]; } export interface TestBundle { owner: string; name: string; runs: TestRunRecord[]; } // --------------------------------------------------------------------- // SAMA verify support: tree listing via API (one call) + raw-content // reads for every cXX_*.ts file (raw.githubusercontent.com, no rate // limit). Used by /sama/verify to inspect any public repo without a // token. Cached per (owner, name) for an hour. // --------------------------------------------------------------------- export interface RepoTreeEntry { path: string; type: "blob" | "tree" | "commit"; size?: number; } interface RepoTree { defaultBranch: string; entries: RepoTreeEntry[]; truncated: boolean; } const TREE_TTL_MS = 60 * 60 * 1000; const treeCache = new Map(); export const fetchRepoTree = async ( repoOwner: string, repoName: string, ): Promise => { const key = `${repoOwner}/${repoName}`; const cached = treeCache.get(key); if (cached && Date.now() - cached.fetchedAt < TREE_TTL_MS) return cached.tree; const repoRes = await fetch(`https://api.github.com/repos/${encodeURIComponent(repoOwner)}/${encodeURIComponent(repoName)}`, { headers: { Accept: "application/vnd.github+json", "User-Agent": "tdd.md" }, }); if (!repoRes.ok) { if (cached) return cached.tree; throw new Error(`GitHub repo lookup failed for ${repoOwner}/${repoName}: HTTP ${repoRes.status}`); } const repoMeta = (await repoRes.json()) as { default_branch?: string }; const defaultBranch = repoMeta.default_branch ?? "main"; const treeRes = await fetch( `https://api.github.com/repos/${encodeURIComponent(repoOwner)}/${encodeURIComponent(repoName)}/git/trees/${encodeURIComponent(defaultBranch)}?recursive=1`, { headers: { Accept: "application/vnd.github+json", "User-Agent": "tdd.md" } }, ); if (!treeRes.ok) { if (cached) return cached.tree; throw new Error(`GitHub tree fetch failed for ${repoOwner}/${repoName}: HTTP ${treeRes.status}`); } const data = (await treeRes.json()) as { tree?: Array<{ path: string; type: string; size?: number }>; truncated?: boolean; }; const entries = (data.tree ?? []).map((e) => ({ path: e.path, type: e.type as RepoTreeEntry["type"], size: e.size, })); const tree: RepoTree = { defaultBranch, entries, truncated: data.truncated ?? false }; treeCache.set(key, { fetchedAt: Date.now(), tree }); return tree; }; // Raw content fetch via raw.githubusercontent.com — no API rate limit. // Per-call timeout via AbortController so a slow upstream can't tie up // the verifier indefinitely. export const fetchRepoRawFile = async ( repoOwner: string, repoName: string, ref: string, path: string, timeoutMs = 10_000, ): Promise => { const url = `https://raw.githubusercontent.com/${encodeURIComponent(repoOwner)}/${encodeURIComponent(repoName)}/${encodeURIComponent(ref)}/${path.split("/").map(encodeURIComponent).join("/")}`; const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), timeoutMs); try { const res = await fetch(url, { headers: { "User-Agent": "tdd.md" }, signal: ctrl.signal, }); if (!res.ok) return null; return await res.text(); } catch { return null; } finally { clearTimeout(t); } }; export const loadTestBundle = async ( repoOwner: string, repoName: string, ): Promise => { try { const file = Bun.file(`./content/git-history/${repoOwner}__${repoName}__tests.json`); if (!(await file.exists())) return null; const data = (await file.json()) as TestBundle; return Array.isArray(data.runs) ? data : null; } catch { return null; } };