Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion board_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));

Expand Down
20 changes: 20 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────


Expand Down
Loading