fix(kanban-dashboard): restore implementations dropped during salvages (#28481)

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).
This commit is contained in:
Teknium 2026-05-18 21:54:56 -07:00 committed by GitHub
parent b58b4188f6
commit 362ef912ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 76 additions and 7 deletions

View file

@ -51,6 +51,24 @@
return str;
}
// ``fetchJSON`` throws ``Error("<status>: <raw body>")`` on non-2xx, and
// FastAPI bodies look like ``{"detail":"<message>"}``. 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

View file

@ -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:

View file

@ -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