diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..59adb4c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.egg-info/ +*.pyc +*.pyo +.pytest_cache/ \ No newline at end of file diff --git a/README.md b/README.md index e19508a..9ef7109 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,95 @@ # trello-plugin -Hermes Agent plugin for Trello board integration \ No newline at end of file +Hermes Agent plugin for Trello board integration. + +## Setup + +### 1. Get Trello Credentials + +1. Get your **API Key** from: https://trello.com/app-key +2. Generate a **Token** from the same page (click "Token" under API Key) +3. Set these as environment variables in your Hermes profile config (`.env` or `config.yaml`): + +```bash +export TRELLO_API_KEY="your-trello-api-key" +export TRELLO_TOKEN="your-trello-token" +``` + +### 2. Install + +```bash +cd trello-plugin +pip install . +``` + +For development: + +```bash +pip install -e ".[dev]" +``` + +### 3. Register with Hermes Agent + +Add the plugin to your Hermes config (`~/.hermes/config.yaml` or profile): + +```yaml +plugins: + - trello-plugin +``` + +## Available Tools + +Once configured, the agent has access to these tools: + +### `trello_verify_credentials` +Verifies your Trello API key and token are valid by calling the Trello API. + +``` +✓ Trello credentials verified successfully (authenticated as your-username) +``` + +### `trello_list_boards` +Lists all Trello boards accessible with your credentials. + +| Field | Description | +|-------|-------------| +| `id` | Trello board ID | +| `name` | Board name | +| `url` | Direct Trello URL | +| `closed` | Whether the board is archived | +| `starred` | Whether the board is starred | + +### `trello_disconnect` +Clears Trello credentials from the in-memory client. Since credentials are stored in environment variables, the agent can reconnect automatically on the next tool call. + +## Development + +```bash +# Install dev dependencies +pip install -e ".[dev]" + +# Run tests +PYTHONPATH=src python3 -m pytest tests/ -v + +# Lint +pip install ruff +ruff check src/ +``` + +## Architecture + +``` +trello-plugin/ +├── src/ +│ └── trello_plugin/ +│ ├── __init__.py # Package exports +│ ├── client.py # TrelloClient — REST API wrapper +│ └── tools.py # Hermes tool definitions +├── tests/ +│ └── test_auth.py # Test suite (18 tests) +├── docs/ +│ └── backend/ +│ └── trello-auth-spec.md +├── pyproject.toml +└── README.md +``` \ No newline at end of file diff --git a/docs/backend/trello-auth-spec.md b/docs/backend/trello-auth-spec.md new file mode 100644 index 0000000..1818f2c --- /dev/null +++ b/docs/backend/trello-auth-spec.md @@ -0,0 +1,111 @@ +# Trello Plugin — Authentication & Connection + +**Feature:** US: Authenticate and Connect to Trello Account +**Issue:** #1 +**Branch:** `feature/trello-auth` + +## Overview + +This feature provides the authentication layer for the Trello plugin. Users configure their Trello credentials via environment variables (`TRELLO_API_KEY` and `TRELLO_TOKEN`), and the plugin exposes tools to verify credentials, list accessible boards, and disconnect. + +## Design Decisions + +- **No interactive credential input** — The Hermes Agent has no UI for prompting users. Credentials are set in the profile's `.env` or `config.yaml` as environment variables, which is the standard Hermes pattern. +- **Trello uses API Key + Token auth** — Trello's API requires an API key (identifies the application) and a token (user-specific authorization). Both are required for any API call. +- **One client class** — All Trello API interactions go through a single `TrelloClient` class to centralize auth, base URL, and error handling. Future features (boards, lists, cards) will extend this same client. + +## Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `TRELLO_API_KEY` | Yes | Trello API application key | +| `TRELLO_TOKEN` | Yes | Trello user authorization token | + +## Tools Exposed + +### 1. `trello_verify_credentials` + +Verifies that `TRELLO_API_KEY` and `TRELLO_TOKEN` are set and the Trello API responds successfully. + +**Parameters:** None + +**Returns:** +```json +{ + "success": true, + "message": "✓ Trello credentials verified successfully (authenticated as user@example.com)" +} +``` + +On failure: +```json +{ + "success": false, + "message": "✗ Trello authentication failed: invalid key" +} +``` + +### 2. `trello_list_boards` + +Fetches and returns all Trello boards accessible to the authenticated user. + +**Parameters:** None + +**Returns:** +```json +{ + "success": true, + "boards": [ + {"id": "abc123", "name": "My Project Board", "url": "https://trello.com/b/abc123"}, + {"id": "def456", "name": "Personal Tasks", "url": "https://trello.com/b/def456"} + ] +} +``` + +### 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. + +**Parameters:** None + +**Returns:** +```json +{ + "success": true, + "message": "Trello credentials cleared. Set TRELLO_API_KEY and TRELLO_TOKEN again to reconnect." +} +``` + +## API Contract + +### Internal: `TrelloClient` + +```python +class TrelloClient: + def __init__(self, api_key: str | None = None, token: str | None = None) + + def verify_credentials(self) -> dict + def list_boards(self) -> list[dict] + def disconnect(self) -> dict +``` + +### Trello API Endpoints Used + +| Purpose | Method | Endpoint | Docs | +|---------|--------|----------|------| +| Verify credentials | GET | `/1/members/me` | https://developer.atlassian.com/cloud/trello/rest/api-group-members/ | +| List boards | GET | `/1/members/me/boards` | https://developer.atlassian.com/cloud/trello/rest/api-group-boards/ | + +## Error Handling + +- Missing env vars → return `{"success": false, "message": "TRELLO_API_KEY and TRELLO_TOKEN must be set"}` with env var name +- Network errors → catch `requests.exceptions.RequestException` and return descriptive message +- Auth failures (401/403) → clear message about invalid credentials +- Rate limiting → Trello returns 429; surface the retry-after header + +## Testing + +- Unit tests with mocked HTTP responses +- Test all error paths: missing creds, invalid creds, network error, rate limit +- Test successful verification via mock that returns member info +- Test board listing with mock JSON response \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6037ebb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,35 @@ +[build-system] +requires = ["setuptools>=64"] +build-backend = "setuptools.build_meta" + +[project] +name = "trello-plugin" +version = "0.1.0" +description = "Hermes Agent plugin for Trello board integration" +requires-python = ">=3.12" +license = {text = "MIT"} +dependencies = [ + "requests>=2.31", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8", + "pytest-cov>=5", + "requests-mock>=1.12", + "ruff>=0.4", +] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] + +[tool.ruff] +line-length = 100 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W", "UP", "PL"] diff --git a/src/trello_plugin/__init__.py b/src/trello_plugin/__init__.py new file mode 100644 index 0000000..63ca571 --- /dev/null +++ b/src/trello_plugin/__init__.py @@ -0,0 +1,25 @@ +""" +trello-plugin — Hermes Agent plugin for Trello board integration. +""" + +from trello_plugin.tools import ( + PLUGIN_DESCRIPTION, + PLUGIN_NAME, + PLUGIN_TOOLS, + PLUGIN_VERSION, + check_requirements, + trello_disconnect, + trello_list_boards, + trello_verify_credentials, +) + +__all__ = [ + "PLUGIN_NAME", + "PLUGIN_DESCRIPTION", + "PLUGIN_VERSION", + "PLUGIN_TOOLS", + "check_requirements", + "trello_verify_credentials", + "trello_list_boards", + "trello_disconnect", +] \ No newline at end of file diff --git a/src/trello_plugin/client.py b/src/trello_plugin/client.py new file mode 100644 index 0000000..1b166a1 --- /dev/null +++ b/src/trello_plugin/client.py @@ -0,0 +1,470 @@ +""" +Trello Plugin — Hermes Agent plugin for Trello board integration. + +Authentication & Connection module. +Provides the TrelloClient for interacting with the Trello REST API. +""" + +from __future__ import annotations + +import os +from typing import Any, TypeGuard, TypedDict + +import requests + +# Base URL for the Trello REST API v1. +# See https://developer.atlassian.com/cloud/trello/rest +TRELLO_API_BASE = "https://api.trello.com/1" + + +# --------------------------------------------------------------------------- +# Typed response contracts +# --------------------------------------------------------------------------- + + +class SuccessResponse(TypedDict): + """A successful API response carrying data.""" + + success: bool + data: dict[str, Any] + + +class ErrorResponse(TypedDict): + """An unsuccessful API response with a human-readable message.""" + + success: bool + message: str + + +ApiResponse = SuccessResponse | ErrorResponse + + +def _is_success(resp: ApiResponse) -> TypeGuard[SuccessResponse]: + """Narrow an ApiResponse union to its success branch.""" + return bool(resp.get("success", False)) # noqa: FBT003 + + +# --------------------------------------------------------------------------- +# Client +# --------------------------------------------------------------------------- + + +class TrelloClient: + """Client for the Trello REST API. + + Reads credentials from environment variables TRELLO_API_KEY and TRELLO_TOKEN + unless explicitly passed. All public methods return plain dicts suitable for + JSON serialization by Hermes tool handlers. + """ + + api_key: str + token: str + _session: requests.Session + + def __init__( + self, + api_key: str | None = None, + token: str | None = None, + session: requests.Session | None = None, + ) -> None: + self.api_key = api_key or os.environ.get("TRELLO_API_KEY", "") + self.token = token or os.environ.get("TRELLO_TOKEN", "") + self._session = session or requests.Session() + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _auth_params(self) -> dict[str, str]: + """Return query parameters common to every Trello API call.""" + return {"key": self.api_key, "token": self.token} + + def _check_credentials(self) -> ErrorResponse | None: + """Return an ErrorResponse if credentials are missing, else None.""" + if not self.api_key or not self.token: + return ErrorResponse( + success=False, + message="TRELLO_API_KEY and TRELLO_TOKEN must both be set as environment variables.", + ) + return None + + def _handle_http_error(self, exc: requests.exceptions.HTTPError) -> ErrorResponse: + """Map known HTTP status codes to user-facing error messages.""" + status = exc.response.status_code if exc.response is not None else 0 + messages: dict[int, str] = { + 401: "Trello authentication failed (401). Check your TRELLO_API_KEY and TRELLO_TOKEN.", + 403: "Trello access denied (403). Your token may not have the required scopes.", + 429: "Trello rate limit exceeded. Try again later.", + } + message = messages.get( + status, f"Trello API error ({status}): {exc}" + ) + return ErrorResponse(success=False, message=message) + + def _request( + self, + method: str, + path: str, + params: dict[str, Any] | None = None, + json_body: dict[str, Any] | None = None, + ) -> ApiResponse: + """Make an authenticated HTTP request and return the JSON response. + + Handles credential checks, HTTP errors, network errors, and timeouts. + """ + cred_error = self._check_credentials() + if cred_error is not None: + return cred_error + + url = f"{TRELLO_API_BASE}{path}" + merged_params = self._auth_params() + if params: + merged_params.update(params) + + try: + resp = self._session.request( + method, url, params=merged_params, json=json_body, timeout=15 + ) + resp.raise_for_status() + data: dict[str, Any] = resp.json() + return SuccessResponse(success=True, data=data) + except requests.exceptions.HTTPError as exc: + return self._handle_http_error(exc) + except requests.exceptions.ConnectionError: + return ErrorResponse( + success=False, + message="Could not connect to Trello API. Check your network connection.", + ) + except requests.exceptions.Timeout: + return ErrorResponse( + success=False, + message="Trello API request timed out. Try again later.", + ) + except requests.exceptions.RequestException as exc: + return ErrorResponse( + success=False, message=f"Trello API request failed: {exc}" + ) + + def _get( + self, path: str, params: dict[str, Any] | None = None + ) -> ApiResponse: + """Make an authenticated GET request.""" + return self._request("GET", path, params=params) + + def _post( + self, path: str, json_body: dict[str, Any] | None = None + ) -> ApiResponse: + """Make an authenticated POST request.""" + return self._request("POST", path, json_body=json_body) + + def _put( + self, path: str, json_body: dict[str, Any] | None = None + ) -> ApiResponse: + """Make an authenticated PUT request.""" + return self._request("PUT", path, json_body=json_body) + + # ------------------------------------------------------------------ + # Public API — Auth & Connection + # ------------------------------------------------------------------ + + def verify_credentials(self) -> dict[str, Any]: + """Verify that the stored credentials are valid by fetching member info. + + Returns: + dict with ``success`` bool and ``message`` string. On success the + ``member`` key contains the Trello member id and username. + """ + result = self._get("/members/me") + if _is_success(result): + member = result["data"] + return { + "success": True, + "message": ( + f"✓ Trello credentials verified successfully " + f"(authenticated as {member.get('username', 'unknown')})" + ), + "member": { + "id": member.get("id"), + "username": member.get("username"), + "full_name": member.get("fullName"), + }, + } + return { + "success": False, + "message": result.get("message", "Trello credential verification failed."), + } + + def list_boards(self) -> dict[str, Any]: + """Fetch all boards accessible to the authenticated user. + + Returns: + dict with ``success`` bool and a ``boards`` list (each containing + ``id``, ``name``, ``url``, ``closed``, ``starred``). + """ + result = self._get( + "/members/me/boards", params={"fields": "id,name,url,closed,starred"} + ) + if not _is_success(result): + return { + "success": False, + "message": result.get("message", "Failed to list Trello boards."), + } + + boards = [ + { + "id": b.get("id"), + "name": b.get("name"), + "url": b.get("url"), + "closed": b.get("closed", False), + "starred": b.get("starred", False), + } + for b in result["data"] + ] + return {"success": True, "boards": boards} + + def disconnect(self) -> dict[str, Any]: + """Clear credentials from the in-memory client. + + 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 account settings if they want full revocation. + """ + self.api_key = "" + self.token = "" + return { + "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." + ), + } + + # ------------------------------------------------------------------ + # Public API — Board Management + # ------------------------------------------------------------------ + + def _resolve_board_id(self, board_id: str) -> dict[str, Any]: + """Resolve a board name or ID to a Trello board ID. + + Returns board info dict with at least an ``id`` key on success, + or an error dict with ``success: False``. + """ + # Try as ID first (Trello IDs are 8-24 char hex strings) + result = self._get(f"/boards/{board_id}", params={"fields": "id,name,url"}) + if _is_success(result): + board = result["data"] + return {"id": board["id"], "name": board.get("name", ""), "url": board.get("url", "")} + + # Try resolving by name + boards_result = self.list_boards() + if not _is_success(boards_result): # type: ignore[arg-type] + return { + "success": False, + "message": ( + f"Board '{board_id}' not found. " + "Use trello_list_boards to see available boards." + ), + } + + matches = [b for b in boards_result["boards"] if b["name"] == board_id] # type: ignore[typeddict-item] + if len(matches) == 1: + return dict(matches[0]) # already has id, name, url + + if len(matches) > 1: + return { + "success": False, + "message": ( + f"Multiple boards named '{board_id}' found. " + "Use the board ID instead of the name." + ), + } + + return { + "success": False, + "message": ( + f"Board '{board_id}' not found. " + "Use trello_list_boards to see available boards." + ), + } + + def create_board( + self, name: str, default_lists: bool = True + ) -> dict[str, Any]: + """Create a new Trello board. + + Args: + name: The name for the new board. + default_lists: Whether to create the default lists (default: True). + + Returns: + dict with board details on success, or an error dict. + """ + body: dict[str, Any] = { + "name": name, + "defaultLists": default_lists, + } + result = self._post("/boards", json_body=body) + if _is_success(result): + board = result["data"] + return { + "success": True, + "board": { + "id": board.get("id"), + "name": board.get("name"), + "url": board.get("url"), + }, + } + return { + "success": False, + "message": result.get("message", "Failed to create board."), + } + + def rename_board(self, board_id: str, name: str) -> dict[str, Any]: + """Rename an existing Trello board. + + Args: + board_id: Board ID or name. + name: The new name for the board. + + Returns: + dict with board 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] + + trello_id = resolved["id"] + result = self._put( + f"/boards/{trello_id}", json_body={"name": name} + ) + if _is_success(result): + board = result["data"] + return { + "success": True, + "board": { + "id": board.get("id"), + "name": board.get("name"), + "url": board.get("url"), + }, + } + return { + "success": False, + "message": result.get("message", "Failed to rename board."), + } + + def archive_board(self, board_id: str) -> dict[str, Any]: + """Close/archive a Trello board. + + Args: + board_id: Board ID or name. + + Returns: + dict with success message or error. + """ + resolved = self._resolve_board_id(board_id) + if "success" in resolved and resolved["success"] is False: + return resolved # type: ignore[typeddict-item] + + trello_id = resolved["id"] + board_name = resolved.get("name", trello_id) + result = self._put( + f"/boards/{trello_id}", json_body={"closed": True} + ) + if _is_success(result): + return {"success": True, "message": f"Board '{board_name}' archived."} + return { + "success": False, + "message": result.get("message", "Failed to archive board."), + } + + def open_board(self, board_id: str) -> dict[str, Any]: + """Re-open a closed/archived Trello board. + + Args: + board_id: Board ID or name. + + Returns: + dict with success message or error. + """ + resolved = self._resolve_board_id(board_id) + if "success" in resolved and resolved["success"] is False: + return resolved # type: ignore[typeddict-item] + + trello_id = resolved["id"] + board_name = resolved.get("name", trello_id) + result = self._put( + f"/boards/{trello_id}", json_body={"closed": False} + ) + if _is_success(result): + return {"success": True, "message": f"Board '{board_name}' opened."} + return { + "success": False, + "message": result.get("message", "Failed to open board."), + } + + def board_details(self, board_id: str) -> dict[str, Any]: + """Fetch detailed information about a Trello board. + + Returns the board's lists, members, and a direct URL. + + Args: + board_id: Board ID or name. + + Returns: + dict with board details including lists and members on success. + """ + resolved = self._resolve_board_id(board_id) + if "success" in resolved and resolved["success"] is False: + return resolved # type: ignore[typeddict-item] + + trello_id = resolved["id"] + result = self._get( + f"/boards/{trello_id}", + params={ + "fields": "id,name,desc,url,closed,starred", + "lists": "all", + "members": "all", + "members_fields": "id,username,fullName", + }, + ) + if not _is_success(result): + return { + "success": False, + "message": result.get("message", "Failed to fetch board details."), + } + + board = result["data"] + return { + "success": True, + "board": { + "id": board.get("id"), + "name": board.get("name"), + "url": board.get("url"), + "desc": board.get("desc", ""), + "closed": board.get("closed", False), + "starred": board.get("starred", False), + "lists": [ + {"id": lst.get("id"), "name": lst.get("name")} + for lst in board.get("lists", []) + ], + "members": [ + { + "id": m.get("id"), + "username": m.get("username"), + "full_name": m.get("fullName"), + } + for m in board.get("members", []) + ], + }, + } + + @staticmethod + def check_requirements() -> bool: + """Check if the required environment variables are set.""" + return bool(os.environ.get("TRELLO_API_KEY")) and bool( + os.environ.get("TRELLO_TOKEN") + ) \ No newline at end of file diff --git a/src/trello_plugin/tools.py b/src/trello_plugin/tools.py new file mode 100644 index 0000000..ab3afe1 --- /dev/null +++ b/src/trello_plugin/tools.py @@ -0,0 +1,96 @@ +""" +Trello Plugin — Hermes Agent tool definitions. + +Each function decorated with ``@tool`` registers itself as a Hermes tool +that the agent can invoke during a conversation. +""" + +from __future__ import annotations + +import json +import os +from typing import Any + +from trello_plugin.client import TrelloClient + +# --------------------------------------------------------------------------- +# Module-level client (lazily initialised so env vars can be overridden) +# --------------------------------------------------------------------------- + +_client: TrelloClient | None = None + + +def _get_client() -> TrelloClient: + global _client # noqa: PLW0603 + api_key = os.environ.get("TRELLO_API_KEY", "") + token = os.environ.get("TRELLO_TOKEN", "") + if _client is None or _client.api_key != api_key or _client.token != token: + _client = TrelloClient(api_key=api_key, token=token) + return _client + + +# --------------------------------------------------------------------------- +# Tool helpers +# --------------------------------------------------------------------------- + +def _respond(data: dict[str, Any]) -> str: + """Wrap a result dict in a JSON string, the Hermes tool contract.""" + return json.dumps(data, indent=2) + + +# --------------------------------------------------------------------------- +# Tools +# --------------------------------------------------------------------------- + +def trello_verify_credentials() -> str: + """Verify that the Trello API key and token are valid. + + Checks the ``TRELLO_API_KEY`` and ``TRELLO_TOKEN`` environment variables + and attempts a test call to the Trello API. Returns the authenticated + user's Trello username on success. + """ + client = _get_client() + result = client.verify_credentials() + return _respond(result) + + +def trello_list_boards() -> str: + """List all Trello boards accessible to the authenticated user. + + Requires ``TRELLO_API_KEY`` and ``TRELLO_TOKEN`` to be set. + Returns board id, name, URL, and whether the board is closed/starred. + """ + client = _get_client() + result = client.list_boards() + return _respond(result) + + +def trello_disconnect() -> str: + """Disconnect from Trello by clearing credentials from memory. + + This does NOT revoke the Trello token — invalidate it via + Trello's account settings if full revocation is desired. + """ + global _client # noqa: PLW0603 + _client = None + 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."}) + + +# --------------------------------------------------------------------------- +# Plugin metadata (used by Hermes plugin loader) +# --------------------------------------------------------------------------- + +PLUGIN_NAME = "trello-plugin" +PLUGIN_DESCRIPTION = "Hermes Agent plugin for Trello board integration" +PLUGIN_VERSION = "0.1.0" +PLUGIN_TOOLS = [ + trello_verify_credentials, + trello_list_boards, + trello_disconnect, +] +PLUGIN_REQUIRES_ENV = ["TRELLO_API_KEY", "TRELLO_TOKEN"] + + +def check_requirements() -> bool: + """Return True when both Trello env vars are present.""" + return TrelloClient.check_requirements() \ No newline at end of file diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..b44767b --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,329 @@ +"""Tests for the Trello plugin — auth & connection feature.""" + +from __future__ import annotations + +import json +import os +from collections.abc import Generator +from typing import Any + +import pytest +from pytest import MonkeyPatch +import requests + +# Tell pytest we plan to set env vars in tests +pytestmark = pytest.mark.usefixtures("clear_env") + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture(autouse=True) +def clear_env(monkeypatch: MonkeyPatch) -> Generator[None, None, None]: + """Remove Trello env vars before each test so state is predictable.""" + monkeypatch.delenv("TRELLO_API_KEY", raising=False) + monkeypatch.delenv("TRELLO_TOKEN", raising=False) + yield + + +def _make_session(mocker: Any) -> requests.Session: + """Build a requests.Session with a mocked adapter.""" + session = requests.Session() + adapter = mocker.get_adapter() + session.mount("https://", adapter) + return session + + +# --------------------------------------------------------------------------- +# TrelloClient — verify_credentials +# --------------------------------------------------------------------------- + +class TestVerifyCredentials: + """Tests for TrelloClient.verify_credentials().""" + + def test_success(self, requests_mock: Any) -> None: + """Happy path: valid creds return member info.""" + from trello_plugin.client import TrelloClient + + requests_mock.get( + "https://api.trello.com/1/members/me", + json={"id": "member123", "username": "testuser", "fullName": "Test User"}, + ) + client = TrelloClient(api_key="key", token="tok") + result = client.verify_credentials() + + assert result["success"] is True + assert "testuser" in result["message"] + assert result["member"]["id"] == "member123" + assert result["member"]["username"] == "testuser" + + def test_missing_credentials(self) -> None: + """Both API key and token must be set.""" + from trello_plugin.client import TrelloClient + + client = TrelloClient(api_key="", token="") + result = client.verify_credentials() + + assert result["success"] is False + assert "TRELLO_API_KEY" in result["message"] + + def test_auth_failure_401(self, requests_mock: Any) -> None: + """401 response surfaces as auth failure.""" + from trello_plugin.client import TrelloClient + + requests_mock.get( + "https://api.trello.com/1/members/me", + status_code=401, + ) + client = TrelloClient(api_key="bad_key", token="bad_tok") + result = client.verify_credentials() + + assert result["success"] is False + assert "401" in result["message"] + + def test_forbidden_403(self, requests_mock: Any) -> None: + """403 surfaces as access denied.""" + from trello_plugin.client import TrelloClient + + requests_mock.get( + "https://api.trello.com/1/members/me", + status_code=403, + ) + client = TrelloClient(api_key="key", token="tok") + result = client.verify_credentials() + + assert result["success"] is False + assert "403" in result["message"] + + def test_rate_limit_429(self, requests_mock: Any) -> None: + """429 surfaces as rate limit error.""" + from trello_plugin.client import TrelloClient + + requests_mock.get( + "https://api.trello.com/1/members/me", + status_code=429, + ) + client = TrelloClient(api_key="key", token="tok") + result = client.verify_credentials() + + assert result["success"] is False + assert "rate limit" in result["message"].lower() + + def test_network_error(self, requests_mock: Any) -> None: + """Connection errors surface as network failure.""" + from trello_plugin.client import TrelloClient + + requests_mock.get( + "https://api.trello.com/1/members/me", + exc=requests.exceptions.ConnectionError("connection refused"), + ) + client = TrelloClient(api_key="key", token="tok") + result = client.verify_credentials() + + assert result["success"] is False + assert "connect" in result["message"].lower() + + def test_timeout(self, requests_mock: Any) -> None: + """Timeout surfaces gracefully.""" + from trello_plugin.client import TrelloClient + + requests_mock.get( + "https://api.trello.com/1/members/me", + exc=requests.exceptions.Timeout("timed out"), + ) + client = TrelloClient(api_key="key", token="tok") + result = client.verify_credentials() + + assert result["success"] is False + assert "timed out" in result["message"].lower() + + +# --------------------------------------------------------------------------- +# TrelloClient — list_boards +# --------------------------------------------------------------------------- + +class TestListBoards: + """Tests for TrelloClient.list_boards().""" + + def test_success(self, requests_mock: Any) -> None: + """Happy path: returns a list of boards.""" + from trello_plugin.client import TrelloClient + + mock_boards = [ + {"id": "b1", "name": "Board One", "url": "https://trello.com/b/b1", "closed": False, "starred": False}, + {"id": "b2", "name": "Board Two", "url": "https://trello.com/b/b2", "closed": True, "starred": False}, + ] + requests_mock.get( + "https://api.trello.com/1/members/me/boards", + json=mock_boards, + ) + client = TrelloClient(api_key="key", token="tok") + result = client.list_boards() + + assert result["success"] is True + assert len(result["boards"]) == 2 + assert result["boards"][0]["name"] == "Board One" + assert result["boards"][1]["closed"] is True + + 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.list_boards() + + assert result["success"] is False + assert "TRELLO_API_KEY" in result["message"] + + def test_empty_boards(self, requests_mock: Any) -> None: + """No boards returns an empty list.""" + from trello_plugin.client import TrelloClient + + requests_mock.get( + "https://api.trello.com/1/members/me/boards", + json=[], + ) + client = TrelloClient(api_key="key", token="tok") + result = client.list_boards() + + assert result["success"] is True + assert result["boards"] == [] + + +# --------------------------------------------------------------------------- +# TrelloClient — disconnect +# --------------------------------------------------------------------------- + +class TestDisconnect: + """Tests for TrelloClient.disconnect().""" + + def test_disconnect_clears_credentials(self) -> None: + """Disconnect clears the in-memory credentials.""" + from trello_plugin.client import TrelloClient + + client = TrelloClient(api_key="key", token="tok") + result = client.disconnect() + + assert result["success"] is True + assert "cleared" in result["message"].lower() + + # Subsequent calls should fail + verify_result = client.verify_credentials() + assert verify_result["success"] is False + assert "TRELLO_API_KEY" in verify_result["message"] + + +# --------------------------------------------------------------------------- +# check_requirements +# --------------------------------------------------------------------------- + +class TestCheckRequirements: + """Tests for TrelloClient.check_requirements().""" + + def test_returns_false_when_missing(self) -> None: + """Returns False when env vars are not set.""" + from trello_plugin.client import TrelloClient + + assert TrelloClient.check_requirements() is False + + def test_returns_true_when_set(self) -> None: + """Returns True when both env vars are set.""" + from trello_plugin.client import TrelloClient + + os.environ["TRELLO_API_KEY"] = "test-key" + os.environ["TRELLO_TOKEN"] = "test-token" + assert TrelloClient.check_requirements() is True + + +# --------------------------------------------------------------------------- +# Tool functions +# --------------------------------------------------------------------------- + +class TestToolFunctions: + """Tests for the Hermes tool wrapper functions.""" + + def test_trello_verify_credentials_tool(self, requests_mock: Any) -> None: + """Tool returns valid JSON string on success.""" + from trello_plugin.tools import trello_verify_credentials + + os.environ["TRELLO_API_KEY"] = "test-key" + os.environ["TRELLO_TOKEN"] = "test-token" + requests_mock.get( + "https://api.trello.com/1/members/me", + json={"id": "m1", "username": "bot", "fullName": "Bot User"}, + ) + result = json.loads(trello_verify_credentials()) + assert result["success"] is True + assert result["member"]["username"] == "bot" + + def test_trello_list_boards_tool(self, requests_mock: Any) -> None: + """Tool returns valid JSON string on success.""" + from trello_plugin.tools import trello_list_boards + + os.environ["TRELLO_API_KEY"] = "test-key" + os.environ["TRELLO_TOKEN"] = "test-token" + requests_mock.get( + "https://api.trello.com/1/members/me/boards", + json=[{"id": "b1", "name": "Test", "url": "https://trello.com/b/b1", "closed": False, "starred": False}], + ) + result = json.loads(trello_list_boards()) + assert result["success"] is True + assert len(result["boards"]) == 1 + + def test_trello_disconnect_tool(self, requests_mock: Any) -> None: + """Tool clears credentials and returns success.""" + from trello_plugin.tools import trello_disconnect, trello_verify_credentials + + os.environ["TRELLO_API_KEY"] = "test-key" + os.environ["TRELLO_TOKEN"] = "test-token" + + # First verify works + requests_mock.get( + "https://api.trello.com/1/members/me", + json={"id": "m1", "username": "bot", "fullName": "Bot User"}, + ) + verify = json.loads(trello_verify_credentials()) + assert verify["success"] is True + + # Then disconnect + disc = json.loads(trello_disconnect()) + assert disc["success"] is True + + # With env vars still set, verify should reconnect successfully + # (disconnect clears the in-memory client; env vars persist) + verify2 = json.loads(trello_verify_credentials()) + assert verify2["success"] is True + assert verify2["member"]["username"] == "bot" + + def test_check_requirements_tool(self, requests_mock: Any) -> None: + """check_requirements returns False when env not set.""" + from trello_plugin.tools import check_requirements + + assert check_requirements() is False + + os.environ["TRELLO_API_KEY"] = "k" + os.environ["TRELLO_TOKEN"] = "t" + assert check_requirements() is True + + +# --------------------------------------------------------------------------- +# Plugin metadata +# --------------------------------------------------------------------------- + +class TestPluginMetadata: + """Tests for the plugin metadata constants.""" + + def test_plugin_constants(self) -> None: + from trello_plugin.tools import ( + PLUGIN_DESCRIPTION, + PLUGIN_NAME, + PLUGIN_TOOLS, + PLUGIN_VERSION, + ) + + assert PLUGIN_NAME == "trello-plugin" + assert isinstance(PLUGIN_DESCRIPTION, str) + assert isinstance(PLUGIN_VERSION, str) + assert len(PLUGIN_TOOLS) == 3 + assert all(callable(t) for t in PLUGIN_TOOLS) \ No newline at end of file