iflow-mcp-m507_ai-soc-agent 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. iflow_mcp_m507_ai_soc_agent-1.0.0.dist-info/METADATA +410 -0
  2. iflow_mcp_m507_ai_soc_agent-1.0.0.dist-info/RECORD +85 -0
  3. iflow_mcp_m507_ai_soc_agent-1.0.0.dist-info/WHEEL +5 -0
  4. iflow_mcp_m507_ai_soc_agent-1.0.0.dist-info/entry_points.txt +2 -0
  5. iflow_mcp_m507_ai_soc_agent-1.0.0.dist-info/licenses/LICENSE +21 -0
  6. iflow_mcp_m507_ai_soc_agent-1.0.0.dist-info/top_level.txt +1 -0
  7. src/__init__.py +8 -0
  8. src/ai_controller/README.md +139 -0
  9. src/ai_controller/__init__.py +12 -0
  10. src/ai_controller/agent_executor.py +596 -0
  11. src/ai_controller/cli/__init__.py +2 -0
  12. src/ai_controller/cli/main.py +243 -0
  13. src/ai_controller/session_manager.py +409 -0
  14. src/ai_controller/web/__init__.py +2 -0
  15. src/ai_controller/web/server.py +1181 -0
  16. src/ai_controller/web/static/css/README.md +102 -0
  17. src/api/__init__.py +13 -0
  18. src/api/case_management.py +271 -0
  19. src/api/edr.py +187 -0
  20. src/api/kb.py +136 -0
  21. src/api/siem.py +308 -0
  22. src/core/__init__.py +10 -0
  23. src/core/config.py +242 -0
  24. src/core/config_storage.py +684 -0
  25. src/core/dto.py +50 -0
  26. src/core/errors.py +36 -0
  27. src/core/logging.py +128 -0
  28. src/integrations/__init__.py +8 -0
  29. src/integrations/case_management/__init__.py +5 -0
  30. src/integrations/case_management/iris/__init__.py +11 -0
  31. src/integrations/case_management/iris/iris_client.py +885 -0
  32. src/integrations/case_management/iris/iris_http.py +274 -0
  33. src/integrations/case_management/iris/iris_mapper.py +263 -0
  34. src/integrations/case_management/iris/iris_models.py +128 -0
  35. src/integrations/case_management/thehive/__init__.py +8 -0
  36. src/integrations/case_management/thehive/thehive_client.py +193 -0
  37. src/integrations/case_management/thehive/thehive_http.py +147 -0
  38. src/integrations/case_management/thehive/thehive_mapper.py +190 -0
  39. src/integrations/case_management/thehive/thehive_models.py +125 -0
  40. src/integrations/cti/__init__.py +6 -0
  41. src/integrations/cti/local_tip/__init__.py +10 -0
  42. src/integrations/cti/local_tip/local_tip_client.py +90 -0
  43. src/integrations/cti/local_tip/local_tip_http.py +110 -0
  44. src/integrations/cti/opencti/__init__.py +10 -0
  45. src/integrations/cti/opencti/opencti_client.py +101 -0
  46. src/integrations/cti/opencti/opencti_http.py +418 -0
  47. src/integrations/edr/__init__.py +6 -0
  48. src/integrations/edr/elastic_defend/__init__.py +6 -0
  49. src/integrations/edr/elastic_defend/elastic_defend_client.py +351 -0
  50. src/integrations/edr/elastic_defend/elastic_defend_http.py +162 -0
  51. src/integrations/eng/__init__.py +10 -0
  52. src/integrations/eng/clickup/__init__.py +8 -0
  53. src/integrations/eng/clickup/clickup_client.py +513 -0
  54. src/integrations/eng/clickup/clickup_http.py +156 -0
  55. src/integrations/eng/github/__init__.py +8 -0
  56. src/integrations/eng/github/github_client.py +169 -0
  57. src/integrations/eng/github/github_http.py +158 -0
  58. src/integrations/eng/trello/__init__.py +8 -0
  59. src/integrations/eng/trello/trello_client.py +207 -0
  60. src/integrations/eng/trello/trello_http.py +162 -0
  61. src/integrations/kb/__init__.py +12 -0
  62. src/integrations/kb/fs_kb_client.py +313 -0
  63. src/integrations/siem/__init__.py +6 -0
  64. src/integrations/siem/elastic/__init__.py +6 -0
  65. src/integrations/siem/elastic/elastic_client.py +3319 -0
  66. src/integrations/siem/elastic/elastic_http.py +165 -0
  67. src/mcp/README.md +183 -0
  68. src/mcp/TOOLS.md +2827 -0
  69. src/mcp/__init__.py +13 -0
  70. src/mcp/__main__.py +18 -0
  71. src/mcp/agent_profiles.py +408 -0
  72. src/mcp/flow_agent_profiles.py +424 -0
  73. src/mcp/mcp_server.py +4086 -0
  74. src/mcp/rules_engine.py +487 -0
  75. src/mcp/runbook_manager.py +264 -0
  76. src/orchestrator/__init__.py +11 -0
  77. src/orchestrator/incident_workflow.py +244 -0
  78. src/orchestrator/tools_case.py +1085 -0
  79. src/orchestrator/tools_cti.py +359 -0
  80. src/orchestrator/tools_edr.py +315 -0
  81. src/orchestrator/tools_eng.py +378 -0
  82. src/orchestrator/tools_kb.py +156 -0
  83. src/orchestrator/tools_siem.py +1709 -0
  84. src/web/__init__.py +8 -0
  85. src/web/config_server.py +511 -0
@@ -0,0 +1,513 @@
1
+ """
2
+ ClickUp client for creating tasks and recommendations.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Dict, Any, Optional, List
8
+
9
+ from ....core.config import SamiConfig
10
+ from ....core.errors import IntegrationError
11
+ from ....core.logging import get_logger
12
+ from .clickup_http import ClickUpHttpClient
13
+
14
+
15
+ logger = get_logger("sami.integrations.clickup.client")
16
+
17
+
18
+ class ClickUpClient:
19
+ """
20
+ Client for interacting with ClickUp API to create tasks and recommendations.
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ http_client: ClickUpHttpClient,
26
+ fine_tuning_list_id: str,
27
+ engineering_list_id: str,
28
+ space_id: Optional[str] = None,
29
+ ) -> None:
30
+ """
31
+ Initialize ClickUp client.
32
+
33
+ Args:
34
+ http_client: HTTP client for ClickUp API
35
+ fine_tuning_list_id: ClickUp list ID for fine-tuning recommendations
36
+ (can be a placeholder; if so, the client will auto-discover a list
37
+ in the configured space)
38
+ engineering_list_id: ClickUp list ID for engineering/visibility recommendations
39
+ (can be a placeholder; if so, the client will auto-discover a list
40
+ in the configured space)
41
+ space_id: Optional ClickUp space ID that contains the engineering boards.
42
+ """
43
+ self._http = http_client
44
+ self.fine_tuning_list_id = fine_tuning_list_id
45
+ self.engineering_list_id = engineering_list_id
46
+ self.space_id = space_id
47
+
48
+ @classmethod
49
+ def from_config(cls, config: SamiConfig) -> "ClickUpClient":
50
+ """
51
+ Factory to construct a client from ``SamiConfig``.
52
+
53
+ Args:
54
+ config: SamiConfig instance with ClickUp configuration
55
+
56
+ Returns:
57
+ ClickUpClient instance
58
+
59
+ Raises:
60
+ IntegrationError: If ClickUp configuration is not set
61
+ """
62
+ if not config.eng or not config.eng.clickup:
63
+ raise IntegrationError("ClickUp configuration is not set in SamiConfig")
64
+
65
+ clickup_config = config.eng.clickup
66
+ http_client = ClickUpHttpClient(
67
+ api_token=clickup_config.api_token,
68
+ timeout_seconds=clickup_config.timeout_seconds,
69
+ verify_ssl=clickup_config.verify_ssl,
70
+ )
71
+
72
+ return cls(
73
+ http_client=http_client,
74
+ fine_tuning_list_id=clickup_config.fine_tuning_list_id,
75
+ engineering_list_id=clickup_config.engineering_list_id,
76
+ space_id=clickup_config.space_id,
77
+ )
78
+
79
+ # ------------------------------------------------------------------
80
+ # Internal helpers for resolving list IDs
81
+ # ------------------------------------------------------------------
82
+
83
+ @staticmethod
84
+ def _is_placeholder_list_id(list_id: Optional[str]) -> bool:
85
+ """
86
+ Return True if the configured list ID looks like a placeholder.
87
+ """
88
+ if not list_id:
89
+ return True
90
+ placeholders = {
91
+ "123456789",
92
+ "987654321",
93
+ "REPLACE_WITH_FINE_TUNING_LIST_ID",
94
+ "REPLACE_WITH_VISIBILITY_LIST_ID",
95
+ }
96
+ return list_id in placeholders
97
+
98
+ def _get_space_lists(self) -> List[Dict[str, Any]]:
99
+ """
100
+ Get all lists in the configured space.
101
+
102
+ This is a lightweight, production-safe version of the helper logic
103
+ we used in tests to enumerate ClickUp lists.
104
+ """
105
+ if not self.space_id:
106
+ return []
107
+
108
+ try:
109
+ response = self._http.get(f"/v2/space/{self.space_id}/list")
110
+ except IntegrationError as e:
111
+ logger.warning(f"Failed to get lists for ClickUp space {self.space_id}: {e}")
112
+ return []
113
+
114
+ lists = response.get("lists")
115
+ if isinstance(lists, list):
116
+ return lists
117
+ if isinstance(response, list):
118
+ return response
119
+ if isinstance(response, dict):
120
+ return [response]
121
+ return []
122
+
123
+ def _auto_discover_list_id(self, purpose: str) -> str:
124
+ """
125
+ Auto-discover a list ID for the given purpose (\"fine_tuning\" or \"engineering\").
126
+
127
+ Strategy:
128
+ - Prefer lists in the configured space (space_id).
129
+ - Try to match list names by keywords (fine-tuning vs visibility/engineering).
130
+ - Fall back to the first list in the space if no better match is found.
131
+ """
132
+ lists = self._get_space_lists()
133
+ if not lists:
134
+ raise IntegrationError(
135
+ "Unable to auto-discover ClickUp list ID: no lists found in the "
136
+ "configured space. Please configure valid fine_tuning_list_id and "
137
+ "engineering_list_id in config.eng.clickup."
138
+ )
139
+
140
+ purpose = purpose.lower()
141
+ keywords: List[str] = []
142
+ if purpose == "fine_tuning":
143
+ # Match things like 'Fine-tuning Tasks'
144
+ keywords = ["fine", "tun"]
145
+ elif purpose == "engineering":
146
+ # Match things like 'Visibility Tasks', 'Engineering Tasks', etc.
147
+ keywords = ["visib", "engineer", "eng"]
148
+
149
+ def matches_purpose(name: str) -> bool:
150
+ name_l = name.lower()
151
+ if not keywords:
152
+ return False
153
+ # For fine-tuning, require both 'fine' and 'tun' to reduce false matches.
154
+ if purpose == "fine_tuning":
155
+ return "fine" in name_l and "tun" in name_l
156
+ # For engineering/visibility, any of the keywords is good enough.
157
+ return any(k in name_l for k in keywords)
158
+
159
+ # First, try to find best matches by name.
160
+ matched_lists: List[Dict[str, Any]] = []
161
+ for lst in lists:
162
+ name = lst.get("name")
163
+ if isinstance(name, str) and matches_purpose(name):
164
+ matched_lists.append(lst)
165
+
166
+ chosen: Optional[Dict[str, Any]] = None
167
+ if matched_lists:
168
+ chosen = matched_lists[0]
169
+ else:
170
+ # Fall back to the first list in the space.
171
+ chosen = lists[0]
172
+
173
+ list_id = chosen.get("id")
174
+ if not list_id:
175
+ raise IntegrationError(
176
+ "Unable to auto-discover ClickUp list ID: discovered list has no 'id'."
177
+ )
178
+
179
+ logger.info(
180
+ "Auto-discovered ClickUp list for %s: %s (id=%s)",
181
+ purpose,
182
+ chosen.get("name", "<unnamed>"),
183
+ list_id,
184
+ )
185
+ return str(list_id)
186
+
187
+ def _resolve_list_id(self, configured_id: str, purpose: str) -> str:
188
+ """
189
+ Resolve the effective list ID for the given purpose.
190
+
191
+ If a non-placeholder list ID is configured, use it directly.
192
+ Otherwise, auto-discover a list ID in the configured space.
193
+ """
194
+ if not self._is_placeholder_list_id(configured_id):
195
+ return configured_id
196
+
197
+ if not self.space_id:
198
+ raise IntegrationError(
199
+ f"ClickUp space_id is not configured, and the {purpose} "
200
+ "list ID looks like a placeholder. Please configure "
201
+ "eng.clickup.space_id and valid list IDs in config.json."
202
+ )
203
+
204
+ return self._auto_discover_list_id(purpose)
205
+
206
+ def create_fine_tuning_recommendation(
207
+ self,
208
+ title: str,
209
+ description: str,
210
+ status: Optional[str] = None,
211
+ tags: Optional[list[str]] = None,
212
+ ) -> Dict[str, Any]:
213
+ """
214
+ Create a fine-tuning recommendation task in ClickUp.
215
+
216
+ Args:
217
+ title: Task name
218
+ description: Task description
219
+ status: Optional status name (defaults to first status in list)
220
+ tags: Optional list of tag names to add to the task
221
+
222
+ Returns:
223
+ Dictionary with task information
224
+ """
225
+ logger.info(f"Creating fine-tuning recommendation: {title}")
226
+
227
+ # Resolve which ClickUp list to use (supports placeholders + auto-discovery).
228
+ list_id = self._resolve_list_id(self.fine_tuning_list_id, purpose="fine_tuning")
229
+
230
+ # Get list info to find status
231
+ list_info = self._http.get(f"/v2/list/{list_id}")
232
+ statuses = list_info.get("statuses", [])
233
+
234
+ # Find the target status (ClickUp expects status as a string, not an object)
235
+ target_status_value: Optional[str] = None
236
+ if status:
237
+ for status_item in statuses:
238
+ if status_item.get("status") == status:
239
+ target_status_value = status_item.get("status")
240
+ break
241
+ if not target_status_value:
242
+ raise IntegrationError(f"Status '{status}' not found in fine-tuning list")
243
+ else:
244
+ # Use the first status if no status specified
245
+ if not statuses:
246
+ raise IntegrationError("No statuses found in fine-tuning list")
247
+ target_status_value = statuses[0].get("status")
248
+
249
+ # Create the task
250
+ task_data = {
251
+ "name": title,
252
+ "description": description,
253
+ "status": target_status_value,
254
+ }
255
+
256
+ # Add tags if provided
257
+ if tags:
258
+ task_data["tags"] = tags
259
+
260
+ response = self._http.post(f"/v2/list/{list_id}/task", json_data=task_data)
261
+ task = response.get("task", response)
262
+
263
+ logger.info(f"Created fine-tuning recommendation task: {task.get('id')}")
264
+ return task
265
+
266
+ def create_visibility_recommendation(
267
+ self,
268
+ title: str,
269
+ description: str,
270
+ status: Optional[str] = None,
271
+ tags: Optional[list[str]] = None,
272
+ ) -> Dict[str, Any]:
273
+ """
274
+ Create a visibility/engineering recommendation task in ClickUp.
275
+
276
+ Args:
277
+ title: Task name
278
+ description: Task description
279
+ status: Optional status name (defaults to first status in list)
280
+ tags: Optional list of tag names to add to the task
281
+
282
+ Returns:
283
+ Dictionary with task information
284
+ """
285
+ logger.info(f"Creating visibility recommendation: {title}")
286
+
287
+ # Resolve which ClickUp list to use (supports placeholders + auto-discovery).
288
+ list_id = self._resolve_list_id(self.engineering_list_id, purpose="engineering")
289
+
290
+ # Get list info to find status
291
+ list_info = self._http.get(f"/v2/list/{list_id}")
292
+ statuses = list_info.get("statuses", [])
293
+
294
+ # Find the target status (ClickUp expects status as a string, not an object)
295
+ target_status_value: Optional[str] = None
296
+ if status:
297
+ for status_item in statuses:
298
+ if status_item.get("status") == status:
299
+ target_status_value = status_item.get("status")
300
+ break
301
+ if not target_status_value:
302
+ raise IntegrationError(f"Status '{status}' not found in engineering list")
303
+ else:
304
+ # Use the first status if no status specified
305
+ if not statuses:
306
+ raise IntegrationError("No statuses found in engineering list")
307
+ target_status_value = statuses[0].get("status")
308
+
309
+ # Create the task
310
+ task_data = {
311
+ "name": title,
312
+ "description": description,
313
+ "status": target_status_value,
314
+ }
315
+
316
+ # Add tags if provided
317
+ if tags:
318
+ task_data["tags"] = tags
319
+
320
+ response = self._http.post(f"/v2/list/{list_id}/task", json_data=task_data)
321
+ task = response.get("task", response)
322
+
323
+ logger.info(f"Created visibility recommendation task: {task.get('id')}")
324
+ return task
325
+
326
+ def list_fine_tuning_recommendations(
327
+ self,
328
+ archived: bool = False,
329
+ include_closed: bool = True,
330
+ order_by: Optional[str] = None,
331
+ reverse: bool = False,
332
+ subtasks: bool = False,
333
+ statuses: Optional[list[str]] = None,
334
+ include_markdown_description: bool = False,
335
+ ) -> List[Dict[str, Any]]:
336
+ """
337
+ List all fine-tuning recommendation tasks from the fine-tuning list.
338
+
339
+ Args:
340
+ archived: Include archived tasks (default: False)
341
+ include_closed: Include closed tasks (default: True)
342
+ order_by: Order tasks by field (e.g., "created", "updated", "priority")
343
+ reverse: Reverse the order (default: False)
344
+ subtasks: Include subtasks (default: False)
345
+ statuses: Filter by status names (optional)
346
+ include_markdown_description: Include markdown in descriptions (default: False)
347
+
348
+ Returns:
349
+ List of task dictionaries
350
+ """
351
+ logger.info("Listing fine-tuning recommendations")
352
+
353
+ # Resolve which ClickUp list to use
354
+ list_id = self._resolve_list_id(self.fine_tuning_list_id, purpose="fine_tuning")
355
+
356
+ # Build query parameters
357
+ params: Dict[str, Any] = {
358
+ "archived": str(archived).lower(),
359
+ "include_closed": str(include_closed).lower(),
360
+ "subtasks": str(subtasks).lower(),
361
+ "include_markdown_description": str(include_markdown_description).lower(),
362
+ }
363
+
364
+ if order_by:
365
+ params["order_by"] = order_by
366
+ if reverse:
367
+ params["reverse"] = "true"
368
+ if statuses:
369
+ params["statuses[]"] = statuses
370
+
371
+ try:
372
+ response = self._http.get(f"/v2/list/{list_id}/task", params=params)
373
+ tasks = response.get("tasks", [])
374
+
375
+ logger.info(f"Found {len(tasks)} fine-tuning recommendation tasks")
376
+ return tasks
377
+ except IntegrationError as e:
378
+ logger.error(f"Failed to list fine-tuning recommendations: {e}")
379
+ raise
380
+
381
+ def list_visibility_recommendations(
382
+ self,
383
+ archived: bool = False,
384
+ include_closed: bool = True,
385
+ order_by: Optional[str] = None,
386
+ reverse: bool = False,
387
+ subtasks: bool = False,
388
+ statuses: Optional[list[str]] = None,
389
+ include_markdown_description: bool = False,
390
+ ) -> List[Dict[str, Any]]:
391
+ """
392
+ List all visibility/engineering recommendation tasks from the engineering list.
393
+
394
+ Args:
395
+ archived: Include archived tasks (default: False)
396
+ include_closed: Include closed tasks (default: True)
397
+ order_by: Order tasks by field (e.g., "created", "updated", "priority")
398
+ reverse: Reverse the order (default: False)
399
+ subtasks: Include subtasks (default: False)
400
+ statuses: Filter by status names (optional)
401
+ include_markdown_description: Include markdown in descriptions (default: False)
402
+
403
+ Returns:
404
+ List of task dictionaries
405
+ """
406
+ logger.info("Listing visibility recommendations")
407
+
408
+ # Resolve which ClickUp list to use
409
+ list_id = self._resolve_list_id(self.engineering_list_id, purpose="engineering")
410
+
411
+ # Build query parameters
412
+ params: Dict[str, Any] = {
413
+ "archived": str(archived).lower(),
414
+ "include_closed": str(include_closed).lower(),
415
+ "subtasks": str(subtasks).lower(),
416
+ "include_markdown_description": str(include_markdown_description).lower(),
417
+ }
418
+
419
+ if order_by:
420
+ params["order_by"] = order_by
421
+ if reverse:
422
+ params["reverse"] = "true"
423
+ if statuses:
424
+ params["statuses[]"] = statuses
425
+
426
+ try:
427
+ response = self._http.get(f"/v2/list/{list_id}/task", params=params)
428
+ tasks = response.get("tasks", [])
429
+
430
+ logger.info(f"Found {len(tasks)} visibility recommendation tasks")
431
+ return tasks
432
+ except IntegrationError as e:
433
+ logger.error(f"Failed to list visibility recommendations: {e}")
434
+ raise
435
+
436
+ def add_comment_to_fine_tuning_recommendation(
437
+ self,
438
+ task_id: str,
439
+ comment_text: str,
440
+ ) -> Dict[str, Any]:
441
+ """
442
+ Add a comment to a fine-tuning recommendation task.
443
+
444
+ Args:
445
+ task_id: ClickUp task ID
446
+ comment_text: Comment text/content
447
+
448
+ Returns:
449
+ Dictionary with comment information
450
+ """
451
+ logger.info(f"Adding comment to fine-tuning recommendation task: {task_id}")
452
+
453
+ comment_data = {
454
+ "comment_text": comment_text,
455
+ }
456
+
457
+ try:
458
+ response = self._http.post(f"/v2/task/{task_id}/comment", json_data=comment_data)
459
+ comment = response.get("comment", response)
460
+
461
+ logger.info(f"Added comment to fine-tuning recommendation task: {task_id}")
462
+ return comment
463
+ except IntegrationError as e:
464
+ logger.error(f"Failed to add comment to fine-tuning recommendation task {task_id}: {e}")
465
+ raise
466
+
467
+ def add_comment_to_visibility_recommendation(
468
+ self,
469
+ task_id: str,
470
+ comment_text: str,
471
+ ) -> Dict[str, Any]:
472
+ """
473
+ Add a comment to a visibility/engineering recommendation task.
474
+
475
+ Args:
476
+ task_id: ClickUp task ID
477
+ comment_text: Comment text/content
478
+
479
+ Returns:
480
+ Dictionary with comment information
481
+ """
482
+ logger.info(f"Adding comment to visibility recommendation task: {task_id}")
483
+
484
+ comment_data = {
485
+ "comment_text": comment_text,
486
+ }
487
+
488
+ try:
489
+ response = self._http.post(f"/v2/task/{task_id}/comment", json_data=comment_data)
490
+ comment = response.get("comment", response)
491
+
492
+ logger.info(f"Added comment to visibility recommendation task: {task_id}")
493
+ return comment
494
+ except IntegrationError as e:
495
+ logger.error(f"Failed to add comment to visibility recommendation task {task_id}: {e}")
496
+ raise
497
+
498
+ def ping(self) -> bool:
499
+ """
500
+ Check if ClickUp API is reachable by testing access to the configured lists.
501
+
502
+ Returns:
503
+ True if API is reachable, False otherwise
504
+ """
505
+ try:
506
+ # Try to get list info for fine-tuning list (this verifies both connectivity and list access)
507
+ list_id = self._resolve_list_id(self.fine_tuning_list_id, purpose="fine_tuning")
508
+ self._http.get(f"/v2/list/{list_id}")
509
+ return True
510
+ except IntegrationError:
511
+ logger.exception("ClickUp ping failed")
512
+ return False
513
+
@@ -0,0 +1,156 @@
1
+ """
2
+ Low-level HTTP client for ClickUp API.
3
+
4
+ This module is responsible for:
5
+ - authentication (API token)
6
+ - building URLs
7
+ - making HTTP requests
8
+ - basic error handling
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from dataclasses import dataclass
15
+ from typing import Any, Dict, Optional
16
+
17
+ import requests
18
+
19
+ from ....core.errors import IntegrationError
20
+ from ....core.logging import get_logger
21
+
22
+
23
+ logger = get_logger("sami.integrations.clickup.http")
24
+
25
+
26
+ @dataclass
27
+ class ClickUpHttpClient:
28
+ """
29
+ Simple HTTP client for ClickUp's REST API.
30
+
31
+ ClickUp API documentation: https://clickup.com/api
32
+ """
33
+
34
+ api_token: str
35
+ timeout_seconds: int = 30
36
+ verify_ssl: bool = True
37
+
38
+ def _headers(self) -> Dict[str, str]:
39
+ """Get authentication headers."""
40
+ return {
41
+ "Authorization": self.api_token,
42
+ "Content-Type": "application/json",
43
+ "Accept": "application/json",
44
+ }
45
+
46
+ def _build_url(self, endpoint: str) -> str:
47
+ """
48
+ Build a full URL from an endpoint.
49
+
50
+ Args:
51
+ endpoint: API endpoint path (e.g., "/v2/list/{list_id}/task")
52
+
53
+ Returns:
54
+ Full URL string
55
+ """
56
+ # Official ClickUp API base URL is https://api.clickup.com/api/v2/...
57
+ # We keep the version in the endpoint and include the /api prefix here.
58
+ base_url = "https://api.clickup.com/api"
59
+ endpoint = endpoint.lstrip("/")
60
+ return f"{base_url}/{endpoint}"
61
+
62
+ def _handle_clickup_error(self, response: requests.Response) -> None:
63
+ """
64
+ Raise IntegrationError if the response indicates an error.
65
+
66
+ Args:
67
+ response: HTTP response object
68
+
69
+ Raises:
70
+ IntegrationError: If the response indicates an error
71
+ """
72
+ if response.status_code < 400:
73
+ return
74
+
75
+ try:
76
+ error_data = response.json()
77
+ message = error_data.get("err", error_data.get("message", f"HTTP {response.status_code}"))
78
+ except Exception:
79
+ message = f"HTTP {response.status_code}: {response.text[:200]}"
80
+
81
+ raise IntegrationError(f"ClickUp API error: {message}")
82
+
83
+ def request(
84
+ self,
85
+ method: str,
86
+ endpoint: str,
87
+ json_data: Optional[Dict[str, Any]] = None,
88
+ params: Optional[Dict[str, Any]] = None,
89
+ ) -> Dict[str, Any]:
90
+ """
91
+ Make an HTTP request to ClickUp API.
92
+
93
+ Args:
94
+ method: HTTP method (GET, POST, PUT, PATCH, DELETE)
95
+ endpoint: API endpoint path
96
+ json_data: JSON payload (for POST, PUT, PATCH)
97
+ params: Query parameters
98
+
99
+ Returns:
100
+ Response JSON as dictionary
101
+
102
+ Raises:
103
+ IntegrationError: If the request fails
104
+ """
105
+ url = self._build_url(endpoint)
106
+ headers = self._headers()
107
+
108
+ try:
109
+ logger.debug(f"ClickUp {method} {url}")
110
+ if params:
111
+ logger.debug(f" Query params: {params}")
112
+ if json_data:
113
+ logger.debug(f" JSON payload: {json.dumps(json_data)[:200]}...")
114
+
115
+ response = requests.request(
116
+ method=method,
117
+ url=url,
118
+ headers=headers,
119
+ json=json_data,
120
+ params=params,
121
+ timeout=self.timeout_seconds,
122
+ verify=self.verify_ssl,
123
+ )
124
+
125
+ logger.debug(f"ClickUp response status: {response.status_code}")
126
+ if response.status_code >= 400:
127
+ logger.error(f"ClickUp API error - Status: {response.status_code}, URL: {url}, Response: {response.text[:500]}")
128
+
129
+ self._handle_clickup_error(response)
130
+
131
+ if response.status_code == 204: # No Content
132
+ return {}
133
+
134
+ return response.json()
135
+
136
+ except requests.exceptions.Timeout as e:
137
+ raise IntegrationError(f"ClickUp API request timeout: {e}") from e
138
+ except requests.exceptions.RequestException as e:
139
+ raise IntegrationError(f"ClickUp API request failed: {e}") from e
140
+
141
+ def get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
142
+ """GET request."""
143
+ return self.request("GET", endpoint, params=params)
144
+
145
+ def post(self, endpoint: str, json_data: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
146
+ """POST request."""
147
+ return self.request("POST", endpoint, json_data=json_data, params=params)
148
+
149
+ def put(self, endpoint: str, json_data: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
150
+ """PUT request."""
151
+ return self.request("PUT", endpoint, json_data=json_data, params=params)
152
+
153
+ def delete(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
154
+ """DELETE request."""
155
+ return self.request("DELETE", endpoint, params=params)
156
+
@@ -0,0 +1,8 @@
1
+ """
2
+ GitHub integration for engineering task management.
3
+ """
4
+
5
+ from .github_client import GitHubClient
6
+
7
+ __all__ = ["GitHubClient"]
8
+