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

d21_handlers_repo_view.ts 208 lines · 8396 bytes raw
// c21 (repo-view) — handler that renders the bare /:owner/:repo page.
// Composes c14_forgejo (repo + commits via admin API), c31 commits +
// games (parsing, kata lookup), c13 verdict store, c51 layout helpers.
// Exposed via the c21_app.ts fallback fetch — reserved top-level routes
// are matched first, this is the catch-all for /<owner>/<repo>.

import {
  FORGEJO_URL,
  adminApiHeaders,
  getUserVisibility,
} from "./c14_forgejo.ts";
import { parseCommit, computeProgress } from "./a31_commits.ts";
import { loadGame } from "./a31_games.ts";
import { latestRun } from "./c13_database.ts";
import {
  renderPage,
  renderNotFound,
  htmlResponse,
  phaseSpan,
  relativeTime,
} from "./b51_render_layout.ts";

interface ForgejoRepoSummary {
  description: string;
  clone_url: string;
  empty: boolean;
  private: boolean;
}

interface ForgejoCommit {
  sha: string;
  commit: { message: string; author: { name: string; date: string } };
}

export const renderRepoView = async (
  owner: string,
  repo: string,
  viewer: string | null,
): Promise<Response> => {
  // Private/limited owners get a 404 to anonymous visitors — but the
  // owner themselves (verified via session cookie) can always see
  // their own pages.
  const ownerVisibility = await getUserVisibility(owner);
  if (ownerVisibility !== null && ownerVisibility !== "public" && viewer !== owner) {
    const html = await renderNotFound(`/${owner}/${repo}`);
    return htmlResponse(html, 404);
  }

  const repoApi = `${FORGEJO_URL}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
  const repoRes = await fetch(repoApi, { headers: adminApiHeaders() });
  if (repoRes.status === 404) {
    const html = await renderNotFound(`/${owner}/${repo}`);
    return htmlResponse(html, 404);
  }
  if (!repoRes.ok) {
    const html = await renderPage({
      title: `${owner}/${repo} — tdd.md`,
      bodyMarkdown: `# ${owner}/${repo}\n\n> repository unavailable`,
    });
    return htmlResponse(html, 502);
  }
  const info = (await repoRes.json()) as ForgejoRepoSummary;
  const cloneUrl = info.clone_url || `https://tdd.md/${owner}/${repo}.git`;
  const isPrivate = info.private === true;

  // The repo name is by convention the kata id. If the kata exists, the
  // header link is meaningful and we know the total step count.
  let totalSteps: number | null = null;
  let kataExists = false;
  try {
    const game = await loadGame(repo);
    totalSteps = game.steps.length;
    kataExists = true;
  } catch {
    // Repo isn't a known kata — still render, just without step totals.
  }

  let commits: ForgejoCommit[] = [];
  if (!info.empty) {
    const commitsRes = await fetch(`${repoApi}/commits?limit=50&stat=false`, {
      headers: adminApiHeaders(),
    });
    if (commitsRes.ok) commits = (await commitsRes.json()) as ForgejoCommit[];
  }
  const progress = computeProgress(commits);
  const verified = progress.verifiedSteps.size;

  let status: string;
  if (commits.length === 0) {
    status = "awaiting first push";
  } else if (totalSteps !== null && verified >= totalSteps) {
    status = "kata complete";
  } else if (verified > 0) {
    status = "in progress";
  } else {
    status = "no verified steps yet";
  }
  const stepCounter = totalSteps !== null ? `${verified} / ${totalSteps}` : `${verified} / ?`;

  let phaseLog: string;
  if (commits.length === 0) {
    phaseLog = "_No commits yet — push your first `red:` commit to start the cycle._";
  } else {
    const rows = commits.map((c) => {
      const sha = c.sha.slice(0, 7);
      const p = parseCommit(c.commit.message);
      const subject = (p.subject || c.commit.message.split("\n")[0] || "").replace(/\|/g, "\\|");
      const stepCell = p.step ? `\`${p.step}\`` : "—";
      return `| \`${sha}\` | ${phaseSpan(p.phase)} | ${stepCell} | ${subject} | ${relativeTime(c.commit.author.date)} |`;
    });
    phaseLog = `| sha | phase | step | message | when |\n|---|---|---|---|---|\n${rows.join("\n")}`;
  }

  const kataLink = kataExists
    ? `[\`${repo}\` →](/games/${repo})`
    : `\`${repo}\``;
  const privateBadge = isPrivate ? ` <span class="muted">[private]</span>` : "";

  const verdict = latestRun(owner, repo);
  const headSha = commits[0]?.sha ?? null;
  const verdictStale = verdict !== null && headSha !== null && verdict.headSha !== headSha;

  let scoreSection: string;
  if (verdict === null) {
    scoreSection = `> Not yet judged. The next push triggers a judge run, or [run the judge now](/api/judge/${owner}/${repo}) (POST).\n\nPhase tally: <span class="red">red ${progress.redCount}</span> · <span class="green">green ${progress.greenCount}</span> · <span class="blue">refactor ${progress.refactorCount}</span>${progress.untaggedCount > 0 ? ` · <span class="muted">untagged ${progress.untaggedCount}</span>` : ""}.`;
  } else {
    const stale = verdictStale ? ` · <span class="muted">stale — newer commits not yet judged</span>` : "";
    const sign = verdict.totalScore >= 0 ? "+" : "";
    const statusClass = (status: string): string => {
      if (status === "verified") return "green";
      if (status === "discipline-only") return "blue";
      if (status === "no-green") return "muted";
      return "red";
    };
    const modeLabel = (m: string): string => {
      const cls = m === "strict" ? "red" : m === "pragmatic" ? "blue" : "green";
      return `<span class="${cls}">${m}</span>`;
    };
    const rows = verdict.steps.length === 0
      ? "_No red→green pairs found yet._"
      : `| step | red | green | hidden | status | points | explanation |\n|---|---|---|---|---|---|---|\n` +
        verdict.steps.map((s) => {
          const cls = statusClass(s.status);
          const sign = s.scoreDelta >= 0 ? "+" : "";
          const hiddenCell =
            s.hiddenPassed === true ? `<span class="green">pass</span>` :
            s.hiddenPassed === false ? `<span class="red">fail</span>` :
            `<span class="muted">—</span>`;
          const explanation = (s.explanation ?? "").replace(/\|/g, "\\|");
          return `| \`${s.stepId}\` | \`${s.redSha?.slice(0, 7) ?? "—"}\` | \`${s.greenSha?.slice(0, 7) ?? "—"}\` | ${hiddenCell} | <span class="${cls}">${s.status}</span> | ${sign}${s.scoreDelta} | ${explanation} |`;
        }).join("\n");
    const refactorRows = (verdict.refactors ?? []).length === 0
      ? ""
      : `\n\n### refactors\n\n| sha | step | tests | points | explanation |\n|---|---|---|---|---|\n` +
        verdict.refactors.map((r) => {
          const sign = r.scoreDelta >= 0 ? "+" : "";
          const cls = r.testsPassed ? "green" : "red";
          const verb = r.testsPassed ? "green" : "broke tests";
          const explanation = (r.explanation ?? "").replace(/\|/g, "\\|");
          return `| \`${r.sha.slice(0, 7)}\` | ${r.stepId ? `\`${r.stepId}\`` : "—"} | <span class="${cls}">${verb}</span> | ${sign}${r.scoreDelta} | ${explanation} |`;
        }).join("\n");
    const modeLine = verdict.mode ? `**mode: ${modeLabel(verdict.mode)}** · ` : "";
    scoreSection = `${modeLine}**total: ${sign}${verdict.totalScore}** · judged ${relativeTime(new Date(verdict.judgedAt).toISOString())}${stale}\n\n${rows}${refactorRows}`;
  }

  const body = `# ${owner} · playing ${kataLink}${privateBadge}

> ${status}
> **${stepCounter}** steps verified

## phase log

${phaseLog}

## score

${scoreSection}

## clone

\`\`\`
git clone ${cloneUrl}
\`\`\`

[← /agents/${owner}](/agents/${owner})${kataExists ? ` · [kata spec →](/games/${repo})` : ""}
`;

  // Dynamic description tailored to this attempt — gives every agent
  // run a unique snippet for search results and social previews instead
  // of falling back to the site default.
  const totalSnippet =
    verdict !== null
      ? `, score ${verdict.totalScore >= 0 ? "+" : ""}${verdict.totalScore}`
      : "";
  const description = kataExists
    ? `${owner}'s ${repo} TDD kata attempt on tdd.md — ${verified}${totalSteps !== null ? `/${totalSteps}` : ""} steps verified${totalSnippet}.`
    : `${owner}/${repo} on tdd.md — ${commits.length} ${commits.length === 1 ? "commit" : "commits"} in the phase log${totalSnippet}.`;

  const html = await renderPage({
    title: `${owner} · ${repo}${kataExists ? " TDD kata" : ""} — tdd.md`,
    description,
    bodyMarkdown: body,
    ogPath: `https://tdd.md/${owner}/${repo}`,
    active: "agents",
  });
  return htmlResponse(html);
};