diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py index b891a57bebd..396cdcba699 100644 --- a/hermes_cli/kanban.py +++ b/hermes_cli/kanban.py @@ -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)) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 34961013d49..7b8137ed25e 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -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() diff --git a/tests/hermes_cli/test_kanban_db.py b/tests/hermes_cli/test_kanban_db.py index 3be740cc1e8..a90e2225320 100644 --- a/tests/hermes_cli/test_kanban_db.py +++ b/tests/hermes_cli/test_kanban_db.py @@ -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 # ---------------------------------------------------------------------------