diff --git a/api.py b/api.py index d4f5193..345d894 100644 --- a/api.py +++ b/api.py @@ -187,11 +187,15 @@ async def _create_milestone(body: dict = Body(...)): # ── features ────────────────────────────────────────────────────────────── @router.get("/features") async def _features(state: str | None = None): - return {"features": store().list_features(state=state)} + # _guard, like every other store-touching route: an unusable board (no repo + # bound, no .beads, br missing) must reach the view as JSON 400 with the + # actionable BoardError message — an escaped BoardError is a text/plain 500 + # the view can only render as a JSON-parse error. + return _guard(lambda: {"features": store().list_features(state=state)}) @router.get("/features/{fid}") async def _feature(fid: str): - f = store().get_feature(fid) + f = _guard(lambda: store().get_feature(fid)) if f is None: raise HTTPException(404, f"unknown feature {fid!r}") return f diff --git a/board_view.py b/board_view.py index 20bf75a..6788c97 100644 --- a/board_view.py +++ b/board_view.py @@ -102,7 +102,14 @@ in_progress:"var(--pl-color-accent)", in_review:"var(--pl-color-status-info)", done:"var(--pl-color-fg-muted)", blocked:"var(--pl-color-status-error)"}; // Slug-aware authed fetch via the kit (rules 2+3) — pass a bare /api/... path. -const api = (p) => kit.apiFetch(p).then(r => r.json()); +// Errors become READABLE: a JSON error body surfaces its `detail` (the actionable +// BoardError message), a non-JSON body its HTTP status — never a raw parse error. +const api = async (p) => { + const r = await kit.apiFetch(p); + const d = await r.json().catch(() => { throw new Error("HTTP " + r.status + " (non-JSON response)"); }); + if (!r.ok) throw new Error(d.detail || "HTTP " + r.status); + return d; +}; const $ = (id) => document.getElementById(id); const esc = (s) => (s||"").replace(/[&<>"]/g, c => ({"&":"&","<":"<",">":">",'"':"""}[c])); diff --git a/tests/test_api.py b/tests/test_api.py index 19a4774..0bcee67 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -136,6 +136,26 @@ def test_data_routes_live_on_the_gated_prefix(monkeypatch): assert c.get("/plugins/project_board/features").status_code == 404 +def test_unusable_board_reads_surface_as_json_400_not_500(monkeypatch): + """An unusable board (no repo bound, no .beads, br missing) raises BoardError + on ANY read — that must reach the view as JSON 400 carrying the actionable + message, not escape as a text/plain 500 the page can only show as a + JSON-parse error.""" + + class BrokenStore(FakeStore): + def list_features(self, state=None): + raise BoardError("repo '.' has no beads workspace — set project_board.repo") + + def get_feature(self, fid): + raise BoardError("repo '.' has no beads workspace — set project_board.repo") + + c = _client(monkeypatch, BrokenStore()) + for path in ("/api/plugins/project_board/features", "/api/plugins/project_board/features/bd-1"): + r = c.get(path) + assert r.status_code == 400, path + assert "beads workspace" in r.json()["detail"], path + + # ── CRUD + the Ready gate surfacing as 400 ──────────────────────────────────────