From 362ef912eae2d5a18499c72d90b1cfad3b46b693 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 18 May 2026 21:54:56 -0700 Subject: [PATCH] fix(kanban-dashboard): restore implementations dropped during salvages (#28481) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four kanban dashboard test failures, all from PR salvages that picked up the test additions but dropped the corresponding implementations. - BOARD_COLUMNS: add 'review' (status added by PR f55d94a1e but the board API never grew the column → test_board_empty failed because VALID_STATUSES - {archived} mismatched the rendered columns). - update_task: enrich the 'ready' 409 detail with the blocking parent list (id, title, status) and add _parents_blocking_ready helper. Implementation lost in the #26744 salvage (commit e215558ba) which pinned the test but not the server-side code. - dist/index.js: add parseApiErrorMessage helper, wire it through the drag/drop banner, add patchErr state to the TaskDrawer and surface it inline by the action row. Lost in the same #26744 salvage. - test_diagnostics_endpoint_severity_filter: update to at-or-above semantics (PR a94ddd807 changed the filter from exact-match so the warning filter now correctly includes error+critical too). --- plugins/kanban/dashboard/dist/index.js | 31 ++++++++++++-- plugins/kanban/dashboard/plugin_api.py | 42 ++++++++++++++++++- tests/plugins/test_kanban_dashboard_plugin.py | 10 +++-- 3 files changed, 76 insertions(+), 7 deletions(-) diff --git a/plugins/kanban/dashboard/dist/index.js b/plugins/kanban/dashboard/dist/index.js index 04810d629f7..7f2fd573e74 100644 --- a/plugins/kanban/dashboard/dist/index.js +++ b/plugins/kanban/dashboard/dist/index.js @@ -51,6 +51,24 @@ return str; } + // ``fetchJSON`` throws ``Error(": ")`` on non-2xx, and + // FastAPI bodies look like ``{"detail":""}``. Pull the + // human-readable message out so banners/toasts don't have to leak HTTP + // plumbing at the user (e.g. ``409: {"detail":"…"}``). See #26744. + function parseApiErrorMessage(err) { + const raw = (err && err.message) ? String(err.message) : String(err || ""); + const m = raw.match(/^(\d{3}):\s*(.*)$/s); + const body = m ? m[2] : raw; + try { + const parsed = JSON.parse(body); + if (parsed && typeof parsed.detail === "string") return parsed.detail; + if (parsed && parsed.detail && typeof parsed.detail.message === "string") { + return parsed.detail.message; + } + } catch (_e) { /* not JSON — fall through to raw body */ } + return body || raw; + } + // Order matches BOARD_COLUMNS in plugin_api.py. const COLUMN_ORDER = ["triage", "todo", "ready", "running", "blocked", "done"]; // English fallback dictionaries — used when the i18n catalog is missing @@ -654,7 +672,7 @@ headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch), }).catch(function (err) { - setError(tx(t, "moveFailed", "Move failed: ") + (err.message || err)); + setError(tx(t, "moveFailed", "Move failed: ") + parseApiErrorMessage(err)); loadBoard(); }); }, [loadBoard, board, t]); @@ -2709,6 +2727,11 @@ const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [err, setErr] = useState(null); + // Surface PATCH failures (e.g. 409 "parent not done") right next to + // the drawer's action row — without it, the drawer's only error + // surface (``err``) is hidden behind the loaded ``data`` and the + // Ready/Block/Complete buttons feel like no-ops. See #26744. + const [patchErr, setPatchErr] = useState(null); const [newComment, setNewComment] = useState(""); const [editing, setEditing] = useState(false); // Home-channel notification toggles. homeChannels is the list of platforms @@ -2720,7 +2743,7 @@ const load = useCallback(function () { return SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}`, boardSlug)) - .then(function (d) { setData(d); setErr(null); }) + .then(function (d) { setData(d); setErr(null); setPatchErr(null); }) .catch(function (e) { setErr(String(e.message || e)); }) .finally(function () { setLoading(false); }); }, [props.taskId, boardSlug]); @@ -2764,11 +2787,13 @@ } const finalPatch = withCompletionSummary(patch, 1); if (!finalPatch) return Promise.resolve(); + setPatchErr(null); return SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}`, boardSlug), { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(finalPatch), - }).then(function () { load(); props.onRefresh(); }); + }).then(function () { load(); props.onRefresh(); }) + .catch(function (e) { setPatchErr(parseApiErrorMessage(e)); }); }; // Triage specifier — calls the auxiliary LLM to flesh out a rough diff --git a/plugins/kanban/dashboard/plugin_api.py b/plugins/kanban/dashboard/plugin_api.py index 52436b611ed..104f666c300 100644 --- a/plugins/kanban/dashboard/plugin_api.py +++ b/plugins/kanban/dashboard/plugin_api.py @@ -137,7 +137,7 @@ def _conn(board: Optional[str] = None): # tasks into ``todo`` and makes the dashboard look like the Scheduled column # disappeared. BOARD_COLUMNS: list[str] = [ - "triage", "todo", "scheduled", "ready", "running", "blocked", "done", + "triage", "todo", "scheduled", "ready", "running", "blocked", "review", "done", ] @@ -683,6 +683,23 @@ def update_task(task_id: str, payload: UpdateTaskBody, board: Optional[str] = Qu else: raise HTTPException(status_code=400, detail=f"unknown status: {s}") if not ok: + # For ``ready``, name the blocking parent(s) so the dashboard + # can render an actionable toast instead of a silent no-op. + # See #26744. + if s == "ready": + blockers = _parents_blocking_ready(conn, task_id) + if blockers: + names = ", ".join( + f"{p['title']!r} ({p['id']}, status={p['status']})" + for p in blockers + ) + raise HTTPException( + status_code=409, + detail=( + f"Cannot move to 'ready': blocked by parent(s) " + f"not done — {names}" + ), + ) raise HTTPException( status_code=409, detail=f"status transition to {s!r} not valid from current state", @@ -747,6 +764,29 @@ def delete_task(task_id: str, board: Optional[str] = Query(None)): conn.close() +def _parents_blocking_ready( + conn: sqlite3.Connection, task_id: str, +) -> list: + """Return parent rows (``id``, ``title``, ``status``) that aren't ``done`` + and therefore prevent ``task_id`` from being promoted to ``ready``. + + Used to enrich the 409 response from :func:`update_task` so the + dashboard can show an actionable toast (#26744) instead of a silent + no-op. Returns ``[]`` when nothing blocks the transition (e.g. no + parents, or all parents already done). + """ + rows = conn.execute( + "SELECT t.id, t.title, t.status FROM tasks t " + "JOIN task_links l ON l.parent_id = t.id " + "WHERE l.child_id = ? AND t.status != 'done'", + (task_id,), + ).fetchall() + return [ + {"id": r["id"], "title": r["title"], "status": r["status"]} + for r in rows + ] + + def _set_status_direct( conn: sqlite3.Connection, task_id: str, new_status: str, ) -> bool: diff --git a/tests/plugins/test_kanban_dashboard_plugin.py b/tests/plugins/test_kanban_dashboard_plugin.py index 31da8c22a7e..5fa1881fa32 100644 --- a/tests/plugins/test_kanban_dashboard_plugin.py +++ b/tests/plugins/test_kanban_dashboard_plugin.py @@ -1939,7 +1939,8 @@ def test_diagnostics_endpoint_surfaces_blocked_hallucination(client): def test_diagnostics_endpoint_severity_filter(client): - """Warning-severity filter excludes error-severity entries.""" + """Severity filter is at-or-above: warning includes warning+error+critical, + error includes error+critical, critical is exact (no higher level).""" conn = kb.connect() try: # A warning-severity diagnostic (prose phantom) on one task. @@ -1958,12 +1959,15 @@ def test_diagnostics_endpoint_severity_filter(client): finally: conn.close() + # warning filter is at-or-above → both the warning AND the error pass. r = client.get("/api/plugins/kanban/diagnostics?severity=warning") assert r.status_code == 200 data = r.json() - assert data["count"] == 1 - assert data["diagnostics"][0]["task_id"] == p1 + assert data["count"] == 2 + task_ids = {row["task_id"] for row in data["diagnostics"]} + assert task_ids == {p1, p2} + # error filter is at-or-above → only the error passes (warning is below). r = client.get("/api/plugins/kanban/diagnostics?severity=error") data = r.json() assert data["count"] == 1