syntaxai/tdd.md · main · src / c14_github.ts

c14_github.ts 353 lines · 11895 bytes raw
// 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;
  }
};