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
31 changes: 28 additions & 3 deletions plugins/kanban/dashboard/dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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
Expand All @@ -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]);
Expand Down Expand Up @@ -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
Expand Down
42 changes: 41 additions & 1 deletion plugins/kanban/dashboard/plugin_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]


Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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:
Expand Down
10 changes: 7 additions & 3 deletions tests/plugins/test_kanban_dashboard_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
Loading