syntaxai/tdd.md · commit 559b0bf

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]>
author
syntaxai <[email protected]>
date
2026-05-08 10:54:05 +01:00
parent
44dbe97
commit
559b0bf030f50d6b7e95614eaba2ceb87be1bb4c

5 files changed · +531 −1

modified public/style.css +59 −0
@@ -448,3 +448,62 @@ main.md table.test-stability td.test-stab-num {
448448 .test-snapshots { grid-template-columns: 1fr; }
449449 main.md table.test-stability { font-size: 0.78rem; }
450450 }
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); }
modified src/db.ts +107 −0
@@ -1,4 +1,5 @@
11 import { Database } from "bun:sqlite";
2+import type { ProjectConfig, TestRunner } from "./projects";
23
34 const DB_PATH = process.env.TDD_DB_PATH ?? ":memory:";
45
@@ -18,6 +19,22 @@ const getDb = (): Database => {
1819 );
1920 CREATE INDEX IF NOT EXISTS idx_runs_owner_repo
2021 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);
2138 `);
2239 return db;
2340 };
@@ -87,6 +104,96 @@ export const latestRun = (owner: string, repo: string): Verdict | null => {
87104 return JSON.parse(row.verdict_json) as Verdict;
88105 };
89106
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+
90197 // Latest verdict per (owner, repo) across all agents — drives the
91198 // leaderboard and the /agents index.
92199 export const allLatestRuns = (): { owner: string; repo: string; verdict: Verdict }[] => {
added 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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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+};
modified src/reports.ts +2 −0
@@ -368,6 +368,8 @@ This is a design preview. The pipeline that ingests real repos isn't wired yet;
368368 - [per-agent drill-down →](/reports/demo/agents/cursor) — trend, failure mix, recent flagged commits
369369 - [tests overzicht →](/reports/demo/tests) — huidige stand per repo + test-stabiliteit per test-naam
370370
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+
371373 ## what gets measured
372374
373375 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:
modified src/server.ts +92 −1
@@ -4,7 +4,7 @@ import * as forgejo from "./forgejo";
44 import { parseCommit, computeProgress, type Phase } from "./commits";
55 import { loadGame, listGames } from "./games";
66 import { judge } from "./judge";
7-import { latestRun, allLatestRuns } from "./db";
7+import { latestRun, allLatestRuns, listActiveProjects, getProject, upsertProject } from "./db";
88 import {
99 reportsLandingMd,
1010 execSummaryMd,
@@ -12,6 +12,13 @@ import {
1212 testsOverviewMd,
1313 DEMO_REPORTS,
1414 } from "./reports";
15+import {
16+ projectsLandingMd,
17+ projectRegisterMd,
18+ projectDetailMd,
19+ parseRepoIdentifier,
20+ fetchProjectConfig,
21+} from "./projects";
1522
1623 const HOME_MD = "./content/home.md";
1724 const GAME_DIR = "./content/games";
@@ -789,6 +796,90 @@ ${rows}
789796 return htmlResponse(html);
790797 },
791798
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+
792883 "/reports": async () => {
793884 const html = await renderPage({
794885 title: "Reports — tdd.md",