diff --git a/hermes_cli/projects_cmd.py b/hermes_cli/projects_cmd.py index 4e2655b5cc25..8b721a181156 100644 --- a/hermes_cli/projects_cmd.py +++ b/hermes_cli/projects_cmd.py @@ -189,6 +189,11 @@ def _print_project(proj) -> None: def _cmd_create(args: argparse.Namespace) -> int: + board_slug = args.board + if board_slug: + board_slug = _resolve_board_slug(board_slug) + if board_slug is None: + return 1 try: with pdb.connect_closing() as conn: pid = pdb.create_project( @@ -200,7 +205,7 @@ def _cmd_create(args: argparse.Namespace) -> int: description=args.description, icon=args.icon, color=args.color, - board_slug=args.board, + board_slug=board_slug, ) if args.use: pdb.set_active(conn, pid) @@ -305,15 +310,46 @@ def _cmd_restore(args, conn, proj) -> int: @_with_project def _cmd_bind_board(args, conn, proj) -> int: - pdb.update_project(conn, proj.id, board_slug=args.board) - if args.board.strip(): - print(f"Bound {proj.slug} -> board {args.board}") - _sync_board_default_workdir(proj, args.board) - else: + if not args.board.strip(): + pdb.update_project(conn, proj.id, board_slug="") print(f"Unbound board from {proj.slug}") + return 0 + slug = _resolve_board_slug(args.board) + if slug is None: + return 1 + pdb.update_project(conn, proj.id, board_slug=slug) + print(f"Bound {proj.slug} -> board {slug}") + _sync_board_default_workdir(proj, slug) return 0 +def _resolve_board_slug(board: str) -> str | None: + """Normalize a board slug and verify it names an existing board. + + Prints an error (with the list of valid slugs) to stderr and returns + ``None`` when the slug is malformed or no such board exists, so callers + can reject the binding instead of persisting a dangling reference. + """ + from hermes_cli import kanban_db as kb + + try: + slug = kb._normalize_board_slug(board) + except ValueError as exc: + print(f"project: {exc}", file=sys.stderr) + return None + if not slug: + return None + if not kb.board_exists(slug): + valid = ", ".join(b["slug"] for b in kb.list_boards()) + print( + f"project: board {slug!r} does not exist" + + (f"; valid boards: {valid}" if valid else ""), + file=sys.stderr, + ) + return None + return slug + + def _sync_board_default_workdir(proj, board_slug: str) -> None: """Best-effort: point the bound board's default_workdir at the primary repo. diff --git a/tests/hermes_cli/test_projects_cli.py b/tests/hermes_cli/test_projects_cli.py index 9e10b6a8cc29..ef55d22913f8 100644 --- a/tests/hermes_cli/test_projects_cli.py +++ b/tests/hermes_cli/test_projects_cli.py @@ -57,5 +57,59 @@ def test_rename_and_archive(tmp_path): assert len(pdb.list_projects(conn)) == 1 +def test_bind_board_rejects_unknown_slug(capsys, tmp_path): + """bind-board must reject a board slug that doesn't exist (rc != 0).""" + assert _run(["create", "My App", str(tmp_path)]) == 0 + + rc = _run(["bind-board", "my-app", "definitely-not-a-real-board"]) + assert rc != 0 + err = capsys.readouterr().err + assert "does not exist" in err + assert "default" in err # lists valid slugs + + # The bogus binding must NOT be persisted. + with pdb.connect_closing() as conn: + assert pdb.get_project(conn, "my-app").board_slug is None + + +def test_bind_board_accepts_existing_slug(capsys, tmp_path): + """bind-board with the default board succeeds and persists the slug.""" + assert _run(["create", "My App", str(tmp_path)]) == 0 + + assert _run(["bind-board", "my-app", "default"]) == 0 + out = capsys.readouterr().out + assert "Bound my-app -> board default" in out + with pdb.connect_closing() as conn: + assert pdb.get_project(conn, "my-app").board_slug == "default" + + +def test_bind_board_unbind(capsys, tmp_path): + """An empty board slug unbinds without validating.""" + assert _run(["create", "My App", str(tmp_path)]) == 0 + assert _run(["bind-board", "my-app", "default"]) == 0 + capsys.readouterr() + + assert _run(["bind-board", "my-app"]) == 0 + assert "Unbound board from my-app" in capsys.readouterr().out + with pdb.connect_closing() as conn: + assert pdb.get_project(conn, "my-app").board_slug is None + + +def test_create_rejects_unknown_board(capsys, tmp_path): + """create --board shares the validation path and must reject bad slugs.""" + rc = _run(["create", "My App", str(tmp_path), "--board", "no-such-board"]) + assert rc != 0 + assert "does not exist" in capsys.readouterr().err + with pdb.connect_closing() as conn: + assert pdb.list_projects(conn) == [] + + +def test_create_accepts_default_board(capsys, tmp_path): + """create --board default succeeds.""" + assert _run(["create", "My App", str(tmp_path), "--board", "default"]) == 0 + with pdb.connect_closing() as conn: + assert pdb.get_project(conn, "my-app").board_slug == "default" + +