feat(kanban): add --sort option to 'hermes kanban list'

Salvages #25745 by @LizerAIDev. Adds --sort {created,created-desc,
priority,priority-desc,status,assignee,title,updated} to 'hermes kanban
list'. Validated against VALID_SORT_ORDERS map; invalid values raise
ValueError. Default behaviour (priority DESC, created ASC) is unchanged
when --sort is omitted.
This commit is contained in:
LizerAIDev 2026-05-18 20:58:37 -07:00 committed by Teknium
parent 206f595f66
commit a846e500b0
3 changed files with 64 additions and 1 deletions

View file

@ -324,6 +324,12 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
p_list.add_argument("--archived", action="store_true",
help="Include archived tasks")
p_list.add_argument("--json", action="store_true")
p_list.add_argument(
"--sort",
default=None,
choices=sorted(kb.VALID_SORT_ORDERS.keys()),
help="Sort order for listed tasks (default: priority)",
)
# --- show ---
p_show = sub.add_parser("show", help="Show a task with comments + events")
@ -1220,6 +1226,7 @@ def _cmd_list(args: argparse.Namespace) -> int:
status=args.status,
tenant=args.tenant,
include_archived=args.archived,
order_by=getattr(args, "sort", None),
)
if getattr(args, "json", False):
print(json.dumps([_task_to_dict(t) for t in tasks], indent=2, ensure_ascii=False))

View file

@ -1531,6 +1531,20 @@ def get_task(conn: sqlite3.Connection, task_id: str) -> Optional[Task]:
return Task.from_row(row) if row else None
# Canonical sort-order mappings for ``hermes kanban list --sort``.
# Each value is a raw SQL fragment appended after ``ORDER BY``.
VALID_SORT_ORDERS: dict[str, str] = {
"created": "created_at ASC, id ASC",
"created-desc": "created_at DESC, id DESC",
"priority": "priority DESC, created_at ASC",
"priority-desc": "priority ASC, created_at ASC",
"status": "status ASC, created_at ASC",
"assignee": "assignee ASC, created_at ASC",
"title": "title ASC, id ASC",
"updated": "started_at DESC NULLS LAST, created_at DESC",
}
def list_tasks(
conn: sqlite3.Connection,
*,
@ -1539,6 +1553,7 @@ def list_tasks(
tenant: Optional[str] = None,
include_archived: bool = False,
limit: Optional[int] = None,
order_by: Optional[str] = None,
) -> list[Task]:
query = "SELECT * FROM tasks WHERE 1=1"
params: list[Any] = []
@ -1555,7 +1570,15 @@ def list_tasks(
params.append(tenant)
if not include_archived and status != "archived":
query += " AND status != 'archived'"
query += " ORDER BY priority DESC, created_at ASC"
if order_by is not None:
order_by = order_by.strip().lower()
if order_by not in VALID_SORT_ORDERS:
raise ValueError(
f"order_by must be one of {sorted(VALID_SORT_ORDERS.keys())}"
)
query += f" ORDER BY {VALID_SORT_ORDERS[order_by]}"
else:
query += " ORDER BY priority DESC, created_at ASC"
if limit:
query += f" LIMIT {int(limit)}"
rows = conn.execute(query, params).fetchall()

View file

@ -726,6 +726,39 @@ def test_delete_archived_task_rejects_non_archived_rows(kanban_home):
assert kb.get_task(conn, tid) is not None
def test_list_tasks_order_by(kanban_home):
with kb.connect() as conn:
# Create tasks with different titles and priorities
t_a = kb.create_task(conn, title="alpha", priority=1)
t_b = kb.create_task(conn, title="beta", priority=2)
t_c = kb.create_task(conn, title="gamma", priority=1)
# Default sort: priority DESC, created ASC
default = kb.list_tasks(conn)
assert [t.id for t in default] == [t_b, t_a, t_c]
# Sort by title ASC
by_title = kb.list_tasks(conn, order_by="title")
assert [t.id for t in by_title] == [t_a, t_b, t_c]
# Sort by assignee
kb.assign_task(conn, t_a, "alice")
kb.assign_task(conn, t_b, "bob")
kb.assign_task(conn, t_c, "alice")
by_assignee = kb.list_tasks(conn, order_by="assignee")
# alice's tasks first (alphabetically), then bob's
assignees = [t.assignee for t in by_assignee]
assert assignees[:2] == ["alice", "alice"]
assert assignees[2] == "bob"
# Invalid sort order raises ValueError
try:
kb.list_tasks(conn, order_by="bogus")
assert False, "Should have raised ValueError"
except ValueError as e:
assert "order_by must be one of" in str(e)
# ---------------------------------------------------------------------------
# Comments / events / worker context
# ---------------------------------------------------------------------------