syntaxai/tdd.md · main · src / c14_github.ts
// 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<string> => {
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<GithubUser> => {
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<string | null> => {
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<ProjectConfig> => {
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<string, { fetchedAt: number; commits: GithubCommit[] }>();
// Deploy-time snapshot: scripts/p620/snapshot-git-history.ts dumps the
// local git log into content/git-history/<owner>__<name>.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<GithubCommit[] | null> => {
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<GithubCommit[]> => {
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<string, { fetchedAt: number; tree: RepoTree }>();
export const fetchRepoTree = async (
repoOwner: string,
repoName: string,
): Promise<RepoTree> => {
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<string | null> => {
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<TestBundle | null> => {
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;
}
};