Block 1 sliver 1: project onboarding via .tdd-md.json + /projects routes
Defines the project-tracking contract that the rest of the ingest pipeline will build on. Repos opt in by adding `.tdd-md.json` at their root with `version`, `test_runner` (none|bun), `tracked_branches`, and optional `display_name`/`team`. The register-flow at /projects/new fetches the file from raw.githubusercontent on the default branch, validates it, and upserts into a new `projects` SQLite table. /projects shows the registry, /projects/<owner>/<name> shows the per-project detail with a "no commits judged yet" placeholder. Per-commit judging (the part that produces real numbers for the report mockups) is intentionally deferred to the next sliver — this commit only ships the contract, the storage, and the onboarding UI. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
5 files changed · +531 −1
public/style.css
+59
−0
| @@ -448,3 +448,62 @@ main.md table.test-stability td.test-stab-num { | ||
| 448 | 448 | .test-snapshots { grid-template-columns: 1fr; } |
| 449 | 449 | main.md table.test-stability { font-size: 0.78rem; } |
| 450 | 450 | } |
| 451 | + | |
| 452 | +/* projects */ | |
| 453 | + | |
| 454 | +.project-form { | |
| 455 | + display: grid; | |
| 456 | + gap: 0.6rem; | |
| 457 | + margin: 1.2rem 0 1.5rem; | |
| 458 | + max-width: 540px; | |
| 459 | +} | |
| 460 | +.project-form label { | |
| 461 | + font-family: ui-monospace, "SF Mono", "JetBrains Mono", "Fira Code", Menlo, Consolas, monospace; | |
| 462 | + font-size: 0.82rem; | |
| 463 | + color: var(--muted); | |
| 464 | +} | |
| 465 | +.project-form label code { | |
| 466 | + font-size: 0.78rem; | |
| 467 | + background: var(--code-bg); | |
| 468 | + border: 1px solid var(--border); | |
| 469 | + padding: 0.05em 0.35em; | |
| 470 | + border-radius: 3px; | |
| 471 | +} | |
| 472 | +.project-form input[type="text"] { | |
| 473 | + font: inherit; | |
| 474 | + font-family: ui-monospace, "SF Mono", "JetBrains Mono", "Fira Code", Menlo, Consolas, monospace; | |
| 475 | + font-size: 0.95rem; | |
| 476 | + padding: 0.65rem 0.8rem; | |
| 477 | + background: var(--code-bg); | |
| 478 | + color: var(--fg); | |
| 479 | + border: 1px solid var(--border); | |
| 480 | + border-radius: 4px; | |
| 481 | + outline: none; | |
| 482 | +} | |
| 483 | +.project-form input[type="text"]:focus { | |
| 484 | + border-color: var(--accent); | |
| 485 | +} | |
| 486 | +.project-form button { | |
| 487 | + font: inherit; | |
| 488 | + font-family: ui-monospace, "SF Mono", "JetBrains Mono", "Fira Code", Menlo, Consolas, monospace; | |
| 489 | + font-size: 0.85rem; | |
| 490 | + padding: 0.55rem 1rem; | |
| 491 | + background: var(--accent); | |
| 492 | + color: var(--bg); | |
| 493 | + border: 1px solid var(--accent); | |
| 494 | + border-radius: 4px; | |
| 495 | + cursor: pointer; | |
| 496 | + justify-self: start; | |
| 497 | +} | |
| 498 | +.project-form button:hover { filter: brightness(1.1); } | |
| 499 | + | |
| 500 | +.project-form-error { | |
| 501 | + background: color-mix(in srgb, var(--red) 10%, transparent); | |
| 502 | + border: 1px solid var(--red); | |
| 503 | + color: var(--fg); | |
| 504 | + padding: 0.7rem 0.9rem; | |
| 505 | + border-radius: 4px; | |
| 506 | + font-size: 0.9rem; | |
| 507 | + margin: 0 0 1.2rem; | |
| 508 | +} | |
| 509 | +.project-form-error strong { color: var(--red); } | |
src/db.ts
+107
−0
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { Database } from "bun:sqlite"; |
| 2 | +import type { ProjectConfig, TestRunner } from "./projects"; | |
| 2 | 3 | |
| 3 | 4 | const DB_PATH = process.env.TDD_DB_PATH ?? ":memory:"; |
| 4 | 5 | |
| @@ -18,6 +19,22 @@ const getDb = (): Database => { | ||
| 18 | 19 | ); |
| 19 | 20 | CREATE INDEX IF NOT EXISTS idx_runs_owner_repo |
| 20 | 21 | ON runs(owner, repo, judged_at DESC); |
| 22 | + | |
| 23 | + CREATE TABLE IF NOT EXISTS projects ( | |
| 24 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 25 | + registered_by TEXT NOT NULL, | |
| 26 | + repo_owner TEXT NOT NULL, | |
| 27 | + repo_name TEXT NOT NULL, | |
| 28 | + test_runner TEXT NOT NULL DEFAULT 'none', | |
| 29 | + tracked_branches TEXT NOT NULL, | |
| 30 | + display_name TEXT, | |
| 31 | + team TEXT, | |
| 32 | + registered_at INTEGER NOT NULL, | |
| 33 | + status TEXT NOT NULL DEFAULT 'active', | |
| 34 | + UNIQUE(repo_owner, repo_name) | |
| 35 | + ); | |
| 36 | + CREATE INDEX IF NOT EXISTS idx_projects_registered_by | |
| 37 | + ON projects(registered_by); | |
| 21 | 38 | `); |
| 22 | 39 | return db; |
| 23 | 40 | }; |
| @@ -87,6 +104,96 @@ export const latestRun = (owner: string, repo: string): Verdict | null => { | ||
| 87 | 104 | return JSON.parse(row.verdict_json) as Verdict; |
| 88 | 105 | }; |
| 89 | 106 | |
| 107 | +export interface ProjectRow { | |
| 108 | + id: number; | |
| 109 | + registeredBy: string; | |
| 110 | + repoOwner: string; | |
| 111 | + repoName: string; | |
| 112 | + testRunner: TestRunner; | |
| 113 | + trackedBranches: string[]; | |
| 114 | + displayName: string | null; | |
| 115 | + team: string | null; | |
| 116 | + registeredAt: number; | |
| 117 | + status: "active" | "paused"; | |
| 118 | +} | |
| 119 | + | |
| 120 | +interface ProjectDbRow { | |
| 121 | + id: number; | |
| 122 | + registered_by: string; | |
| 123 | + repo_owner: string; | |
| 124 | + repo_name: string; | |
| 125 | + test_runner: string; | |
| 126 | + tracked_branches: string; | |
| 127 | + display_name: string | null; | |
| 128 | + team: string | null; | |
| 129 | + registered_at: number; | |
| 130 | + status: string; | |
| 131 | +} | |
| 132 | + | |
| 133 | +const rowToProject = (r: ProjectDbRow): ProjectRow => ({ | |
| 134 | + id: r.id, | |
| 135 | + registeredBy: r.registered_by, | |
| 136 | + repoOwner: r.repo_owner, | |
| 137 | + repoName: r.repo_name, | |
| 138 | + testRunner: (r.test_runner === "bun" ? "bun" : "none") as TestRunner, | |
| 139 | + trackedBranches: JSON.parse(r.tracked_branches) as string[], | |
| 140 | + displayName: r.display_name, | |
| 141 | + team: r.team, | |
| 142 | + registeredAt: r.registered_at, | |
| 143 | + status: r.status === "paused" ? "paused" : "active", | |
| 144 | +}); | |
| 145 | + | |
| 146 | +// Inserts or updates a project. Re-registering the same repo refreshes | |
| 147 | +// its config (test_runner, tracked_branches, display_name, team) without | |
| 148 | +// duplicating the row. Returns the stored project. | |
| 149 | +export const upsertProject = ( | |
| 150 | + registeredBy: string, | |
| 151 | + repoOwner: string, | |
| 152 | + repoName: string, | |
| 153 | + config: ProjectConfig, | |
| 154 | +): ProjectRow => { | |
| 155 | + const now = Date.now(); | |
| 156 | + const branches = JSON.stringify(config.tracked_branches); | |
| 157 | + const display = config.display_name ?? null; | |
| 158 | + const team = config.team ?? null; | |
| 159 | + getDb().run( | |
| 160 | + `INSERT INTO projects (registered_by, repo_owner, repo_name, test_runner, tracked_branches, display_name, team, registered_at, status) | |
| 161 | + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active') | |
| 162 | + ON CONFLICT(repo_owner, repo_name) DO UPDATE SET | |
| 163 | + test_runner = excluded.test_runner, | |
| 164 | + tracked_branches = excluded.tracked_branches, | |
| 165 | + display_name = excluded.display_name, | |
| 166 | + team = excluded.team, | |
| 167 | + status = 'active'`, | |
| 168 | + [registeredBy, repoOwner, repoName, config.test_runner, branches, display, team, now], | |
| 169 | + ); | |
| 170 | + const row = getDb() | |
| 171 | + .query<ProjectDbRow, [string, string]>( | |
| 172 | + `SELECT * FROM projects WHERE repo_owner = ? AND repo_name = ?`, | |
| 173 | + ) | |
| 174 | + .get(repoOwner, repoName); | |
| 175 | + if (!row) throw new Error("project upsert returned no row"); | |
| 176 | + return rowToProject(row); | |
| 177 | +}; | |
| 178 | + | |
| 179 | +export const getProject = (repoOwner: string, repoName: string): ProjectRow | null => { | |
| 180 | + const row = getDb() | |
| 181 | + .query<ProjectDbRow, [string, string]>( | |
| 182 | + `SELECT * FROM projects WHERE repo_owner = ? AND repo_name = ?`, | |
| 183 | + ) | |
| 184 | + .get(repoOwner, repoName); | |
| 185 | + return row ? rowToProject(row) : null; | |
| 186 | +}; | |
| 187 | + | |
| 188 | +export const listActiveProjects = (): ProjectRow[] => { | |
| 189 | + const rows = getDb() | |
| 190 | + .query<ProjectDbRow, []>( | |
| 191 | + `SELECT * FROM projects WHERE status = 'active' ORDER BY registered_at DESC`, | |
| 192 | + ) | |
| 193 | + .all(); | |
| 194 | + return rows.map(rowToProject); | |
| 195 | +}; | |
| 196 | + | |
| 90 | 197 | // Latest verdict per (owner, repo) across all agents — drives the |
| 91 | 198 | // leaderboard and the /agents index. |
| 92 | 199 | export const allLatestRuns = (): { owner: string; repo: string; verdict: Verdict }[] => { |
src/projects.ts
+271
−0
| @@ -0,0 +1,271 @@ | ||
| 1 | +import type { ProjectRow } from "./db"; | |
| 2 | + | |
| 3 | +// Project-tracking ingest contract — block 1 of the reporting pipeline. | |
| 4 | +// | |
| 5 | +// A "project" is a real repo whose pushes get scored on TDD discipline. | |
| 6 | +// Distinct from a kata: katas are the practice ground (fixed steps, | |
| 7 | +// hidden tests); projects are production code judged purely structurally. | |
| 8 | +// | |
| 9 | +// Onboarding: a repo opts in by adding `.tdd-md.json` at its root on the | |
| 10 | +// default branch. tdd.md fetches the file (via raw.githubusercontent), | |
| 11 | +// validates it, and registers the project in our SQLite store. Per-commit | |
| 12 | +// judging follows in a later sliver — this module covers config + ingest | |
| 13 | +// of the registration itself. | |
| 14 | + | |
| 15 | +export const PROJECT_CONFIG_PATH = ".tdd-md.json"; | |
| 16 | +export const PROJECT_CONFIG_VERSION = 1; | |
| 17 | + | |
| 18 | +export type TestRunner = "none" | "bun"; | |
| 19 | +export type AgentSlug = "claude-code" | "cursor" | "aider" | "unknown"; | |
| 20 | + | |
| 21 | +export interface ProjectConfig { | |
| 22 | + version: number; | |
| 23 | + // "none" → trace-mode judging only (commit discipline, no test execution). | |
| 24 | + // "bun" → full sandbox-runner judging (later sliver — registration accepts | |
| 25 | + // the value but judging stays trace-only until the runner ships). | |
| 26 | + test_runner: TestRunner; | |
| 27 | + // Branches whose pushes get scored. Defaults to ["main"]. | |
| 28 | + tracked_branches: string[]; | |
| 29 | + // Optional reporting metadata. | |
| 30 | + display_name?: string; | |
| 31 | + team?: string; | |
| 32 | +} | |
| 33 | + | |
| 34 | +export const DEFAULT_CONFIG: ProjectConfig = { | |
| 35 | + version: PROJECT_CONFIG_VERSION, | |
| 36 | + test_runner: "none", | |
| 37 | + tracked_branches: ["main"], | |
| 38 | +}; | |
| 39 | + | |
| 40 | +// Validates and normalises a parsed JSON blob into a ProjectConfig. | |
| 41 | +// Throws with a human-readable message on failure — those messages are | |
| 42 | +// surfaced verbatim to the registering user, so they need to be useful. | |
| 43 | +export const parseProjectConfig = (raw: unknown): ProjectConfig => { | |
| 44 | + if (!raw || typeof raw !== "object") { | |
| 45 | + throw new Error(".tdd-md.json must be a JSON object"); | |
| 46 | + } | |
| 47 | + const obj = raw as Record<string, unknown>; | |
| 48 | + const version = obj.version; | |
| 49 | + if (typeof version !== "number" || version !== PROJECT_CONFIG_VERSION) { | |
| 50 | + throw new Error( | |
| 51 | + `.tdd-md.json has version ${JSON.stringify(version)}; expected ${PROJECT_CONFIG_VERSION}`, | |
| 52 | + ); | |
| 53 | + } | |
| 54 | + let testRunner: TestRunner = "none"; | |
| 55 | + if (obj.test_runner !== undefined) { | |
| 56 | + if (obj.test_runner !== "none" && obj.test_runner !== "bun") { | |
| 57 | + throw new Error( | |
| 58 | + `.tdd-md.json: test_runner must be "none" or "bun" (got ${JSON.stringify(obj.test_runner)})`, | |
| 59 | + ); | |
| 60 | + } | |
| 61 | + testRunner = obj.test_runner; | |
| 62 | + } | |
| 63 | + let trackedBranches: string[] = ["main"]; | |
| 64 | + if (obj.tracked_branches !== undefined) { | |
| 65 | + if (!Array.isArray(obj.tracked_branches) || obj.tracked_branches.some((b) => typeof b !== "string" || !b)) { | |
| 66 | + throw new Error(".tdd-md.json: tracked_branches must be a non-empty array of branch names"); | |
| 67 | + } | |
| 68 | + trackedBranches = obj.tracked_branches as string[]; | |
| 69 | + } | |
| 70 | + const config: ProjectConfig = { | |
| 71 | + version, | |
| 72 | + test_runner: testRunner, | |
| 73 | + tracked_branches: trackedBranches, | |
| 74 | + }; | |
| 75 | + if (typeof obj.display_name === "string" && obj.display_name) { | |
| 76 | + config.display_name = obj.display_name; | |
| 77 | + } | |
| 78 | + if (typeof obj.team === "string" && obj.team) { | |
| 79 | + config.team = obj.team; | |
| 80 | + } | |
| 81 | + return config; | |
| 82 | +}; | |
| 83 | + | |
| 84 | +// Pulls .tdd-md.json from a public GitHub repo's default branch via the | |
| 85 | +// raw-content host. No auth — public-repo only for now (private repos | |
| 86 | +// land when we install a GitHub App, deferred to a later sliver). | |
| 87 | +export const fetchProjectConfig = async ( | |
| 88 | + repoOwner: string, | |
| 89 | + repoName: string, | |
| 90 | +): Promise<ProjectConfig> => { | |
| 91 | + const url = `https://raw.githubusercontent.com/${encodeURIComponent(repoOwner)}/${encodeURIComponent(repoName)}/HEAD/${PROJECT_CONFIG_PATH}`; | |
| 92 | + const res = await fetch(url, { | |
| 93 | + headers: { Accept: "application/json", "User-Agent": "tdd.md" }, | |
| 94 | + }); | |
| 95 | + if (res.status === 404) { | |
| 96 | + throw new Error( | |
| 97 | + `${PROJECT_CONFIG_PATH} not found in ${repoOwner}/${repoName} on the default branch (or the repo is private; private repos aren't supported yet).`, | |
| 98 | + ); | |
| 99 | + } | |
| 100 | + if (!res.ok) { | |
| 101 | + throw new Error( | |
| 102 | + `Couldn't fetch ${PROJECT_CONFIG_PATH} from ${repoOwner}/${repoName}: HTTP ${res.status}`, | |
| 103 | + ); | |
| 104 | + } | |
| 105 | + let parsed: unknown; | |
| 106 | + try { | |
| 107 | + parsed = await res.json(); | |
| 108 | + } catch { | |
| 109 | + throw new Error(`${PROJECT_CONFIG_PATH} in ${repoOwner}/${repoName} isn't valid JSON`); | |
| 110 | + } | |
| 111 | + return parseProjectConfig(parsed); | |
| 112 | +}; | |
| 113 | + | |
| 114 | +// Parse a GitHub repo URL or owner/repo shorthand. Accepts: | |
| 115 | +// https://github.com/syntaxai/tdd.md | |
| 116 | +// https://github.com/syntaxai/tdd.md.git | |
| 117 | +// github.com/syntaxai/tdd.md | |
| 118 | +// syntaxai/tdd.md | |
| 119 | +// Returns the owner + repo or throws with a precise message. | |
| 120 | +export const parseRepoIdentifier = (raw: string): { owner: string; repo: string } => { | |
| 121 | + const trimmed = raw.trim(); | |
| 122 | + if (!trimmed) throw new Error("Repository URL is required."); | |
| 123 | + let path = trimmed; | |
| 124 | + const httpsMatch = path.match(/^https?:\/\/(?:www\.)?github\.com\/(.+)$/i); | |
| 125 | + if (httpsMatch?.[1]) path = httpsMatch[1]; | |
| 126 | + const bareMatch = path.match(/^github\.com\/(.+)$/i); | |
| 127 | + if (bareMatch?.[1]) path = bareMatch[1]; | |
| 128 | + path = path.replace(/\.git$/i, "").replace(/\/+$/, ""); | |
| 129 | + const parts = path.split("/").filter(Boolean); | |
| 130 | + const owner = parts[0]; | |
| 131 | + const repo = parts[1]; | |
| 132 | + if (parts.length !== 2 || !owner || !repo) { | |
| 133 | + throw new Error( | |
| 134 | + `Couldn't parse "${raw}" as a GitHub repo. Use a URL like https://github.com/owner/name or the shorthand owner/name.`, | |
| 135 | + ); | |
| 136 | + } | |
| 137 | + if (!/^[A-Za-z0-9._-]+$/.test(owner) || !/^[A-Za-z0-9._-]+$/.test(repo)) { | |
| 138 | + throw new Error(`"${raw}" contains characters that aren't valid for a GitHub owner/repo.`); | |
| 139 | + } | |
| 140 | + return { owner, repo }; | |
| 141 | +}; | |
| 142 | + | |
| 143 | +const escape = (s: string): string => | |
| 144 | + s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">"); | |
| 145 | + | |
| 146 | +const projectListRow = (p: ProjectRow): string => { | |
| 147 | + const slug = `${p.repoOwner}/${p.repoName}`; | |
| 148 | + const display = p.displayName ?? slug; | |
| 149 | + const team = p.team ? ` <span class="muted">· ${escape(p.team)}</span>` : ""; | |
| 150 | + const branches = p.trackedBranches.map((b) => `\`${b}\``).join(", "); | |
| 151 | + const runner = p.testRunner === "none" ? "trace-only" : p.testRunner; | |
| 152 | + return `| [${escape(display)}](/projects/${p.repoOwner}/${p.repoName}) ${team} | ${branches} | ${runner} |`; | |
| 153 | +}; | |
| 154 | + | |
| 155 | +export const projectsLandingMd = (projects: ProjectRow[]): string => { | |
| 156 | + const rows = projects.length === 0 | |
| 157 | + ? `| _no projects yet — [register one](/projects/new)_ | | |` | |
| 158 | + : projects.map(projectListRow).join("\n"); | |
| 159 | + return `# projects | |
| 160 | + | |
| 161 | +> 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). | |
| 162 | + | |
| 163 | +## tracked | |
| 164 | + | |
| 165 | +| project | branches | runner | | |
| 166 | +|---|---|---| | |
| 167 | +${rows} | |
| 168 | + | |
| 169 | +## register a repo | |
| 170 | + | |
| 171 | +[Register a project →](/projects/new) — paste a public GitHub URL; tdd.md fetches \`${PROJECT_CONFIG_PATH}\` from the default branch and onboards it. | |
| 172 | + | |
| 173 | +## the config file | |
| 174 | + | |
| 175 | +Drop \`${PROJECT_CONFIG_PATH}\` at the root of your repo's default branch: | |
| 176 | + | |
| 177 | +\`\`\`json | |
| 178 | +{ | |
| 179 | + "version": 1, | |
| 180 | + "test_runner": "none", | |
| 181 | + "tracked_branches": ["main"], | |
| 182 | + "display_name": "API Gateway", | |
| 183 | + "team": "platform" | |
| 184 | +} | |
| 185 | +\`\`\` | |
| 186 | + | |
| 187 | +- **\`test_runner\`** — \`"none"\` for trace-mode (commit-discipline only, language-agnostic). \`"bun"\` will run the test suite once the sandbox-runner ships. | |
| 188 | +- **\`tracked_branches\`** — pushes to these branches get scored. Defaults to \`["main"]\`. | |
| 189 | +- **\`display_name\`** / **\`team\`** — optional, only used in the reporting UI. | |
| 190 | + | |
| 191 | +## what comes next | |
| 192 | + | |
| 193 | +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. | |
| 194 | + | |
| 195 | +[← back to tdd.md](/) · [the reports](/reports) | |
| 196 | +`; | |
| 197 | +}; | |
| 198 | + | |
| 199 | +export const projectRegisterMd = ( | |
| 200 | + viewer: string | null, | |
| 201 | + prefilled?: string, | |
| 202 | + errorMessage?: string, | |
| 203 | +): string => { | |
| 204 | + if (!viewer) { | |
| 205 | + return `# register a project | |
| 206 | + | |
| 207 | +> You need to sign in before registering a project. We use your GitHub identity to record who onboarded the repo. | |
| 208 | + | |
| 209 | +[ sign in with github → ](/auth/github/start) | |
| 210 | + | |
| 211 | +[← all projects](/projects) | |
| 212 | +`; | |
| 213 | + } | |
| 214 | + const error = errorMessage | |
| 215 | + ? `<div class="project-form-error"><strong>Couldn't register that repo:</strong><br>${escape(errorMessage)}</div>` | |
| 216 | + : ""; | |
| 217 | + const value = prefilled ? ` value="${escape(prefilled)}"` : ""; | |
| 218 | + return `# register a project | |
| 219 | + | |
| 220 | +> 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. | |
| 221 | + | |
| 222 | +${error} | |
| 223 | + | |
| 224 | +<form method="post" action="/projects/new" class="project-form"> | |
| 225 | + <label for="repo-url">Repository URL or <code>owner/name</code></label> | |
| 226 | + <input id="repo-url" name="repo" type="text" required | |
| 227 | + placeholder="https://github.com/owner/name" | |
| 228 | + autocomplete="off" autocapitalize="off" autocorrect="off"${value} /> | |
| 229 | + <button type="submit">Register</button> | |
| 230 | +</form> | |
| 231 | + | |
| 232 | +> Signed in as <code>${escape(viewer)}</code>. Don't have \`${PROJECT_CONFIG_PATH}\` yet? [See the format on /projects](/projects#the-config-file). | |
| 233 | + | |
| 234 | +[← all projects](/projects) | |
| 235 | +`; | |
| 236 | +}; | |
| 237 | + | |
| 238 | +export const projectDetailMd = (p: ProjectRow): string => { | |
| 239 | + const display = p.displayName ?? `${p.repoOwner}/${p.repoName}`; | |
| 240 | + const registeredAt = new Date(p.registeredAt).toISOString().slice(0, 10); | |
| 241 | + const branches = p.trackedBranches.map((b) => `\`${b}\``).join(", "); | |
| 242 | + const runnerNote = p.testRunner === "none" | |
| 243 | + ? "Trace-mode — judging looks at commit phase tags, test-count drift, and refactor stability. No test execution." | |
| 244 | + : "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.)"; | |
| 245 | + return `# ${escape(display)} | |
| 246 | + | |
| 247 | +> [${escape(p.repoOwner)}/${escape(p.repoName)}](https://github.com/${p.repoOwner}/${p.repoName}) · registered by [${escape(p.registeredBy)}](/agents/${p.registeredBy}) on ${registeredAt}. | |
| 248 | + | |
| 249 | +## config | |
| 250 | + | |
| 251 | +| key | value | | |
| 252 | +|---|---| | |
| 253 | +| test_runner | \`${p.testRunner}\` | | |
| 254 | +| tracked_branches | ${branches} | | |
| 255 | +| display_name | ${p.displayName ? `\`${escape(p.displayName)}\`` : "_(none)_"} | | |
| 256 | +| team | ${p.team ? `\`${escape(p.team)}\`` : "_(none)_"} | | |
| 257 | +| status | \`${p.status}\` | | |
| 258 | + | |
| 259 | +${runnerNote} | |
| 260 | + | |
| 261 | +## scored commits | |
| 262 | + | |
| 263 | +> _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. | |
| 264 | + | |
| 265 | +## refresh | |
| 266 | + | |
| 267 | +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. | |
| 268 | + | |
| 269 | +[← all projects](/projects) | |
| 270 | +`; | |
| 271 | +}; | |
src/reports.ts
+2
−0
| @@ -368,6 +368,8 @@ This is a design preview. The pipeline that ingests real repos isn't wired yet; | ||
| 368 | 368 | - [per-agent drill-down →](/reports/demo/agents/cursor) — trend, failure mix, recent flagged commits |
| 369 | 369 | - [tests overzicht →](/reports/demo/tests) — huidige stand per repo + test-stabiliteit per test-naam |
| 370 | 370 | |
| 371 | +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. | |
| 372 | + | |
| 371 | 373 | ## what gets measured |
| 372 | 374 | |
| 373 | 375 | 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: |
src/server.ts
+92
−1
| @@ -4,7 +4,7 @@ import * as forgejo from "./forgejo"; | ||
| 4 | 4 | import { parseCommit, computeProgress, type Phase } from "./commits"; |
| 5 | 5 | import { loadGame, listGames } from "./games"; |
| 6 | 6 | import { judge } from "./judge"; |
| 7 | -import { latestRun, allLatestRuns } from "./db"; | |
| 7 | +import { latestRun, allLatestRuns, listActiveProjects, getProject, upsertProject } from "./db"; | |
| 8 | 8 | import { |
| 9 | 9 | reportsLandingMd, |
| 10 | 10 | execSummaryMd, |
| @@ -12,6 +12,13 @@ import { | ||
| 12 | 12 | testsOverviewMd, |
| 13 | 13 | DEMO_REPORTS, |
| 14 | 14 | } from "./reports"; |
| 15 | +import { | |
| 16 | + projectsLandingMd, | |
| 17 | + projectRegisterMd, | |
| 18 | + projectDetailMd, | |
| 19 | + parseRepoIdentifier, | |
| 20 | + fetchProjectConfig, | |
| 21 | +} from "./projects"; | |
| 15 | 22 | |
| 16 | 23 | const HOME_MD = "./content/home.md"; |
| 17 | 24 | const GAME_DIR = "./content/games"; |
| @@ -789,6 +796,90 @@ ${rows} | ||
| 789 | 796 | return htmlResponse(html); |
| 790 | 797 | }, |
| 791 | 798 | |
| 799 | + "/projects": async () => { | |
| 800 | + const projects = listActiveProjects(); | |
| 801 | + const html = await renderPage({ | |
| 802 | + title: "Projects — tdd.md", | |
| 803 | + 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.", | |
| 804 | + bodyMarkdown: projectsLandingMd(projects), | |
| 805 | + ogPath: "https://tdd.md/projects", | |
| 806 | + }); | |
| 807 | + return htmlResponse(html); | |
| 808 | + }, | |
| 809 | + | |
| 810 | + "/projects/new": async (req) => { | |
| 811 | + const viewer = await getViewer(req); | |
| 812 | + if (req.method === "GET") { | |
| 813 | + const url = new URL(req.url); | |
| 814 | + const prefilled = url.searchParams.get("repo") ?? undefined; | |
| 815 | + const html = await renderPage({ | |
| 816 | + title: "Register a project — tdd.md", | |
| 817 | + 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.", | |
| 818 | + bodyMarkdown: projectRegisterMd(viewer, prefilled), | |
| 819 | + ogPath: "https://tdd.md/projects/new", | |
| 820 | + noindex: true, | |
| 821 | + }); | |
| 822 | + return htmlResponse(html); | |
| 823 | + } | |
| 824 | + if (req.method !== "POST") return new Response("method not allowed", { status: 405 }); | |
| 825 | + if (!viewer) return new Response("unauthorized — sign in first", { status: 401 }); | |
| 826 | + | |
| 827 | + let raw = ""; | |
| 828 | + try { | |
| 829 | + const form = await req.formData(); | |
| 830 | + raw = String(form.get("repo") ?? "").trim(); | |
| 831 | + } catch { | |
| 832 | + return new Response("invalid form body", { status: 400 }); | |
| 833 | + } | |
| 834 | + | |
| 835 | + const renderError = async (message: string, status = 400): Promise<Response> => { | |
| 836 | + const html = await renderPage({ | |
| 837 | + title: "Register a project — tdd.md", | |
| 838 | + bodyMarkdown: projectRegisterMd(viewer, raw, message), | |
| 839 | + ogPath: "https://tdd.md/projects/new", | |
| 840 | + noindex: true, | |
| 841 | + }); | |
| 842 | + return htmlResponse(html, status); | |
| 843 | + }; | |
| 844 | + | |
| 845 | + let owner: string; | |
| 846 | + let repo: string; | |
| 847 | + try { | |
| 848 | + ({ owner, repo } = parseRepoIdentifier(raw)); | |
| 849 | + } catch (err) { | |
| 850 | + return renderError((err as Error).message); | |
| 851 | + } | |
| 852 | + | |
| 853 | + let config; | |
| 854 | + try { | |
| 855 | + config = await fetchProjectConfig(owner, repo); | |
| 856 | + } catch (err) { | |
| 857 | + return renderError((err as Error).message); | |
| 858 | + } | |
| 859 | + | |
| 860 | + upsertProject(viewer, owner, repo, config); | |
| 861 | + return new Response(null, { | |
| 862 | + status: 303, | |
| 863 | + headers: { Location: `/projects/${owner}/${repo}` }, | |
| 864 | + }); | |
| 865 | + }, | |
| 866 | + | |
| 867 | + "/projects/:repoOwner/:repoName": async (req) => { | |
| 868 | + const { repoOwner, repoName } = req.params; | |
| 869 | + const project = getProject(repoOwner, repoName); | |
| 870 | + if (!project) { | |
| 871 | + const html = await renderNotFound(`/projects/${repoOwner}/${repoName}`); | |
| 872 | + return htmlResponse(html, 404); | |
| 873 | + } | |
| 874 | + const html = await renderPage({ | |
| 875 | + title: `${project.displayName ?? `${project.repoOwner}/${project.repoName}`} — tdd.md`, | |
| 876 | + description: `${project.repoOwner}/${project.repoName} on tdd.md — ${project.testRunner === "none" ? "trace-mode" : project.testRunner} judging across ${project.trackedBranches.join(", ")}.`, | |
| 877 | + bodyMarkdown: projectDetailMd(project), | |
| 878 | + ogPath: `https://tdd.md/projects/${project.repoOwner}/${project.repoName}`, | |
| 879 | + }); | |
| 880 | + return htmlResponse(html); | |
| 881 | + }, | |
| 882 | + | |
| 792 | 883 | "/reports": async () => { |
| 793 | 884 | const html = await renderPage({ |
| 794 | 885 | title: "Reports — tdd.md", |