559b0bf030f50d6b7e95614eaba2ceb87be1bb4c diff --git a/public/style.css b/public/style.css index 66ad590dc4b9433ad33db43ca8210b041ac60e5b..fbe05e12ade844a5388631064c964455fa593460 100644 --- a/public/style.css +++ b/public/style.css @@ -448,3 +448,62 @@ main.md table.test-stability td.test-stab-num { .test-snapshots { grid-template-columns: 1fr; } main.md table.test-stability { font-size: 0.78rem; } } + +/* projects */ + +.project-form { + display: grid; + gap: 0.6rem; + margin: 1.2rem 0 1.5rem; + max-width: 540px; +} +.project-form label { + font-family: ui-monospace, "SF Mono", "JetBrains Mono", "Fira Code", Menlo, Consolas, monospace; + font-size: 0.82rem; + color: var(--muted); +} +.project-form label code { + font-size: 0.78rem; + background: var(--code-bg); + border: 1px solid var(--border); + padding: 0.05em 0.35em; + border-radius: 3px; +} +.project-form input[type="text"] { + font: inherit; + font-family: ui-monospace, "SF Mono", "JetBrains Mono", "Fira Code", Menlo, Consolas, monospace; + font-size: 0.95rem; + padding: 0.65rem 0.8rem; + background: var(--code-bg); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 4px; + outline: none; +} +.project-form input[type="text"]:focus { + border-color: var(--accent); +} +.project-form button { + font: inherit; + font-family: ui-monospace, "SF Mono", "JetBrains Mono", "Fira Code", Menlo, Consolas, monospace; + font-size: 0.85rem; + padding: 0.55rem 1rem; + background: var(--accent); + color: var(--bg); + border: 1px solid var(--accent); + border-radius: 4px; + cursor: pointer; + justify-self: start; +} +.project-form button:hover { filter: brightness(1.1); } + +.project-form-error { + background: color-mix(in srgb, var(--red) 10%, transparent); + border: 1px solid var(--red); + color: var(--fg); + padding: 0.7rem 0.9rem; + border-radius: 4px; + font-size: 0.9rem; + margin: 0 0 1.2rem; +} +.project-form-error strong { color: var(--red); } diff --git a/src/db.ts b/src/db.ts index 4612b3b3bbcf12b6917a96b543d4252795b39c30..2091e827d77d5b6704883bc92c0aa20ff5ba8a9c 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1,4 +1,5 @@ import { Database } from "bun:sqlite"; +import type { ProjectConfig, TestRunner } from "./projects"; const DB_PATH = process.env.TDD_DB_PATH ?? ":memory:"; @@ -18,6 +19,22 @@ const getDb = (): Database => { ); CREATE INDEX IF NOT EXISTS idx_runs_owner_repo ON runs(owner, repo, judged_at DESC); + + CREATE TABLE IF NOT EXISTS projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + registered_by TEXT NOT NULL, + repo_owner TEXT NOT NULL, + repo_name TEXT NOT NULL, + test_runner TEXT NOT NULL DEFAULT 'none', + tracked_branches TEXT NOT NULL, + display_name TEXT, + team TEXT, + registered_at INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + UNIQUE(repo_owner, repo_name) + ); + CREATE INDEX IF NOT EXISTS idx_projects_registered_by + ON projects(registered_by); `); return db; }; @@ -87,6 +104,96 @@ export const latestRun = (owner: string, repo: string): Verdict | null => { return JSON.parse(row.verdict_json) as Verdict; }; +export interface ProjectRow { + id: number; + registeredBy: string; + repoOwner: string; + repoName: string; + testRunner: TestRunner; + trackedBranches: string[]; + displayName: string | null; + team: string | null; + registeredAt: number; + status: "active" | "paused"; +} + +interface ProjectDbRow { + id: number; + registered_by: string; + repo_owner: string; + repo_name: string; + test_runner: string; + tracked_branches: string; + display_name: string | null; + team: string | null; + registered_at: number; + status: string; +} + +const rowToProject = (r: ProjectDbRow): ProjectRow => ({ + id: r.id, + registeredBy: r.registered_by, + repoOwner: r.repo_owner, + repoName: r.repo_name, + testRunner: (r.test_runner === "bun" ? "bun" : "none") as TestRunner, + trackedBranches: JSON.parse(r.tracked_branches) as string[], + displayName: r.display_name, + team: r.team, + registeredAt: r.registered_at, + status: r.status === "paused" ? "paused" : "active", +}); + +// Inserts or updates a project. Re-registering the same repo refreshes +// its config (test_runner, tracked_branches, display_name, team) without +// duplicating the row. Returns the stored project. +export const upsertProject = ( + registeredBy: string, + repoOwner: string, + repoName: string, + config: ProjectConfig, +): ProjectRow => { + const now = Date.now(); + const branches = JSON.stringify(config.tracked_branches); + const display = config.display_name ?? null; + const team = config.team ?? null; + getDb().run( + `INSERT INTO projects (registered_by, repo_owner, repo_name, test_runner, tracked_branches, display_name, team, registered_at, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active') + ON CONFLICT(repo_owner, repo_name) DO UPDATE SET + test_runner = excluded.test_runner, + tracked_branches = excluded.tracked_branches, + display_name = excluded.display_name, + team = excluded.team, + status = 'active'`, + [registeredBy, repoOwner, repoName, config.test_runner, branches, display, team, now], + ); + const row = getDb() + .query( + `SELECT * FROM projects WHERE repo_owner = ? AND repo_name = ?`, + ) + .get(repoOwner, repoName); + if (!row) throw new Error("project upsert returned no row"); + return rowToProject(row); +}; + +export const getProject = (repoOwner: string, repoName: string): ProjectRow | null => { + const row = getDb() + .query( + `SELECT * FROM projects WHERE repo_owner = ? AND repo_name = ?`, + ) + .get(repoOwner, repoName); + return row ? rowToProject(row) : null; +}; + +export const listActiveProjects = (): ProjectRow[] => { + const rows = getDb() + .query( + `SELECT * FROM projects WHERE status = 'active' ORDER BY registered_at DESC`, + ) + .all(); + return rows.map(rowToProject); +}; + // Latest verdict per (owner, repo) across all agents — drives the // leaderboard and the /agents index. export const allLatestRuns = (): { owner: string; repo: string; verdict: Verdict }[] => { diff --git a/src/projects.ts b/src/projects.ts new file mode 100644 index 0000000000000000000000000000000000000000..f49b34957824250ea00f49c2593fa1d2501e645a --- /dev/null +++ b/src/projects.ts @@ -0,0 +1,271 @@ +import type { ProjectRow } from "./db"; + +// Project-tracking ingest contract — block 1 of the reporting pipeline. +// +// A "project" is a real repo whose pushes get scored on TDD discipline. +// Distinct from a kata: katas are the practice ground (fixed steps, +// hidden tests); projects are production code judged purely structurally. +// +// Onboarding: a repo opts in by adding `.tdd-md.json` at its root on the +// default branch. tdd.md fetches the file (via raw.githubusercontent), +// validates it, and registers the project in our SQLite store. Per-commit +// judging follows in a later sliver — this module covers config + ingest +// of the registration itself. + +export const PROJECT_CONFIG_PATH = ".tdd-md.json"; +export const PROJECT_CONFIG_VERSION = 1; + +export type TestRunner = "none" | "bun"; +export type AgentSlug = "claude-code" | "cursor" | "aider" | "unknown"; + +export interface ProjectConfig { + version: number; + // "none" → trace-mode judging only (commit discipline, no test execution). + // "bun" → full sandbox-runner judging (later sliver — registration accepts + // the value but judging stays trace-only until the runner ships). + test_runner: TestRunner; + // Branches whose pushes get scored. Defaults to ["main"]. + tracked_branches: string[]; + // Optional reporting metadata. + display_name?: string; + team?: string; +} + +export const DEFAULT_CONFIG: ProjectConfig = { + version: PROJECT_CONFIG_VERSION, + test_runner: "none", + tracked_branches: ["main"], +}; + +// Validates and normalises a parsed JSON blob into a ProjectConfig. +// Throws with a human-readable message on failure — those messages are +// surfaced verbatim to the registering user, so they need to be useful. +export const parseProjectConfig = (raw: unknown): ProjectConfig => { + if (!raw || typeof raw !== "object") { + throw new Error(".tdd-md.json must be a JSON object"); + } + const obj = raw as Record; + const version = obj.version; + if (typeof version !== "number" || version !== PROJECT_CONFIG_VERSION) { + throw new Error( + `.tdd-md.json has version ${JSON.stringify(version)}; expected ${PROJECT_CONFIG_VERSION}`, + ); + } + let testRunner: TestRunner = "none"; + if (obj.test_runner !== undefined) { + if (obj.test_runner !== "none" && obj.test_runner !== "bun") { + throw new Error( + `.tdd-md.json: test_runner must be "none" or "bun" (got ${JSON.stringify(obj.test_runner)})`, + ); + } + testRunner = obj.test_runner; + } + let trackedBranches: string[] = ["main"]; + if (obj.tracked_branches !== undefined) { + if (!Array.isArray(obj.tracked_branches) || obj.tracked_branches.some((b) => typeof b !== "string" || !b)) { + throw new Error(".tdd-md.json: tracked_branches must be a non-empty array of branch names"); + } + trackedBranches = obj.tracked_branches as string[]; + } + const config: ProjectConfig = { + version, + test_runner: testRunner, + tracked_branches: trackedBranches, + }; + if (typeof obj.display_name === "string" && obj.display_name) { + config.display_name = obj.display_name; + } + if (typeof obj.team === "string" && obj.team) { + config.team = obj.team; + } + return config; +}; + +// 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 => { + 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); +}; + +// Parse a GitHub repo URL or owner/repo shorthand. Accepts: +// https://github.com/syntaxai/tdd.md +// https://github.com/syntaxai/tdd.md.git +// github.com/syntaxai/tdd.md +// syntaxai/tdd.md +// Returns the owner + repo or throws with a precise message. +export const parseRepoIdentifier = (raw: string): { owner: string; repo: string } => { + const trimmed = raw.trim(); + if (!trimmed) throw new Error("Repository URL is required."); + let path = trimmed; + const httpsMatch = path.match(/^https?:\/\/(?:www\.)?github\.com\/(.+)$/i); + if (httpsMatch?.[1]) path = httpsMatch[1]; + const bareMatch = path.match(/^github\.com\/(.+)$/i); + if (bareMatch?.[1]) path = bareMatch[1]; + path = path.replace(/\.git$/i, "").replace(/\/+$/, ""); + const parts = path.split("/").filter(Boolean); + const owner = parts[0]; + const repo = parts[1]; + if (parts.length !== 2 || !owner || !repo) { + throw new Error( + `Couldn't parse "${raw}" as a GitHub repo. Use a URL like https://github.com/owner/name or the shorthand owner/name.`, + ); + } + if (!/^[A-Za-z0-9._-]+$/.test(owner) || !/^[A-Za-z0-9._-]+$/.test(repo)) { + throw new Error(`"${raw}" contains characters that aren't valid for a GitHub owner/repo.`); + } + return { owner, repo }; +}; + +const escape = (s: string): string => + s.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); + +const projectListRow = (p: ProjectRow): string => { + const slug = `${p.repoOwner}/${p.repoName}`; + const display = p.displayName ?? slug; + const team = p.team ? ` · ${escape(p.team)}` : ""; + const branches = p.trackedBranches.map((b) => `\`${b}\``).join(", "); + const runner = p.testRunner === "none" ? "trace-only" : p.testRunner; + return `| [${escape(display)}](/projects/${p.repoOwner}/${p.repoName}) ${team} | ${branches} | ${runner} |`; +}; + +export const projectsLandingMd = (projects: ProjectRow[]): string => { + const rows = projects.length === 0 + ? `| _no projects yet — [register one](/projects/new)_ | | |` + : projects.map(projectListRow).join("\n"); + return `# projects + +> Real repos that opted in to tdd.md scoring. Each project drops \`${PROJECT_CONFIG_PATH}\` at its root, registers here, and from then on its commits on tracked branches get judged structurally — red-fails, green-passes, no test-deletion, no regression. The aggregated scores feed [the reports](/reports). + +## tracked + +| project | branches | runner | +|---|---|---| +${rows} + +## register a repo + +[Register a project →](/projects/new) — paste a public GitHub URL; tdd.md fetches \`${PROJECT_CONFIG_PATH}\` from the default branch and onboards it. + +## the config file + +Drop \`${PROJECT_CONFIG_PATH}\` at the root of your repo's default branch: + +\`\`\`json +{ + "version": 1, + "test_runner": "none", + "tracked_branches": ["main"], + "display_name": "API Gateway", + "team": "platform" +} +\`\`\` + +- **\`test_runner\`** — \`"none"\` for trace-mode (commit-discipline only, language-agnostic). \`"bun"\` will run the test suite once the sandbox-runner ships. +- **\`tracked_branches\`** — pushes to these branches get scored. Defaults to \`["main"]\`. +- **\`display_name\`** / **\`team\`** — optional, only used in the reporting UI. + +## what comes next + +Registration just stores the project. Per-commit judging (the part that produces score data for the reports) lands in the next sliver — until then the [report pages](/reports) keep showing the demo dataset. + +[← back to tdd.md](/) · [the reports](/reports) +`; +}; + +export const projectRegisterMd = ( + viewer: string | null, + prefilled?: string, + errorMessage?: string, +): string => { + if (!viewer) { + return `# register a project + +> You need to sign in before registering a project. We use your GitHub identity to record who onboarded the repo. + +[ sign in with github → ](/auth/github/start) + +[← all projects](/projects) +`; + } + const error = errorMessage + ? `
Couldn't register that repo:
${escape(errorMessage)}
` + : ""; + const value = prefilled ? ` value="${escape(prefilled)}"` : ""; + return `# register a project + +> Paste a public GitHub URL. tdd.md fetches \`${PROJECT_CONFIG_PATH}\` from its default branch, validates it, and onboards the repo. Re-register the same repo to refresh the config. + +${error} + +
+ + + +
+ +> Signed in as ${escape(viewer)}. Don't have \`${PROJECT_CONFIG_PATH}\` yet? [See the format on /projects](/projects#the-config-file). + +[← all projects](/projects) +`; +}; + +export const projectDetailMd = (p: ProjectRow): string => { + const display = p.displayName ?? `${p.repoOwner}/${p.repoName}`; + const registeredAt = new Date(p.registeredAt).toISOString().slice(0, 10); + const branches = p.trackedBranches.map((b) => `\`${b}\``).join(", "); + const runnerNote = p.testRunner === "none" + ? "Trace-mode — judging looks at commit phase tags, test-count drift, and refactor stability. No test execution." + : "Bun runner — test suite executes in a sandbox at every tracked-branch commit. (Sandbox-runner ships in the next sliver; meanwhile this falls back to trace-mode.)"; + return `# ${escape(display)} + +> [${escape(p.repoOwner)}/${escape(p.repoName)}](https://github.com/${p.repoOwner}/${p.repoName}) · registered by [${escape(p.registeredBy)}](/agents/${p.registeredBy}) on ${registeredAt}. + +## config + +| key | value | +|---|---| +| test_runner | \`${p.testRunner}\` | +| tracked_branches | ${branches} | +| display_name | ${p.displayName ? `\`${escape(p.displayName)}\`` : "_(none)_"} | +| team | ${p.team ? `\`${escape(p.team)}\`` : "_(none)_"} | +| status | \`${p.status}\` | + +${runnerNote} + +## scored commits + +> _No commits judged yet._ The webhook ingest + judging pipeline lands in the next sliver — once it does, scored commits for tracked branches will appear here grouped by agent. + +## refresh + +Push an updated \`${PROJECT_CONFIG_PATH}\` to your default branch and [re-register](/projects/new?repo=${encodeURIComponent(`${p.repoOwner}/${p.repoName}`)}) to pick up the new config. + +[← all projects](/projects) +`; +}; diff --git a/src/reports.ts b/src/reports.ts index e5a003d491d306fd5f3e5e6bd332ae3b288a4871..7b71618c29bf37f979724231250ceef6fbb41799 100644 --- a/src/reports.ts +++ b/src/reports.ts @@ -368,6 +368,8 @@ This is a design preview. The pipeline that ingests real repos isn't wired yet; - [per-agent drill-down →](/reports/demo/agents/cursor) — trend, failure mix, recent flagged commits - [tests overzicht →](/reports/demo/tests) — huidige stand per repo + test-stabiliteit per test-naam +Want a real repo on this layer? [Register a project →](/projects) — drops \`.tdd-md.json\` at the repo root, onboards in seconds. Per-commit judging follows in the next sliver; until then registered projects show up under [/projects](/projects) but don't yet feed the report numbers. + ## what gets measured This layer measures **discipline**, not code-quality. Without hidden tests (those only exist on katas), tdd.md can't catch tautologies or weakened assertions on real repos. It *can* catch: diff --git a/src/server.ts b/src/server.ts index a7bb7f05950ab1967d486697b4cba920e2ca4f28..b270b6f3eaf49d3722a1ff737872872065650ef1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4,7 +4,7 @@ import * as forgejo from "./forgejo"; import { parseCommit, computeProgress, type Phase } from "./commits"; import { loadGame, listGames } from "./games"; import { judge } from "./judge"; -import { latestRun, allLatestRuns } from "./db"; +import { latestRun, allLatestRuns, listActiveProjects, getProject, upsertProject } from "./db"; import { reportsLandingMd, execSummaryMd, @@ -12,6 +12,13 @@ import { testsOverviewMd, DEMO_REPORTS, } from "./reports"; +import { + projectsLandingMd, + projectRegisterMd, + projectDetailMd, + parseRepoIdentifier, + fetchProjectConfig, +} from "./projects"; const HOME_MD = "./content/home.md"; const GAME_DIR = "./content/games"; @@ -789,6 +796,90 @@ ${rows} return htmlResponse(html); }, + "/projects": async () => { + const projects = listActiveProjects(); + const html = await renderPage({ + title: "Projects — tdd.md", + description: "Real repos opted in to tdd.md scoring. Each project drops .tdd-md.json at its root and gets its commits judged structurally for TDD discipline.", + bodyMarkdown: projectsLandingMd(projects), + ogPath: "https://tdd.md/projects", + }); + return htmlResponse(html); + }, + + "/projects/new": async (req) => { + const viewer = await getViewer(req); + if (req.method === "GET") { + const url = new URL(req.url); + const prefilled = url.searchParams.get("repo") ?? undefined; + const html = await renderPage({ + title: "Register a project — tdd.md", + description: "Onboard a real repo for TDD-discipline scoring. Drops .tdd-md.json at the repo root, register here, and the reports begin tracking commits on its tracked branches.", + bodyMarkdown: projectRegisterMd(viewer, prefilled), + ogPath: "https://tdd.md/projects/new", + noindex: true, + }); + return htmlResponse(html); + } + if (req.method !== "POST") return new Response("method not allowed", { status: 405 }); + if (!viewer) return new Response("unauthorized — sign in first", { status: 401 }); + + let raw = ""; + try { + const form = await req.formData(); + raw = String(form.get("repo") ?? "").trim(); + } catch { + return new Response("invalid form body", { status: 400 }); + } + + const renderError = async (message: string, status = 400): Promise => { + const html = await renderPage({ + title: "Register a project — tdd.md", + bodyMarkdown: projectRegisterMd(viewer, raw, message), + ogPath: "https://tdd.md/projects/new", + noindex: true, + }); + return htmlResponse(html, status); + }; + + let owner: string; + let repo: string; + try { + ({ owner, repo } = parseRepoIdentifier(raw)); + } catch (err) { + return renderError((err as Error).message); + } + + let config; + try { + config = await fetchProjectConfig(owner, repo); + } catch (err) { + return renderError((err as Error).message); + } + + upsertProject(viewer, owner, repo, config); + return new Response(null, { + status: 303, + headers: { Location: `/projects/${owner}/${repo}` }, + }); + }, + + "/projects/:repoOwner/:repoName": async (req) => { + const { repoOwner, repoName } = req.params; + const project = getProject(repoOwner, repoName); + if (!project) { + const html = await renderNotFound(`/projects/${repoOwner}/${repoName}`); + return htmlResponse(html, 404); + } + const html = await renderPage({ + title: `${project.displayName ?? `${project.repoOwner}/${project.repoName}`} — tdd.md`, + description: `${project.repoOwner}/${project.repoName} on tdd.md — ${project.testRunner === "none" ? "trace-mode" : project.testRunner} judging across ${project.trackedBranches.join(", ")}.`, + bodyMarkdown: projectDetailMd(project), + ogPath: `https://tdd.md/projects/${project.repoOwner}/${project.repoName}`, + }); + return htmlResponse(html); + }, + "/reports": async () => { const html = await renderPage({ title: "Reports — tdd.md",