# Copyright (c) 2025 Patrick Motsch # All rights reserved. """ClickUp API service (OAuth or personal token via UserConnection).""" import json import logging import asyncio from typing import Any, Callable, Dict, List, Optional, Union import aiohttp logger = logging.getLogger(__name__) _CLICKUP_API_BASE = "https://api.clickup.com/api/v2" def clickup_authorization_header(token: str) -> str: """ClickUp: personal tokens are `pk_...` without Bearer; OAuth uses Bearer.""" t = (token or "").strip() if t.startswith("pk_"): return t return f"Bearer {t}" class ClickupService: """ClickUp REST API v2 — teams, hierarchy, lists as tables (tasks + custom fields).""" def __init__(self, context, get_service: Callable[[str], Any]): self._context = context self._get_service = get_service self.accessToken: Optional[str] = None def setAccessTokenFromConnection(self, userConnection) -> bool: """Load OAuth/personal token from SecurityService for this UserConnection.""" try: if not userConnection: logger.error("UserConnection is required to set access token") return False if isinstance(userConnection, dict): connection_id = userConnection.get("id") else: connection_id = getattr(userConnection, "id", None) if not connection_id: logger.error("UserConnection must have an 'id' field") return False security = self._get_service("security") if not security: logger.error("Security service not available for token access") return False token = security.getFreshToken(connection_id) if not token: logger.error(f"No token found for connection {connection_id}") return False self.accessToken = token.tokenAccess return True except Exception as e: logger.error(f"Error setting ClickUp access token: {e}") return False def setAccessToken(self, token: str) -> None: """Set token directly (e.g. connector adapter).""" self.accessToken = token async def _request( self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None, json_body: Optional[Dict[str, Any]] = None, data: Optional[aiohttp.FormData] = None, ) -> Union[Dict[str, Any], List[Any], bytes, None]: if not self.accessToken: return {"error": "Access token is not set. Call setAccessTokenFromConnection first."} url = f"{_CLICKUP_API_BASE}/{path.lstrip('/')}" headers: Dict[str, str] = { "Authorization": clickup_authorization_header(self.accessToken), } if json_body is not None: headers["Content-Type"] = "application/json" timeout = aiohttp.ClientTimeout(total=60) try: async with aiohttp.ClientSession(timeout=timeout) as session: kwargs: Dict[str, Any] = {"headers": headers, "params": params} if json_body is not None: kwargs["json"] = json_body if data is not None: kwargs["data"] = data async with session.request(method.upper(), url, **kwargs) as resp: if resp.status == 204: return {} text = await resp.text() if resp.status >= 400: # 404 on GET is common (wrong id / preview) — avoid ERROR noise in logs log = logger.warning if resp.status == 404 else logger.error log(f"ClickUp API {method} {url} -> {resp.status}: {text[:500]}") return {"error": f"HTTP {resp.status}", "body": text} if not text: return {} try: return json.loads(text) except Exception: return {"raw": text} except asyncio.TimeoutError: return {"error": f"ClickUp API timeout: {path}"} except Exception as e: logger.error(f"ClickUp API error: {e}") return {"error": str(e)} async def requestRaw( self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None, json_body: Optional[Dict[str, Any]] = None, ) -> Union[Dict[str, Any], List[Any], None]: """Escape hatch: call any v2 path under /api/v2 (path without leading /api/v2).""" return await self._request(method, path, params=params, json_body=json_body) # --- Teams / user --- async def getAuthorizedUser(self) -> Dict[str, Any]: return await self._request("GET", "/user") async def getAuthorizedTeams(self) -> Dict[str, Any]: return await self._request("GET", "/team") async def getTeam(self, team_id: str) -> Dict[str, Any]: return await self._request("GET", f"/team/{team_id}") # --- Hierarchy --- async def getSpaces(self, team_id: str) -> Dict[str, Any]: return await self._request("GET", f"/team/{team_id}/space") async def getSpace(self, space_id: str) -> Dict[str, Any]: return await self._request("GET", f"/space/{space_id}") async def getFolders(self, space_id: str) -> Dict[str, Any]: return await self._request("GET", f"/space/{space_id}/folder") async def getFolder(self, folder_id: str) -> Dict[str, Any]: return await self._request("GET", f"/folder/{folder_id}") async def getListsInFolder(self, folder_id: str) -> Dict[str, Any]: return await self._request("GET", f"/folder/{folder_id}/list") async def getFolderlessLists(self, space_id: str) -> Dict[str, Any]: return await self._request("GET", f"/space/{space_id}/list") async def getList(self, list_id: str) -> Dict[str, Any]: return await self._request("GET", f"/list/{list_id}") async def getListFields(self, list_id: str) -> Dict[str, Any]: return await self._request("GET", f"/list/{list_id}/field") # --- Tasks (rows) --- async def getTasksInList( self, list_id: str, *, page: int = 0, include_closed: bool = False, subtasks: bool = True, ) -> Dict[str, Any]: params: Dict[str, Any] = { "page": page, "subtasks": str(subtasks).lower(), "include_closed": str(include_closed).lower(), } return await self._request("GET", f"/list/{list_id}/task", params=params) async def getTask(self, task_id: str, *, include_subtasks: bool = True) -> Dict[str, Any]: params = {"include_subtasks": str(include_subtasks).lower()} return await self._request("GET", f"/task/{task_id}", params=params) async def createTask(self, list_id: str, body: Dict[str, Any]) -> Dict[str, Any]: return await self._request("POST", f"/list/{list_id}/task", json_body=body) async def updateTask(self, task_id: str, body: Dict[str, Any]) -> Dict[str, Any]: return await self._request("PUT", f"/task/{task_id}", json_body=body) async def deleteTask(self, task_id: str) -> Dict[str, Any]: return await self._request("DELETE", f"/task/{task_id}") async def searchTeamTasks( self, team_id: str, *, query: str, page: int = 0, ) -> Dict[str, Any]: """Search tasks in a workspace (team).""" params = {"query": query, "page": page} return await self._request("GET", f"/team/{team_id}/task", params=params) async def uploadTaskAttachment(self, task_id: str, file_bytes: bytes, file_name: str) -> Dict[str, Any]: """Upload a file attachment to a task (multipart).""" if not self.accessToken: return {"error": "Access token is not set."} url = f"{_CLICKUP_API_BASE}/task/{task_id}/attachment" headers = {"Authorization": clickup_authorization_header(self.accessToken)} data = aiohttp.FormData() data.add_field( "attachment", file_bytes, filename=file_name, content_type="application/octet-stream", ) timeout = aiohttp.ClientTimeout(total=120) try: async with aiohttp.ClientSession(timeout=timeout) as session: async with session.post(url, headers=headers, data=data) as resp: text = await resp.text() if resp.status >= 400: return {"error": f"HTTP {resp.status}", "body": text} return json.loads(text) if text else {} except Exception as e: return {"error": str(e)}