diff --git a/docs/backend/manage-boards-spec.md b/docs/backend/manage-boards-spec.md new file mode 100644 index 0000000..d0f3451 --- /dev/null +++ b/docs/backend/manage-boards-spec.md @@ -0,0 +1,116 @@ +# Trello Plugin — Manage Trello Boards + +**Feature:** US: Manage Trello Boards +**Issue:** #2 +**Branch:** `feature/manage-boards` + +## Overview + +Extends the Trello plugin with board management capabilities: create, rename, close/archive, open, and view details of Trello boards. + +## Design Decisions + +- **Extends existing TrelloClient** — All board operations go through the same `TrelloClient` class from the auth feature. +- **Board selection by ID** — The Trello API identifies boards by ID. The tool accepts both ID and name (client resolves name to ID). +- **Resolves board name to ID** — When a user provides a board name instead of ID, the client fetches all boards and matches by name. + +## Tools + +### `trello_create_board` +Create a new Trello board. + +**Parameters:** +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | Yes | Board name | +| `default_lists` | bool | No | Whether to create the default lists (default: true) | + +**Returns:** +```json +{ + "success": true, + "board": {"id": "abc123", "name": "My Board", "url": "https://trello.com/b/abc123"} +} +``` + +### `trello_rename_board` + +Rename an existing board. + +**Parameters:** +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `board_id` | string | Yes | Board ID or name | +| `name` | string | Yes | New board name | + +**Returns:** +```json +{"success": true, "board": {"id": "abc123", "name": "New Name"}} +``` + +### `trello_archive_board` + +Close/archive a board. + +**Parameters:** +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `board_id` | string | Yes | Board ID or name | + +**Returns:** +```json +{"success": true, "message": "Board 'My Board' archived."} +``` + +### `trello_open_board` + +Re-open a closed/archived board. + +**Parameters:** +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `board_id` | string | Yes | Board ID or name | + +**Returns:** +```json +{"success": true, "message": "Board 'My Board' opened."} +``` + +### `trello_board_details` + +View details of a specific board, including its lists and members. + +**Parameters:** +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `board_id` | string | Yes | Board ID or name | + +**Returns:** +```json +{ + "success": true, + "board": { + "id": "abc123", + "name": "My Board", + "url": "https://trello.com/b/abc123", + "desc": "", + "closed": false, + "starred": false, + "lists": [{"id": "l1", "name": "To Do"}, {"id": "l2", "name": "In Progress"}], + "members": [{"id": "m1", "username": "user1", "full_name": "User One"}] + } +} +``` + +## Trello API Endpoints + +| Purpose | Method | Endpoint | +|---------|--------|----------| +| Create board | POST | `/1/boards/` | +| Update board | PUT | `/1/boards/{id}` | +| Get board details | GET | `/1/boards/{id}` (with lists and members fields) | + +## Error Handling + +- Board not found → clear message suggesting `trello_list_boards` to find the ID +- Validation errors → surfaced directly from Trello API \ No newline at end of file diff --git a/src/trello_plugin/__init__.py b/src/trello_plugin/__init__.py index 63ca571..db6a648 100644 --- a/src/trello_plugin/__init__.py +++ b/src/trello_plugin/__init__.py @@ -8,8 +8,13 @@ from trello_plugin.tools import ( PLUGIN_TOOLS, PLUGIN_VERSION, check_requirements, + trello_archive_board, + trello_board_details, + trello_create_board, trello_disconnect, trello_list_boards, + trello_open_board, + trello_rename_board, trello_verify_credentials, ) @@ -22,4 +27,9 @@ __all__ = [ "trello_verify_credentials", "trello_list_boards", "trello_disconnect", + "trello_create_board", + "trello_rename_board", + "trello_archive_board", + "trello_open_board", + "trello_board_details", ] \ No newline at end of file diff --git a/src/trello_plugin/tools.py b/src/trello_plugin/tools.py index ab3afe1..515486f 100644 --- a/src/trello_plugin/tools.py +++ b/src/trello_plugin/tools.py @@ -76,6 +76,105 @@ def trello_disconnect() -> str: return _respond({"success": True, "message": "Trello credentials cleared from memory. Next tool call will re-read TRELLO_API_KEY and TRELLO_TOKEN from environment and reconnect automatically."}) +# --------------------------------------------------------------------------- +# Tools — Board Management +# --------------------------------------------------------------------------- + + +def trello_create_board(name: str, default_lists: bool = True) -> str: # noqa: FBT001, FBT002 + """Create a new Trello board. + + Parameters + ---------- + name : str + The name for the new board. + default_lists : bool, optional + Whether to create default lists (default: True). + + Returns + ------- + str + JSON with board id, name, and URL on success. + """ + client = _get_client() + result = client.create_board(name=name, default_lists=default_lists) + return _respond(result) + + +def trello_rename_board(board_id: str, name: str) -> str: + """Rename an existing Trello board. + + Parameters + ---------- + board_id : str + Board ID or name. + name : str + The new name for the board. + + Returns + ------- + str + JSON with updated board details on success. + """ + client = _get_client() + result = client.rename_board(board_id=board_id, name=name) + return _respond(result) + + +def trello_archive_board(board_id: str) -> str: + """Close/archive a Trello board. + + Parameters + ---------- + board_id : str + Board ID or name. + + Returns + ------- + str + JSON with success message. + """ + client = _get_client() + result = client.archive_board(board_id=board_id) + return _respond(result) + + +def trello_open_board(board_id: str) -> str: + """Re-open a closed/archived Trello board. + + Parameters + ---------- + board_id : str + Board ID or name. + + Returns + ------- + str + JSON with success message. + """ + client = _get_client() + result = client.open_board(board_id=board_id) + return _respond(result) + + +def trello_board_details(board_id: str) -> str: + """View details of a Trello board, including its lists and members. + + Parameters + ---------- + board_id : str + Board ID or name. + + Returns + ------- + str + JSON with board details including lists and members. + """ + client = _get_client() + result = client.board_details(board_id=board_id) + return _respond(result) + + # --------------------------------------------------------------------------- # Plugin metadata (used by Hermes plugin loader) # --------------------------------------------------------------------------- @@ -87,6 +186,11 @@ PLUGIN_TOOLS = [ trello_verify_credentials, trello_list_boards, trello_disconnect, + trello_create_board, + trello_rename_board, + trello_archive_board, + trello_open_board, + trello_board_details, ] PLUGIN_REQUIRES_ENV = ["TRELLO_API_KEY", "TRELLO_TOKEN"] diff --git a/tests/test_auth.py b/tests/test_auth.py index b44767b..6f00b7d 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -325,5 +325,5 @@ class TestPluginMetadata: assert PLUGIN_NAME == "trello-plugin" assert isinstance(PLUGIN_DESCRIPTION, str) assert isinstance(PLUGIN_VERSION, str) - assert len(PLUGIN_TOOLS) == 3 + assert len(PLUGIN_TOOLS) == 8 assert all(callable(t) for t in PLUGIN_TOOLS) \ No newline at end of file diff --git a/tests/test_boards.py b/tests/test_boards.py new file mode 100644 index 0000000..d617e28 --- /dev/null +++ b/tests/test_boards.py @@ -0,0 +1,406 @@ +"""Tests for the Trello plugin — board management feature.""" + +from __future__ import annotations + +import json +import os +from typing import Any + +import pytest +import requests + +pytestmark = pytest.mark.usefixtures("clear_env") + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture(autouse=True) +def clear_env(monkeypatch: pytest.MonkeyPatch) -> Any: + """Remove Trello env vars before each test so state is predictable.""" + monkeypatch.delenv("TRELLO_API_KEY", raising=False) + monkeypatch.delenv("TRELLO_TOKEN", raising=False) + + +# --------------------------------------------------------------------------- +# TrelloClient — create_board +# --------------------------------------------------------------------------- + +class TestCreateBoard: + """Tests for TrelloClient.create_board().""" + + def test_success(self, requests_mock: Any) -> None: + """Happy path: creates a board and returns details.""" + from trello_plugin.client import TrelloClient + + requests_mock.post( + "https://api.trello.com/1/boards", + json={"id": "b1", "name": "New Board", "url": "https://trello.com/b/b1"}, + status_code=200, + ) + client = TrelloClient(api_key="key", token="tok") + result = client.create_board(name="New Board") + + assert result["success"] is True + assert result["board"]["name"] == "New Board" + assert result["board"]["id"] == "b1" + + def test_missing_credentials(self) -> None: + """Missing creds returns error before any network call.""" + from trello_plugin.client import TrelloClient + + client = TrelloClient(api_key="", token="") + result = client.create_board(name="Board") + + assert result["success"] is False + assert "TRELLO_API_KEY" in result["message"] + + +# --------------------------------------------------------------------------- +# TrelloClient — rename_board +# --------------------------------------------------------------------------- + +class TestRenameBoard: + """Tests for TrelloClient.rename_board().""" + + def test_success_by_id(self, requests_mock: Any) -> None: + """Renaming a board by ID works.""" + from trello_plugin.client import TrelloClient + + # Resolve board + requests_mock.get( + "https://api.trello.com/1/boards/b1", + json={"id": "b1", "name": "Old Name", "url": "https://trello.com/b/b1"}, + ) + # Rename + requests_mock.put( + "https://api.trello.com/1/boards/b1", + json={"id": "b1", "name": "New Name", "url": "https://trello.com/b/b1"}, + ) + + client = TrelloClient(api_key="key", token="tok") + result = client.rename_board(board_id="b1", name="New Name") + + assert result["success"] is True + assert result["board"]["name"] == "New Name" + + def test_board_not_found(self, requests_mock: Any) -> None: + """Non-existent board returns a clear error.""" + from trello_plugin.client import TrelloClient + + # Board lookup fails + requests_mock.get( + "https://api.trello.com/1/boards/nonexistent", + status_code=404, + ) + # List boards returns empty + requests_mock.get( + "https://api.trello.com/1/members/me/boards", + json=[], + ) + + client = TrelloClient(api_key="key", token="tok") + result = client.rename_board(board_id="nonexistent", name="New") + + assert result["success"] is False + assert "not found" in result["message"].lower() + + +# --------------------------------------------------------------------------- +# TrelloClient — archive_board +# --------------------------------------------------------------------------- + +class TestArchiveBoard: + """Tests for TrelloClient.archive_board().""" + + def test_success(self, requests_mock: Any) -> None: + """Archiving a board returns success message.""" + from trello_plugin.client import TrelloClient + + # Resolve + requests_mock.get( + "https://api.trello.com/1/boards/b1", + json={"id": "b1", "name": "My Board", "url": "https://trello.com/b/b1"}, + ) + # Archive + requests_mock.put( + "https://api.trello.com/1/boards/b1", + json={"id": "b1", "name": "My Board", "closed": True}, + ) + + client = TrelloClient(api_key="key", token="tok") + result = client.archive_board(board_id="b1") + + assert result["success"] is True + assert "archived" in result["message"].lower() + + +# --------------------------------------------------------------------------- +# TrelloClient — open_board +# --------------------------------------------------------------------------- + +class TestOpenBoard: + """Tests for TrelloClient.open_board().""" + + def test_success(self, requests_mock: Any) -> None: + """Opening a closed board returns success message.""" + from trello_plugin.client import TrelloClient + + # Resolve + requests_mock.get( + "https://api.trello.com/1/boards/b1", + json={"id": "b1", "name": "My Board", "url": "https://trello.com/b/b1"}, + ) + # Open + requests_mock.put( + "https://api.trello.com/1/boards/b1", + json={"id": "b1", "name": "My Board", "closed": False}, + ) + + client = TrelloClient(api_key="key", token="tok") + result = client.open_board(board_id="b1") + + assert result["success"] is True + assert "opened" in result["message"].lower() + + +# --------------------------------------------------------------------------- +# TrelloClient — board_details +# --------------------------------------------------------------------------- + +class TestBoardDetails: + """Tests for TrelloClient.board_details().""" + + def test_success(self, requests_mock: Any) -> None: + """Board details return lists and members.""" + from trello_plugin.client import TrelloClient + + # Resolve + requests_mock.get( + "https://api.trello.com/1/boards/b1", + json={"id": "b1", "name": "My Board", "url": "https://trello.com/b/b1"}, + ) + # Details fetch + requests_mock.get( + "https://api.trello.com/1/boards/b1", + json={ + "id": "b1", + "name": "My Board", + "url": "https://trello.com/b/b1", + "desc": "A test board", + "closed": False, + "starred": False, + "lists": [ + {"id": "l1", "name": "To Do"}, + {"id": "l2", "name": "Done"}, + ], + "members": [ + {"id": "m1", "username": "user1", "fullName": "User One"}, + ], + }, + ) + + client = TrelloClient(api_key="key", token="tok") + result = client.board_details(board_id="b1") + + assert result["success"] is True + assert len(result["board"]["lists"]) == 2 + assert len(result["board"]["members"]) == 1 + assert result["board"]["lists"][0]["name"] == "To Do" + + def test_not_found(self, requests_mock: Any) -> None: + """Non-existent board returns error.""" + from trello_plugin.client import TrelloClient + + requests_mock.get( + "https://api.trello.com/1/boards/nonexistent", + status_code=404, + ) + requests_mock.get( + "https://api.trello.com/1/members/me/boards", + json=[], + ) + + client = TrelloClient(api_key="key", token="tok") + result = client.board_details(board_id="nonexistent") + + assert result["success"] is False + assert "not found" in result["message"].lower() + + +# --------------------------------------------------------------------------- +# TrelloClient — _resolve_board_id +# --------------------------------------------------------------------------- + +class TestResolveBoardId: + """Tests for TrelloClient._resolve_board_id().""" + + def test_resolves_by_id(self, requests_mock: Any) -> None: + """Resolves a valid ID directly.""" + from trello_plugin.client import TrelloClient + + requests_mock.get( + "https://api.trello.com/1/boards/b1", + json={"id": "b1", "name": "My Board", "url": "https://trello.com/b/b1"}, + ) + + client = TrelloClient(api_key="key", token="tok") + result = client._resolve_board_id("b1") + assert result["id"] == "b1" + + def test_resolves_by_name(self, requests_mock: Any) -> None: + """Resolves a board name to its ID.""" + from trello_plugin.client import TrelloClient + + # ID lookup fails + requests_mock.get( + "https://api.trello.com/1/boards/My%20Board", + status_code=404, + ) + # List boards for name matching + requests_mock.get( + "https://api.trello.com/1/members/me/boards", + json=[ + {"id": "b1", "name": "My Board", "url": "https://trello.com/b/b1", "closed": False, "starred": False}, + {"id": "b2", "name": "Other Board", "url": "https://trello.com/b/b2", "closed": False, "starred": False}, + ], + ) + + client = TrelloClient(api_key="key", token="tok") + result = client._resolve_board_id("My Board") + assert result["id"] == "b1" + + def test_duplicate_name_error(self, requests_mock: Any) -> None: + """Multiple boards with same name returns error.""" + from trello_plugin.client import TrelloClient + + requests_mock.get( + "https://api.trello.com/1/boards/Duplicate", + status_code=404, + ) + requests_mock.get( + "https://api.trello.com/1/members/me/boards", + json=[ + {"id": "b1", "name": "Duplicate", "url": "", "closed": False, "starred": False}, + {"id": "b2", "name": "Duplicate", "url": "", "closed": False, "starred": False}, + ], + ) + + client = TrelloClient(api_key="key", token="tok") + result = client._resolve_board_id("Duplicate") + assert "success" in result and result["success"] is False + assert "multiple" in result["message"].lower() + + +# --------------------------------------------------------------------------- +# Tool functions +# --------------------------------------------------------------------------- + +class TestBoardToolFunctions: + """Tests for the Hermes board management tool wrappers.""" + + def test_trello_create_board_tool(self, requests_mock: Any) -> None: + """Create board tool returns valid JSON.""" + from trello_plugin.tools import trello_create_board + + os.environ["TRELLO_API_KEY"] = "key" + os.environ["TRELLO_TOKEN"] = "tok" + + requests_mock.post( + "https://api.trello.com/1/boards", + json={"id": "b1", "name": "New Board", "url": "https://trello.com/b/b1"}, + ) + + result = json.loads(trello_create_board(name="New Board")) + assert result["success"] is True + assert result["board"]["name"] == "New Board" + + def test_trello_rename_board_tool(self, requests_mock: Any) -> None: + """Rename board tool returns valid JSON.""" + from trello_plugin.tools import trello_rename_board + + os.environ["TRELLO_API_KEY"] = "key" + os.environ["TRELLO_TOKEN"] = "tok" + + requests_mock.get( + "https://api.trello.com/1/boards/b1", + json={"id": "b1", "name": "Old", "url": ""}, + ) + requests_mock.put( + "https://api.trello.com/1/boards/b1", + json={"id": "b1", "name": "Renamed", "url": ""}, + ) + + result = json.loads(trello_rename_board(board_id="b1", name="Renamed")) + assert result["success"] is True + assert result["board"]["name"] == "Renamed" + + def test_trello_archive_board_tool(self, requests_mock: Any) -> None: + """Archive board tool returns valid JSON.""" + from trello_plugin.tools import trello_archive_board + + os.environ["TRELLO_API_KEY"] = "key" + os.environ["TRELLO_TOKEN"] = "tok" + + requests_mock.get( + "https://api.trello.com/1/boards/b1", + json={"id": "b1", "name": "Test Board", "url": ""}, + ) + requests_mock.put( + "https://api.trello.com/1/boards/b1", + json={"id": "b1", "name": "Test Board", "closed": True}, + ) + + result = json.loads(trello_archive_board(board_id="b1")) + assert result["success"] is True + assert "archived" in result["message"].lower() + + def test_trello_open_board_tool(self, requests_mock: Any) -> None: + """Open board tool returns valid JSON.""" + from trello_plugin.tools import trello_open_board + + os.environ["TRELLO_API_KEY"] = "key" + os.environ["TRELLO_TOKEN"] = "tok" + + requests_mock.get( + "https://api.trello.com/1/boards/b1", + json={"id": "b1", "name": "Test Board", "url": ""}, + ) + requests_mock.put( + "https://api.trello.com/1/boards/b1", + json={"id": "b1", "name": "Test Board", "closed": False}, + ) + + result = json.loads(trello_open_board(board_id="b1")) + assert result["success"] is True + assert "opened" in result["message"].lower() + + def test_trello_board_details_tool(self, requests_mock: Any) -> None: + """Board details tool returns valid JSON.""" + from trello_plugin.tools import trello_board_details + + os.environ["TRELLO_API_KEY"] = "key" + os.environ["TRELLO_TOKEN"] = "tok" + + requests_mock.get( + "https://api.trello.com/1/boards/b1", + json={"id": "b1", "name": "Test", "url": ""}, + ) + requests_mock.get( + "https://api.trello.com/1/boards/b1", + json={ + "id": "b1", + "name": "Test", + "url": "", + "desc": "", + "closed": False, + "starred": False, + "lists": [{"id": "l1", "name": "To Do"}], + "members": [], + }, + ) + + result = json.loads(trello_board_details(board_id="b1")) + assert result["success"] is True + assert len(result["board"]["lists"]) == 1 \ No newline at end of file