312fbc3b789143a2486c438d1cdf73a79fc557ce diff --git a/public/style.css b/public/style.css index 3c69c443ceca60e800b7998e6f9c381200a77355..63119bd3212855595862f2c2ff7cc375f3029770 100644 --- a/public/style.css +++ b/public/style.css @@ -177,6 +177,7 @@ main.md strong { font-weight: 600; } .red { color: var(--red); } .green { color: var(--green); } .blue { color: var(--blue); } +.muted { color: var(--muted); } ::selection { background: var(--accent); diff --git a/src/commits.test.ts b/src/commits.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b64f42ce4fc0fb379dbfc8190addcfb0e44da466 --- /dev/null +++ b/src/commits.test.ts @@ -0,0 +1,42 @@ +import { test, expect } from "bun:test"; +import { parseCommit, computeProgress } from "./commits"; + +test("parseCommit reads a phase prefix", () => { + expect(parseCommit("red: failing test for empty")).toEqual({ + phase: "red", + step: null, + subject: "failing test for empty", + }); +}); + +test("parseCommit extracts step from phase(step): form", () => { + expect(parseCommit("green(single-number): return n for one number")).toEqual({ + phase: "green", + step: "single-number", + subject: "return n for one number", + }); +}); + +test("parseCommit recognizes 'Initial commit' as init", () => { + expect(parseCommit("Initial commit").phase).toBe("init"); +}); + +test("parseCommit returns untagged for unknown messages", () => { + expect(parseCommit("wip — fixing something").phase).toBe("untagged"); +}); + +test("computeProgress verifies a step after red→green for the same step", () => { + const commits = [ + { commit: { message: "green(empty): returns 0" } }, + { commit: { message: "red(empty): empty string returns 0" } }, + ]; // newest first, like Forgejo + const p = computeProgress(commits); + expect(p.verifiedSteps).toEqual(new Set(["empty"])); + expect(p.redCount).toBe(1); + expect(p.greenCount).toBe(1); +}); + +test("computeProgress does not verify green-without-prior-red", () => { + const commits = [{ commit: { message: "green(empty): returns 0" } }]; + expect(computeProgress(commits).verifiedSteps.size).toBe(0); +}); diff --git a/src/commits.ts b/src/commits.ts new file mode 100644 index 0000000000000000000000000000000000000000..1b3f8f657e7480b82721eea4c6d40213a4d76280 --- /dev/null +++ b/src/commits.ts @@ -0,0 +1,61 @@ +export type Phase = "red" | "green" | "refactor" | "init" | "untagged"; + +export interface ParsedCommit { + phase: Phase; + step: string | null; + subject: string; +} + +const PHASE_RE = /^(red|green|refactor)(?:\(([a-z][a-z0-9-]*)\))?:\s*(.*)$/i; + +export const parseCommit = (message: string): ParsedCommit => { + const subject = message.split("\n")[0] ?? ""; + const m = subject.match(PHASE_RE); + if (m) { + return { + phase: m[1]!.toLowerCase() as Phase, + step: m[2] ?? null, + subject: m[3] ?? "", + }; + } + if (/^Initial commit$/i.test(subject)) { + return { phase: "init", step: null, subject }; + } + return { phase: "untagged", step: null, subject }; +}; + +export interface Progress { + verifiedSteps: Set; + redCount: number; + greenCount: number; + refactorCount: number; + untaggedCount: number; +} + +// A step counts as "verified" when its red commit is followed by a green +// for the same step. Refactor and untagged commits are tallied separately +// for the score breakdown but don't move verification. +export const computeProgress = (commits: { commit: { message: string } }[]): Progress => { + const pendingRed = new Set(); + const verifiedSteps = new Set(); + let redCount = 0; + let greenCount = 0; + let refactorCount = 0; + let untaggedCount = 0; + // Forgejo returns commits newest-first; walk oldest-first to get sequence. + for (const c of [...commits].reverse()) { + const p = parseCommit(c.commit.message); + if (p.phase === "red") { + redCount++; + if (p.step) pendingRed.add(p.step); + } else if (p.phase === "green") { + greenCount++; + if (p.step && pendingRed.has(p.step)) verifiedSteps.add(p.step); + } else if (p.phase === "refactor") { + refactorCount++; + } else if (p.phase === "untagged") { + untaggedCount++; + } + } + return { verifiedSteps, redCount, greenCount, refactorCount, untaggedCount }; +}; diff --git a/src/server.ts b/src/server.ts index 363d809b101d3be5d4d00ffb873b3ac10edcc27b..870fbaa03131ca5ed4e071121babd0b9a2865953 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,6 +1,8 @@ import { renderPage, renderNotFound } from "./render"; import * as github from "./github_oauth"; import * as forgejo from "./forgejo"; +import { parseCommit, computeProgress, type Phase } from "./commits"; +import { loadGame } from "./games"; const HOME_MD = "./content/home.md"; const GAME_DIR = "./content/games"; @@ -206,6 +208,19 @@ interface ForgejoCommit { commit: { message: string; author: { name: string; date: string } }; } +const phaseSpan = (p: Phase): string => { + const cls = p === "red" ? "red" : p === "green" ? "green" : p === "refactor" ? "blue" : "muted"; + return `${p}`; +}; + +const relativeTime = (iso: string): string => { + const ms = Date.now() - new Date(iso).getTime(); + if (ms < 60_000) return `${Math.max(0, Math.floor(ms / 1000))}s ago`; + if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m ago`; + if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)}h ago`; + return `${Math.floor(ms / 86_400_000)}d ago`; +}; + const renderRepoView = async (owner: string, repo: string): Promise => { const repoApi = `${FORGEJO_INTERNAL}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; const repoRes = await fetch(repoApi); @@ -221,31 +236,70 @@ const renderRepoView = async (owner: string, repo: string): Promise => return htmlResponse(html, 502); } const info = (await repoRes.json()) as ForgejoRepoSummary; + const cloneUrl = info.clone_url || `https://tdd.md/${owner}/${repo}.git`; - let commitList = "> No commits yet."; + // 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=8&stat=false`); - if (commitsRes.ok) { - const commits = (await commitsRes.json()) as ForgejoCommit[]; - if (commits.length > 0) { - commitList = commits - .map((c) => { - const sha = c.sha.slice(0, 7); - const subject = c.commit.message.split("\n")[0]!; - const escaped = subject.replace(/\|/g, "\\|"); - return `| \`${sha}\` | ${escaped} | ${c.commit.author.name} |`; - }) - .join("\n"); - commitList = `| sha | message | author |\n|---|---|---|\n${commitList}`; - } - } + const commitsRes = await fetch(`${repoApi}/commits?limit=50&stat=false`); + 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 cloneUrl = info.clone_url || `https://tdd.md/${owner}/${repo}.git`; + const kataLink = kataExists + ? `[\`${repo}\` →](/games/${repo})` + : `\`${repo}\``; + + const body = `# ${owner} · playing ${kataLink} + +> ${status} +> **${stepCounter}** steps verified - const body = `# ${owner}/${repo} +## phase log -${info.description ? `> ${info.description}\n` : ""} +${phaseLog} + +## score + +> Final scoring lands when the judge module ships. Phase tally: red ${progress.redCount} · green ${progress.greenCount} · refactor ${progress.refactorCount}${progress.untaggedCount > 0 ? ` · untagged ${progress.untaggedCount}` : ""}. ## clone @@ -253,11 +307,7 @@ ${info.description ? `> ${info.description}\n` : ""} git clone ${cloneUrl} \`\`\` -## recent commits - -${commitList} - -[← agents/${owner}](/agents/${owner}) +[← /agents/${owner}](/agents/${owner})${kataExists ? ` · [kata spec →](/games/${repo})` : ""} `; const html = await renderPage({