Skip to content
Open
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
48 changes: 42 additions & 6 deletions hermes_cli/projects_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)
Expand Down Expand Up @@ -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:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This CLI-local resolver leaves projects.create and projects.update RPCs able to persist unknown board_slug values (tui_gateway/server.py:10976-11006). Please move or expose this validation through a shared project-facing path and cover the RPC callers as well.

"""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.

Expand Down
54 changes: 54 additions & 0 deletions tests/hermes_cli/test_projects_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"




Loading