datarobot-genai 0.2.11__py3-none-any.whl → 0.2.19__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.
@@ -31,6 +31,14 @@ from .atlassian import get_atlassian_cloud_id
31
31
  logger = logging.getLogger(__name__)
32
32
 
33
33
 
34
+ class ConfluenceError(Exception):
35
+ """Exception for Confluence API errors."""
36
+
37
+ def __init__(self, message: str, status_code: int | None = None) -> None:
38
+ super().__init__(message)
39
+ self.status_code = status_code
40
+
41
+
34
42
  class ConfluencePage(BaseModel):
35
43
  """Pydantic model for Confluence page."""
36
44
 
@@ -51,6 +59,22 @@ class ConfluencePage(BaseModel):
51
59
  }
52
60
 
53
61
 
62
+ class ConfluenceComment(BaseModel):
63
+ """Pydantic model for Confluence comment."""
64
+
65
+ comment_id: str = Field(..., description="The unique comment ID")
66
+ page_id: str = Field(..., description="The page ID where the comment was added")
67
+ body: str = Field(..., description="Comment content in storage format")
68
+
69
+ def as_flat_dict(self) -> dict[str, Any]:
70
+ """Return a flat dictionary representation of the comment."""
71
+ return {
72
+ "comment_id": self.comment_id,
73
+ "page_id": self.page_id,
74
+ "body": self.body,
75
+ }
76
+
77
+
54
78
  class ConfluenceClient:
55
79
  """
56
80
  Client for interacting with Confluence API using OAuth access token.
@@ -133,7 +157,7 @@ class ConfluenceClient:
133
157
 
134
158
  Raises
135
159
  ------
136
- ValueError: If page is not found
160
+ ConfluenceError: If page is not found
137
161
  httpx.HTTPStatusError: If the API request fails
138
162
  """
139
163
  cloud_id = await self._get_cloud_id()
@@ -142,7 +166,7 @@ class ConfluenceClient:
142
166
  response = await self._client.get(url, params={"expand": self.EXPAND_FIELDS})
143
167
 
144
168
  if response.status_code == HTTPStatus.NOT_FOUND:
145
- raise ValueError(f"Page with ID '{page_id}' not found")
169
+ raise ConfluenceError(f"Page with ID '{page_id}' not found", status_code=404)
146
170
 
147
171
  response.raise_for_status()
148
172
  return self._parse_response(response.json())
@@ -161,7 +185,7 @@ class ConfluenceClient:
161
185
 
162
186
  Raises
163
187
  ------
164
- ValueError: If the page is not found
188
+ ConfluenceError: If the page is not found
165
189
  httpx.HTTPStatusError: If the API request fails
166
190
  """
167
191
  cloud_id = await self._get_cloud_id()
@@ -181,10 +205,183 @@ class ConfluenceClient:
181
205
  results = data.get("results", [])
182
206
 
183
207
  if not results:
184
- raise ValueError(f"Page with title '{title}' not found in space '{space_key}'")
208
+ raise ConfluenceError(
209
+ f"Page with title '{title}' not found in space '{space_key}'", status_code=404
210
+ )
185
211
 
186
212
  return self._parse_response(results[0])
187
213
 
214
+ def _extract_error_message(self, response: httpx.Response) -> str:
215
+ """Extract error message from Confluence API error response."""
216
+ try:
217
+ error_data = response.json()
218
+ # Confluence API returns errors in different formats
219
+ if "message" in error_data:
220
+ return error_data["message"]
221
+ if "errorMessages" in error_data and error_data["errorMessages"]:
222
+ return "; ".join(error_data["errorMessages"])
223
+ if "errors" in error_data:
224
+ errors = error_data["errors"]
225
+ if isinstance(errors, list):
226
+ return "; ".join(str(e) for e in errors)
227
+ if isinstance(errors, dict):
228
+ return "; ".join(f"{k}: {v}" for k, v in errors.items())
229
+ except Exception:
230
+ pass
231
+ return response.text or "Unknown error"
232
+
233
+ async def create_page(
234
+ self,
235
+ space_key: str,
236
+ title: str,
237
+ body_content: str,
238
+ parent_id: int | None = None,
239
+ ) -> ConfluencePage:
240
+ """
241
+ Create a new Confluence page in a specified space.
242
+
243
+ Args:
244
+ space_key: The key of the Confluence space where the page should live
245
+ title: The title of the new page
246
+ body_content: The content in Confluence Storage Format (XML) or raw text
247
+ parent_id: Optional ID of the parent page for creating a child page
248
+
249
+ Returns
250
+ -------
251
+ ConfluencePage with the created page data
252
+
253
+ Raises
254
+ ------
255
+ ConfluenceError: If space not found, parent page not found, duplicate title,
256
+ permission denied, or invalid content
257
+ httpx.HTTPStatusError: If the API request fails with unexpected status
258
+ """
259
+ cloud_id = await self._get_cloud_id()
260
+ url = f"{ATLASSIAN_API_BASE}/ex/confluence/{cloud_id}/wiki/rest/api/content"
261
+
262
+ payload: dict[str, Any] = {
263
+ "type": "page",
264
+ "title": title,
265
+ "space": {"key": space_key},
266
+ "body": {
267
+ "storage": {
268
+ "value": body_content,
269
+ "representation": "storage",
270
+ }
271
+ },
272
+ }
273
+
274
+ if parent_id is not None:
275
+ payload["ancestors"] = [{"id": parent_id}]
276
+
277
+ response = await self._client.post(url, json=payload)
278
+
279
+ if response.status_code == HTTPStatus.NOT_FOUND:
280
+ error_msg = self._extract_error_message(response)
281
+ if parent_id is not None and "ancestor" in error_msg.lower():
282
+ raise ConfluenceError(
283
+ f"Parent page with ID '{parent_id}' not found", status_code=404
284
+ )
285
+ raise ConfluenceError(
286
+ f"Space '{space_key}' not found or resource unavailable: {error_msg}",
287
+ status_code=404,
288
+ )
289
+
290
+ if response.status_code == HTTPStatus.CONFLICT:
291
+ raise ConfluenceError(
292
+ f"A page with title '{title}' already exists in space '{space_key}'",
293
+ status_code=409,
294
+ )
295
+
296
+ if response.status_code == HTTPStatus.FORBIDDEN:
297
+ raise ConfluenceError(
298
+ f"Permission denied: you don't have access to create pages in space '{space_key}'",
299
+ status_code=403,
300
+ )
301
+
302
+ if response.status_code == HTTPStatus.BAD_REQUEST:
303
+ error_msg = self._extract_error_message(response)
304
+ raise ConfluenceError(f"Invalid request: {error_msg}", status_code=400)
305
+
306
+ if response.status_code == HTTPStatus.TOO_MANY_REQUESTS:
307
+ raise ConfluenceError("Rate limit exceeded. Please try again later.", status_code=429)
308
+
309
+ response.raise_for_status()
310
+
311
+ return self._parse_response(response.json())
312
+
313
+ def _parse_comment_response(self, data: dict, page_id: str) -> ConfluenceComment:
314
+ """Parse API response into ConfluenceComment."""
315
+ body_content = ""
316
+ body = data.get("body", {})
317
+ if isinstance(body, dict):
318
+ storage = body.get("storage", {})
319
+ if isinstance(storage, dict):
320
+ body_content = storage.get("value", "")
321
+
322
+ return ConfluenceComment(
323
+ comment_id=str(data.get("id", "")),
324
+ page_id=page_id,
325
+ body=body_content,
326
+ )
327
+
328
+ async def add_comment(self, page_id: str, comment_body: str) -> ConfluenceComment:
329
+ """
330
+ Add a comment to a Confluence page.
331
+
332
+ Args:
333
+ page_id: The numeric page ID where the comment will be added
334
+ comment_body: The text content of the comment
335
+
336
+ Returns
337
+ -------
338
+ ConfluenceComment with the created comment data
339
+
340
+ Raises
341
+ ------
342
+ ConfluenceError: If page not found, permission denied, or invalid content
343
+ httpx.HTTPStatusError: If the API request fails with unexpected status
344
+ """
345
+ cloud_id = await self._get_cloud_id()
346
+ url = f"{ATLASSIAN_API_BASE}/ex/confluence/{cloud_id}/wiki/rest/api/content"
347
+
348
+ payload: dict[str, Any] = {
349
+ "type": "comment",
350
+ "container": {"id": page_id, "type": "page"},
351
+ "body": {
352
+ "storage": {
353
+ "value": comment_body,
354
+ "representation": "storage",
355
+ }
356
+ },
357
+ }
358
+
359
+ response = await self._client.post(url, json=payload)
360
+
361
+ if response.status_code == HTTPStatus.NOT_FOUND:
362
+ error_msg = self._extract_error_message(response)
363
+ raise ConfluenceError(
364
+ f"Page with ID '{page_id}' not found: {error_msg}",
365
+ status_code=404,
366
+ )
367
+
368
+ if response.status_code == HTTPStatus.FORBIDDEN:
369
+ raise ConfluenceError(
370
+ f"Permission denied: you don't have access to add comments to page '{page_id}'",
371
+ status_code=403,
372
+ )
373
+
374
+ if response.status_code == HTTPStatus.BAD_REQUEST:
375
+ error_msg = self._extract_error_message(response)
376
+ raise ConfluenceError(f"Invalid request: {error_msg}", status_code=400)
377
+
378
+ if response.status_code == HTTPStatus.TOO_MANY_REQUESTS:
379
+ raise ConfluenceError("Rate limit exceeded. Please try again later.", status_code=429)
380
+
381
+ response.raise_for_status()
382
+
383
+ return self._parse_comment_response(response.json(), page_id)
384
+
188
385
  async def __aenter__(self) -> "ConfluenceClient":
189
386
  """Async context manager entry."""
190
387
  return self
@@ -25,6 +25,18 @@ from .atlassian import get_atlassian_cloud_id
25
25
 
26
26
  logger = logging.getLogger(__name__)
27
27
 
28
+ RESPONSE_JIRA_ISSUE_FIELDS = {
29
+ "id",
30
+ "key",
31
+ "summary",
32
+ "status",
33
+ "reporter",
34
+ "assignee",
35
+ "created",
36
+ "updated",
37
+ }
38
+ RESPONSE_JIRA_ISSUE_FIELDS_STR = ",".join(RESPONSE_JIRA_ISSUE_FIELDS)
39
+
28
40
 
29
41
  class _IssuePerson(BaseModel):
30
42
  email_address: str = Field(alias="emailAddress")
@@ -107,6 +119,42 @@ class JiraClient:
107
119
  self._cloud_id = await get_atlassian_cloud_id(self._client, service_type="jira")
108
120
  return self._cloud_id
109
121
 
122
+ async def _get_full_url(self, path: str) -> str:
123
+ """Return URL for Jira API."""
124
+ cloud_id = await self._get_cloud_id()
125
+ return f"{ATLASSIAN_API_BASE}/ex/jira/{cloud_id}/rest/api/3/{path}"
126
+
127
+ async def search_jira_issues(self, jql_query: str, max_results: int) -> list[Issue]:
128
+ """
129
+ Search Jira issues using JQL (Jira Query Language).
130
+
131
+ Args:
132
+ jql_query: JQL Query
133
+ max_results: Maximum number of issues to return
134
+
135
+ Returns
136
+ -------
137
+ List of Jira issues
138
+
139
+ Raises
140
+ ------
141
+ httpx.HTTPStatusError: If the API request fails
142
+ """
143
+ url = await self._get_full_url("search/jql")
144
+ response = await self._client.post(
145
+ url,
146
+ json={
147
+ "jql": jql_query,
148
+ "fields": list(RESPONSE_JIRA_ISSUE_FIELDS),
149
+ "maxResults": max_results,
150
+ },
151
+ )
152
+
153
+ response.raise_for_status()
154
+ raw_issues = response.json().get("issues", [])
155
+ issues = [Issue(**issue) for issue in raw_issues]
156
+ return issues
157
+
110
158
  async def get_jira_issue(self, issue_key: str) -> Issue:
111
159
  """
112
160
  Get a Jira issue by its key.
@@ -122,12 +170,8 @@ class JiraClient:
122
170
  ------
123
171
  httpx.HTTPStatusError: If the API request fails
124
172
  """
125
- cloud_id = await self._get_cloud_id()
126
- url = f"{ATLASSIAN_API_BASE}/ex/jira/{cloud_id}/rest/api/3/issue/{issue_key}"
127
-
128
- response = await self._client.get(
129
- url, params={"fields": "id,key,summary,status,reporter,assignee,created,updated"}
130
- )
173
+ url = await self._get_full_url(f"issue/{issue_key}")
174
+ response = await self._client.get(url, params={"fields": RESPONSE_JIRA_ISSUE_FIELDS_STR})
131
175
 
132
176
  if response.status_code == HTTPStatus.NOT_FOUND:
133
177
  raise ValueError(f"{issue_key} not found")
@@ -136,6 +180,149 @@ class JiraClient:
136
180
  issue = Issue(**response.json())
137
181
  return issue
138
182
 
183
+ async def get_jira_issue_types(self, project_key: str) -> dict[str, str]:
184
+ """
185
+ Get Jira issue types possible for given project.
186
+
187
+ Args:
188
+ project_key: The key of the Jira project, e.g., 'PROJ'
189
+
190
+ Returns
191
+ -------
192
+ Dictionary where key is the issue type name and value is the issue type ID
193
+
194
+ Raises
195
+ ------
196
+ httpx.HTTPStatusError: If the API request fails
197
+ """
198
+ url = await self._get_full_url(f"issue/createmeta/{project_key}/issuetypes")
199
+ response = await self._client.get(url)
200
+
201
+ response.raise_for_status()
202
+ jsoned = response.json()
203
+ issue_types = {
204
+ issue_type["untranslatedName"]: issue_type["id"]
205
+ for issue_type in jsoned.get("issueTypes", [])
206
+ }
207
+ return issue_types
208
+
209
+ async def create_jira_issue(
210
+ self, project_key: str, summary: str, issue_type_id: str, description: str | None
211
+ ) -> str:
212
+ """
213
+ Create Jira issue.
214
+
215
+ Args:
216
+ project_key: The key of the Jira project, e.g., 'PROJ'
217
+ summary: Summary of Jira issue (title), e.g., 'Fix bug abc'
218
+ issue_type_id: ID type of Jira issue, e.g., "1"
219
+ description: Optional description of Jira issue
220
+
221
+ Returns
222
+ -------
223
+ Jira issue key
224
+
225
+ Raises
226
+ ------
227
+ httpx.HTTPStatusError: If the API request fails
228
+ """
229
+ url = await self._get_full_url("issue")
230
+ payload = {
231
+ "fields": {
232
+ "project": {"key": project_key},
233
+ "summary": summary,
234
+ "issuetype": {"id": issue_type_id},
235
+ }
236
+ }
237
+
238
+ if description:
239
+ payload["fields"]["description"] = {
240
+ "content": [
241
+ {"content": [{"text": description, "type": "text"}], "type": "paragraph"}
242
+ ],
243
+ "type": "doc",
244
+ "version": 1,
245
+ }
246
+
247
+ response = await self._client.post(url, json=payload)
248
+
249
+ response.raise_for_status()
250
+ jsoned = response.json()
251
+ return jsoned["key"]
252
+
253
+ async def update_jira_issue(self, issue_key: str, fields: dict[str, Any]) -> list[str]:
254
+ """
255
+ Update Jira issue.
256
+
257
+ Args:
258
+ issue_key: The key of the Jira issue, e.g., 'PROJ-123'
259
+ fields: A dictionary of field names and their new values
260
+ e.g., {'description': 'New content'}
261
+
262
+ Returns
263
+ -------
264
+ List of updated fields
265
+
266
+ Raises
267
+ ------
268
+ httpx.HTTPStatusError: If the API request fails
269
+ """
270
+ url = await self._get_full_url(f"issue/{issue_key}")
271
+ payload = {"fields": fields}
272
+
273
+ response = await self._client.put(url, json=payload)
274
+
275
+ response.raise_for_status()
276
+ return list(fields.keys())
277
+
278
+ async def get_available_jira_transitions(self, issue_key: str) -> dict[str, str]:
279
+ """
280
+ Get Available Jira Transitions.
281
+
282
+ Args:
283
+ issue_key: The key of the Jira issue, e.g., 'PROJ-123'
284
+
285
+ Returns
286
+ -------
287
+ Dictionary where key is the transition name and value is the transition ID
288
+
289
+ Raises
290
+ ------
291
+ httpx.HTTPStatusError: If the API request fails
292
+ """
293
+ url = await self._get_full_url(f"issue/{issue_key}/transitions")
294
+ response = await self._client.get(url)
295
+ response.raise_for_status()
296
+ jsoned = response.json()
297
+ transitions = {
298
+ transition["name"]: transition["id"] for transition in jsoned.get("transitions", [])
299
+ }
300
+ return transitions
301
+
302
+ async def transition_jira_issue(self, issue_key: str, transition_id: str) -> None:
303
+ """
304
+ Transition Jira issue.
305
+
306
+ Args:
307
+ issue_key: The key of the Jira issue, e.g., 'PROJ-123'
308
+ transition_id: Id of target transitionm e.g. '123'.
309
+ Can be obtained from `get_available_jira_transitions`.
310
+
311
+ Returns
312
+ -------
313
+ Nothing
314
+
315
+ Raises
316
+ ------
317
+ httpx.HTTPStatusError: If the API request fails
318
+ """
319
+ url = await self._get_full_url(f"issue/{issue_key}")
320
+ payload = {"transition": {"id": transition_id}}
321
+
322
+ response = await self._client.post(url, json=payload)
323
+
324
+ response.raise_for_status()
325
+
139
326
  async def __aenter__(self) -> "JiraClient":
140
327
  """Async context manager entry."""
141
328
  return self
@@ -23,6 +23,7 @@ from fastmcp.tools.tool import ToolResult
23
23
  from datarobot_genai.drmcp.core.mcp_instance import dr_mcp_tool
24
24
  from datarobot_genai.drmcp.tools.clients.atlassian import get_atlassian_access_token
25
25
  from datarobot_genai.drmcp.tools.clients.confluence import ConfluenceClient
26
+ from datarobot_genai.drmcp.tools.clients.confluence import ConfluenceError
26
27
 
27
28
  logger = logging.getLogger(__name__)
28
29
 
@@ -65,8 +66,8 @@ async def confluence_get_page(
65
66
  "'space_key' is required when identifying a page by title."
66
67
  )
67
68
  page_response = await client.get_page_by_title(page_id_or_title, space_key)
68
- except ValueError as e:
69
- logger.error(f"Value error getting Confluence page: {e}")
69
+ except ConfluenceError as e:
70
+ logger.error(f"Confluence error getting page: {e}")
70
71
  raise ToolError(str(e))
71
72
  except Exception as e:
72
73
  logger.error(f"Unexpected error getting Confluence page: {e}")
@@ -79,3 +80,109 @@ async def confluence_get_page(
79
80
  content=f"Successfully retrieved page '{page_response.title}'.",
80
81
  structured_content=page_response.as_flat_dict(),
81
82
  )
83
+
84
+
85
+ @dr_mcp_tool(tags={"confluence", "write", "create", "page"})
86
+ async def confluence_create_page(
87
+ *,
88
+ space_key: Annotated[str, "The key of the Confluence space where the new page should live."],
89
+ title: Annotated[str, "The title of the new page."],
90
+ body_content: Annotated[
91
+ str,
92
+ "The content of the page, typically in Confluence Storage Format (XML) or raw text.",
93
+ ],
94
+ parent_id: Annotated[
95
+ int | None,
96
+ "The ID of the parent page, used to create a child page.",
97
+ ] = None,
98
+ ) -> ToolResult:
99
+ """Create a new documentation page in a specified Confluence space.
100
+
101
+ Use this tool to create new Confluence pages with content in storage format.
102
+ The page will be created at the root level of the space unless a parent_id
103
+ is provided, in which case it will be created as a child page.
104
+
105
+ Usage:
106
+ - Root page: space_key="PROJ", title="New Page", body_content="<p>Content</p>"
107
+ - Child page: space_key="PROJ", title="Sub Page", body_content="<p>Content</p>",
108
+ parent_id=123456
109
+ """
110
+ if not all([space_key, title, body_content]):
111
+ raise ToolError(
112
+ "Argument validation error: space_key, title, and body_content are required fields."
113
+ )
114
+
115
+ access_token = await get_atlassian_access_token()
116
+ if isinstance(access_token, ToolError):
117
+ raise access_token
118
+
119
+ try:
120
+ async with ConfluenceClient(access_token) as client:
121
+ page_response = await client.create_page(
122
+ space_key=space_key,
123
+ title=title,
124
+ body_content=body_content,
125
+ parent_id=parent_id,
126
+ )
127
+ except ConfluenceError as e:
128
+ logger.error(f"Confluence error creating page: {e}")
129
+ raise ToolError(str(e))
130
+ except Exception as e:
131
+ logger.error(f"Unexpected error creating Confluence page: {e}")
132
+ raise ToolError(
133
+ f"An unexpected error occurred while creating Confluence page "
134
+ f"'{title}' in space '{space_key}': {str(e)}"
135
+ )
136
+
137
+ return ToolResult(
138
+ content=f"New page '{title}' created successfully in space '{space_key}'.",
139
+ structured_content={"new_page_id": page_response.page_id, "title": page_response.title},
140
+ )
141
+
142
+
143
+ @dr_mcp_tool(tags={"confluence", "write", "add", "comment"})
144
+ async def confluence_add_comment(
145
+ *,
146
+ page_id: Annotated[str, "The numeric ID of the page where the comment will be added."],
147
+ comment_body: Annotated[str, "The text content of the comment."],
148
+ ) -> ToolResult:
149
+ """Add a new comment to a specified Confluence page for collaboration.
150
+
151
+ Use this tool to add comments to Confluence pages to facilitate collaboration
152
+ and discussion. Comments are added at the page level.
153
+
154
+ Usage:
155
+ - Add comment: page_id="856391684", comment_body="Great work on this documentation!"
156
+ """
157
+ if not page_id:
158
+ raise ToolError("Argument validation error: 'page_id' cannot be empty.")
159
+
160
+ if not comment_body:
161
+ raise ToolError("Argument validation error: 'comment_body' cannot be empty.")
162
+
163
+ access_token = await get_atlassian_access_token()
164
+ if isinstance(access_token, ToolError):
165
+ raise access_token
166
+
167
+ try:
168
+ async with ConfluenceClient(access_token) as client:
169
+ comment_response = await client.add_comment(
170
+ page_id=page_id,
171
+ comment_body=comment_body,
172
+ )
173
+ except ConfluenceError as e:
174
+ logger.error(f"Confluence error adding comment: {e}")
175
+ raise ToolError(str(e))
176
+ except Exception as e:
177
+ logger.error(f"Unexpected error adding comment to Confluence page: {e}")
178
+ raise ToolError(
179
+ f"An unexpected error occurred while adding comment to page '{page_id}': {str(e)}"
180
+ )
181
+
182
+ return ToolResult(
183
+ content=f"Comment added successfully to page ID {page_id}.",
184
+ structured_content={
185
+ "comment_id": comment_response.comment_id,
186
+ "page_id": page_id,
187
+ },
188
+ )