mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-07 02:51:50 +00:00
feat(kanban): multi-project boards — one install, many kanbans (#19653)
Adds first-class board support to kanban so users can separate unrelated
streams of work (projects, repos, domains) into isolated queues. Single-
project users stay on the 'default' board and see no UI change.
Isolation model
---------------
- Each board is a directory at `~/.hermes/kanban/boards/<slug>/` with
its own `kanban.db`, `workspaces/`, and `logs/`. The 'default' board
keeps its legacy path (`~/.hermes/kanban.db`) for back-compat — fresh
installs and pre-boards users get zero migration.
- Workers spawned by the dispatcher have `HERMES_KANBAN_BOARD` pinned in
their env alongside the existing `HERMES_KANBAN_DB` /
`HERMES_KANBAN_WORKSPACES_ROOT` pins, so workers physically cannot see
other boards' tasks.
- The gateway's single dispatcher loop now sweeps every board per tick;
per-tick cost is a few extra filesystem stats.
- CAS concurrency guarantees are preserved per-board (each board is its
own SQLite DB, same WAL+IMMEDIATE machinery as before).
CLI
---
hermes kanban boards list|create|switch|show|rename|rm
hermes kanban --board <slug> <any-subcommand>
Board resolution order: `--board` flag → `HERMES_KANBAN_BOARD` env →
`~/.hermes/kanban/current` file → `default`. Slug validation is strict:
lowercase alphanumerics + hyphens + underscores, 1-64 chars, starts with
alphanumeric. Uppercase is auto-downcased; slashes / dots / `..` /
control chars are rejected so boards can't name their way out of the
boards/ directory.
Passive discoverability: when more than one board exists, `hermes kanban
list` prints a one-line header ("Board: foo (2 other boards …)") so
users who stumble across multi-project never have to hunt for the
feature. Invisible for single-board installs.
Dashboard
---------
- New `BoardSwitcher` component at the top of the Kanban tab: dropdown
with all boards + task counts, `+ New board` button, `Archive`
button (non-default only). Hidden entirely when only `default` exists
and is empty — single-project users never see it.
- New `NewBoardDialog` modal: slug / display name / description / icon
+ "switch to this board after creating" checkbox.
- Selected board persists to `localStorage` so browser users don't
shift the CLI's active board out from under a terminal they left open.
- New `?board=<slug>` query param on every existing endpoint plus a
new `/boards` CRUD surface (`GET /boards`, `POST /boards`,
`PATCH /boards/<slug>`, `DELETE /boards/<slug>`,
`POST /boards/<slug>/switch`).
- Events WebSocket is pinned to a board at connection time; switching
opens a fresh WS against the new board.
Also fixes a pre-existing bug in the plugin's tenant / assignee
filters: the SDK's `Select` uses `onValueChange(value)`, not
native `onChange(event)`, so those filters silently didn't work.
New `selectChangeHandler` helper wires both signatures.
Tests
-----
49 new tests in `tests/hermes_cli/test_kanban_boards.py` covering:
slug validation (valid / invalid / auto-downcase), path resolution
(default = legacy path, named = `boards/<slug>/`, env var override),
current-board resolution chain (env > file > default), board CRUD +
archive / hard-delete, per-board connection isolation (tasks don't
leak), worker spawn env injection (`HERMES_KANBAN_BOARD`,
`HERMES_KANBAN_DB`, `HERMES_KANBAN_WORKSPACES_ROOT` all point at the
right board), and end-to-end CLI surface.
Regression surface: all 264 pre-existing kanban tests continue to pass.
Live-tested via the dashboard: created 3 boards (default,
hermes-agent, atm10-server), created tasks on each via both CLI
(`--board <slug> create`) and dashboard (inline create on the Ready
column), confirmed zero cross-board leakage, confirmed `BoardSwitcher`
+ `NewBoardDialog` work end-to-end in the browser.
This commit is contained in:
parent
135b4c8b35
commit
5ec6baa400
8 changed files with 2191 additions and 212 deletions
483
tests/hermes_cli/test_kanban_boards.py
Normal file
483
tests/hermes_cli/test_kanban_boards.py
Normal file
|
|
@ -0,0 +1,483 @@
|
|||
"""Tests for the multi-board kanban layer (``hermes kanban boards …``).
|
||||
|
||||
Covers the pieces added when boards became a first-class concept:
|
||||
|
||||
* Slug validation and normalisation.
|
||||
* Path resolution for ``default`` (legacy ``<root>/kanban.db``) vs
|
||||
named boards (``<root>/kanban/boards/<slug>/kanban.db``).
|
||||
* Current-board persistence via ``<root>/kanban/current`` and
|
||||
``HERMES_KANBAN_BOARD`` env var.
|
||||
* ``connect(board=)`` isolation — writes on one board don't leak.
|
||||
* ``create_board`` / ``list_boards`` / ``remove_board`` round trip.
|
||||
* CLI surface: ``hermes kanban boards list/create/switch/rm``.
|
||||
* ``_default_spawn`` injects ``HERMES_KANBAN_BOARD`` into worker env.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Ensure the worktree (not the stale global clone) is first on sys.path.
|
||||
_WORKTREE = Path(__file__).resolve().parents[2]
|
||||
if str(_WORKTREE) not in sys.path:
|
||||
sys.path.insert(0, str(_WORKTREE))
|
||||
|
||||
from hermes_cli import kanban_db as kb
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixture
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_home(tmp_path, monkeypatch):
|
||||
"""Isolated HERMES_HOME with no prior kanban state.
|
||||
|
||||
The autouse hermetic conftest already nukes credentials + TZ; this
|
||||
fixture layers a per-test HERMES_HOME plus a path-init cache reset
|
||||
so each test sees a truly empty board set.
|
||||
"""
|
||||
home = tmp_path / "hermes_home"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
for var in (
|
||||
"HERMES_KANBAN_DB",
|
||||
"HERMES_KANBAN_WORKSPACES_ROOT",
|
||||
"HERMES_KANBAN_HOME",
|
||||
"HERMES_KANBAN_BOARD",
|
||||
):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
# Also reset hermes_constants cache so get_default_hermes_root() re-reads.
|
||||
try:
|
||||
import hermes_constants
|
||||
hermes_constants._cached_default_hermes_root = None # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
# Kanban module-level init cache must not leak between tests.
|
||||
kb._INITIALIZED_PATHS.clear()
|
||||
return home
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Slug validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSlugValidation:
|
||||
@pytest.mark.parametrize("good", [
|
||||
"default", "atm10-server", "hermes-agent", "proj_1", "a",
|
||||
"very-long-but-still-ok-slug-with-hyphens-and-numbers-1234",
|
||||
])
|
||||
def test_accepts_valid(self, good):
|
||||
assert kb._normalize_board_slug(good) == good
|
||||
|
||||
@pytest.mark.parametrize("bad", [
|
||||
"-leading-hyphen", "_leading_underscore",
|
||||
"with/slash", "with space",
|
||||
"has.dot", "has?question",
|
||||
"..", "../etc", "foo\x00bar",
|
||||
])
|
||||
def test_rejects_invalid(self, bad):
|
||||
with pytest.raises(ValueError):
|
||||
kb._normalize_board_slug(bad)
|
||||
|
||||
def test_empty_returns_none(self):
|
||||
assert kb._normalize_board_slug(None) is None
|
||||
assert kb._normalize_board_slug("") is None
|
||||
assert kb._normalize_board_slug(" ") is None
|
||||
|
||||
def test_auto_lowercases(self):
|
||||
# Uppercase is auto-downcased (friendlier than rejecting). ``Default``
|
||||
# → ``default``, ``ATM10`` → ``atm10``. The on-disk slug is always
|
||||
# lowercase regardless of what the user typed.
|
||||
assert kb._normalize_board_slug("Default") == "default"
|
||||
assert kb._normalize_board_slug("ATM10-Server") == "atm10-server"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Path resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPathResolution:
|
||||
def test_default_board_legacy_path(self, fresh_home):
|
||||
"""The default board's DB lives at ``<root>/kanban.db`` for back-compat."""
|
||||
assert kb.kanban_db_path() == fresh_home / "kanban.db"
|
||||
assert kb.kanban_db_path(board="default") == fresh_home / "kanban.db"
|
||||
|
||||
def test_named_board_under_boards_dir(self, fresh_home):
|
||||
p = kb.kanban_db_path(board="atm10-server")
|
||||
assert p == fresh_home / "kanban" / "boards" / "atm10-server" / "kanban.db"
|
||||
|
||||
def test_workspaces_per_board(self, fresh_home):
|
||||
assert kb.workspaces_root() == fresh_home / "kanban" / "workspaces"
|
||||
# Uppercase input gets auto-downcased to the on-disk slug.
|
||||
assert kb.workspaces_root(board="projA") == (
|
||||
fresh_home / "kanban" / "boards" / "proja" / "workspaces"
|
||||
)
|
||||
|
||||
def test_logs_per_board(self, fresh_home):
|
||||
assert kb.worker_logs_dir() == fresh_home / "kanban" / "logs"
|
||||
assert kb.worker_logs_dir(board="other") == (
|
||||
fresh_home / "kanban" / "boards" / "other" / "logs"
|
||||
)
|
||||
|
||||
def test_env_var_db_override_still_wins(self, fresh_home, tmp_path, monkeypatch):
|
||||
"""``HERMES_KANBAN_DB`` pins the file regardless of board= arg."""
|
||||
forced = tmp_path / "custom.db"
|
||||
monkeypatch.setenv("HERMES_KANBAN_DB", str(forced))
|
||||
assert kb.kanban_db_path() == forced
|
||||
assert kb.kanban_db_path(board="ignored") == forced
|
||||
|
||||
def test_env_var_workspaces_override(self, fresh_home, tmp_path, monkeypatch):
|
||||
forced = tmp_path / "ws"
|
||||
monkeypatch.setenv("HERMES_KANBAN_WORKSPACES_ROOT", str(forced))
|
||||
assert kb.workspaces_root(board="any") == forced
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Current-board resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCurrentBoard:
|
||||
def test_default_when_unset(self, fresh_home):
|
||||
assert kb.get_current_board() == "default"
|
||||
|
||||
def test_env_var_takes_precedence(self, fresh_home, monkeypatch):
|
||||
# Create the board so the env-var value is honoured (get_current_board
|
||||
# trusts env-var validity, but the resolution chain doesn't require
|
||||
# the board to exist; we just test that env trumps).
|
||||
kb.create_board("envboard")
|
||||
monkeypatch.setenv("HERMES_KANBAN_BOARD", "envboard")
|
||||
assert kb.get_current_board() == "envboard"
|
||||
|
||||
def test_file_pointer_honoured(self, fresh_home):
|
||||
kb.create_board("filepick")
|
||||
kb.set_current_board("filepick")
|
||||
assert kb.get_current_board() == "filepick"
|
||||
|
||||
def test_env_beats_file(self, fresh_home, monkeypatch):
|
||||
kb.create_board("a")
|
||||
kb.create_board("b")
|
||||
kb.set_current_board("a")
|
||||
monkeypatch.setenv("HERMES_KANBAN_BOARD", "b")
|
||||
assert kb.get_current_board() == "b"
|
||||
|
||||
def test_invalid_env_falls_through(self, fresh_home, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_KANBAN_BOARD", "!!bad!!")
|
||||
# Should not crash — falls through to default.
|
||||
assert kb.get_current_board() == "default"
|
||||
|
||||
def test_clear_current_board(self, fresh_home):
|
||||
kb.create_board("x")
|
||||
kb.set_current_board("x")
|
||||
kb.clear_current_board()
|
||||
assert kb.get_current_board() == "default"
|
||||
|
||||
def test_kanban_db_path_reads_current(self, fresh_home):
|
||||
"""kanban_db_path() with no args respects the on-disk pointer."""
|
||||
kb.create_board("my-proj")
|
||||
kb.set_current_board("my-proj")
|
||||
expected = fresh_home / "kanban" / "boards" / "my-proj" / "kanban.db"
|
||||
assert kb.kanban_db_path() == expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Board CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBoardCRUD:
|
||||
def test_create_and_list(self, fresh_home):
|
||||
assert [b["slug"] for b in kb.list_boards()] == ["default"]
|
||||
kb.create_board("foo", name="Foo Board", description="test")
|
||||
slugs = [b["slug"] for b in kb.list_boards()]
|
||||
assert slugs == ["default", "foo"]
|
||||
|
||||
def test_create_is_idempotent(self, fresh_home):
|
||||
kb.create_board("bar")
|
||||
kb.create_board("bar") # no error
|
||||
slugs = [b["slug"] for b in kb.list_boards()]
|
||||
assert slugs == ["default", "bar"]
|
||||
|
||||
def test_create_writes_metadata(self, fresh_home):
|
||||
meta = kb.create_board(
|
||||
"baz",
|
||||
name="Baz",
|
||||
description="desc",
|
||||
icon="📦",
|
||||
color="#abcdef",
|
||||
)
|
||||
assert meta["slug"] == "baz"
|
||||
assert meta["name"] == "Baz"
|
||||
assert meta["icon"] == "📦"
|
||||
# Round-trip via read_board_metadata.
|
||||
again = kb.read_board_metadata("baz")
|
||||
assert again["name"] == "Baz"
|
||||
assert again["description"] == "desc"
|
||||
assert again["icon"] == "📦"
|
||||
|
||||
def test_remove_archive(self, fresh_home):
|
||||
kb.create_board("toremove")
|
||||
res = kb.remove_board("toremove")
|
||||
assert res["action"] == "archived"
|
||||
assert Path(res["new_path"]).exists()
|
||||
assert "toremove" not in [b["slug"] for b in kb.list_boards()]
|
||||
|
||||
def test_remove_hard_delete(self, fresh_home):
|
||||
kb.create_board("nuke")
|
||||
d = kb.board_dir("nuke")
|
||||
assert d.exists()
|
||||
res = kb.remove_board("nuke", archive=False)
|
||||
assert res["action"] == "deleted"
|
||||
assert not d.exists()
|
||||
|
||||
def test_remove_default_forbidden(self, fresh_home):
|
||||
with pytest.raises(ValueError, match="default"):
|
||||
kb.remove_board("default")
|
||||
|
||||
def test_remove_nonexistent_raises(self, fresh_home):
|
||||
with pytest.raises(ValueError, match="does not exist"):
|
||||
kb.remove_board("nosuch")
|
||||
|
||||
def test_remove_clears_current_pointer(self, fresh_home):
|
||||
kb.create_board("pinned")
|
||||
kb.set_current_board("pinned")
|
||||
kb.remove_board("pinned")
|
||||
assert kb.get_current_board() == "default"
|
||||
|
||||
def test_rename_updates_metadata(self, fresh_home):
|
||||
kb.create_board("slug-immutable")
|
||||
kb.write_board_metadata("slug-immutable", name="New Display Name")
|
||||
assert kb.read_board_metadata("slug-immutable")["name"] == "New Display Name"
|
||||
# Slug must not change.
|
||||
assert kb.board_exists("slug-immutable")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Connection isolation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestConnectionIsolation:
|
||||
def test_tasks_do_not_leak_across_boards(self, fresh_home):
|
||||
kb.create_board("alpha")
|
||||
kb.create_board("beta")
|
||||
|
||||
with kb.connect(board="alpha") as conn:
|
||||
kb.create_task(conn, title="alpha-task-1", assignee="dev")
|
||||
kb.create_task(conn, title="alpha-task-2", assignee="dev")
|
||||
|
||||
with kb.connect(board="beta") as conn:
|
||||
kb.create_task(conn, title="beta-only", assignee="dev")
|
||||
|
||||
with kb.connect(board="alpha") as conn:
|
||||
a = kb.list_tasks(conn)
|
||||
with kb.connect(board="beta") as conn:
|
||||
b = kb.list_tasks(conn)
|
||||
with kb.connect(board="default") as conn:
|
||||
d = kb.list_tasks(conn)
|
||||
|
||||
assert {t.title for t in a} == {"alpha-task-1", "alpha-task-2"}
|
||||
assert {t.title for t in b} == {"beta-only"}
|
||||
assert d == []
|
||||
|
||||
def test_connect_without_args_uses_current(self, fresh_home):
|
||||
kb.create_board("curr")
|
||||
kb.set_current_board("curr")
|
||||
with kb.connect() as conn:
|
||||
kb.create_task(conn, title="implicit", assignee="x")
|
||||
with kb.connect(board="curr") as conn:
|
||||
tasks = kb.list_tasks(conn)
|
||||
assert [t.title for t in tasks] == ["implicit"]
|
||||
|
||||
def test_connect_env_var_overrides_current(self, fresh_home, monkeypatch):
|
||||
kb.create_board("persist")
|
||||
kb.create_board("envwin")
|
||||
kb.set_current_board("persist")
|
||||
monkeypatch.setenv("HERMES_KANBAN_BOARD", "envwin")
|
||||
with kb.connect() as conn:
|
||||
kb.create_task(conn, title="via-env", assignee="x")
|
||||
with kb.connect(board="envwin") as conn:
|
||||
assert [t.title for t in kb.list_tasks(conn)] == ["via-env"]
|
||||
with kb.connect(board="persist") as conn:
|
||||
assert kb.list_tasks(conn) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Worker spawn env injection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestWorkerSpawnEnv:
|
||||
"""Ensure the dispatcher pins ``HERMES_KANBAN_BOARD`` / DB / workspaces on spawn.
|
||||
|
||||
We monkey-patch ``subprocess.Popen`` to capture the child env without
|
||||
actually spawning anything.
|
||||
"""
|
||||
|
||||
def test_default_spawn_sets_env_vars(self, fresh_home, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
class FakeProc:
|
||||
pid = 12345
|
||||
|
||||
def fake_popen(cmd, *args, **kwargs):
|
||||
captured["cmd"] = cmd
|
||||
captured["env"] = kwargs.get("env", {})
|
||||
return FakeProc()
|
||||
|
||||
monkeypatch.setattr(subprocess, "Popen", fake_popen)
|
||||
kb.create_board("spawntest")
|
||||
|
||||
task = kb.Task(
|
||||
id="t_abc",
|
||||
title="worker test",
|
||||
body=None,
|
||||
assignee="teknium",
|
||||
status="ready",
|
||||
priority=0,
|
||||
created_by="user",
|
||||
created_at=0,
|
||||
started_at=None,
|
||||
completed_at=None,
|
||||
workspace_kind="scratch",
|
||||
workspace_path=None,
|
||||
claim_lock=None,
|
||||
claim_expires=None,
|
||||
tenant=None,
|
||||
)
|
||||
|
||||
kb._default_spawn(task, str(fresh_home / "ws"), board="spawntest")
|
||||
|
||||
env = captured["env"]
|
||||
assert env["HERMES_KANBAN_BOARD"] == "spawntest"
|
||||
assert env["HERMES_KANBAN_TASK"] == "t_abc"
|
||||
# DB path should match the per-board DB, not the legacy default.
|
||||
expected_db = fresh_home / "kanban" / "boards" / "spawntest" / "kanban.db"
|
||||
assert env["HERMES_KANBAN_DB"] == str(expected_db)
|
||||
expected_ws = fresh_home / "kanban" / "boards" / "spawntest" / "workspaces"
|
||||
assert env["HERMES_KANBAN_WORKSPACES_ROOT"] == str(expected_ws)
|
||||
|
||||
def test_default_board_spawn_keeps_legacy_paths(self, fresh_home, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
class FakeProc:
|
||||
pid = 1
|
||||
|
||||
def fake_popen(cmd, *args, **kwargs):
|
||||
captured["env"] = kwargs.get("env", {})
|
||||
return FakeProc()
|
||||
|
||||
monkeypatch.setattr(subprocess, "Popen", fake_popen)
|
||||
task = kb.Task(
|
||||
id="t_def",
|
||||
title="",
|
||||
body=None,
|
||||
assignee="teknium",
|
||||
status="ready",
|
||||
priority=0,
|
||||
created_by=None,
|
||||
created_at=0,
|
||||
started_at=None,
|
||||
completed_at=None,
|
||||
workspace_kind="scratch",
|
||||
workspace_path=None,
|
||||
claim_lock=None,
|
||||
claim_expires=None,
|
||||
tenant=None,
|
||||
)
|
||||
kb._default_spawn(task, str(fresh_home / "ws"), board=None)
|
||||
env = captured["env"]
|
||||
assert env["HERMES_KANBAN_BOARD"] == "default"
|
||||
assert env["HERMES_KANBAN_DB"] == str(fresh_home / "kanban.db")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI surface
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _cli(args: list[str], env_extra: dict | None = None) -> subprocess.CompletedProcess:
|
||||
"""Run ``hermes kanban …`` with PYTHONPATH pinned to the worktree."""
|
||||
env = dict(os.environ)
|
||||
env["PYTHONPATH"] = str(_WORKTREE)
|
||||
if env_extra:
|
||||
env.update(env_extra)
|
||||
return subprocess.run(
|
||||
[sys.executable, "-m", "hermes_cli.main", "kanban"] + args,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(_WORKTREE),
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
|
||||
class TestCLI:
|
||||
def test_boards_list_default_only(self, tmp_path):
|
||||
env = {"HERMES_HOME": str(tmp_path)}
|
||||
res = _cli(["boards", "list", "--json"], env_extra=env)
|
||||
assert res.returncode == 0, res.stderr
|
||||
data = json.loads(res.stdout)
|
||||
slugs = [b["slug"] for b in data]
|
||||
assert slugs == ["default"]
|
||||
assert data[0]["is_current"] is True
|
||||
|
||||
def test_boards_create_and_switch(self, tmp_path):
|
||||
env = {"HERMES_HOME": str(tmp_path)}
|
||||
r1 = _cli(
|
||||
["boards", "create", "myproj", "--name", "My Project", "--switch"],
|
||||
env_extra=env,
|
||||
)
|
||||
assert r1.returncode == 0, r1.stderr
|
||||
assert "created" in r1.stdout
|
||||
assert "Switched" in r1.stdout
|
||||
|
||||
r2 = _cli(["boards", "list", "--json"], env_extra=env)
|
||||
data = json.loads(r2.stdout)
|
||||
cur = [b for b in data if b["is_current"]][0]
|
||||
assert cur["slug"] == "myproj"
|
||||
|
||||
def test_per_board_task_isolation_via_cli(self, tmp_path):
|
||||
env = {"HERMES_HOME": str(tmp_path)}
|
||||
assert _cli(["boards", "create", "projA"], env_extra=env).returncode == 0
|
||||
assert _cli(["boards", "create", "projB"], env_extra=env).returncode == 0
|
||||
|
||||
# Create one task on each via --board.
|
||||
r = _cli(["--board", "projA", "create", "Task A", "--assignee", "dev"], env_extra=env)
|
||||
assert r.returncode == 0, r.stderr
|
||||
r = _cli(["--board", "projB", "create", "Task B", "--assignee", "dev"], env_extra=env)
|
||||
assert r.returncode == 0, r.stderr
|
||||
|
||||
# list on each board only shows its own.
|
||||
listA = _cli(["--board", "projA", "list", "--json"], env_extra=env)
|
||||
listB = _cli(["--board", "projB", "list", "--json"], env_extra=env)
|
||||
listD = _cli(["list", "--json"], env_extra=env)
|
||||
|
||||
titlesA = [t["title"] for t in json.loads(listA.stdout)]
|
||||
titlesB = [t["title"] for t in json.loads(listB.stdout)]
|
||||
titlesD = [t["title"] for t in json.loads(listD.stdout)]
|
||||
|
||||
assert titlesA == ["Task A"]
|
||||
assert titlesB == ["Task B"]
|
||||
assert titlesD == []
|
||||
|
||||
def test_board_flag_rejects_unknown(self, tmp_path):
|
||||
env = {"HERMES_HOME": str(tmp_path)}
|
||||
r = _cli(["--board", "ghost", "list"], env_extra=env)
|
||||
# main.py's dispatcher doesn't propagate return codes today, so we
|
||||
# assert the user-visible signal: a stderr error message. Whether
|
||||
# the exit code stays 0 is a separate (pre-existing) issue.
|
||||
assert "does not exist" in r.stderr
|
||||
|
||||
def test_boards_rm_archives(self, tmp_path):
|
||||
env = {"HERMES_HOME": str(tmp_path)}
|
||||
_cli(["boards", "create", "rmme"], env_extra=env)
|
||||
r = _cli(["boards", "rm", "rmme"], env_extra=env)
|
||||
assert r.returncode == 0, r.stderr
|
||||
assert "archived" in r.stdout
|
||||
# Default board list no longer shows it.
|
||||
res = _cli(["boards", "list", "--json"], env_extra=env)
|
||||
slugs = [b["slug"] for b in json.loads(res.stdout)]
|
||||
assert "rmme" not in slugs
|
||||
Loading…
Add table
Add a link
Reference in a new issue