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

c14_real_reports.ts 171 lines · 5411 bytes raw
// c32 — logic: aggregate real GitHub commit history into the same
// AgentReport / RecentFlagged shape that c51_render_reports renders.
// Pure (given fetched commits in, produces report objects out); the
// I/O happens in c14_github.fetchRepoCommits which we call here.
//
// Attribution: Co-Authored-By footers are the agent-attribution channel
// the existing tdd.md commit history already uses. Anything without a
// recognised footer is bucketed as "unknown" and reported separately —
// it's still useful for volume context.

import { parseCommit } from "./a31_commits.ts";
import { fetchRepoCommits, type GithubCommit } from "./c14_github.ts";
import type {
  AgentReport,
  FailureSlice,
  RecentFlagged,
} from "./a31_reports_demo.ts";

type LiveAgentSlug = AgentReport["slug"] | "unknown";

export const detectAgent = (msg: string): LiveAgentSlug => {
  if (/Co-Authored-By:.*Claude/i.test(msg)) return "claude-code";
  if (/Co-Authored-By:.*Cursor/i.test(msg)) return "cursor";
  if (/Co-Authored-By:.*Aider/i.test(msg)) return "aider";
  return "unknown";
};

const AGENT_NAMES: Record<AgentReport["slug"], string> = {
  "claude-code": "Claude Code",
  cursor: "Cursor",
  aider: "Aider",
};

// 30-day daily commit-count series, oldest → newest. When there are no
// commits in a day, that day's value is 0 — the sparkline still renders
// but flat-lines, which honestly reflects the data.
export const buildTrend = (commits: GithubCommit[], days = 30): number[] => {
  const out = new Array<number>(days).fill(0);
  const today = new Date();
  today.setUTCHours(0, 0, 0, 0);
  for (const c of commits) {
    const d = new Date(c.commit.author.date);
    d.setUTCHours(0, 0, 0, 0);
    const ageDays = Math.floor((today.getTime() - d.getTime()) / (24 * 60 * 60 * 1000));
    if (ageDays < 0 || ageDays >= days) continue;
    const idx = days - 1 - ageDays;
    const cur = out[idx] ?? 0;
    out[idx] = cur + 1;
  }
  return out;
};

const buildAgentReport = (
  slug: AgentReport["slug"],
  agentCommits: GithubCommit[],
  repoSlug: string,
): AgentReport => {
  const tagged = agentCommits.filter((c) => {
    const phase = parseCommit(c.commit.message).phase;
    return phase === "red" || phase === "green" || phase === "refactor";
  });
  const phaseCoveragePct = agentCommits.length === 0
    ? 0
    : Math.round((tagged.length / agentCommits.length) * 100);

  // Score is a proxy: phase-coverage is the only structural signal we
  // can compute without running the test suite. When coverage is 0 the
  // agent isn't attempting TDD, so the score is honestly low.
  const score = phaseCoveragePct;

  // Failure mix collapses to two slices for live data — phase-tagged vs
  // not. Fine-grained failure modes (red-did-not-fail, test-deleted, etc)
  // need the runner sliver before they're computable.
  const failureMix: FailureSlice[] = [
    { label: "phase-tagged", pct: phaseCoveragePct, tone: "green" },
    { label: "no phase tag", pct: 100 - phaseCoveragePct, tone: "muted" },
  ];

  const recent: RecentFlagged[] = agentCommits
    .slice(0, 5)
    .map((c) => {
      const parsed = parseCommit(c.commit.message);
      const phase = parsed.phase === "red" || parsed.phase === "green" || parsed.phase === "refactor"
        ? parsed.phase
        : "green";
      const failure = parsed.phase === "untagged" || parsed.phase === "init"
        ? "no phase tag"
        : `${parsed.phase} (live judge not yet wired)`;
      return {
        date: c.commit.author.date.slice(0, 10),
        repo: repoSlug,
        sha: c.sha.slice(0, 7),
        phase,
        failure,
        pts: 0,
      };
    });

  const topIssueLabel = phaseCoveragePct === 100 ? "no current issues" : "no phase tag";
  const topIssuePct = 100 - phaseCoveragePct;

  return {
    slug,
    name: AGENT_NAMES[slug],
    score,
    delta: 0,
    commits: agentCommits.length,
    phaseCoveragePct,
    streak: 0,
    streakBroken: false,
    topIssueLabel,
    topIssuePct,
    failureMix,
    trend: buildTrend(agentCommits),
    recent,
  };
};

export interface LiveReports {
  reports: AgentReport[];
  unknownCount: number;
  totalCommits: number;
  earliest: string | null;
  latest: string | null;
  fetchedAt: number;
}

export const buildLiveReports = async (
  repoOwner: string,
  repoName: string,
  perPage = 100,
): Promise<LiveReports> => {
  const commits = await fetchRepoCommits(repoOwner, repoName, perPage);
  const repoSlug = `${repoOwner}/${repoName}`;
  const byAgent = new Map<AgentReport["slug"], GithubCommit[]>();
  let unknownCount = 0;

  for (const c of commits) {
    const a = detectAgent(c.commit.message);
    if (a === "unknown") {
      unknownCount++;
      continue;
    }
    const arr = byAgent.get(a) ?? [];
    arr.push(c);
    byAgent.set(a, arr);
  }

  const order: AgentReport["slug"][] = ["claude-code", "cursor", "aider"];
  const reports = order
    .map((slug) => {
      const list = byAgent.get(slug);
      if (!list || list.length === 0) return null;
      return buildAgentReport(slug, list, repoSlug);
    })
    .filter((r): r is AgentReport => r !== null);

  const dates = commits.map((c) => c.commit.author.date).sort();
  const earliest = dates[0] ?? null;
  const latest = dates[dates.length - 1] ?? null;

  return {
    reports,
    unknownCount,
    totalCommits: commits.length,
    earliest,
    latest,
    fetchedAt: Date.now(),
  };
};