Skip to content

Commit e286e38

Browse files
committed
cleanup
1 parent c1a654b commit e286e38

File tree

8 files changed

+353
-86
lines changed

8 files changed

+353
-86
lines changed

src/codebots/bots/_bot.py

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,32 @@
1+
import os
12
import json
23

34

45
class BaseBot():
56
"""BaseBot class for the other bots.
6-
"""
7+
Modern token handling: supports environment variables for CI/CD and falls back to token files."""
78

89
def __init__(self, config_file) -> None:
910
self._credentials = self._get_token(config_file)
1011
for k, v in self._credentials.items():
1112
self.__setattr__(k, v)
1213

1314
def _get_token(self, config_file):
14-
"""read the access token form a json file.
15+
"""Read the access token from environment variable or json file.
1516
16-
Parameters
17-
----------
18-
config_file : json
19-
json file containing the bot_token and the bot_chatID
20-
21-
Returns
22-
-------
23-
dict
24-
credential info
17+
Priority:
18+
1. Environment variable (recommended for CI/CD)
19+
2. Token file (for local development)
2520
"""
21+
env_var = f'{self.__class__.__name__.upper()}_BOT_TOKEN'
22+
env_token = os.getenv(env_var)
23+
if env_token:
24+
# If only token is needed, return as dict
25+
return {'bot_token': env_token}
26+
# Fallback to file
2627
try:
2728
with open(config_file, "r") as f:
2829
token = json.load(f)
2930
return token
3031
except FileNotFoundError:
31-
raise FileNotFoundError("there is no token for this bot in the tokens folder.\n\n\
32-
please provide one by running in the command line the following command:\n\
33-
`{} set-token` and provide the required information\n\n\
34-
or set a new tokens folder using:\n\
35-
`codebots set-tokens-path \"path-to-tokens-folder\"`\n".format(self.__name__))
32+
raise FileNotFoundError(f"No token found for {self.__class__.__name__}.\nSet the environment variable {env_var} or run the CLI set-token command.")

src/codebots/bots/clickupbot.py

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
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]}")

src/codebots/bots/deploybot.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,20 @@ class DeployBot(BaseBot):
5454

5555
def __init__(self, project=None, config_file=None) -> None:
5656
self.__name__ = "deploybot"
57+
# Use BaseBot for modern token handling
5758
if not config_file:
5859
if not project:
5960
raise ValueError("Either a project name or a config_file must be passed")
6061
from .. import TOKENS
61-
config_file = os.path .join(TOKENS, "{}.json".format(project))
62+
config_file = os.path.join(TOKENS, f"{project}.json")
6263
super().__init__(config_file)
63-
self.server_complete_path = "{}:{}".format(self.server_address, self.server_repo_path)
64+
# Ensure required attributes are set from credentials
65+
self.local_repo_path = getattr(self, 'local_repo_path', None)
66+
self.server_repo_path = getattr(self, 'server_repo_path', None)
67+
self.server_address = getattr(self, 'server_address', None)
68+
if not all([self.local_repo_path, self.server_repo_path, self.server_address]):
69+
raise ValueError("Missing required deployment configuration. Check your token file or environment variables.")
70+
self.server_complete_path = f"{self.server_address}:{self.server_repo_path}"
6471
self.local_repo = Repo(self.local_repo_path)
6572

6673
def deploy_to_server(self, remote_name="deploy", local_name="master", commit_message="deployed"):

src/codebots/bots/drivebot.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,14 @@ class DriveBot():
3333
if True stores the credentials in the .tokens folder for future use, by default True.
3434
"""
3535

36-
def __init__(self, authentication, save_credentials=True) -> None:
37-
GoogleAuth.DEFAULT_SETTINGS['client_config_file'] = SECRETS
36+
def __init__(self, authentication=None, save_credentials=True) -> None:
37+
# Modern token handling: env var first, then config file
38+
env_token = os.getenv("DRIVEBOT_TOKEN")
39+
if env_token:
40+
self.token = env_token
41+
else:
42+
from .. import TOKENS, SECRETS
43+
GoogleAuth.DEFAULT_SETTINGS['client_config_file'] = SECRETS
3844
self._gauth = GoogleAuth()
3945
self._drive = self._authenticate(authentication, save_credentials)
4046

@@ -46,7 +52,7 @@ def gauth(self):
4652
@property
4753
def drive(self):
4854
"""obj : pydrive GoogleDrive object"""
49-
return self._gdrive
55+
return self._drive
5056

5157
def _authenticate(self, authentication, save_credentials):
5258
"""Sign in your Google Drive.

0 commit comments

Comments
 (0)