Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added find
Empty file.
104 changes: 103 additions & 1 deletion src/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,13 +128,38 @@ async def _request(

def _format_error_message(self, status: int, response_text: str) -> str:
"""Format error message based on HTTP status code"""
base_msg = f"API Error {status}: {response_text}"
# Try to extract a human-readable message from OpenProject's JSON error body
api_message = None
try:
error_json = json.loads(response_text)
if isinstance(error_json, dict):
api_message = error_json.get("message")
error_id = error_json.get("errorIdentifier", "")
# Also collect field-level validation errors if present
details = error_json.get("_embedded", {}).get("details", {})
attribute_errors = details.get("attribute", [])
if attribute_errors:
api_message = api_message or "Validation failed"
api_message += ": " + "; ".join(
f"{e.get('title', '')} - {e.get('message', '')}"
for e in attribute_errors
if isinstance(e, dict)
)
except (json.JSONDecodeError, AttributeError):
pass

if api_message:
base_msg = f"API Error {status}: {api_message}"
else:
base_msg = f"API Error {status}: {response_text}"

error_hints = {
401: "Authentication failed. Please check your API key.",
403: "Access denied. The user lacks required permissions.",
404: "Resource not found. Please verify the URL and resource exists.",
407: "Proxy authentication required.",
409: "Update conflict: the resource was modified elsewhere. Retry the operation.",
422: "The request was rejected by the server. Check field values and workflow rules.",
500: "Internal server error. Please try again later.",
502: "Bad gateway. The server or proxy is not responding correctly.",
503: "Service unavailable. The server might be under maintenance.",
Expand Down Expand Up @@ -275,6 +300,12 @@ async def create_work_package(self, data: Dict) -> Dict:
payload["_links"]["version"] = {
"href": f"/api/v3/versions/{data['version_id']}"
}
if "sprint_id" in data:
if "_links" not in payload:
payload["_links"] = {}
payload["_links"]["sprint"] = {
"href": f"/api/v3/sprints/{data['sprint_id']}"
}

# Add date fields (ISO 8601 format: YYYY-MM-DD)
if "startDate" in data:
Expand All @@ -284,6 +315,10 @@ async def create_work_package(self, data: Dict) -> Dict:
if "date" in data:
payload["date"] = data["date"]

# Story points (agile estimate)
if "story_points" in data:
payload["storyPoints"] = data["story_points"]

# Create work package
return await self._request("POST", "/work_packages", payload)

Expand Down Expand Up @@ -481,6 +516,17 @@ async def update_work_package(self, work_package_id: int, data: Dict) -> Dict:
payload["_links"]["version"] = {
"href": f"/api/v3/versions/{data['version_id']}"
}
if "sprint_id" in data:
if "_links" not in payload:
payload["_links"] = {}
if data["sprint_id"] is None:
payload["_links"]["sprint"] = {"href": None}
else:
payload["_links"]["sprint"] = {
"href": f"/api/v3/sprints/{data['sprint_id']}"
}
if "story_points" in data:
payload["storyPoints"] = data["story_points"]
if "percentage_done" in data:
payload["percentageDone"] = data["percentage_done"]
if "parent_id" in data:
Expand Down Expand Up @@ -748,6 +794,62 @@ async def create_version(self, project_id: int, data: Dict) -> Dict:

return await self._request("POST", "/versions", payload)

async def get_sprints(
self,
project_id: Optional[int] = None,
filters: Optional[str] = None,
offset: Optional[int] = None,
page_size: Optional[int] = None,
) -> Dict:
"""
Retrieve sprints, optionally scoped to a project.

Args:
project_id: Optional project ID to scope the listing
filters: Optional JSON-encoded filter string
offset: Optional starting index for pagination
page_size: Optional number of results per page

Returns:
Dict: API response containing sprints
"""
if project_id:
endpoint = f"/projects/{project_id}/sprints"
else:
endpoint = "/sprints"

query_params = []
if filters:
query_params.append(f"filters={quote(filters)}")
if offset is not None:
query_params.append(f"offset={offset}")
if page_size is not None:
query_params.append(f"pageSize={page_size}")

if query_params:
endpoint += "?" + "&".join(query_params)

result = await self._request("GET", endpoint)

if "_embedded" not in result:
result["_embedded"] = {"elements": []}
elif "elements" not in result.get("_embedded", {}):
result["_embedded"]["elements"] = []

return result

async def get_sprint(self, sprint_id: int) -> Dict:
"""
Retrieve a single sprint by ID.

Args:
sprint_id: The sprint ID

Returns:
Dict: Sprint data
"""
return await self._request("GET", f"/sprints/{sprint_id}")

async def check_permissions(self) -> Dict:
"""
Check user permissions and capabilities.
Expand Down
3 changes: 2 additions & 1 deletion src/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,9 @@ def get_client():
from src.tools import versions # 2 tools: list, create
from src.tools import weekly_reports # 4 tools: generate_weekly_report, get_report_data, generate_this_week_report, generate_last_week_report
from src.tools import news # 5 tools: list_news, create_news, get_news, update_news, delete_news
from src.tools import sprints # 4 tools: list_sprints, get_sprint, list_sprint_work_packages, list_backlog_work_packages

logger.info("✅ All 49 tool modules loaded successfully")
logger.info("✅ All 53 tool modules loaded successfully")
except ImportError as e:
logger.warning(f"⚠️ Some tool modules failed to import: {e}")
raise
Expand Down
Loading