From c85164c943b7e95df34f82b7cdb0d0ec5da6b9cb Mon Sep 17 00:00:00 2001 From: Christian Nennemann Date: Thu, 9 Apr 2026 16:29:18 +0200 Subject: [PATCH 1/2] fix: correct 8 bugs and add test management tools to stdio MCP server Bug fixes: - create_item: pass all 5 required args to post_item (was missing location + child_item_type_id) - get_item_children: fix method name from get_items_children to get_item_children - get_pick_lists: remove incorrect project_id param (API is global) - get_project: implement via get_projects() filter (method doesn't exist in client) - get_relationships: rename to get_relationship_types (matched wrong method) - get_item_relationships: replace with upstream/downstream variants (was passing item_id to project-scoped get_relationships) New features: - create_test_plan: direct REST call to POST /testplans (not in py_jama_rest_client) - create_test_cycle, get_test_cycle, get_test_runs, update_test_run - create_relationship: expose post_relationship for traceability links - get_item_upstream_relationships, get_item_downstream_relationships --- jama_mcp_server/core/stdio_server.py | 235 ++++++++++++++++++++++++--- tests/test_mcp_stdio_server.py | 42 +++-- 2 files changed, 246 insertions(+), 31 deletions(-) diff --git a/jama_mcp_server/core/stdio_server.py b/jama_mcp_server/core/stdio_server.py index df5f7d6..60131ad 100644 --- a/jama_mcp_server/core/stdio_server.py +++ b/jama_mcp_server/core/stdio_server.py @@ -92,9 +92,20 @@ async def list_tools() -> list[Tool]: "properties": { "project_id": {"type": "integer", "description": "Project ID"}, "item_type_id": {"type": "integer", "description": "Item type ID"}, - "fields": {"type": "object", "description": "Item fields"}, + "child_item_type_id": { + "type": "integer", + "description": "Child item type ID (for Sets/Components). Omit if not applicable.", + }, + "location": { + "type": "object", + "description": ( + 'Parent location. E.g. {"item": 12345} for child of item, ' + 'or {"project": 42} for project root' + ), + }, + "fields": {"type": "object", "description": "Item fields (name, description, etc.)"}, }, - "required": ["project_id", "item_type_id", "fields"], + "required": ["project_id", "item_type_id", "location", "fields"], }, ), Tool( @@ -132,7 +143,7 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="get_relationships", + name="get_relationship_types", description="Get all relationship types", inputSchema={ "type": "object", @@ -140,8 +151,19 @@ async def list_tools() -> list[Tool]: }, ), Tool( - name="get_item_relationships", - description="Get relationships for an item", + name="get_item_upstream_relationships", + description="Get upstream relationships for an item", + inputSchema={ + "type": "object", + "properties": { + "item_id": {"type": "integer", "description": "Item ID"}, + }, + "required": ["item_id"], + }, + ), + Tool( + name="get_item_downstream_relationships", + description="Get downstream relationships for an item", inputSchema={ "type": "object", "properties": { @@ -174,13 +196,10 @@ async def list_tools() -> list[Tool]: ), Tool( name="get_pick_lists", - description="Get all pick lists in a project", + description="Get all pick lists (global, not project-specific)", inputSchema={ "type": "object", - "properties": { - "project_id": {"type": "integer", "description": "Project ID"}, - }, - "required": ["project_id"], + "properties": {}, }, ), Tool( @@ -288,6 +307,110 @@ async def list_tools() -> list[Tool]: "required": ["filter_id"], }, ), + Tool( + name="create_relationship", + description="Create a relationship (traceability link) between two items", + inputSchema={ + "type": "object", + "properties": { + "from_item": {"type": "integer", "description": "Source item ID"}, + "to_item": {"type": "integer", "description": "Target item ID"}, + "relationship_type": { + "type": "integer", + "description": "Relationship type ID. Omit for default 'Related to'.", + }, + }, + "required": ["from_item", "to_item"], + }, + ), + Tool( + name="create_test_plan", + description="Create a new test plan in a project", + inputSchema={ + "type": "object", + "properties": { + "project_id": {"type": "integer", "description": "Project ID"}, + "name": {"type": "string", "description": "Test plan name"}, + "description": { + "type": "string", + "description": "Test plan description (optional)", + }, + "start_date": { + "type": "string", + "description": "Start date YYYY-MM-DD (optional)", + }, + "end_date": { + "type": "string", + "description": "End date YYYY-MM-DD (optional)", + }, + }, + "required": ["project_id", "name"], + }, + ), + Tool( + name="get_test_cycle", + description="Get a specific test cycle by ID", + inputSchema={ + "type": "object", + "properties": { + "test_cycle_id": {"type": "integer", "description": "Test cycle ID"}, + }, + "required": ["test_cycle_id"], + }, + ), + Tool( + name="get_test_runs", + description="Get test runs for a test cycle", + inputSchema={ + "type": "object", + "properties": { + "test_cycle_id": {"type": "integer", "description": "Test cycle ID"}, + }, + "required": ["test_cycle_id"], + }, + ), + Tool( + name="create_test_cycle", + description="Create a test cycle under a test plan", + inputSchema={ + "type": "object", + "properties": { + "testplan_id": {"type": "integer", "description": "Test plan ID"}, + "name": {"type": "string", "description": "Test cycle name"}, + "start_date": { + "type": "string", + "description": "Start date (YYYY-MM-DD)", + }, + "end_date": { + "type": "string", + "description": "End date (YYYY-MM-DD)", + }, + "testgroups_to_include": { + "type": "array", + "items": {"type": "integer"}, + "description": "Test group IDs to include (optional)", + }, + "testrun_status_to_include": { + "type": "array", + "items": {"type": "string"}, + "description": "Test run statuses to include (optional)", + }, + }, + "required": ["testplan_id", "name", "start_date", "end_date"], + }, + ), + Tool( + name="update_test_run", + description="Update a test run (e.g. set status/result)", + inputSchema={ + "type": "object", + "properties": { + "test_run_id": {"type": "integer", "description": "Test run ID"}, + "data": {"type": "object", "description": "Test run data to update"}, + }, + "required": ["test_run_id", "data"], + }, + ), ] @self.mcp.call_tool() @@ -329,9 +452,13 @@ async def _execute_tool(self, name: str, arguments: dict[str, Any]) -> Any: return await loop.run_in_executor(None, self.jama_client.get_projects) elif name == "get_project": - return await loop.run_in_executor( - None, self.jama_client.get_project, arguments["project_id"] - ) + + def get_single_project() -> Any: + projects = self.jama_client.get_projects() # type: ignore[union-attr] + project_id = arguments["project_id"] + return next((p for p in projects if p["id"] == project_id), None) + + return await loop.run_in_executor(None, get_single_project) elif name == "get_item": return await loop.run_in_executor(None, self.jama_client.get_item, arguments["item_id"]) @@ -347,6 +474,8 @@ async def _execute_tool(self, name: str, arguments: dict[str, Any]) -> Any: self.jama_client.post_item, arguments["project_id"], arguments["item_type_id"], + arguments.get("child_item_type_id"), + arguments["location"], arguments["fields"], ) @@ -367,15 +496,20 @@ def update_fn() -> Any: elif name == "get_item_children": return await loop.run_in_executor( - None, self.jama_client.get_items_children, arguments["item_id"] + None, self.jama_client.get_item_children, arguments["item_id"] ) - elif name == "get_relationships": + elif name == "get_relationship_types": return await loop.run_in_executor(None, self.jama_client.get_relationship_types) - elif name == "get_item_relationships": + elif name == "get_item_upstream_relationships": return await loop.run_in_executor( - None, self.jama_client.get_relationships, arguments["item_id"] + None, self.jama_client.get_items_upstream_relationships, arguments["item_id"] + ) + + elif name == "get_item_downstream_relationships": + return await loop.run_in_executor( + None, self.jama_client.get_items_downstream_relationships, arguments["item_id"] ) elif name == "get_tags": @@ -389,9 +523,7 @@ def update_fn() -> Any: ) elif name == "get_pick_lists": - return await loop.run_in_executor( - None, self.jama_client.get_pick_lists, arguments["project_id"] - ) + return await loop.run_in_executor(None, self.jama_client.get_pick_lists) elif name == "get_baselines": return await loop.run_in_executor( @@ -439,6 +571,69 @@ def update_fn() -> Any: None, self.jama_client.get_filter_results, arguments["filter_id"] ) + elif name == "create_relationship": + return await loop.run_in_executor( + None, + self.jama_client.post_relationship, + arguments["from_item"], + arguments["to_item"], + arguments.get("relationship_type"), + ) + + elif name == "create_test_plan": + + def _create_test_plan() -> int: + """Bypass py_jama_rest_client (no upstream method) — POST /testplans directly.""" + fields: dict[str, Any] = {"name": arguments["name"]} + if arguments.get("description"): + fields["description"] = arguments["description"] + if arguments.get("start_date"): + fields["startDate"] = arguments["start_date"] + if arguments.get("end_date"): + fields["endDate"] = arguments["end_date"] + + body = {"project": arguments["project_id"], "fields": fields} + headers = {"content-type": "application/json"} + response = self.jama_client._JamaClient__core.post( # type: ignore[union-attr] + "testplans", data=json.dumps(body), headers=headers + ) + if not (200 <= response.status_code < 300): + msg = response.json().get("meta", {}).get("message", "Unknown error") + raise RuntimeError(f"POST testplans failed [{response.status_code}]: {msg}") + return int(response.json()["meta"]["id"]) + + return await loop.run_in_executor(None, _create_test_plan) + + elif name == "get_test_cycle": + return await loop.run_in_executor( + None, self.jama_client.get_test_cycle, arguments["test_cycle_id"] + ) + + elif name == "get_test_runs": + return await loop.run_in_executor( + None, self.jama_client.get_testruns, arguments["test_cycle_id"] + ) + + elif name == "create_test_cycle": + return await loop.run_in_executor( + None, + self.jama_client.post_testplans_testcycles, + arguments["testplan_id"], + arguments["name"], + arguments["start_date"], + arguments["end_date"], + arguments.get("testgroups_to_include"), + arguments.get("testrun_status_to_include"), + ) + + elif name == "update_test_run": + return await loop.run_in_executor( + None, + self.jama_client.put_test_run, + arguments["test_run_id"], + arguments["data"], + ) + else: raise ValueError(f"Unknown tool: {name}") diff --git a/tests/test_mcp_stdio_server.py b/tests/test_mcp_stdio_server.py index 47e8171..ae8b007 100644 --- a/tests/test_mcp_stdio_server.py +++ b/tests/test_mcp_stdio_server.py @@ -151,9 +151,13 @@ async def test_execute_get_projects(self, server_with_client): @pytest.mark.asyncio async def test_execute_get_project(self, server_with_client): """Test get_project tool.""" - server_with_client.jama_client.get_project.return_value = {"id": 1, "name": "Test"} + server_with_client.jama_client.get_projects.return_value = [ + {"id": 1, "name": "Test"}, + {"id": 2, "name": "Other"}, + ] result = await server_with_client._execute_tool("get_project", {"project_id": 1}) assert result["id"] == 1 + assert result["name"] == "Test" @pytest.mark.asyncio async def test_execute_get_item(self, server_with_client): @@ -175,7 +179,12 @@ async def test_execute_create_item(self, server_with_client): server_with_client.jama_client.post_item.return_value = 123 result = await server_with_client._execute_tool( "create_item", - {"project_id": 1, "item_type_id": 33, "fields": {"name": "Test"}}, + { + "project_id": 1, + "item_type_id": 33, + "location": {"project": 1}, + "fields": {"name": "Test"}, + }, ) assert result == 123 @@ -199,24 +208,35 @@ async def test_execute_delete_item(self, server_with_client): @pytest.mark.asyncio async def test_execute_get_item_children(self, server_with_client): """Test get_item_children tool.""" - server_with_client.jama_client.get_items_children.return_value = [{"id": 2}] + server_with_client.jama_client.get_item_children.return_value = [{"id": 2}] result = await server_with_client._execute_tool("get_item_children", {"item_id": 1}) assert result == [{"id": 2}] @pytest.mark.asyncio - async def test_execute_get_relationships(self, server_with_client): - """Test get_relationships tool.""" + async def test_execute_get_relationship_types(self, server_with_client): + """Test get_relationship_types tool.""" server_with_client.jama_client.get_relationship_types.return_value = [{"id": 1}] - result = await server_with_client._execute_tool("get_relationships", {}) + result = await server_with_client._execute_tool("get_relationship_types", {}) assert result == [{"id": 1}] @pytest.mark.asyncio - async def test_execute_get_item_relationships(self, server_with_client): - """Test get_item_relationships tool.""" - server_with_client.jama_client.get_relationships.return_value = [{"id": 1}] - result = await server_with_client._execute_tool("get_item_relationships", {"item_id": 100}) + async def test_execute_get_item_upstream_relationships(self, server_with_client): + """Test get_item_upstream_relationships tool.""" + server_with_client.jama_client.get_items_upstream_relationships.return_value = [{"id": 1}] + result = await server_with_client._execute_tool( + "get_item_upstream_relationships", {"item_id": 100} + ) assert result == [{"id": 1}] + @pytest.mark.asyncio + async def test_execute_get_item_downstream_relationships(self, server_with_client): + """Test get_item_downstream_relationships tool.""" + server_with_client.jama_client.get_items_downstream_relationships.return_value = [{"id": 2}] + result = await server_with_client._execute_tool( + "get_item_downstream_relationships", {"item_id": 100} + ) + assert result == [{"id": 2}] + @pytest.mark.asyncio async def test_execute_get_tags(self, server_with_client): """Test get_tags tool.""" @@ -238,7 +258,7 @@ async def test_execute_get_item_type(self, server_with_client): async def test_execute_get_pick_lists(self, server_with_client): """Test get_pick_lists tool.""" server_with_client.jama_client.get_pick_lists.return_value = [{"id": 1}] - result = await server_with_client._execute_tool("get_pick_lists", {"project_id": 1}) + result = await server_with_client._execute_tool("get_pick_lists", {}) assert result == [{"id": 1}] @pytest.mark.asyncio From 9b4766d35176b6ce5cc2033fc71ccda17d432ee1 Mon Sep 17 00:00:00 2001 From: Christian Nennemann Date: Thu, 9 Apr 2026 16:51:31 +0200 Subject: [PATCH 2/2] refactor: replace py_jama_rest_client with async httpx-based client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New jama_cli/core/http_client.py: async HTTP client with auth (basic, API key, OAuth), pagination (page size 50 vs old 20), retry on 429, typed exceptions - New jama_cli/core/api.py: ~60 typed async API methods including create_test_plan (no longer needs __core bypass) - New jama_cli/core/sync_wrapper.py: background-thread sync facade for CLI - New jama_cli/core/exceptions.py: clean exception hierarchy preserving class names for backward compatibility Updated consumers: - jama_cli/core/client.py: uses SyncJamaApi instead of PyJamaClient, eliminates _JamaClient__core hack - jama_mcp_server/core/stdio_server.py: uses async JamaApi directly, removes all run_in_executor boilerplate Dependency change: py-jama-rest-client -> httpx in pyproject.toml Note: jama_mcp_server/core/server.py (HTTP MCP) still uses py_jama_rest_client — will be updated in a follow-up. --- docs/plans/replace-py-jama-rest-client.md | 30 ++ jama_cli/core/api.py | 408 ++++++++++++++++++++++ jama_cli/core/client.py | 199 +++++------ jama_cli/core/exceptions.py | 58 +++ jama_cli/core/http_client.py | 285 +++++++++++++++ jama_cli/core/sync_wrapper.py | 305 ++++++++++++++++ jama_mcp_server/core/stdio_server.py | 221 ++++-------- pyproject.toml | 4 +- tests/test_cache.py | 4 +- tests/test_mcp_stdio_server.py | 106 +++--- tests/test_write_operations.py | 2 +- 11 files changed, 1304 insertions(+), 318 deletions(-) create mode 100644 docs/plans/replace-py-jama-rest-client.md create mode 100644 jama_cli/core/api.py create mode 100644 jama_cli/core/exceptions.py create mode 100644 jama_cli/core/http_client.py create mode 100644 jama_cli/core/sync_wrapper.py diff --git a/docs/plans/replace-py-jama-rest-client.md b/docs/plans/replace-py-jama-rest-client.md new file mode 100644 index 0000000..28bb5aa --- /dev/null +++ b/docs/plans/replace-py-jama-rest-client.md @@ -0,0 +1,30 @@ +# Plan: Replace py_jama_rest_client with Async JamaHttpClient + +## Architecture + +``` +Phase 1: New files (no existing code touched) + 1.1 jama_cli/core/exceptions.py — exception hierarchy + 1.2 jama_cli/core/http_client.py — async httpx client (auth, pagination, retry) + 1.3 jama_cli/core/api.py — typed async API methods (~60 wrappers) + 1.4 jama_cli/core/sync_wrapper.py — background-thread sync facade for CLI + +Phase 2: Update jama_cli/core/client.py (swap internals, keep public API) +Phase 3: Update MCP servers (remove run_in_executor, await directly) + 3.1 jama_mcp_server/core/stdio_server.py + 3.2 jama_mcp_server/core/server.py +Phase 4: pyproject.toml (drop py-jama-rest-client, add httpx) +Phase 5: Update tests +Phase 6: Cleanup (CLAUDE.md, mypy overrides) +``` + +## Key Decisions + +- **httpx.AsyncClient** for transport (both sync CLI via thread-bridge and async MCP) +- **Async-native + background-thread sync wrapper** (one implementation, no duplication) +- **Preserve exception class names** (APIException, AlreadyExistsException, etc.) +- **Page size 50** (fix the library's hardcoded 20) +- **Retry on 429** with exponential backoff (3 retries) +- **jama_mcp_server imports from jama_cli.core** for shared client layer + +## Phases 2+3 are independent and can be parallelized. diff --git a/jama_cli/core/api.py b/jama_cli/core/api.py new file mode 100644 index 0000000..fcf5996 --- /dev/null +++ b/jama_cli/core/api.py @@ -0,0 +1,408 @@ +"""Typed async API methods for the Jama REST API. + +Thin layer over JamaHttpClient that maps Jama domain operations to HTTP calls. +Replaces all ~60 methods from py_jama_rest_client.client.JamaClient. +""" + +from __future__ import annotations + +import json +from typing import Any + +from jama_cli.core.http_client import JamaHttpClient + + +class JamaApi: + """Async Jama REST API with typed methods.""" + + def __init__(self, http: JamaHttpClient) -> None: + self._http = http + + # ========================================================================= + # Projects + # ========================================================================= + + async def get_projects(self) -> list[dict[str, Any]]: + """Get all accessible projects.""" + return await self._http.get_all("projects") + + async def get_project(self, project_id: int) -> dict[str, Any]: + """Get a specific project by ID.""" + data = await self._http.get(f"projects/{project_id}") + return data.get("data", data) + + # ========================================================================= + # Items + # ========================================================================= + + async def get_items(self, project_id: int) -> list[dict[str, Any]]: + """Get all items in a project.""" + return await self._http.get_all("items", params={"project": project_id}) + + async def get_items_page( + self, + project_id: int, + start_at: int = 0, + max_results: int = 50, + ) -> list[dict[str, Any]]: + """Get a single page of items (faster than fetching all).""" + return await self._http.get_page( + "items", + params={"project": project_id}, + start_at=start_at, + max_results=max_results, + ) + + async def get_item(self, item_id: int) -> dict[str, Any]: + """Get a specific item by ID.""" + data = await self._http.get(f"items/{item_id}") + return data.get("data", data) + + async def get_item_children(self, item_id: int) -> list[dict[str, Any]]: + """Get children of an item.""" + return await self._http.get_all(f"items/{item_id}/children") + + async def get_abstract_items( + self, + project: int | None = None, + item_type: int | None = None, + contains: str | None = None, + ) -> list[dict[str, Any]]: + """Get abstract items with optional filters.""" + params: dict[str, Any] = {} + if project is not None: + params["project"] = project + if item_type is not None: + params["itemType"] = item_type + if contains is not None: + params["contains"] = contains + return await self._http.get_all("abstractitems", params=params) + + async def post_item( + self, + project: int, + item_type_id: int, + child_item_type_id: int | None, + location: dict[str, Any], + fields: dict[str, Any], + global_id: str | None = None, + ) -> int: + """Create a new item. Returns the created item ID.""" + body: dict[str, Any] = { + "project": project, + "itemType": item_type_id, + "location": {"parent": location}, + "fields": fields, + } + if child_item_type_id is not None: + body["childItemType"] = child_item_type_id + if global_id is not None: + body["globalId"] = global_id + resp = await self._http.post("items", json=body) + return int(resp["meta"]["id"]) + + async def patch_item(self, item_id: int, patches: list[dict[str, Any]]) -> int: + """Update an item using JSON Patch operations.""" + resp = await self._http.patch(f"items/{item_id}", json=patches) + return resp.get("meta", {}).get("status", 200) + + async def put_item( + self, + project: int, + item_id: int, + item_type_id: int, + child_item_type_id: int | None, + location: dict[str, Any], + fields: dict[str, Any], + ) -> int: + """Replace an item entirely.""" + body: dict[str, Any] = { + "project": project, + "itemType": item_type_id, + "location": {"parent": location}, + "fields": fields, + } + if child_item_type_id is not None: + body["childItemType"] = child_item_type_id + resp = await self._http.put(f"items/{item_id}", json=body) + return resp.get("meta", {}).get("status", 200) + + async def delete_item(self, item_id: int) -> int: + """Delete an item.""" + resp = await self._http.delete(f"items/{item_id}") + return resp.get("meta", {}).get("status", 200) + + # ========================================================================= + # Item Versions + # ========================================================================= + + async def get_item_versions(self, item_id: int) -> list[dict[str, Any]]: + """Get version history for an item.""" + return await self._http.get_all(f"items/{item_id}/versions") + + async def get_item_version(self, item_id: int, version_num: int) -> dict[str, Any]: + """Get a specific version of an item.""" + data = await self._http.get(f"items/{item_id}/versions/{version_num}") + return data.get("data", data) + + # ========================================================================= + # Relationships + # ========================================================================= + + async def get_relationships(self, project_id: int) -> list[dict[str, Any]]: + """Get all relationships in a project.""" + return await self._http.get_all("relationships", params={"project": project_id}) + + async def get_relationship(self, relationship_id: int) -> dict[str, Any]: + """Get a specific relationship.""" + data = await self._http.get(f"relationships/{relationship_id}") + return data.get("data", data) + + async def get_items_upstream_relationships(self, item_id: int) -> list[dict[str, Any]]: + """Get upstream relationships for an item.""" + return await self._http.get_all(f"items/{item_id}/upstreamrelationships") + + async def get_items_downstream_relationships(self, item_id: int) -> list[dict[str, Any]]: + """Get downstream relationships for an item.""" + return await self._http.get_all(f"items/{item_id}/downstreamrelationships") + + async def get_items_upstream_related(self, item_id: int) -> list[dict[str, Any]]: + """Get upstream related items.""" + return await self._http.get_all(f"items/{item_id}/upstreamrelated") + + async def get_items_downstream_related(self, item_id: int) -> list[dict[str, Any]]: + """Get downstream related items.""" + return await self._http.get_all(f"items/{item_id}/downstreamrelated") + + async def post_relationship( + self, + from_item: int, + to_item: int, + relationship_type: int | None = None, + ) -> int: + """Create a relationship between items. Returns relationship ID.""" + body: dict[str, Any] = {"fromItem": from_item, "toItem": to_item} + if relationship_type is not None: + body["relationshipType"] = relationship_type + resp = await self._http.post("relationships", json=body) + return int(resp["meta"]["id"]) + + async def delete_relationship(self, relationship_id: int) -> int: + """Delete a relationship.""" + resp = await self._http.delete(f"relationships/{relationship_id}") + return resp.get("meta", {}).get("status", 200) + + # ========================================================================= + # Relationship Types + # ========================================================================= + + async def get_relationship_types(self) -> list[dict[str, Any]]: + """Get all relationship types.""" + return await self._http.get_all("relationshiptypes") + + async def get_relationship_type(self, relationship_type_id: int) -> dict[str, Any]: + """Get a specific relationship type.""" + data = await self._http.get(f"relationshiptypes/{relationship_type_id}") + return data.get("data", data) + + # ========================================================================= + # Item Types + # ========================================================================= + + async def get_item_types(self) -> list[dict[str, Any]]: + """Get all item types.""" + return await self._http.get_all("itemtypes") + + async def get_item_type(self, item_type_id: int) -> dict[str, Any]: + """Get a specific item type.""" + data = await self._http.get(f"itemtypes/{item_type_id}") + return data.get("data", data) + + # ========================================================================= + # Pick Lists + # ========================================================================= + + async def get_pick_lists(self) -> list[dict[str, Any]]: + """Get all pick lists (global, not project-specific).""" + return await self._http.get_all("picklists") + + async def get_pick_list(self, pick_list_id: int) -> dict[str, Any]: + """Get a specific pick list.""" + data = await self._http.get(f"picklists/{pick_list_id}") + return data.get("data", data) + + async def get_pick_list_options(self, pick_list_id: int) -> list[dict[str, Any]]: + """Get options for a pick list.""" + return await self._http.get_all(f"picklists/{pick_list_id}/options") + + # ========================================================================= + # Tags + # ========================================================================= + + async def get_tags(self, project_id: int) -> list[dict[str, Any]]: + """Get all tags in a project.""" + return await self._http.get_all("tags", params={"project": project_id}) + + async def get_tagged_items(self, tag_id: int) -> list[dict[str, Any]]: + """Get items with a specific tag.""" + return await self._http.get_all(f"tags/{tag_id}/items") + + async def get_item_tags(self, item_id: int) -> list[dict[str, Any]]: + """Get tags for an item.""" + return await self._http.get_all(f"items/{item_id}/tags") + + async def post_item_tag(self, item_id: int, tag_id: int) -> int: + """Add a tag to an item.""" + resp = await self._http.post(f"items/{item_id}/tags", json={"tag": tag_id}) + return resp.get("meta", {}).get("status", 200) + + async def post_tag(self, name: str, project: int) -> int: + """Create a new tag.""" + resp = await self._http.post("tags", json={"name": name, "project": project}) + return int(resp["meta"]["id"]) + + # ========================================================================= + # Tests + # ========================================================================= + + async def get_test_cycle(self, test_cycle_id: int) -> dict[str, Any]: + """Get a specific test cycle.""" + data = await self._http.get(f"testcycles/{test_cycle_id}") + return data.get("data", data) + + async def get_testruns(self, test_cycle_id: int) -> list[dict[str, Any]]: + """Get test runs for a test cycle.""" + return await self._http.get_all(f"testcycles/{test_cycle_id}/testruns") + + async def create_test_plan( + self, + project_id: int, + name: str, + description: str | None = None, + start_date: str | None = None, + end_date: str | None = None, + ) -> int: + """Create a new test plan. Returns the test plan ID.""" + fields: dict[str, Any] = {"name": name} + if description is not None: + fields["description"] = description + if start_date is not None: + fields["startDate"] = start_date + if end_date is not None: + fields["endDate"] = end_date + resp = await self._http.post("testplans", json={"project": project_id, "fields": fields}) + return int(resp["meta"]["id"]) + + async def post_testplans_testcycles( + self, + testplan_id: int, + testcycle_name: str, + start_date: str, + end_date: str, + testgroups_to_include: list[int] | None = None, + testrun_status_to_include: list[str] | None = None, + ) -> int: + """Create a test cycle under a test plan. Returns test cycle ID.""" + body: dict[str, Any] = { + "fields": {"name": testcycle_name, "startDate": start_date, "endDate": end_date}, + } + if testgroups_to_include is not None: + body["testGroupsToInclude"] = testgroups_to_include + if testrun_status_to_include is not None: + body["testRunStatusToInclude"] = testrun_status_to_include + resp = await self._http.post(f"testplans/{testplan_id}/testcycles", json=body) + return int(resp["meta"]["id"]) + + async def put_test_run(self, test_run_id: int, data: dict[str, Any] | None = None) -> int: + """Update a test run.""" + resp = await self._http.put(f"testruns/{test_run_id}", json=data or {}) + return resp.get("meta", {}).get("status", 200) + + # ========================================================================= + # Baselines + # ========================================================================= + + async def get_baselines(self, project_id: int) -> list[dict[str, Any]]: + """Get all baselines for a project.""" + return await self._http.get_all("baselines", params={"project": project_id}) + + async def get_baseline(self, baseline_id: int) -> dict[str, Any]: + """Get a specific baseline.""" + data = await self._http.get(f"baselines/{baseline_id}") + return data.get("data", data) + + async def get_baselines_versioneditems(self, baseline_id: int) -> list[dict[str, Any]]: + """Get versioned items in a baseline.""" + return await self._http.get_all(f"baselines/{baseline_id}/versioneditems") + + # ========================================================================= + # Users + # ========================================================================= + + async def get_users(self) -> list[dict[str, Any]]: + """Get all users.""" + return await self._http.get_all("users") + + async def get_user(self, user_id: int) -> dict[str, Any]: + """Get a specific user.""" + data = await self._http.get(f"users/{user_id}") + return data.get("data", data) + + async def get_current_user(self) -> dict[str, Any]: + """Get the current authenticated user.""" + data = await self._http.get("users/current") + return data.get("data", data) + + # ========================================================================= + # Attachments + # ========================================================================= + + async def get_attachment(self, attachment_id: int) -> dict[str, Any]: + """Get attachment metadata.""" + data = await self._http.get(f"attachments/{attachment_id}") + return data.get("data", data) + + async def post_item_attachment(self, item_id: int, attachment_id: int) -> int: + """Link an attachment to an item.""" + resp = await self._http.post( + f"items/{item_id}/attachments", json={"attachment": attachment_id} + ) + return resp.get("meta", {}).get("status", 200) + + # ========================================================================= + # Workflow + # ========================================================================= + + async def get_item_workflow_transitions(self, item_id: int) -> list[dict[str, Any]]: + """Get available workflow transitions for an item.""" + data = await self._http.get(f"items/{item_id}/workflowtransitionoptions") + return data.get("data", []) + + # ========================================================================= + # Filters + # ========================================================================= + + async def get_filter_results( + self, + filter_id: int, + project_id: int | None = None, + ) -> list[dict[str, Any]]: + """Execute a saved filter and get results.""" + params: dict[str, Any] = {} + if project_id is not None: + params["project"] = project_id + return await self._http.get_all(f"filters/{filter_id}/results", params=params) + + # ========================================================================= + # Item Lock + # ========================================================================= + + async def get_item_lock(self, item_id: int) -> dict[str, Any]: + """Get lock status for an item.""" + data = await self._http.get(f"items/{item_id}/lock") + return data.get("data", data) + + async def put_item_lock(self, item_id: int, locked: bool) -> int: + """Lock or unlock an item.""" + resp = await self._http.put(f"items/{item_id}/lock", json={"locked": locked}) + return resp.get("meta", {}).get("status", 200) diff --git a/jama_cli/core/client.py b/jama_cli/core/client.py index e904423..43eb4a3 100644 --- a/jama_cli/core/client.py +++ b/jama_cli/core/client.py @@ -5,7 +5,6 @@ import contextlib import hashlib import json -import logging import time from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor, as_completed @@ -14,13 +13,10 @@ from typing import Any, TypeVar from loguru import logger -from py_jama_rest_client.client import JamaClient as PyJamaClient +from jama_cli.core.sync_wrapper import SyncJamaApi from jama_cli.models import JamaProfile -# Suppress verbose logging from py_jama_rest_client -logging.getLogger("py_jama_rest_client").setLevel(logging.CRITICAL) - # Default cache directory CACHE_DIR = Path.home() / ".cache" / "jama-cli" @@ -199,7 +195,7 @@ def wrapper(self: JamaClient, *args: Any, **kwargs: Any) -> T: class JamaClient: - """Synchronous wrapper around py-jama-rest-client for CLI use. + """Jama API client for CLI use. Features: - Automatic connection management @@ -226,7 +222,7 @@ def __init__(self, profile: JamaProfile, use_disk_cache: bool = True) -> None: use_disk_cache: Enable persistent disk caching for large datasets """ self.profile = profile - self._client: PyJamaClient | None = None + self._api: SyncJamaApi | None = None self._cache = Cache() # Disk cache for large datasets (persists between CLI runs) @@ -240,36 +236,36 @@ def __init__(self, profile: JamaProfile, use_disk_cache: bool = True) -> None: def connect(self) -> None: """Establish connection to Jama.""" - if self._client is not None: + if self._api is not None: return if self.profile.auth_type == "api_key": - self._client = PyJamaClient( - host_domain=self.profile.url, + self._api = SyncJamaApi.from_credentials( + base_url=self.profile.url, credentials=(self.profile.api_key, ""), oauth=False, ) elif self.profile.auth_type == "oauth": - self._client = PyJamaClient( - host_domain=self.profile.url, + self._api = SyncJamaApi.from_credentials( + base_url=self.profile.url, credentials=(self.profile.client_id, self.profile.client_secret), oauth=True, ) elif self.profile.auth_type == "basic": - self._client = PyJamaClient( - host_domain=self.profile.url, + self._api = SyncJamaApi.from_credentials( + base_url=self.profile.url, credentials=(self.profile.username, self.profile.password), oauth=False, ) else: raise ValueError(f"Unknown auth type: {self.profile.auth_type}") - def _ensure_connected(self) -> PyJamaClient: + def _ensure_connected(self) -> SyncJamaApi: """Ensure client is connected and return it.""" - if self._client is None: + if self._api is None: self.connect() - assert self._client is not None - return self._client + assert self._api is not None + return self._api def clear_cache(self) -> None: """Clear all cached data.""" @@ -319,8 +315,8 @@ def get_project_relationships_bulk( return cached # Fetch from API - client = self._ensure_connected() - relationships = client.get_relationships(project_id) + api = self._ensure_connected() + relationships = api.get_relationships(project_id) # Cache to disk if self._disk_cache: @@ -408,8 +404,8 @@ def get_items_bulk( return items # Fetch from API - client = self._ensure_connected() - items = client.get_items(project_id) + api = self._ensure_connected() + items = api.get_items(project_id) # Cache to disk (without type filter, so cache works for all types) if self._disk_cache: @@ -608,14 +604,14 @@ def analyze_traceability_fast( @cached(ttl=300, key_prefix="projects") # 5 minutes def get_projects(self) -> list[dict[str, Any]]: """Get all accessible projects (cached for 5 minutes).""" - client = self._ensure_connected() - return client.get_projects() + api = self._ensure_connected() + return api.get_projects() def get_project(self, project_id: int) -> dict[str, Any]: """Get a specific project by ID.""" - client = self._ensure_connected() + api = self._ensure_connected() # py-jama-rest-client doesn't have get_project, so filter from get_projects - projects = client.get_projects() + projects = api.get_projects() for project in projects: if project.get("id") == project_id: return project @@ -638,13 +634,13 @@ def get_items( item_type: Optional item type ID to filter by max_results: Maximum number of items to return (for faster queries) """ - client = self._ensure_connected() + api = self._ensure_connected() if max_results and max_results <= 50: # Use single page fetch for small limits (much faster) items = self._get_items_page(project_id, start_at=0, max_results=max_results) else: - items = client.get_items(project_id) + items = api.get_items(project_id) if item_type: items = [item for item in items if item.get("itemType") == item_type] @@ -667,25 +663,18 @@ def _get_items_page( start_at: Starting index max_results: Maximum items per page (max 50) """ - client = self._ensure_connected() - # Access the underlying API directly for single page - resource_path = ( - f"items?project={project_id}&startAt={start_at}&maxResults={min(max_results, 50)}" - ) - response = client._JamaClient__core.get(resource_path) - # Parse JSON from response - data = response.json() - return data.get("data", []) + api = self._ensure_connected() + return api.get_items_page(project_id, start_at, max_results) def get_item(self, item_id: int) -> dict[str, Any]: """Get a specific item by ID.""" - client = self._ensure_connected() - return client.get_item(item_id) + api = self._ensure_connected() + return api.get_item(item_id) def get_item_children(self, item_id: int) -> list[dict[str, Any]]: """Get children of an item.""" - client = self._ensure_connected() - return client.get_item_children(item_id) + api = self._ensure_connected() + return api.get_item_children(item_id) def create_item( self, @@ -700,8 +689,8 @@ def create_item( Returns: The ID of the created item """ - client = self._ensure_connected() - return client.post_item( + api = self._ensure_connected() + return api.post_item( project=project_id, item_type_id=item_type_id, child_item_type_id=child_item_type_id, @@ -711,17 +700,17 @@ def create_item( def update_item(self, item_id: int, fields: dict[str, Any]) -> None: """Update an item's fields using JSON patch.""" - client = self._ensure_connected() + api = self._ensure_connected() patches = [ {"op": "replace", "path": f"/fields/{field}", "value": value} for field, value in fields.items() ] - client.patch_item(item_id, patches) + api.patch_item(item_id, patches) def delete_item(self, item_id: int) -> None: """Delete an item.""" - client = self._ensure_connected() - client.delete_item(item_id) + api = self._ensure_connected() + api.delete_item(item_id) # ========================================================================= # Relationships @@ -729,33 +718,33 @@ def delete_item(self, item_id: int) -> None: def get_relationships(self, project_id: int) -> list[dict[str, Any]]: """Get all relationships in a project.""" - client = self._ensure_connected() - return client.get_relationships(project_id) + api = self._ensure_connected() + return api.get_relationships(project_id) def get_relationship(self, relationship_id: int) -> dict[str, Any]: """Get a specific relationship.""" - client = self._ensure_connected() - return client.get_relationship(relationship_id) + api = self._ensure_connected() + return api.get_relationship(relationship_id) def get_item_upstream_relationships(self, item_id: int) -> list[dict[str, Any]]: """Get upstream relationships for an item.""" - client = self._ensure_connected() - return client.get_items_upstream_relationships(item_id) + api = self._ensure_connected() + return api.get_items_upstream_relationships(item_id) def get_item_downstream_relationships(self, item_id: int) -> list[dict[str, Any]]: """Get downstream relationships for an item.""" - client = self._ensure_connected() - return client.get_items_downstream_relationships(item_id) + api = self._ensure_connected() + return api.get_items_downstream_relationships(item_id) def get_item_upstream_related(self, item_id: int) -> list[dict[str, Any]]: """Get upstream related items.""" - client = self._ensure_connected() - return client.get_items_upstream_related(item_id) + api = self._ensure_connected() + return api.get_items_upstream_related(item_id) def get_item_downstream_related(self, item_id: int) -> list[dict[str, Any]]: """Get downstream related items.""" - client = self._ensure_connected() - return client.get_items_downstream_related(item_id) + api = self._ensure_connected() + return api.get_items_downstream_related(item_id) def create_relationship( self, @@ -768,13 +757,13 @@ def create_relationship( Returns: The ID of the created relationship """ - client = self._ensure_connected() - return client.post_relationship(from_item, to_item, relationship_type) + api = self._ensure_connected() + return api.post_relationship(from_item, to_item, relationship_type) def delete_relationship(self, relationship_id: int) -> None: """Delete a relationship.""" - client = self._ensure_connected() - client.delete_relationship(relationship_id) + api = self._ensure_connected() + api.delete_relationship(relationship_id) # ========================================================================= # Item Types (cached - rarely change) @@ -783,14 +772,14 @@ def delete_relationship(self, relationship_id: int) -> None: @cached(ttl=3600, key_prefix="item_types") # 1 hour def get_item_types(self) -> list[dict[str, Any]]: """Get all item types (cached for 1 hour).""" - client = self._ensure_connected() - return client.get_item_types() + api = self._ensure_connected() + return api.get_item_types() @cached(ttl=3600, key_prefix="item_type") # 1 hour def get_item_type(self, item_type_id: int) -> dict[str, Any]: """Get a specific item type (cached for 1 hour).""" - client = self._ensure_connected() - return client.get_item_type(item_type_id) + api = self._ensure_connected() + return api.get_item_type(item_type_id) # ========================================================================= # Pick Lists (cached - rarely change) @@ -799,20 +788,20 @@ def get_item_type(self, item_type_id: int) -> dict[str, Any]: @cached(ttl=3600, key_prefix="pick_lists") # 1 hour def get_pick_lists(self) -> list[dict[str, Any]]: """Get all pick lists (cached for 1 hour).""" - client = self._ensure_connected() - return client.get_pick_lists() + api = self._ensure_connected() + return api.get_pick_lists() @cached(ttl=3600, key_prefix="pick_list") # 1 hour def get_pick_list(self, pick_list_id: int) -> dict[str, Any]: """Get a specific pick list (cached for 1 hour).""" - client = self._ensure_connected() - return client.get_pick_list(pick_list_id) + api = self._ensure_connected() + return api.get_pick_list(pick_list_id) @cached(ttl=3600, key_prefix="pick_list_options") # 1 hour def get_pick_list_options(self, pick_list_id: int) -> list[dict[str, Any]]: """Get options for a pick list (cached for 1 hour).""" - client = self._ensure_connected() - return client.get_pick_list_options(pick_list_id) + api = self._ensure_connected() + return api.get_pick_list_options(pick_list_id) # ========================================================================= # Tags @@ -820,13 +809,13 @@ def get_pick_list_options(self, pick_list_id: int) -> list[dict[str, Any]]: def get_tags(self, project_id: int) -> list[dict[str, Any]]: """Get all tags in a project.""" - client = self._ensure_connected() - return client.get_tags(project_id) + api = self._ensure_connected() + return api.get_tags(project_id) def get_tagged_items(self, tag_id: int) -> list[dict[str, Any]]: """Get items with a specific tag.""" - client = self._ensure_connected() - return client.get_tagged_items(tag_id) + api = self._ensure_connected() + return api.get_tagged_items(tag_id) # ========================================================================= # Tests @@ -834,13 +823,13 @@ def get_tagged_items(self, tag_id: int) -> list[dict[str, Any]]: def get_test_cycle(self, test_cycle_id: int) -> dict[str, Any]: """Get a specific test cycle.""" - client = self._ensure_connected() - return client.get_test_cycle(test_cycle_id) + api = self._ensure_connected() + return api.get_test_cycle(test_cycle_id) def get_test_runs(self, test_cycle_id: int) -> list[dict[str, Any]]: """Get test runs for a test cycle.""" - client = self._ensure_connected() - return client.get_testruns(test_cycle_id) + api = self._ensure_connected() + return api.get_testruns(test_cycle_id) # ========================================================================= # Users (cached - change infrequently) @@ -849,14 +838,14 @@ def get_test_runs(self, test_cycle_id: int) -> list[dict[str, Any]]: @cached(ttl=600, key_prefix="users") # 10 minutes def get_users(self) -> list[dict[str, Any]]: """Get all users (cached for 10 minutes).""" - client = self._ensure_connected() - return client.get_users() + api = self._ensure_connected() + return api.get_users() @cached(ttl=600, key_prefix="current_user") # 10 minutes def get_current_user(self) -> dict[str, Any]: """Get the current user (cached for 10 minutes).""" - client = self._ensure_connected() - return client.get_current_user() + api = self._ensure_connected() + return api.get_current_user() # ========================================================================= # Baselines @@ -864,18 +853,18 @@ def get_current_user(self) -> dict[str, Any]: def get_baselines(self, project_id: int) -> list[dict[str, Any]]: """Get all baselines for a project.""" - client = self._ensure_connected() - return client.get_baselines(project_id) + api = self._ensure_connected() + return api.get_baselines(project_id) def get_baseline(self, baseline_id: int) -> dict[str, Any]: """Get a specific baseline.""" - client = self._ensure_connected() - return client.get_baseline(baseline_id) + api = self._ensure_connected() + return api.get_baseline(baseline_id) def get_baseline_versioned_items(self, baseline_id: int) -> list[dict[str, Any]]: """Get versioned items in a baseline.""" - client = self._ensure_connected() - return client.get_baselines_versioneditems(baseline_id) + api = self._ensure_connected() + return api.get_baselines_versioneditems(baseline_id) # ========================================================================= # Item Versions @@ -883,13 +872,13 @@ def get_baseline_versioned_items(self, baseline_id: int) -> list[dict[str, Any]] def get_item_versions(self, item_id: int) -> list[dict[str, Any]]: """Get version history for an item.""" - client = self._ensure_connected() - return client.get_item_versions(item_id) + api = self._ensure_connected() + return api.get_item_versions(item_id) def get_item_version(self, item_id: int, version: int) -> dict[str, Any]: """Get a specific version of an item.""" - client = self._ensure_connected() - return client.get_item_version(item_id, version) + api = self._ensure_connected() + return api.get_item_version(item_id, version) # ========================================================================= # Relationship Types @@ -898,8 +887,8 @@ def get_item_version(self, item_id: int, version: int) -> dict[str, Any]: @cached(ttl=3600, key_prefix="relationship_types") # 1 hour def get_relationship_types(self) -> list[dict[str, Any]]: """Get all relationship types (cached for 1 hour).""" - client = self._ensure_connected() - return client.get_relationship_types() + api = self._ensure_connected() + return api.get_relationship_types() # ========================================================================= # Attachments @@ -907,13 +896,13 @@ def get_relationship_types(self) -> list[dict[str, Any]]: def get_attachment(self, attachment_id: int) -> dict[str, Any]: """Get attachment metadata.""" - client = self._ensure_connected() - return client.get_attachment(attachment_id) + api = self._ensure_connected() + return api.get_attachment(attachment_id) def get_item_tags(self, item_id: int) -> list[dict[str, Any]]: """Get tags for an item.""" - client = self._ensure_connected() - return client.get_item_tags(item_id) + api = self._ensure_connected() + return api.get_item_tags(item_id) def download_attachment(self, attachment_id: int, output_path: Path) -> None: """Download attachment file content. @@ -921,8 +910,8 @@ def download_attachment(self, attachment_id: int, output_path: Path) -> None: Note: The py-jama-rest-client doesn't have a direct download method, so this gets the attachment URL and downloads via the file URL. """ - client = self._ensure_connected() - attachment = client.get_attachment(attachment_id) + api = self._ensure_connected() + attachment = api.get_attachment(attachment_id) # Get the file URL from attachment metadata _file_url = attachment.get("fileName") # This may need adjustment based on API response @@ -950,5 +939,5 @@ def upload_attachment( Returns: The ID of the created attachment """ - client = self._ensure_connected() - return client.post_item_attachment(item_id, str(file_path)) + api = self._ensure_connected() + return api.post_item_attachment(item_id, str(file_path)) diff --git a/jama_cli/core/exceptions.py b/jama_cli/core/exceptions.py new file mode 100644 index 0000000..94ff0db --- /dev/null +++ b/jama_cli/core/exceptions.py @@ -0,0 +1,58 @@ +"""Exception hierarchy for the Jama API client. + +Replaces py_jama_rest_client exceptions with a clean hierarchy that preserves +the same class names for backward compatibility. +""" + +from __future__ import annotations + + +class JamaException(Exception): + """Base exception for all Jama client errors.""" + + def __init__(self, message: str, status_code: int | None = None, reason: str | None = None): + super().__init__(message) + self.status_code = status_code + self.reason = reason + + +class CoreException(JamaException): + """Transport-level errors (connection, timeout, DNS).""" + + +class APIException(JamaException): + """Generic API error (non-2xx response).""" + + +class APIClientException(APIException): + """Client error (4xx).""" + + +class APIServerException(APIException): + """Server error (5xx).""" + + +class UnauthorizedException(APIClientException): + """401 Unauthorized.""" + + +class ResourceNotFoundException(APIClientException): + """404 Not Found.""" + + +class AlreadyExistsException(APIClientException): + """409 Conflict / resource already exists.""" + + +class TooManyRequestsException(APIClientException): + """429 Too Many Requests.""" + + def __init__( + self, + message: str, + status_code: int = 429, + reason: str | None = None, + retry_after: float | None = None, + ): + super().__init__(message, status_code, reason) + self.retry_after = retry_after diff --git a/jama_cli/core/http_client.py b/jama_cli/core/http_client.py new file mode 100644 index 0000000..242a019 --- /dev/null +++ b/jama_cli/core/http_client.py @@ -0,0 +1,285 @@ +"""Async HTTP client for the Jama REST API. + +Replaces py_jama_rest_client with a native async implementation using httpx. +Handles authentication (basic, API key, OAuth), pagination, retry on 429, +and maps HTTP status codes to typed exceptions. +""" + +from __future__ import annotations + +import asyncio +import math +import time +from typing import Any, AsyncGenerator + +import httpx +from loguru import logger + +from jama_cli.core.exceptions import ( + AlreadyExistsException, + APIClientException, + APIException, + APIServerException, + CoreException, + ResourceNotFoundException, + TooManyRequestsException, + UnauthorizedException, +) + +# Jama API max page size +MAX_PAGE_SIZE = 50 + + +class JamaHttpClient: + """Async HTTP client for the Jama REST API. + + Supports three auth modes: + - basic: username + password + - api_key: API key as username, empty password + - oauth: client_credentials grant with proactive token refresh + """ + + def __init__( + self, + base_url: str, + credentials: tuple[str, str], + oauth: bool = False, + timeout: float = 30.0, + page_size: int = MAX_PAGE_SIZE, + ) -> None: + self._base_url = base_url.rstrip("/") + self._api_url = f"{self._base_url}/rest/v1/" + self._credentials = credentials + self._oauth = oauth + self._page_size = min(page_size, MAX_PAGE_SIZE) + + # OAuth token state + self._token: str | None = None + self._token_expires_at: float = 0 + + # httpx client (created lazily or via context manager) + auth = None if oauth else httpx.BasicAuth(*credentials) + self._client = httpx.AsyncClient( + base_url=self._api_url, + auth=auth, + timeout=timeout, + headers={"Accept": "application/json"}, + ) + + # --- Lifecycle --- + + async def __aenter__(self) -> JamaHttpClient: + return self + + async def __aexit__(self, *args: Any) -> None: + await self.close() + + async def close(self) -> None: + """Close the underlying HTTP client.""" + await self._client.aclose() + + # --- OAuth --- + + async def _ensure_token(self) -> None: + """Refresh OAuth token if expired or about to expire (within 60s).""" + if not self._oauth: + return + if self._token and time.time() < self._token_expires_at - 60: + return + await self._refresh_token() + + async def _refresh_token(self) -> None: + """Fetch a new OAuth bearer token via client_credentials grant.""" + token_url = f"{self._base_url}/rest/oauth/token" + time_before = time.time() + + try: + response = await self._client.post( + token_url, + data={"grant_type": "client_credentials"}, + auth=httpx.BasicAuth(*self._credentials), + ) + response.raise_for_status() + except httpx.HTTPStatusError as e: + raise UnauthorizedException( + f"OAuth token request failed: {e}", + status_code=e.response.status_code, + ) from e + except httpx.HTTPError as e: + raise CoreException(f"OAuth token request failed: {e}") from e + + data = response.json() + self._token = data["access_token"] + self._token_expires_at = math.floor(time_before) + data["expires_in"] + logger.debug("OAuth token refreshed") + + # --- Core HTTP --- + + async def _request( + self, + method: str, + path: str, + max_retries: int = 3, + **kwargs: Any, + ) -> httpx.Response: + """Execute an HTTP request with auth, status mapping, and 429 retry. + + Args: + method: HTTP method (GET, POST, PUT, PATCH, DELETE) + path: API resource path (e.g. "projects", "items/123") + max_retries: Max retries on 429 + **kwargs: Passed to httpx (params, json, content, headers, etc.) + + Returns: + httpx.Response + + Raises: + Typed exceptions for non-2xx responses + """ + if self._oauth: + await self._ensure_token() + + for attempt in range(max_retries + 1): + try: + # Set OAuth bearer header per-request + if self._oauth and self._token: + headers = kwargs.pop("headers", {}) + headers["Authorization"] = f"Bearer {self._token}" + kwargs["headers"] = headers + + response = await self._client.request(method, path, **kwargs) + + if response.status_code == 429: + if attempt < max_retries: + retry_after = float(response.headers.get("Retry-After", 2 ** attempt)) + logger.warning(f"Rate limited, retrying in {retry_after}s (attempt {attempt + 1})") + await asyncio.sleep(retry_after) + continue + self._raise_for_status(response) + + self._raise_for_status(response) + return response + + except httpx.HTTPError as e: + if attempt < max_retries and "429" not in str(e): + raise + raise CoreException(f"HTTP request failed: {e}") from e + + # Should not reach here, but satisfy type checker + raise CoreException("Max retries exceeded") + + @staticmethod + def _raise_for_status(response: httpx.Response) -> None: + """Map HTTP status to typed exceptions.""" + code = response.status_code + if 200 <= code < 300: + return + + try: + body = response.json() + message = body.get("meta", {}).get("message", response.text) + except Exception: + message = response.text + + if code == 401: + raise UnauthorizedException(message, status_code=code) + if code == 404: + raise ResourceNotFoundException(message, status_code=code) + if code == 409 or (code == 400 and "already exists" in message.lower()): + raise AlreadyExistsException(message, status_code=code) + if code == 429: + retry_after = float(response.headers.get("Retry-After", 0)) or None + raise TooManyRequestsException( + message, status_code=code, retry_after=retry_after + ) + if 400 <= code < 500: + raise APIClientException(message, status_code=code) + if 500 <= code < 600: + raise APIServerException(message, status_code=code) + raise APIException(message, status_code=code) + + # --- Convenience HTTP methods --- + + async def get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + """GET a single resource. Returns the response JSON.""" + response = await self._request("GET", path, params=params) + return response.json() + + async def post(self, path: str, json: dict[str, Any] | None = None, **kwargs: Any) -> dict[str, Any]: + """POST to a resource. Returns the response JSON.""" + response = await self._request("POST", path, json=json, **kwargs) + return response.json() + + async def put(self, path: str, json: dict[str, Any] | None = None) -> dict[str, Any]: + """PUT a resource. Returns the response JSON.""" + response = await self._request("PUT", path, json=json) + return response.json() + + async def patch(self, path: str, json: Any = None) -> dict[str, Any]: + """PATCH a resource. Returns the response JSON.""" + response = await self._request("PATCH", path, json=json) + return response.json() + + async def delete(self, path: str) -> dict[str, Any]: + """DELETE a resource. Returns the response JSON.""" + response = await self._request("DELETE", path) + return response.json() + + # --- Pagination --- + + async def get_all(self, path: str, params: dict[str, Any] | None = None) -> list[dict[str, Any]]: + """GET all pages of a paginated resource. + + Uses page size 50 (API max) instead of py_jama_rest_client's hardcoded 20. + """ + results: list[dict[str, Any]] = [] + async for page in self.get_pages(path, params): + results.extend(page) + return results + + async def get_pages( + self, + path: str, + params: dict[str, Any] | None = None, + ) -> AsyncGenerator[list[dict[str, Any]], None]: + """Yield pages from a paginated Jama API endpoint. + + Reads `meta.pageInfo` from the Jama response envelope: + - startIndex: current offset + - resultCount: items in this page + - totalResults: total items available + """ + params = dict(params or {}) + start_at = 0 + + while True: + params["startAt"] = start_at + params["maxResults"] = self._page_size + + data = await self.get(path, params=params) + page_info = data.get("meta", {}).get("pageInfo", {}) + items = data.get("data", []) + + if items: + yield items + + result_count = page_info.get("resultCount", len(items)) + total_results = page_info.get("totalResults", result_count) + + start_at += result_count + if start_at >= total_results or result_count == 0: + break + + async def get_page( + self, + path: str, + params: dict[str, Any] | None = None, + start_at: int = 0, + max_results: int = MAX_PAGE_SIZE, + ) -> list[dict[str, Any]]: + """GET a single page of results.""" + params = dict(params or {}) + params["startAt"] = start_at + params["maxResults"] = min(max_results, MAX_PAGE_SIZE) + data = await self.get(path, params=params) + return data.get("data", []) diff --git a/jama_cli/core/sync_wrapper.py b/jama_cli/core/sync_wrapper.py new file mode 100644 index 0000000..aa2dee5 --- /dev/null +++ b/jama_cli/core/sync_wrapper.py @@ -0,0 +1,305 @@ +"""Synchronous wrapper around JamaApi for CLI use. + +Runs an asyncio event loop on a background daemon thread and dispatches +all async API calls to it via run_coroutine_threadsafe. This avoids +creating a new event loop per call and allows httpx connection reuse. +""" + +from __future__ import annotations + +import asyncio +import threading +from typing import Any + +from jama_cli.core.api import JamaApi +from jama_cli.core.http_client import JamaHttpClient + + +class SyncJamaApi: + """Synchronous facade over JamaApi for CLI use. + + Usage: + api = SyncJamaApi.from_credentials(url, credentials, oauth=True) + projects = api.get_projects() + api.close() + """ + + def __init__(self, api: JamaApi, http: JamaHttpClient) -> None: + self._api = api + self._http = http + self._loop = asyncio.new_event_loop() + self._thread = threading.Thread(target=self._loop.run_forever, daemon=True) + self._thread.start() + + @classmethod + def from_credentials( + cls, + base_url: str, + credentials: tuple[str, str], + oauth: bool = False, + timeout: float = 30.0, + ) -> SyncJamaApi: + """Create a SyncJamaApi from connection credentials.""" + http = JamaHttpClient( + base_url=base_url, + credentials=credentials, + oauth=oauth, + timeout=timeout, + ) + api = JamaApi(http) + return cls(api, http) + + def _run(self, coro: Any) -> Any: + """Run an async coroutine synchronously via the background event loop.""" + future = asyncio.run_coroutine_threadsafe(coro, self._loop) + return future.result() + + def close(self) -> None: + """Shut down the background event loop and HTTP client.""" + asyncio.run_coroutine_threadsafe(self._http.close(), self._loop).result() + self._loop.call_soon_threadsafe(self._loop.stop) + self._thread.join(timeout=5) + + # ========================================================================= + # Projects + # ========================================================================= + + def get_projects(self) -> list[dict[str, Any]]: + return self._run(self._api.get_projects()) + + def get_project(self, project_id: int) -> dict[str, Any]: + return self._run(self._api.get_project(project_id)) + + # ========================================================================= + # Items + # ========================================================================= + + def get_items(self, project_id: int) -> list[dict[str, Any]]: + return self._run(self._api.get_items(project_id)) + + def get_items_page( + self, + project_id: int, + start_at: int = 0, + max_results: int = 50, + ) -> list[dict[str, Any]]: + return self._run(self._api.get_items_page(project_id, start_at, max_results)) + + def get_item(self, item_id: int) -> dict[str, Any]: + return self._run(self._api.get_item(item_id)) + + def get_item_children(self, item_id: int) -> list[dict[str, Any]]: + return self._run(self._api.get_item_children(item_id)) + + def post_item( + self, + project: int, + item_type_id: int, + child_item_type_id: int | None, + location: dict[str, Any], + fields: dict[str, Any], + global_id: str | None = None, + ) -> int: + return self._run( + self._api.post_item(project, item_type_id, child_item_type_id, location, fields, global_id) + ) + + def patch_item(self, item_id: int, patches: list[dict[str, Any]]) -> int: + return self._run(self._api.patch_item(item_id, patches)) + + def delete_item(self, item_id: int) -> int: + return self._run(self._api.delete_item(item_id)) + + # ========================================================================= + # Item Versions + # ========================================================================= + + def get_item_versions(self, item_id: int) -> list[dict[str, Any]]: + return self._run(self._api.get_item_versions(item_id)) + + def get_item_version(self, item_id: int, version_num: int) -> dict[str, Any]: + return self._run(self._api.get_item_version(item_id, version_num)) + + # ========================================================================= + # Relationships + # ========================================================================= + + def get_relationships(self, project_id: int) -> list[dict[str, Any]]: + return self._run(self._api.get_relationships(project_id)) + + def get_relationship(self, relationship_id: int) -> dict[str, Any]: + return self._run(self._api.get_relationship(relationship_id)) + + def get_items_upstream_relationships(self, item_id: int) -> list[dict[str, Any]]: + return self._run(self._api.get_items_upstream_relationships(item_id)) + + def get_items_downstream_relationships(self, item_id: int) -> list[dict[str, Any]]: + return self._run(self._api.get_items_downstream_relationships(item_id)) + + def get_items_upstream_related(self, item_id: int) -> list[dict[str, Any]]: + return self._run(self._api.get_items_upstream_related(item_id)) + + def get_items_downstream_related(self, item_id: int) -> list[dict[str, Any]]: + return self._run(self._api.get_items_downstream_related(item_id)) + + def post_relationship( + self, + from_item: int, + to_item: int, + relationship_type: int | None = None, + ) -> int: + return self._run(self._api.post_relationship(from_item, to_item, relationship_type)) + + def delete_relationship(self, relationship_id: int) -> int: + return self._run(self._api.delete_relationship(relationship_id)) + + # ========================================================================= + # Relationship Types + # ========================================================================= + + def get_relationship_types(self) -> list[dict[str, Any]]: + return self._run(self._api.get_relationship_types()) + + def get_relationship_type(self, relationship_type_id: int) -> dict[str, Any]: + return self._run(self._api.get_relationship_type(relationship_type_id)) + + # ========================================================================= + # Item Types + # ========================================================================= + + def get_item_types(self) -> list[dict[str, Any]]: + return self._run(self._api.get_item_types()) + + def get_item_type(self, item_type_id: int) -> dict[str, Any]: + return self._run(self._api.get_item_type(item_type_id)) + + # ========================================================================= + # Pick Lists + # ========================================================================= + + def get_pick_lists(self) -> list[dict[str, Any]]: + return self._run(self._api.get_pick_lists()) + + def get_pick_list(self, pick_list_id: int) -> dict[str, Any]: + return self._run(self._api.get_pick_list(pick_list_id)) + + def get_pick_list_options(self, pick_list_id: int) -> list[dict[str, Any]]: + return self._run(self._api.get_pick_list_options(pick_list_id)) + + # ========================================================================= + # Tags + # ========================================================================= + + def get_tags(self, project_id: int) -> list[dict[str, Any]]: + return self._run(self._api.get_tags(project_id)) + + def get_tagged_items(self, tag_id: int) -> list[dict[str, Any]]: + return self._run(self._api.get_tagged_items(tag_id)) + + def get_item_tags(self, item_id: int) -> list[dict[str, Any]]: + return self._run(self._api.get_item_tags(item_id)) + + def post_item_tag(self, item_id: int, tag_id: int) -> int: + return self._run(self._api.post_item_tag(item_id, tag_id)) + + # ========================================================================= + # Tests + # ========================================================================= + + def get_test_cycle(self, test_cycle_id: int) -> dict[str, Any]: + return self._run(self._api.get_test_cycle(test_cycle_id)) + + def get_testruns(self, test_cycle_id: int) -> list[dict[str, Any]]: + return self._run(self._api.get_testruns(test_cycle_id)) + + def create_test_plan( + self, + project_id: int, + name: str, + description: str | None = None, + start_date: str | None = None, + end_date: str | None = None, + ) -> int: + return self._run( + self._api.create_test_plan(project_id, name, description, start_date, end_date) + ) + + def post_testplans_testcycles( + self, + testplan_id: int, + testcycle_name: str, + start_date: str, + end_date: str, + testgroups_to_include: list[int] | None = None, + testrun_status_to_include: list[str] | None = None, + ) -> int: + return self._run( + self._api.post_testplans_testcycles( + testplan_id, testcycle_name, start_date, end_date, + testgroups_to_include, testrun_status_to_include, + ) + ) + + def put_test_run(self, test_run_id: int, data: dict[str, Any] | None = None) -> int: + return self._run(self._api.put_test_run(test_run_id, data)) + + # ========================================================================= + # Baselines + # ========================================================================= + + def get_baselines(self, project_id: int) -> list[dict[str, Any]]: + return self._run(self._api.get_baselines(project_id)) + + def get_baseline(self, baseline_id: int) -> dict[str, Any]: + return self._run(self._api.get_baseline(baseline_id)) + + def get_baselines_versioneditems(self, baseline_id: int) -> list[dict[str, Any]]: + return self._run(self._api.get_baselines_versioneditems(baseline_id)) + + # ========================================================================= + # Users + # ========================================================================= + + def get_users(self) -> list[dict[str, Any]]: + return self._run(self._api.get_users()) + + def get_current_user(self) -> dict[str, Any]: + return self._run(self._api.get_current_user()) + + # ========================================================================= + # Attachments + # ========================================================================= + + def get_attachment(self, attachment_id: int) -> dict[str, Any]: + return self._run(self._api.get_attachment(attachment_id)) + + def post_item_attachment(self, item_id: int, attachment_id: int) -> int: + return self._run(self._api.post_item_attachment(item_id, attachment_id)) + + # ========================================================================= + # Workflow + # ========================================================================= + + def get_item_workflow_transitions(self, item_id: int) -> list[dict[str, Any]]: + return self._run(self._api.get_item_workflow_transitions(item_id)) + + # ========================================================================= + # Filters + # ========================================================================= + + def get_filter_results( + self, + filter_id: int, + project_id: int | None = None, + ) -> list[dict[str, Any]]: + return self._run(self._api.get_filter_results(filter_id, project_id)) + + # ========================================================================= + # Item Lock + # ========================================================================= + + def get_item_lock(self, item_id: int) -> dict[str, Any]: + return self._run(self._api.get_item_lock(item_id)) + + def put_item_lock(self, item_id: int, locked: bool) -> int: + return self._run(self._api.put_item_lock(item_id, locked)) diff --git a/jama_mcp_server/core/stdio_server.py b/jama_mcp_server/core/stdio_server.py index 60131ad..3b26287 100644 --- a/jama_mcp_server/core/stdio_server.py +++ b/jama_mcp_server/core/stdio_server.py @@ -7,15 +7,15 @@ from __future__ import annotations -import asyncio import json from typing import Any from loguru import logger from mcp.server import Server from mcp.types import TextContent, Tool -from py_jama_rest_client.client import JamaClient +from jama_cli.core.api import JamaApi +from jama_cli.core.http_client import JamaHttpClient from jama_mcp_server.models import JamaConfig @@ -30,7 +30,8 @@ def __init__(self, config: JamaConfig): config: Jama configuration """ self.config = config - self.jama_client: JamaClient | None = None + self._api: JamaApi | None = None + self._http: JamaHttpClient | None = None self.mcp = Server("jama-mcp-server") # Register request handlers @@ -417,7 +418,7 @@ async def list_tools() -> list[Tool]: async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: """Execute a tool with the given arguments.""" try: - if not self.jama_client: + if not self._api: raise RuntimeError("Jama client not initialized") result = await self._execute_tool(name, arguments) @@ -429,49 +430,26 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: return [TextContent(type="text", text=json.dumps({"error": str(e), "tool": name}))] async def _execute_tool(self, name: str, arguments: dict[str, Any]) -> Any: - """Execute the specified tool with arguments. - - Args: - name: Tool name to execute - arguments: Tool arguments - - Returns: - Tool execution result - - Raises: - ValueError: If tool is unknown - RuntimeError: If Jama client is not initialized - """ - if not self.jama_client: + """Execute the specified tool with arguments.""" + if not self._api: raise RuntimeError("Jama client not initialized") - # Run synchronous Jama client methods in executor - loop = asyncio.get_running_loop() + api = self._api if name == "get_projects": - return await loop.run_in_executor(None, self.jama_client.get_projects) + return await api.get_projects() elif name == "get_project": - - def get_single_project() -> Any: - projects = self.jama_client.get_projects() # type: ignore[union-attr] - project_id = arguments["project_id"] - return next((p for p in projects if p["id"] == project_id), None) - - return await loop.run_in_executor(None, get_single_project) + return await api.get_project(arguments["project_id"]) elif name == "get_item": - return await loop.run_in_executor(None, self.jama_client.get_item, arguments["item_id"]) + return await api.get_item(arguments["item_id"]) elif name == "get_items": - return await loop.run_in_executor( - None, self.jama_client.get_items, arguments["project_id"] - ) + return await api.get_items(arguments["project_id"]) elif name == "create_item": - return await loop.run_in_executor( - None, - self.jama_client.post_item, + return await api.post_item( arguments["project_id"], arguments["item_type_id"], arguments.get("child_item_type_id"), @@ -480,144 +458,90 @@ def get_single_project() -> Any: ) elif name == "update_item": - # Use patch_item for updates - def update_fn() -> Any: - patches = [] - for field, value in arguments["fields"].items(): - patches.append({"op": "replace", "path": f"/fields/{field}", "value": value}) - return self.jama_client.patch_item(arguments["item_id"], patches) - - return await loop.run_in_executor(None, update_fn) + patches = [ + {"op": "replace", "path": f"/fields/{field}", "value": value} + for field, value in arguments["fields"].items() + ] + return await api.patch_item(arguments["item_id"], patches) elif name == "delete_item": - return await loop.run_in_executor( - None, self.jama_client.delete_item, arguments["item_id"] - ) + return await api.delete_item(arguments["item_id"]) elif name == "get_item_children": - return await loop.run_in_executor( - None, self.jama_client.get_item_children, arguments["item_id"] - ) + return await api.get_item_children(arguments["item_id"]) elif name == "get_relationship_types": - return await loop.run_in_executor(None, self.jama_client.get_relationship_types) + return await api.get_relationship_types() elif name == "get_item_upstream_relationships": - return await loop.run_in_executor( - None, self.jama_client.get_items_upstream_relationships, arguments["item_id"] - ) + return await api.get_items_upstream_relationships(arguments["item_id"]) elif name == "get_item_downstream_relationships": - return await loop.run_in_executor( - None, self.jama_client.get_items_downstream_relationships, arguments["item_id"] - ) + return await api.get_items_downstream_relationships(arguments["item_id"]) elif name == "get_tags": - return await loop.run_in_executor( - None, self.jama_client.get_tags, arguments["project_id"] - ) + return await api.get_tags(arguments["project_id"]) elif name == "get_item_type": - return await loop.run_in_executor( - None, self.jama_client.get_item_type, arguments["item_type_id"] - ) + return await api.get_item_type(arguments["item_type_id"]) elif name == "get_pick_lists": - return await loop.run_in_executor(None, self.jama_client.get_pick_lists) + return await api.get_pick_lists() elif name == "get_baselines": - return await loop.run_in_executor( - None, self.jama_client.get_baselines, arguments["project_id"] - ) + return await api.get_baselines(arguments["project_id"]) elif name == "get_baseline": - return await loop.run_in_executor( - None, self.jama_client.get_baseline, arguments["baseline_id"] - ) + return await api.get_baseline(arguments["baseline_id"]) elif name == "get_current_user": - return await loop.run_in_executor(None, self.jama_client.get_current_user) + return await api.get_current_user() elif name == "get_users": - return await loop.run_in_executor(None, self.jama_client.get_users) + return await api.get_users() elif name == "get_item_versions": - return await loop.run_in_executor( - None, self.jama_client.get_item_versions, arguments["item_id"] - ) + return await api.get_item_versions(arguments["item_id"]) elif name == "get_item_tags": - return await loop.run_in_executor( - None, self.jama_client.get_item_tags, arguments["item_id"] - ) + return await api.get_item_tags(arguments["item_id"]) elif name == "post_item_tag": - return await loop.run_in_executor( - None, self.jama_client.post_item_tag, arguments["item_id"], arguments["tag_id"] - ) + return await api.post_item_tag(arguments["item_id"], arguments["tag_id"]) elif name == "get_item_workflow_transitions": - return await loop.run_in_executor( - None, self.jama_client.get_item_workflow_transitions, arguments["item_id"] - ) + return await api.get_item_workflow_transitions(arguments["item_id"]) elif name == "get_attachment": - return await loop.run_in_executor( - None, self.jama_client.get_attachment, arguments["attachment_id"] - ) + return await api.get_attachment(arguments["attachment_id"]) elif name == "get_filter_results": - return await loop.run_in_executor( - None, self.jama_client.get_filter_results, arguments["filter_id"] - ) + return await api.get_filter_results(arguments["filter_id"]) elif name == "create_relationship": - return await loop.run_in_executor( - None, - self.jama_client.post_relationship, + return await api.post_relationship( arguments["from_item"], arguments["to_item"], arguments.get("relationship_type"), ) elif name == "create_test_plan": - - def _create_test_plan() -> int: - """Bypass py_jama_rest_client (no upstream method) — POST /testplans directly.""" - fields: dict[str, Any] = {"name": arguments["name"]} - if arguments.get("description"): - fields["description"] = arguments["description"] - if arguments.get("start_date"): - fields["startDate"] = arguments["start_date"] - if arguments.get("end_date"): - fields["endDate"] = arguments["end_date"] - - body = {"project": arguments["project_id"], "fields": fields} - headers = {"content-type": "application/json"} - response = self.jama_client._JamaClient__core.post( # type: ignore[union-attr] - "testplans", data=json.dumps(body), headers=headers - ) - if not (200 <= response.status_code < 300): - msg = response.json().get("meta", {}).get("message", "Unknown error") - raise RuntimeError(f"POST testplans failed [{response.status_code}]: {msg}") - return int(response.json()["meta"]["id"]) - - return await loop.run_in_executor(None, _create_test_plan) + return await api.create_test_plan( + arguments["project_id"], + arguments["name"], + arguments.get("description"), + arguments.get("start_date"), + arguments.get("end_date"), + ) elif name == "get_test_cycle": - return await loop.run_in_executor( - None, self.jama_client.get_test_cycle, arguments["test_cycle_id"] - ) + return await api.get_test_cycle(arguments["test_cycle_id"]) elif name == "get_test_runs": - return await loop.run_in_executor( - None, self.jama_client.get_testruns, arguments["test_cycle_id"] - ) + return await api.get_testruns(arguments["test_cycle_id"]) elif name == "create_test_cycle": - return await loop.run_in_executor( - None, - self.jama_client.post_testplans_testcycles, + return await api.post_testplans_testcycles( arguments["testplan_id"], arguments["name"], arguments["start_date"], @@ -627,58 +551,51 @@ def _create_test_plan() -> int: ) elif name == "update_test_run": - return await loop.run_in_executor( - None, - self.jama_client.put_test_run, - arguments["test_run_id"], - arguments["data"], - ) + return await api.put_test_run(arguments["test_run_id"], arguments["data"]) else: raise ValueError(f"Unknown tool: {name}") - async def initialize_client(self): - """Initialize the Jama client.""" + async def initialize_client(self) -> None: + """Initialize the async Jama HTTP client and API.""" try: logger.info(f"Initializing Jama client for {self.config.url}") - # Use OAuth client credentials if provided if self.config.client_id and self.config.client_secret: logger.info("Using OAuth client credentials authentication") - self.jama_client = JamaClient( - host_domain=self.config.url, - credentials=(self.config.client_id, self.config.client_secret), - oauth=True, - ) - # Use API key if provided + credentials = (self.config.client_id, self.config.client_secret) + oauth = True elif self.config.api_key: logger.info("Using API key authentication") - self.jama_client = JamaClient( - host_domain=self.config.url, credentials=(self.config.api_key,) - ) - # Fall back to username/password + credentials = (self.config.api_key, "") + oauth = False else: logger.info("Using username/password authentication") - self.jama_client = JamaClient( - host_domain=self.config.url, - credentials=(self.config.username, self.config.password), - oauth=self.config.oauth, - ) + credentials = (self.config.username, self.config.password) + oauth = self.config.oauth + self._http = JamaHttpClient( + base_url=self.config.url, + credentials=credentials, + oauth=oauth, + ) + self._api = JamaApi(self._http) logger.info("Jama client initialized successfully") except Exception as e: logger.error(f"Failed to initialize Jama client: {e}") raise - async def run(self): + async def run(self) -> None: """Run the stdio MCP server.""" from mcp.server.stdio import stdio_server - # Initialize Jama client await self.initialize_client() - logger.info("Starting Jama stdio MCP server") - async with stdio_server() as (read_stream, write_stream): - await self.mcp.run(read_stream, write_stream, self.mcp.create_initialization_options()) + try: + async with stdio_server() as (read_stream, write_stream): + await self.mcp.run(read_stream, write_stream, self.mcp.create_initialization_options()) + finally: + if self._http: + await self._http.close() diff --git a/pyproject.toml b/pyproject.toml index 6e19fb4..e432df3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ include = ["NOTICE"] [tool.poetry.dependencies] python = "^3.10" aiohttp = "^3.9.0" -py-jama-rest-client = "^1.17.0" +httpx = ">=0.27.0" pydantic = "^2.0.0" loguru = "^0.7.0" typer = {version = ">=0.12.0", extras = ["all"]} @@ -87,10 +87,8 @@ strict_concatenate = true [[tool.mypy.overrides]] module = [ - "py_jama_rest_client.*", "aiohttp_swagger3.*", "mcp.*", - "requests.*", ] ignore_missing_imports = true diff --git a/tests/test_cache.py b/tests/test_cache.py index 4e58892..cb51bdf 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -122,7 +122,7 @@ def test_cached_method_uses_cache(self, client: JamaClient) -> None: mock_jama_client = MagicMock() mock_jama_client.get_item_types.return_value = [{"id": 1, "name": "Test"}] - with patch.object(client, "_client", mock_jama_client): + with patch.object(client, "_api", mock_jama_client): # First call - should hit the API result1 = client.get_item_types() @@ -141,7 +141,7 @@ def test_cached_method_respects_ttl(self, client: JamaClient) -> None: # Set a very short TTL for testing client._cache.set("projects:():[]", [{"id": 1}], ttl=0) - with patch.object(client, "_client", mock_jama_client): + with patch.object(client, "_api", mock_jama_client): time.sleep(0.01) # Wait for cache to expire # Should call API because cache expired diff --git a/tests/test_mcp_stdio_server.py b/tests/test_mcp_stdio_server.py index ae8b007..a202464 100644 --- a/tests/test_mcp_stdio_server.py +++ b/tests/test_mcp_stdio_server.py @@ -1,6 +1,6 @@ -"""Comprehensive tests for stdio MCP server to achieve 100% coverage.""" +"""Comprehensive tests for stdio MCP server.""" -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -21,7 +21,7 @@ def test_init_with_oauth(self): ) server = JamaStdioMCPServer(config) assert server.config == config - assert server.jama_client is None + assert server._api is None assert server.mcp is not None def test_init_with_api_key(self): @@ -57,14 +57,14 @@ async def test_initialize_client_oauth(self): ) server = JamaStdioMCPServer(config) - with patch("jama_mcp_server.core.stdio_server.JamaClient") as mock_client: + with patch("jama_mcp_server.core.stdio_server.JamaHttpClient") as mock_http: await server.initialize_client() - mock_client.assert_called_once_with( - host_domain="https://test.jamacloud.com", + mock_http.assert_called_once_with( + base_url="https://test.jamacloud.com", credentials=("client123", "secret456"), oauth=True, ) - assert server.jama_client is not None + assert server._api is not None @pytest.mark.asyncio async def test_initialize_client_api_key(self): @@ -75,11 +75,12 @@ async def test_initialize_client_api_key(self): ) server = JamaStdioMCPServer(config) - with patch("jama_mcp_server.core.stdio_server.JamaClient") as mock_client: + with patch("jama_mcp_server.core.stdio_server.JamaHttpClient") as mock_http: await server.initialize_client() - mock_client.assert_called_once_with( - host_domain="https://test.jamacloud.com", - credentials=("apikey123",), + mock_http.assert_called_once_with( + base_url="https://test.jamacloud.com", + credentials=("apikey123", ""), + oauth=False, ) @pytest.mark.asyncio @@ -93,10 +94,10 @@ async def test_initialize_client_basic_auth(self): ) server = JamaStdioMCPServer(config) - with patch("jama_mcp_server.core.stdio_server.JamaClient") as mock_client: + with patch("jama_mcp_server.core.stdio_server.JamaHttpClient") as mock_http: await server.initialize_client() - mock_client.assert_called_once_with( - host_domain="https://test.jamacloud.com", + mock_http.assert_called_once_with( + base_url="https://test.jamacloud.com", credentials=("user", "pass"), oauth=False, ) @@ -111,8 +112,8 @@ async def test_initialize_client_failure(self): ) server = JamaStdioMCPServer(config) - with patch("jama_mcp_server.core.stdio_server.JamaClient") as mock_client: - mock_client.side_effect = Exception("Connection failed") + with patch("jama_mcp_server.core.stdio_server.JamaHttpClient") as mock_http: + mock_http.side_effect = Exception("Connection failed") with pytest.raises(Exception, match="Connection failed"): await server.initialize_client() @@ -122,14 +123,14 @@ class TestExecuteTool: @pytest.fixture def server_with_client(self): - """Create server with mocked client.""" + """Create server with mocked async API.""" config = JamaConfig( url="https://test.jamacloud.com", client_id="client123", client_secret="secret456", ) server = JamaStdioMCPServer(config) - server.jama_client = MagicMock() + server._api = AsyncMock() return server @pytest.mark.asyncio @@ -144,17 +145,14 @@ async def test_execute_tool_no_client(self): @pytest.mark.asyncio async def test_execute_get_projects(self, server_with_client): """Test get_projects tool.""" - server_with_client.jama_client.get_projects.return_value = [{"id": 1}] + server_with_client._api.get_projects.return_value = [{"id": 1}] result = await server_with_client._execute_tool("get_projects", {}) assert result == [{"id": 1}] @pytest.mark.asyncio async def test_execute_get_project(self, server_with_client): """Test get_project tool.""" - server_with_client.jama_client.get_projects.return_value = [ - {"id": 1, "name": "Test"}, - {"id": 2, "name": "Other"}, - ] + server_with_client._api.get_project.return_value = {"id": 1, "name": "Test"} result = await server_with_client._execute_tool("get_project", {"project_id": 1}) assert result["id"] == 1 assert result["name"] == "Test" @@ -162,21 +160,21 @@ async def test_execute_get_project(self, server_with_client): @pytest.mark.asyncio async def test_execute_get_item(self, server_with_client): """Test get_item tool.""" - server_with_client.jama_client.get_item.return_value = {"id": 100} + server_with_client._api.get_item.return_value = {"id": 100} result = await server_with_client._execute_tool("get_item", {"item_id": 100}) assert result["id"] == 100 @pytest.mark.asyncio async def test_execute_get_items(self, server_with_client): """Test get_items tool.""" - server_with_client.jama_client.get_items.return_value = [{"id": 1}, {"id": 2}] + server_with_client._api.get_items.return_value = [{"id": 1}, {"id": 2}] result = await server_with_client._execute_tool("get_items", {"project_id": 1}) assert len(result) == 2 @pytest.mark.asyncio async def test_execute_create_item(self, server_with_client): """Test create_item tool.""" - server_with_client.jama_client.post_item.return_value = 123 + server_with_client._api.post_item.return_value = 123 result = await server_with_client._execute_tool( "create_item", { @@ -191,38 +189,38 @@ async def test_execute_create_item(self, server_with_client): @pytest.mark.asyncio async def test_execute_update_item(self, server_with_client): """Test update_item tool.""" - server_with_client.jama_client.patch_item.return_value = True + server_with_client._api.patch_item.return_value = 200 result = await server_with_client._execute_tool( "update_item", {"item_id": 100, "fields": {"name": "Updated"}}, ) - assert result is True + assert result == 200 @pytest.mark.asyncio async def test_execute_delete_item(self, server_with_client): """Test delete_item tool.""" - server_with_client.jama_client.delete_item.return_value = True + server_with_client._api.delete_item.return_value = 200 result = await server_with_client._execute_tool("delete_item", {"item_id": 100}) - assert result is True + assert result == 200 @pytest.mark.asyncio async def test_execute_get_item_children(self, server_with_client): """Test get_item_children tool.""" - server_with_client.jama_client.get_item_children.return_value = [{"id": 2}] + server_with_client._api.get_item_children.return_value = [{"id": 2}] result = await server_with_client._execute_tool("get_item_children", {"item_id": 1}) assert result == [{"id": 2}] @pytest.mark.asyncio async def test_execute_get_relationship_types(self, server_with_client): """Test get_relationship_types tool.""" - server_with_client.jama_client.get_relationship_types.return_value = [{"id": 1}] + server_with_client._api.get_relationship_types.return_value = [{"id": 1}] result = await server_with_client._execute_tool("get_relationship_types", {}) assert result == [{"id": 1}] @pytest.mark.asyncio async def test_execute_get_item_upstream_relationships(self, server_with_client): """Test get_item_upstream_relationships tool.""" - server_with_client.jama_client.get_items_upstream_relationships.return_value = [{"id": 1}] + server_with_client._api.get_items_upstream_relationships.return_value = [{"id": 1}] result = await server_with_client._execute_tool( "get_item_upstream_relationships", {"item_id": 100} ) @@ -231,7 +229,7 @@ async def test_execute_get_item_upstream_relationships(self, server_with_client) @pytest.mark.asyncio async def test_execute_get_item_downstream_relationships(self, server_with_client): """Test get_item_downstream_relationships tool.""" - server_with_client.jama_client.get_items_downstream_relationships.return_value = [{"id": 2}] + server_with_client._api.get_items_downstream_relationships.return_value = [{"id": 2}] result = await server_with_client._execute_tool( "get_item_downstream_relationships", {"item_id": 100} ) @@ -240,14 +238,14 @@ async def test_execute_get_item_downstream_relationships(self, server_with_clien @pytest.mark.asyncio async def test_execute_get_tags(self, server_with_client): """Test get_tags tool.""" - server_with_client.jama_client.get_tags.return_value = [{"id": 1, "name": "Tag1"}] + server_with_client._api.get_tags.return_value = [{"id": 1, "name": "Tag1"}] result = await server_with_client._execute_tool("get_tags", {"project_id": 1}) assert result[0]["name"] == "Tag1" @pytest.mark.asyncio async def test_execute_get_item_type(self, server_with_client): """Test get_item_type tool.""" - server_with_client.jama_client.get_item_type.return_value = { + server_with_client._api.get_item_type.return_value = { "id": 33, "display": "Requirement", } @@ -257,65 +255,65 @@ async def test_execute_get_item_type(self, server_with_client): @pytest.mark.asyncio async def test_execute_get_pick_lists(self, server_with_client): """Test get_pick_lists tool.""" - server_with_client.jama_client.get_pick_lists.return_value = [{"id": 1}] + server_with_client._api.get_pick_lists.return_value = [{"id": 1}] result = await server_with_client._execute_tool("get_pick_lists", {}) assert result == [{"id": 1}] @pytest.mark.asyncio async def test_execute_get_baselines(self, server_with_client): """Test get_baselines tool.""" - server_with_client.jama_client.get_baselines.return_value = [{"id": 1}] + server_with_client._api.get_baselines.return_value = [{"id": 1}] result = await server_with_client._execute_tool("get_baselines", {"project_id": 1}) assert result == [{"id": 1}] @pytest.mark.asyncio async def test_execute_get_baseline(self, server_with_client): """Test get_baseline tool.""" - server_with_client.jama_client.get_baseline.return_value = {"id": 1} + server_with_client._api.get_baseline.return_value = {"id": 1} result = await server_with_client._execute_tool("get_baseline", {"baseline_id": 1}) assert result["id"] == 1 @pytest.mark.asyncio async def test_execute_get_current_user(self, server_with_client): """Test get_current_user tool.""" - server_with_client.jama_client.get_current_user.return_value = {"id": 1, "username": "user"} + server_with_client._api.get_current_user.return_value = {"id": 1, "username": "user"} result = await server_with_client._execute_tool("get_current_user", {}) assert result["username"] == "user" @pytest.mark.asyncio async def test_execute_get_users(self, server_with_client): """Test get_users tool.""" - server_with_client.jama_client.get_users.return_value = [{"id": 1}] + server_with_client._api.get_users.return_value = [{"id": 1}] result = await server_with_client._execute_tool("get_users", {}) assert result == [{"id": 1}] @pytest.mark.asyncio async def test_execute_get_item_versions(self, server_with_client): """Test get_item_versions tool.""" - server_with_client.jama_client.get_item_versions.return_value = [{"version": 1}] + server_with_client._api.get_item_versions.return_value = [{"version": 1}] result = await server_with_client._execute_tool("get_item_versions", {"item_id": 100}) assert result[0]["version"] == 1 @pytest.mark.asyncio async def test_execute_get_item_tags(self, server_with_client): """Test get_item_tags tool.""" - server_with_client.jama_client.get_item_tags.return_value = [{"id": 1}] + server_with_client._api.get_item_tags.return_value = [{"id": 1}] result = await server_with_client._execute_tool("get_item_tags", {"item_id": 100}) assert result == [{"id": 1}] @pytest.mark.asyncio async def test_execute_post_item_tag(self, server_with_client): """Test post_item_tag tool.""" - server_with_client.jama_client.post_item_tag.return_value = True + server_with_client._api.post_item_tag.return_value = 200 result = await server_with_client._execute_tool( "post_item_tag", {"item_id": 100, "tag_id": 1} ) - assert result is True + assert result == 200 @pytest.mark.asyncio async def test_execute_get_item_workflow_transitions(self, server_with_client): """Test get_item_workflow_transitions tool.""" - server_with_client.jama_client.get_item_workflow_transitions.return_value = [{"id": 1}] + server_with_client._api.get_item_workflow_transitions.return_value = [{"id": 1}] result = await server_with_client._execute_tool( "get_item_workflow_transitions", {"item_id": 100} ) @@ -324,7 +322,7 @@ async def test_execute_get_item_workflow_transitions(self, server_with_client): @pytest.mark.asyncio async def test_execute_get_attachment(self, server_with_client): """Test get_attachment tool.""" - server_with_client.jama_client.get_attachment.return_value = { + server_with_client._api.get_attachment.return_value = { "id": 1, "fileName": "test.txt", } @@ -334,7 +332,7 @@ async def test_execute_get_attachment(self, server_with_client): @pytest.mark.asyncio async def test_execute_get_filter_results(self, server_with_client): """Test get_filter_results tool.""" - server_with_client.jama_client.get_filter_results.return_value = [{"id": 1}] + server_with_client._api.get_filter_results.return_value = [{"id": 1}] result = await server_with_client._execute_tool("get_filter_results", {"filter_id": 1}) assert result == [{"id": 1}] @@ -352,7 +350,6 @@ def test_tools_registered(self): """Test that tools are properly registered.""" config = JamaConfig(url="https://test.jamacloud.com", api_key="key") server = JamaStdioMCPServer(config) - # The mcp server should have handlers registered assert server.mcp is not None @@ -360,17 +357,16 @@ class TestRunServer: """Tests for server run method.""" @pytest.mark.asyncio - async def test_initialize_client_sets_client(self): - """Test that initialize_client sets jama_client.""" + async def test_initialize_client_sets_api(self): + """Test that initialize_client sets _api.""" config = JamaConfig( url="https://test.jamacloud.com", client_id="client123", client_secret="secret456", ) server = JamaStdioMCPServer(config) - assert server.jama_client is None + assert server._api is None - with patch("jama_mcp_server.core.stdio_server.JamaClient") as mock_client: - mock_client.return_value = MagicMock() + with patch("jama_mcp_server.core.stdio_server.JamaHttpClient"): await server.initialize_client() - assert server.jama_client is not None + assert server._api is not None diff --git a/tests/test_write_operations.py b/tests/test_write_operations.py index 86ea546..6fcdbf6 100644 --- a/tests/test_write_operations.py +++ b/tests/test_write_operations.py @@ -116,7 +116,7 @@ async def test_create_and_delete_relationship( self, jama_config: JamaConfig, test_item_id: int, test_project_id: int ) -> None: """Test creating and deleting a relationship.""" - from py_jama_rest_client.client import AlreadyExistsException + from jama_cli.core.exceptions import AlreadyExistsException server = JamaMCPServer(config=jama_config)