feat: implement Trello list management
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
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
+1
-1
@@ -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)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user