diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py index a861a6f2c23..1e7169c26cf 100644 --- a/hermes_cli/kanban.py +++ b/hermes_cli/kanban.py @@ -552,7 +552,7 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu p_promote = sub.add_parser( "promote", - help="Manually move a todo/blocked task to ready (recovery path)", + help="Manually move one or more todo/blocked tasks to ready (recovery path)", ) p_promote.add_argument("task_id") p_promote.add_argument( @@ -560,6 +560,12 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu nargs="*", help="Audit-trail reason (recorded on the task_events row)", ) + p_promote.add_argument( + "--ids", + nargs="+", + default=None, + help="Additional task ids to promote with the same reason (bulk mode)", + ) p_promote.add_argument( "--force", action="store_true", @@ -1987,37 +1993,51 @@ def _cmd_promote(args: argparse.Namespace) -> int: reason = " ".join(args.reason).strip() if args.reason else None author = _profile_author() as_json = getattr(args, "json", False) + extra_ids = list(getattr(args, "ids", None) or []) + # Dedupe while preserving order; positional task_id always first. + ids: list[str] = [] + seen: set[str] = set() + for tid in [args.task_id, *extra_ids]: + if tid not in seen: + ids.append(tid) + seen.add(tid) + + results: list[dict[str, object]] = [] with kb.connect() as conn: - ok, err = kb.promote_task( - conn, - args.task_id, - actor=author, - reason=reason, - force=bool(args.force), - dry_run=bool(args.dry_run), - ) - if as_json: - print(json.dumps( - { - "task_id": args.task_id, + for tid in ids: + ok, err = kb.promote_task( + conn, + tid, + actor=author, + reason=reason, + force=bool(args.force), + dry_run=bool(args.dry_run), + ) + results.append({ + "task_id": tid, "promoted": ok, "dry_run": bool(args.dry_run), "forced": bool(args.force), "reason": reason, "error": err, - }, - indent=2, - ensure_ascii=False, - )) - return 0 if ok else 1 - if not ok: - print(f"cannot promote {args.task_id}: {err}", file=sys.stderr) - return 1 + }) + + failed = [r for r in results if not r["promoted"]] + if as_json: + # Single-id stays a flat object for back-compat; bulk emits a list. + payload: object = results[0] if len(results) == 1 else results + print(json.dumps(payload, indent=2, ensure_ascii=False)) + return 0 if not failed else 1 + tag = " (dry)" if args.dry_run else "" label = "Would promote" if args.dry_run else "Promoted" - print(f"{label} {args.task_id} -> ready{tag}" - + (f": {reason}" if reason else "")) - return 0 + for r in results: + if r["promoted"]: + suffix = f": {reason}" if reason else "" + print(f"{label} {r['task_id']} -> ready{tag}{suffix}") + else: + print(f"cannot promote {r['task_id']}: {r['error']}", file=sys.stderr) + return 0 if not failed else 1 def _cmd_archive(args: argparse.Namespace) -> int: diff --git a/scripts/release.py b/scripts/release.py index 5df4b7cf04e..b37b42e8043 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -77,6 +77,7 @@ AUTHOR_MAP = { "oleksii.lisikh@gmail.com": "olisikh", "jithendranaidunara@gmail.com": "JithendraNara", "jeremy@geocaching.com": "outdoorsea", + "54763683+thedavidmurray@users.noreply.github.com": "thedavidmurray", "leone.parise@gmail.com": "leoneparise", "mr@shu.io": "mrshu", "adam.manning@gmail.com": "am423", diff --git a/tests/hermes_cli/test_kanban_promote.py b/tests/hermes_cli/test_kanban_promote.py index 00bbfecb41e..6cbf3b77071 100644 --- a/tests/hermes_cli/test_kanban_promote.py +++ b/tests/hermes_cli/test_kanban_promote.py @@ -8,11 +8,13 @@ Direct-SQL setup is used to construct that state deterministically. from __future__ import annotations +import argparse import json from pathlib import Path import pytest +from hermes_cli import kanban as kb_cli from hermes_cli import kanban_db as kb @@ -155,3 +157,98 @@ def test_promote_blocked_task_works(conn): ) assert ok and err is None assert kb.get_task(conn, tid).status == "ready" + + +# --------------------------------------------------------------------------- +# CLI `_cmd_promote` — bulk via `--ids` (the issue's anti-respawn use case: +# promote all children of a closed parent in one command). +# --------------------------------------------------------------------------- + + +def _promote_ns(task_id, *, ids=None, reason=None, force=False, + dry_run=False, as_json=False): + return argparse.Namespace( + task_id=task_id, + reason=list(reason or []), + ids=list(ids or []) or None, + force=force, + dry_run=dry_run, + json=as_json, + ) + + +def test_cli_promote_bulk_ids_promotes_all(kanban_home, capsys): + with kb.connect() as conn: + parent = kb.create_task(conn, title="parent") + children = [ + kb.create_task(conn, title=f"c{i}", parents=[parent]) + for i in range(3) + ] + conn.execute("UPDATE tasks SET status='done' WHERE id=?", (parent,)) + rc = kb_cli._cmd_promote(_promote_ns(children[0], ids=children[1:])) + assert rc == 0 + out = capsys.readouterr().out + for c in children: + assert c in out + with kb.connect() as conn: + for c in children: + assert kb.get_task(conn, c).status == "ready" + + +def test_cli_promote_bulk_partial_failure_exits_1(kanban_home, capsys): + """Bulk with one bad id: good ones still promote, exit code reflects failure.""" + with kb.connect() as conn: + parent = kb.create_task(conn, title="parent") + good = kb.create_task(conn, title="good", parents=[parent]) + conn.execute("UPDATE tasks SET status='done' WHERE id=?", (parent,)) + rc = kb_cli._cmd_promote(_promote_ns(good, ids=["t_nope"])) + assert rc == 1 + captured = capsys.readouterr() + assert good in captured.out # good one promoted + assert "t_nope" in captured.err and "not found" in captured.err + with kb.connect() as conn: + assert kb.get_task(conn, good).status == "ready" + + +def test_cli_promote_bulk_json_emits_list(kanban_home, capsys): + with kb.connect() as conn: + parent = kb.create_task(conn, title="parent") + a = kb.create_task(conn, title="a", parents=[parent]) + b = kb.create_task(conn, title="b", parents=[parent]) + conn.execute("UPDATE tasks SET status='done' WHERE id=?", (parent,)) + rc = kb_cli._cmd_promote(_promote_ns(a, ids=[b], as_json=True)) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert isinstance(payload, list) and len(payload) == 2 + assert {r["task_id"] for r in payload} == {a, b} + assert all(r["promoted"] for r in payload) + + +def test_cli_promote_single_json_stays_flat_object(kanban_home, capsys): + """Back-compat: single-id JSON is still a flat object, not a list.""" + with kb.connect() as conn: + parent = kb.create_task(conn, title="parent") + child = kb.create_task(conn, title="c", parents=[parent]) + conn.execute("UPDATE tasks SET status='done' WHERE id=?", (parent,)) + rc = kb_cli._cmd_promote(_promote_ns(child, as_json=True)) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert isinstance(payload, dict) + assert payload["task_id"] == child and payload["promoted"] is True + + +def test_cli_promote_dedupes_duplicate_ids(kanban_home, capsys): + """Same id in positional + --ids must only attempt the promotion once.""" + with kb.connect() as conn: + parent = kb.create_task(conn, title="parent") + child = kb.create_task(conn, title="c", parents=[parent]) + conn.execute("UPDATE tasks SET status='done' WHERE id=?", (parent,)) + rc = kb_cli._cmd_promote(_promote_ns(child, ids=[child, child])) + assert rc == 0 + with kb.connect() as conn: + n = conn.execute( + "SELECT COUNT(*) AS n FROM task_events " + "WHERE task_id = ? AND kind = 'promoted_manual'", + (child,), + ).fetchone()["n"] + assert n == 1