1
+ import os
2
+ import time
3
+ import requests
4
+ from typing import Any , Dict , Iterable , List , Optional , Union
5
+
6
+ # Prefer absolute import to avoid relative import issues
7
+ from codebots .bots ._bot import BaseBot
8
+
9
+ __all__ = ["ClickUpBot" ]
10
+
11
+
12
+ class ClickUpBot (BaseBot ):
13
+ """Bot to interact with ClickUp API v2 using a personal API token.
14
+
15
+ Token handling:
16
+ - Uses environment variable CLICKUPBOT_BOT_TOKEN if set (recommended for CI/CD).
17
+ - Falls back to ~/.tokens/clickup.json with {"bot_token": "..."} for local dev.
18
+
19
+ Parameters
20
+ ----------
21
+ config_file : str, optional
22
+ Path to a JSON token file. Defaults to ~/.tokens/clickup.json
23
+ base_url : str, optional
24
+ Override ClickUp API base URL. Defaults to https://api.clickup.com/api/v2
25
+ timeout : float, optional
26
+ HTTP request timeout in seconds (default 30.0)
27
+ """
28
+
29
+ def __init__ (
30
+ self ,
31
+ config_file : Optional [str ] = None ,
32
+ base_url : str = "https://api.clickup.com/api/v2" ,
33
+ timeout : float = 30.0 ,
34
+ ) -> None :
35
+ if not config_file :
36
+ from .. import TOKENS
37
+ config_file = os .path .join (TOKENS , "clickup.json" )
38
+ super ().__init__ (config_file )
39
+
40
+ token = getattr (self , "bot_token" , None )
41
+ if not token :
42
+ raise ValueError (
43
+ "ClickUp token not found. Set CLICKUPBOT_BOT_TOKEN or provide a token file."
44
+ )
45
+
46
+ self .base_url = base_url .rstrip ("/" )
47
+ self .timeout = timeout
48
+ self ._session = requests .Session ()
49
+ # ClickUp expects the token directly in the Authorization header (no 'Bearer' prefix)
50
+ self ._session .headers .update (
51
+ {
52
+ "Authorization" : token ,
53
+ "Content-Type" : "application/json" ,
54
+ "Accept" : "application/json" ,
55
+ }
56
+ )
57
+
58
+ @property
59
+ def session (self ) -> requests .Session :
60
+ return self ._session
61
+
62
+ # ---------------------------
63
+ # Low-level HTTP helper
64
+ # ---------------------------
65
+ def _request (
66
+ self ,
67
+ method : str ,
68
+ path : str ,
69
+ params : Optional [Dict [str , Any ]] = None ,
70
+ json : Optional [Dict [str , Any ]] = None ,
71
+ max_retries : int = 3 ,
72
+ ) -> Dict [str , Any ]:
73
+ """Issue an HTTP request with basic retry for 429/5xx."""
74
+ url = f"{ self .base_url } /{ path .lstrip ('/' )} "
75
+ attempt = 0
76
+ while True :
77
+ attempt += 1
78
+ resp = self .session .request (
79
+ method = method .upper (),
80
+ url = url ,
81
+ params = params ,
82
+ json = json ,
83
+ timeout = self .timeout ,
84
+ )
85
+
86
+ # Handle rate limits and transient errors
87
+ if resp .status_code in (429 , 500 , 502 , 503 , 504 ) and attempt <= max_retries :
88
+ retry_after = resp .headers .get ("Retry-After" )
89
+ delay = float (retry_after ) if retry_after else min (2 ** attempt , 10 )
90
+ time .sleep (delay )
91
+ continue
92
+
93
+ # Raise for other non-success responses
94
+ if not (200 <= resp .status_code < 300 ):
95
+ try :
96
+ detail = resp .json ()
97
+ except Exception :
98
+ detail = resp .text
99
+ raise RuntimeError (
100
+ f"ClickUp API error { resp .status_code } { resp .reason } at { url } : { detail } "
101
+ )
102
+
103
+ # Return JSON payload
104
+ try :
105
+ return resp .json ()
106
+ except ValueError :
107
+ return {}
108
+
109
+ # ---------------------------
110
+ # Team / Space / Folder / List
111
+ # ---------------------------
112
+ def get_teams (self ) -> List [Dict [str , Any ]]:
113
+ """Return all teams the token has access to."""
114
+ data = self ._request ("GET" , "/team" )
115
+ return data .get ("teams" , [])
116
+
117
+ def find_team_id (self , name : str ) -> Optional [str ]:
118
+ """Find a team id by its name."""
119
+ for team in self .get_teams ():
120
+ if team .get ("name" ) == name :
121
+ return str (team .get ("id" ))
122
+ return None
123
+
124
+ def get_spaces (self , team_id : Union [int , str ], archived : bool = False ) -> List [Dict [str , Any ]]:
125
+ data = self ._request ("GET" , f"/team/{ team_id } /space" , params = {"archived" : str (archived ).lower ()})
126
+ return data .get ("spaces" , [])
127
+
128
+ def get_folders (self , space_id : Union [int , str ], archived : bool = False ) -> List [Dict [str , Any ]]:
129
+ data = self ._request ("GET" , f"/space/{ space_id } /folder" , params = {"archived" : str (archived ).lower ()})
130
+ return data .get ("folders" , [])
131
+
132
+ def get_lists (self , folder_id : Union [int , str ], archived : bool = False ) -> List [Dict [str , Any ]]:
133
+ data = self ._request ("GET" , f"/folder/{ folder_id } /list" , params = {"archived" : str (archived ).lower ()})
134
+ return data .get ("lists" , [])
135
+
136
+ # ---------------------------
137
+ # Tasks
138
+ # ---------------------------
139
+ def list_tasks (
140
+ self ,
141
+ list_id : Union [int , str ],
142
+ page : Optional [int ] = None ,
143
+ archived : bool = False ,
144
+ include_subtasks : Optional [bool ] = None ,
145
+ ) -> List [Dict [str , Any ]]:
146
+ params : Dict [str , Any ] = {"archived" : str (archived ).lower ()}
147
+ if page is not None :
148
+ params ["page" ] = page
149
+ if include_subtasks is not None :
150
+ params ["subtasks" ] = str (include_subtasks ).lower ()
151
+ data = self ._request ("GET" , f"/list/{ list_id } /task" , params = params )
152
+ return data .get ("tasks" , [])
153
+
154
+ def get_task (self , task_id : Union [int , str ]) -> Dict [str , Any ]:
155
+ return self ._request ("GET" , f"/task/{ task_id } " )
156
+
157
+ def create_task (
158
+ self ,
159
+ list_id : Union [int , str ],
160
+ name : str ,
161
+ description : Optional [str ] = None ,
162
+ status : Optional [str ] = None ,
163
+ assignees : Optional [Iterable [Union [int , str ]]] = None ,
164
+ tags : Optional [Iterable [str ]] = None ,
165
+ priority : Optional [int ] = None ,
166
+ due_date : Optional [int ] = None , # Unix ms
167
+ due_date_time : Optional [bool ] = None ,
168
+ start_date : Optional [int ] = None , # Unix ms
169
+ start_date_time : Optional [bool ] = None ,
170
+ notify_all : Optional [bool ] = None ,
171
+ parent : Optional [str ] = None ,
172
+ time_estimate : Optional [int ] = None ,
173
+ custom_fields : Optional [List [Dict [str , Any ]]] = None ,
174
+ extra : Optional [Dict [str , Any ]] = None ,
175
+ ) -> Dict [str , Any ]:
176
+ """Create a task in a list. Supply additional API fields via `extra` if needed."""
177
+ payload : Dict [str , Any ] = {"name" : name }
178
+ if description is not None :
179
+ payload ["description" ] = description
180
+ if status is not None :
181
+ payload ["status" ] = status
182
+ if assignees is not None :
183
+ payload ["assignees" ] = [int (a ) for a in assignees ]
184
+ if tags is not None :
185
+ payload ["tags" ] = list (tags )
186
+ if priority is not None :
187
+ payload ["priority" ] = int (priority )
188
+ if due_date is not None :
189
+ payload ["due_date" ] = int (due_date )
190
+ if due_date_time is not None :
191
+ payload ["due_date_time" ] = bool (due_date_time )
192
+ if start_date is not None :
193
+ payload ["start_date" ] = int (start_date )
194
+ if start_date_time is not None :
195
+ payload ["start_date_time" ] = bool (start_date_time )
196
+ if notify_all is not None :
197
+ payload ["notify_all" ] = bool (notify_all )
198
+ if parent is not None :
199
+ payload ["parent" ] = str (parent )
200
+ if time_estimate is not None :
201
+ payload ["time_estimate" ] = int (time_estimate )
202
+ if custom_fields is not None :
203
+ payload ["custom_fields" ] = custom_fields
204
+ if extra :
205
+ payload .update (extra )
206
+
207
+ return self ._request ("POST" , f"/list/{ list_id } /task" , json = payload )
208
+
209
+ def update_task (
210
+ self ,
211
+ task_id : Union [int , str ],
212
+ fields : Dict [str , Any ],
213
+ ) -> Dict [str , Any ]:
214
+ """Update a task. Provide API fields in `fields` (e.g., {'status': 'in progress'})."""
215
+ return self ._request ("PUT" , f"/task/{ task_id } " , json = fields )
216
+
217
+ def add_comment (
218
+ self ,
219
+ task_id : Union [int , str ],
220
+ comment_text : str ,
221
+ assignee_id : Optional [Union [int , str ]] = None ,
222
+ notify_all : bool = False ,
223
+ ) -> Dict [str , Any ]:
224
+ """Add a comment to a task."""
225
+ payload : Dict [str , Any ] = {"comment_text" : comment_text , "notify_all" : bool (notify_all )}
226
+ if assignee_id is not None :
227
+ payload ["assignee" ] = int (assignee_id )
228
+ return self ._request ("POST" , f"/task/{ task_id } /comment" , json = payload )
229
+
230
+
231
+ # Debug
232
+ if __name__ == "__main__" :
233
+ # Example usage:
234
+ # export CLICKUPBOT_BOT_TOKEN="your-token"
235
+ bot = ClickUpBot ()
236
+ teams = bot .get_teams ()
237
+ print (f"Teams: { [t .get ('name' ) for t in teams ]} " )
0 commit comments