02961dfb45997448bc389c1ce35b636bf8f57276 diff --git a/Containerfile b/Containerfile index f35b7f9b18aaa090df3f79d8cb223e6bdca3392f..c8516be4edb8b4eb8f240d26bf1fb3b39e55a0db 100644 --- a/Containerfile +++ b/Containerfile @@ -8,6 +8,9 @@ COPY package.json bun.lock ./ RUN bun install --frozen-lockfile --production FROM docker.io/oven/bun:1-alpine AS runtime +# git is needed by the judge module (clone agent repos, walk commits via +# `git log`/`checkout`). +RUN apk add --no-cache git WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY package.json bun.lock tsconfig.json ./ diff --git a/scripts/p620/tdd-md.container b/scripts/p620/tdd-md.container index 093a64327e3357b35302349fe9580b1b0a0e88fb..e586614a7c39c350481d4984a012016cc9721bbb 100644 --- a/scripts/p620/tdd-md.container +++ b/scripts/p620/tdd-md.container @@ -15,6 +15,11 @@ Environment=PORT=3000 Environment=NODE_ENV=production Environment=BASE_URL=https://tdd.md +# SQLite voor judge-verdicts. Persisted in named podman volume. +# :Z relabel voor SELinux (Fedora Atomic). +Volume=tdd-md-data:/app/data:Z +Environment=TDD_DB_PATH=/app/data/runs.db + # Praat met Forgejo via host-network (Forgejo publisht :44400 op de host). # host.containers.internal is de standaard rootless-podman alias voor de host. Environment=FORGEJO_URL=http://host.containers.internal:44400 diff --git a/src/db.ts b/src/db.ts new file mode 100644 index 0000000000000000000000000000000000000000..deaea29ceb719f3e6ac5ef1344009f6a08670302 --- /dev/null +++ b/src/db.ts @@ -0,0 +1,57 @@ +import { Database } from "bun:sqlite"; + +const DB_PATH = process.env.TDD_DB_PATH ?? ":memory:"; + +let db: Database | null = null; + +const getDb = (): Database => { + if (db) return db; + db = new Database(DB_PATH, { create: true }); + db.exec(` + CREATE TABLE IF NOT EXISTS runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner TEXT NOT NULL, + repo TEXT NOT NULL, + head_sha TEXT NOT NULL, + judged_at INTEGER NOT NULL, + verdict_json TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_runs_owner_repo + ON runs(owner, repo, judged_at DESC); + `); + return db; +}; + +export interface StepVerdict { + stepId: string; + redSha: string | null; + greenSha: string | null; + redFailed: boolean | null; + greenPassed: boolean | null; + status: "verified" | "no-green" | "red-did-not-fail" | "green-did-not-pass"; + scoreDelta: number; +} + +export interface Verdict { + headSha: string; + steps: StepVerdict[]; + totalScore: number; + judgedAt: number; +} + +export const saveRun = (owner: string, repo: string, verdict: Verdict): void => { + getDb().run( + `INSERT INTO runs (owner, repo, head_sha, judged_at, verdict_json) VALUES (?, ?, ?, ?, ?)`, + [owner, repo, verdict.headSha, verdict.judgedAt, JSON.stringify(verdict)], + ); +}; + +export const latestRun = (owner: string, repo: string): Verdict | null => { + const row = getDb() + .query<{ verdict_json: string }, [string, string]>( + `SELECT verdict_json FROM runs WHERE owner = ? AND repo = ? ORDER BY judged_at DESC LIMIT 1`, + ) + .get(owner, repo); + if (!row) return null; + return JSON.parse(row.verdict_json) as Verdict; +}; diff --git a/src/judge.ts b/src/judge.ts new file mode 100644 index 0000000000000000000000000000000000000000..01f1e02c7b73867e1bbc85a260dcfc135d03b234 --- /dev/null +++ b/src/judge.ts @@ -0,0 +1,135 @@ +import { mkdtempSync, rmSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import { parseCommit, type Phase } from "./commits"; +import { saveRun, type Verdict, type StepVerdict } from "./db"; + +const FORGEJO_INTERNAL = process.env.FORGEJO_URL ?? "https://git.tdd.md"; +const TEST_TIMEOUT_MS = 8000; + +// Sandboxed env passed to git and bun subprocesses. Strips every secret +// from the parent process — agent code never sees FORGEJO_ADMIN_TOKEN, +// GITHUB_CLIENT_SECRET, or SESSION_SECRET. PATH is fixed; HOME and TMPDIR +// stay inside the per-run temp dir so dotfile writes can't escape. +const sandboxEnv = (cwd: string): Record => ({ + PATH: "/usr/local/bin:/usr/bin:/bin", + HOME: cwd, + TMPDIR: cwd, + NODE_ENV: "test", +}); + +const runProc = async ( + cmd: string[], + cwd: string, + timeoutMs: number, +): Promise<{ stdout: string; stderr: string; exitCode: number; timedOut: boolean }> => { + const proc = Bun.spawn(cmd, { + cwd, + stdout: "pipe", + stderr: "pipe", + env: sandboxEnv(cwd), + }); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + proc.kill("SIGKILL"); + }, timeoutMs); + const exitCode = await proc.exited; + clearTimeout(timer); + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode, timedOut }; +}; + +const runTests = async (cwd: string): Promise => { + const r = await runProc(["bun", "test"], cwd, TEST_TIMEOUT_MS); + // Bun test exits 0 only when all tests pass. + return !r.timedOut && r.exitCode === 0; +}; + +interface CommitInfo { + sha: string; + phase: Phase; + step: string | null; +} + +const readCommits = async (cwd: string): Promise => { + const r = await runProc(["git", "log", "--reverse", "--pretty=format:%H%x1f%B%x1e"], cwd, 10000); + if (r.exitCode !== 0) return []; + const out: CommitInfo[] = []; + for (const block of r.stdout.split("\x1e")) { + const t = block.trim(); + if (!t) continue; + const [sha, message = ""] = t.split("\x1f"); + if (!sha) continue; + const p = parseCommit(message); + out.push({ sha, phase: p.phase, step: p.step }); + } + return out; +}; + +export const judge = async (owner: string, repo: string): Promise => { + const cwd = mkdtempSync(join(tmpdir(), `judge-${owner}-${repo}-`)); + try { + const cloneUrl = `${FORGEJO_INTERNAL}/${owner}/${repo}.git`; + const cloneR = await runProc(["git", "clone", "--quiet", cloneUrl, "."], cwd, 30000); + if (cloneR.exitCode !== 0) { + throw new Error(`clone failed: ${cloneR.stderr || cloneR.stdout}`); + } + + const commits = await readCommits(cwd); + const headR = await runProc(["git", "rev-parse", "HEAD"], cwd, 5000); + const headSha = headR.stdout; + + // First red per step + first green-after-red per step (chronological). + const stepRed = new Map(); + const stepGreen = new Map(); + for (const c of commits) { + if (!c.step) continue; + if (c.phase === "red" && !stepRed.has(c.step)) { + stepRed.set(c.step, c.sha); + } else if (c.phase === "green" && stepRed.has(c.step) && !stepGreen.has(c.step)) { + stepGreen.set(c.step, c.sha); + } + } + + const steps: StepVerdict[] = []; + for (const [stepId, redSha] of stepRed) { + const greenSha = stepGreen.get(stepId) ?? null; + await runProc(["git", "checkout", "--quiet", redSha], cwd, 5000); + const redPassed = await runTests(cwd); + const redFailed = !redPassed; + let greenPassed: boolean | null = null; + if (greenSha) { + await runProc(["git", "checkout", "--quiet", greenSha], cwd, 5000); + greenPassed = await runTests(cwd); + } + let status: StepVerdict["status"]; + let scoreDelta = 0; + if (greenSha === null) { + status = "no-green"; + } else if (!redFailed) { + status = "red-did-not-fail"; + scoreDelta = -5; + } else if (greenPassed === false) { + status = "green-did-not-pass"; + scoreDelta = -5; + } else { + status = "verified"; + scoreDelta = 20; + } + steps.push({ stepId, redSha, greenSha, redFailed, greenPassed, status, scoreDelta }); + } + + const totalScore = steps.reduce((a, s) => a + s.scoreDelta, 0); + const verdict: Verdict = { headSha, steps, totalScore, judgedAt: Date.now() }; + saveRun(owner, repo, verdict); + return verdict; + } finally { + try { + rmSync(cwd, { recursive: true, force: true }); + } catch { + // best effort cleanup + } + } +}; diff --git a/src/server.ts b/src/server.ts index e841bb322c7045de900b0a6b254fbf6858e715f9..e166d7c8e543047e460543f8ac634501db564ece 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,6 +3,8 @@ import * as github from "./github_oauth"; import * as forgejo from "./forgejo"; import { parseCommit, computeProgress, type Phase } from "./commits"; import { loadGame } from "./games"; +import { judge } from "./judge"; +import { latestRun } from "./db"; const HOME_MD = "./content/home.md"; const GAME_DIR = "./content/games"; @@ -288,6 +290,27 @@ const renderRepoView = async (owner: string, repo: string): Promise => ? `[\`${repo}\` →](/games/${repo})` : `\`${repo}\``; + 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: red ${progress.redCount} · green ${progress.greenCount} · refactor ${progress.refactorCount}${progress.untaggedCount > 0 ? ` · untagged ${progress.untaggedCount}` : ""}.`; + } else { + const stale = verdictStale ? ` · stale — newer commits not yet judged` : ""; + const sign = verdict.totalScore >= 0 ? "+" : ""; + const rows = verdict.steps.length === 0 + ? "_No red→green pairs found yet._" + : `| step | red | green | status | points |\n|---|---|---|---|---|\n` + + verdict.steps.map((s) => { + const cls = s.status === "verified" ? "green" : s.status === "no-green" ? "muted" : "red"; + const sign = s.scoreDelta >= 0 ? "+" : ""; + return `| \`${s.stepId}\` | \`${s.redSha?.slice(0, 7) ?? "—"}\` | \`${s.greenSha?.slice(0, 7) ?? "—"}\` | ${s.status} | ${sign}${s.scoreDelta} |`; + }).join("\n"); + scoreSection = `**total: ${sign}${verdict.totalScore}** · judged ${relativeTime(new Date(verdict.judgedAt).toISOString())}${stale}\n\n${rows}`; + } + const body = `# ${owner} · playing ${kataLink} > ${status} @@ -299,7 +322,7 @@ ${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}` : ""}. +${scoreSection} ## clone @@ -408,6 +431,18 @@ const server = Bun.serve({ "/leaderboard": htmlResponse(LEADERBOARD_HTML), + "/api/judge/:owner/:repo": async (req) => { + if (req.method !== "POST") { + return new Response("method not allowed; POST to trigger a judge run", { status: 405 }); + } + try { + const verdict = await judge(req.params.owner, req.params.repo); + return Response.json(verdict); + } catch (err) { + return Response.json({ error: (err as Error).message }, { status: 500 }); + } + }, + "/auth/github/start": (_req) => { if (!github.isConfigured() || !forgejo.isConfigured()) { return errorPage("registration is not configured on this server", 503);