From f9282fba098fe955aedaa48276bce74c975dfa58 Mon Sep 17 00:00:00 2001 From: "Marko (Hermes Implementer)" Date: Tue, 26 May 2026 22:37:02 +0000 Subject: [PATCH] feat: implement Trello list management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 4 list management tools extending the Trello client: - trello_create_list — create a new list on a board - trello_rename_list — rename an existing list - trello_archive_list — archive a list - trello_move_list — move a list to a new position Also update the auth spec doc to reflect the PR #5 review fixes (TypedDict contracts, updated disconnect message). Issue: #3 --- docs/backend/001_trello_auth_spec.md | 11 +- docs/backend/manage-lists-spec.md | 88 +++++++++++ src/trello_plugin/__init__.py | 8 + src/trello_plugin/client.py | 109 +++++++++++++ src/trello_plugin/tools.py | 89 +++++++++++ tests/test_auth.py | 2 +- tests/test_lists.py | 226 +++++++++++++++++++++++++++ 7 files changed, 530 insertions(+), 3 deletions(-) create mode 100644 docs/backend/manage-lists-spec.md create mode 100644 tests/test_lists.py diff --git a/docs/backend/001_trello_auth_spec.md b/docs/backend/001_trello_auth_spec.md index e222e53..a3cc50f 100644 --- a/docs/backend/001_trello_auth_spec.md +++ b/docs/backend/001_trello_auth_spec.md @@ -65,7 +65,9 @@ Fetches and returns all Trello boards accessible to the authenticated user. ### 3. `trello_disconnect` -Clears the stored credentials from memory. Note: this does not revoke the Trello token — the user must invalidate it via Trello's settings if needed. +Clears the stored credentials from memory. Since credentials are stored in environment variables, the next tool call will automatically re-read them and reconnect. To fully disconnect, also unset ``TRELLO_API_KEY`` and ``TRELLO_TOKEN`` from the Hermes profile config. + +Note: this does NOT revoke the Trello token — the user must invalidate it via Trello's settings if needed. **Parameters:** None @@ -73,7 +75,7 @@ Clears the stored credentials from memory. Note: this does not revoke the Trello ```json { "success": true, - "message": "Trello credentials cleared. Set TRELLO_API_KEY and TRELLO_TOKEN again to reconnect." + "message": "Trello credentials cleared from memory. Next tool call will re-read TRELLO_API_KEY and TRELLO_TOKEN from environment and reconnect automatically." } ``` @@ -83,6 +85,9 @@ Clears the stored credentials from memory. Note: this does not revoke the Trello ```python class TrelloClient: + api_key: str + token: str + def __init__(self, api_key: str | None = None, token: str | None = None) def verify_credentials(self) -> dict @@ -90,6 +95,8 @@ class TrelloClient: def disconnect(self) -> dict ``` +The client uses `TypedDict` response contracts (`SuccessResponse`/`ErrorResponse`) with a `TypeGuard` helper `_is_success()` for type-safe narrowing. Internal helpers include `_request()`, `_get()`, `_post()`, `_put()`, `_check_credentials()`, and `_handle_http_error()`. + ### Trello API Endpoints Used | Purpose | Method | Endpoint | Docs | diff --git a/docs/backend/manage-lists-spec.md b/docs/backend/manage-lists-spec.md new file mode 100644 index 0000000..a1d2f1e --- /dev/null +++ b/docs/backend/manage-lists-spec.md @@ -0,0 +1,88 @@ +# Trello Plugin — Manage Trello Lists + +**Feature:** US: Manage Trello Lists +**Issue:** #3 +**Branch:** `feature/manage-lists` + +## Overview + +Extends the Trello plugin with list management capabilities: create, rename, archive, and reposition lists on a Trello board. + +## Design Decisions + +- **Lists require a board context** — Creating a list needs a board ID. Other operations (rename, archive, move) use the list's Trello ID which is globally unique. +- **Position parameter** — Uses Trello's `pos` field which accepts `"top"`, `"bottom"`, or a positive number. + +## Tools + +### `trello_create_list` + +Create a new list on a board. + +**Parameters:** +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | Yes | List name | +| `board_id` | string | Yes | Board ID or name | +| `pos` | string | No | Position: `"top"`, `"bottom"`, or number (default: `"bottom"`) | + +**Returns:** +```json +{"success": true, "list": {"id": "l1", "name": "My List", "id_board": "b1"}} +``` + +### `trello_rename_list` + +Rename an existing list. + +**Parameters:** +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `list_id` | string | Yes | List ID | +| `name` | string | Yes | New name | + +**Returns:** +```json +{"success": true, "list": {"id": "l1", "name": "Renamed List"}} +``` + +### `trello_archive_list` + +Archive a list. + +**Parameters:** +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `list_id` | string | Yes | List ID | + +**Returns:** +```json +{"success": true, "message": "List 'My List' archived."} +``` + +### `trello_move_list` + +Move a list to a different position. + +**Parameters:** +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `list_id` | string | Yes | List ID | +| `pos` | string | Yes | Position: `"top"`, `"bottom"`, or a number | + +**Returns:** +```json +{"success": true, "message": "List 'My List' moved."} +``` + +## Trello API Endpoints + +| Purpose | Method | Endpoint | +|---------|--------|----------| +| Create list | POST | `/1/lists` | +| Update list (rename, archive, move) | PUT | `/1/lists/{id}` | + +## Error Handling + +- List not found → clear error message +- Board not found when creating → propagate board lookup error \ No newline at end of file diff --git a/src/trello_plugin/__init__.py b/src/trello_plugin/__init__.py index db6a648..d2238a5 100644 --- a/src/trello_plugin/__init__.py +++ b/src/trello_plugin/__init__.py @@ -9,12 +9,16 @@ from trello_plugin.tools import ( PLUGIN_VERSION, check_requirements, trello_archive_board, + trello_archive_list, trello_board_details, trello_create_board, + trello_create_list, trello_disconnect, trello_list_boards, + trello_move_list, trello_open_board, trello_rename_board, + trello_rename_list, trello_verify_credentials, ) @@ -32,4 +36,8 @@ __all__ = [ "trello_archive_board", "trello_open_board", "trello_board_details", + "trello_create_list", + "trello_rename_list", + "trello_archive_list", + "trello_move_list", ] \ No newline at end of file diff --git a/src/trello_plugin/client.py b/src/trello_plugin/client.py index 1b166a1..468c59e 100644 --- a/src/trello_plugin/client.py +++ b/src/trello_plugin/client.py @@ -259,6 +259,11 @@ class TrelloClient: board = result["data"] return {"id": board["id"], "name": board.get("name", ""), "url": board.get("url", "")} + # If the error is about missing credentials, propagate that directly + error_msg = result.get("message", "") + if "TRELLO_API_KEY" in error_msg: + return {"success": False, "message": error_msg} + # Try resolving by name boards_result = self.list_boards() if not _is_success(boards_result): # type: ignore[arg-type] @@ -462,6 +467,110 @@ class TrelloClient: }, } + # ------------------------------------------------------------------ + # Public API — List Management + # ------------------------------------------------------------------ + + def create_list(self, name: str, board_id: str, pos: str = "bottom") -> dict[str, Any]: + """Create a new list on a Trello board. + + Args: + name: The name for the new list. + board_id: Board ID or name. + pos: Position — ``"top"``, ``"bottom"``, or a number. + + Returns: + dict with list details on success, or an error dict. + """ + resolved = self._resolve_board_id(board_id) + if "success" in resolved and resolved["success"] is False: + return resolved # type: ignore[typeddict-item] + + body: dict[str, Any] = { + "name": name, + "idBoard": resolved["id"], + "pos": pos, + } + result = self._post("/lists", json_body=body) + if _is_success(result): + lst = result["data"] + return { + "success": True, + "list": { + "id": lst.get("id"), + "name": lst.get("name"), + "id_board": lst.get("idBoard"), + }, + } + return { + "success": False, + "message": result.get("message", "Failed to create list."), + } + + def rename_list(self, list_id: str, name: str) -> dict[str, Any]: + """Rename an existing list. + + Args: + list_id: Trello list ID. + name: The new name. + + Returns: + dict with list details on success, or an error dict. + """ + result = self._put(f"/lists/{list_id}", json_body={"name": name}) + if _is_success(result): + lst = result["data"] + return { + "success": True, + "list": { + "id": lst.get("id"), + "name": lst.get("name"), + }, + } + return { + "success": False, + "message": result.get("message", "Failed to rename list."), + } + + def archive_list(self, list_id: str) -> dict[str, Any]: + """Archive a list. + + Args: + list_id: Trello list ID. + + Returns: + dict with success message or error. + """ + result = self._put(f"/lists/{list_id}", json_body={"closed": True}) + if _is_success(result): + lst = result["data"] + list_name = lst.get("name", list_id) + return {"success": True, "message": f"List '{list_name}' archived."} + return { + "success": False, + "message": result.get("message", "Failed to archive list."), + } + + def move_list(self, list_id: str, pos: str = "bottom") -> dict[str, Any]: + """Move a list to a different position on the board. + + Args: + list_id: Trello list ID. + pos: Position — ``"top"``, ``"bottom"``, or a number. + + Returns: + dict with success message or error. + """ + result = self._put(f"/lists/{list_id}", json_body={"pos": pos}) + if _is_success(result): + lst = result["data"] + list_name = lst.get("name", list_id) + return {"success": True, "message": f"List '{list_name}' moved."} + return { + "success": False, + "message": result.get("message", "Failed to move list."), + } + @staticmethod def check_requirements() -> bool: """Check if the required environment variables are set.""" diff --git a/src/trello_plugin/tools.py b/src/trello_plugin/tools.py index 515486f..42c04a0 100644 --- a/src/trello_plugin/tools.py +++ b/src/trello_plugin/tools.py @@ -175,6 +175,91 @@ def trello_board_details(board_id: str) -> str: return _respond(result) +# --------------------------------------------------------------------------- +# Tools — List Management +# --------------------------------------------------------------------------- + + +def trello_create_list(name: str, board_id: str, pos: str = "bottom") -> str: + """Create a new list on a Trello board. + + Parameters + ---------- + name : str + The name for the new list. + board_id : str + Board ID or name. + pos : str, optional + Position: ``"top"``, ``"bottom"``, or a number (default: ``"bottom"``). + + Returns + ------- + str + JSON with list details on success. + """ + client = _get_client() + result = client.create_list(name=name, board_id=board_id, pos=pos) + return _respond(result) + + +def trello_rename_list(list_id: str, name: str) -> str: + """Rename an existing list. + + Parameters + ---------- + list_id : str + Trello list ID. + name : str + The new name. + + Returns + ------- + str + JSON with updated list details on success. + """ + client = _get_client() + result = client.rename_list(list_id=list_id, name=name) + return _respond(result) + + +def trello_archive_list(list_id: str) -> str: + """Archive a list. + + Parameters + ---------- + list_id : str + Trello list ID. + + Returns + ------- + str + JSON with success message. + """ + client = _get_client() + result = client.archive_list(list_id=list_id) + return _respond(result) + + +def trello_move_list(list_id: str, pos: str = "bottom") -> str: + """Move a list to a different position on the board. + + Parameters + ---------- + list_id : str + Trello list ID. + pos : str, optional + Position: ``"top"``, ``"bottom"``, or a number (default: ``"bottom"``). + + Returns + ------- + str + JSON with success message. + """ + client = _get_client() + result = client.move_list(list_id=list_id, pos=pos) + return _respond(result) + + # --------------------------------------------------------------------------- # Plugin metadata (used by Hermes plugin loader) # --------------------------------------------------------------------------- @@ -191,6 +276,10 @@ PLUGIN_TOOLS = [ trello_archive_board, trello_open_board, trello_board_details, + trello_create_list, + trello_rename_list, + trello_archive_list, + trello_move_list, ] PLUGIN_REQUIRES_ENV = ["TRELLO_API_KEY", "TRELLO_TOKEN"] diff --git a/tests/test_auth.py b/tests/test_auth.py index 6f00b7d..aff6fa9 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) == 8 + assert len(PLUGIN_TOOLS) == 12 assert all(callable(t) for t in PLUGIN_TOOLS) \ No newline at end of file diff --git a/tests/test_lists.py b/tests/test_lists.py new file mode 100644 index 0000000..800fb1a --- /dev/null +++ b/tests/test_lists.py @@ -0,0 +1,226 @@ +"""Tests for the Trello plugin — list management feature.""" + +from __future__ import annotations + +import json +import os +from typing import Any + +import pytest + +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_list +# --------------------------------------------------------------------------- + +class TestCreateList: + """Tests for TrelloClient.create_list().""" + + def test_success(self, requests_mock: Any) -> None: + """Happy path: creates a list and returns details.""" + from trello_plugin.client import TrelloClient + + # Resolve board + requests_mock.get( + "https://api.trello.com/1/boards/b1", + json={"id": "b1", "name": "My Board", "url": ""}, + ) + # Create list + requests_mock.post( + "https://api.trello.com/1/lists", + json={"id": "l1", "name": "To Do", "idBoard": "b1"}, + ) + + client = TrelloClient(api_key="key", token="tok") + result = client.create_list(name="To Do", board_id="b1") + + assert result["success"] is True + assert result["list"]["name"] == "To Do" + assert result["list"]["id_board"] == "b1" + + def test_board_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.create_list(name="List", board_id="nonexistent") + + assert result["success"] is False + assert "not found" in result["message"].lower() + + 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_list(name="List", board_id="b1") + + assert result["success"] is False + assert "TRELLO_API_KEY" in result["message"] + + +# --------------------------------------------------------------------------- +# TrelloClient — rename_list +# --------------------------------------------------------------------------- + +class TestRenameList: + """Tests for TrelloClient.rename_list().""" + + def test_success(self, requests_mock: Any) -> None: + """Renaming a list works.""" + from trello_plugin.client import TrelloClient + + requests_mock.put( + "https://api.trello.com/1/lists/l1", + json={"id": "l1", "name": "Renamed List"}, + ) + + client = TrelloClient(api_key="key", token="tok") + result = client.rename_list(list_id="l1", name="Renamed List") + + assert result["success"] is True + assert result["list"]["name"] == "Renamed List" + + +# --------------------------------------------------------------------------- +# TrelloClient — archive_list +# --------------------------------------------------------------------------- + +class TestArchiveList: + """Tests for TrelloClient.archive_list().""" + + def test_success(self, requests_mock: Any) -> None: + """Archiving a list returns success message.""" + from trello_plugin.client import TrelloClient + + requests_mock.put( + "https://api.trello.com/1/lists/l1", + json={"id": "l1", "name": "My List", "closed": True}, + ) + + client = TrelloClient(api_key="key", token="tok") + result = client.archive_list(list_id="l1") + + assert result["success"] is True + assert "archived" in result["message"].lower() + + +# --------------------------------------------------------------------------- +# TrelloClient — move_list +# --------------------------------------------------------------------------- + +class TestMoveList: + """Tests for TrelloClient.move_list().""" + + def test_success(self, requests_mock: Any) -> None: + """Moving a list returns success message.""" + from trello_plugin.client import TrelloClient + + requests_mock.put( + "https://api.trello.com/1/lists/l1", + json={"id": "l1", "name": "My List", "pos": 1}, + ) + + client = TrelloClient(api_key="key", token="tok") + result = client.move_list(list_id="l1", pos="top") + + assert result["success"] is True + assert "moved" in result["message"].lower() + + +# --------------------------------------------------------------------------- +# Tool functions +# --------------------------------------------------------------------------- + +class TestListToolFunctions: + """Tests for the Hermes list management tool wrappers.""" + + def test_trello_create_list_tool(self, requests_mock: Any) -> None: + """Create list tool returns valid JSON.""" + from trello_plugin.tools import trello_create_list + + 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": "Board", "url": ""}, + ) + requests_mock.post( + "https://api.trello.com/1/lists", + json={"id": "l1", "name": "New List", "idBoard": "b1"}, + ) + + result = json.loads(trello_create_list(name="New List", board_id="b1")) + assert result["success"] is True + assert result["list"]["name"] == "New List" + + def test_trello_rename_list_tool(self, requests_mock: Any) -> None: + """Rename list tool returns valid JSON.""" + from trello_plugin.tools import trello_rename_list + + os.environ["TRELLO_API_KEY"] = "key" + os.environ["TRELLO_TOKEN"] = "tok" + + requests_mock.put( + "https://api.trello.com/1/lists/l1", + json={"id": "l1", "name": "Renamed"}, + ) + + result = json.loads(trello_rename_list(list_id="l1", name="Renamed")) + assert result["success"] is True + assert result["list"]["name"] == "Renamed" + + def test_trello_archive_list_tool(self, requests_mock: Any) -> None: + """Archive list tool returns valid JSON.""" + from trello_plugin.tools import trello_archive_list + + os.environ["TRELLO_API_KEY"] = "key" + os.environ["TRELLO_TOKEN"] = "tok" + + requests_mock.put( + "https://api.trello.com/1/lists/l1", + json={"id": "l1", "name": "My List", "closed": True}, + ) + + result = json.loads(trello_archive_list(list_id="l1")) + assert result["success"] is True + assert "archived" in result["message"].lower() + + def test_trello_move_list_tool(self, requests_mock: Any) -> None: + """Move list tool returns valid JSON.""" + from trello_plugin.tools import trello_move_list + + os.environ["TRELLO_API_KEY"] = "key" + os.environ["TRELLO_TOKEN"] = "tok" + + requests_mock.put( + "https://api.trello.com/1/lists/l1", + json={"id": "l1", "name": "My List", "pos": 1}, + ) + + result = json.loads(trello_move_list(list_id="l1", pos="top")) + assert result["success"] is True + assert "moved" in result["message"].lower() \ No newline at end of file