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.
- datarobot_genai/drmcp/core/utils.py +7 -0
- datarobot_genai/drmcp/test_utils/elicitation_test_tool.py +89 -0
- datarobot_genai/drmcp/test_utils/integration_mcp_server.py +7 -0
- datarobot_genai/drmcp/test_utils/mcp_utils_ete.py +9 -1
- datarobot_genai/drmcp/test_utils/mcp_utils_integration.py +17 -4
- datarobot_genai/drmcp/test_utils/openai_llm_mcp_client.py +71 -8
- datarobot_genai/drmcp/test_utils/test_interactive.py +205 -0
- datarobot_genai/drmcp/test_utils/tool_base_ete.py +22 -20
- datarobot_genai/drmcp/tools/clients/confluence.py +201 -4
- datarobot_genai/drmcp/tools/clients/jira.py +193 -6
- datarobot_genai/drmcp/tools/confluence/tools.py +109 -2
- datarobot_genai/drmcp/tools/jira/tools.py +192 -1
- datarobot_genai/drmcp/tools/predictive/data.py +60 -32
- datarobot_genai/nat/agent.py +20 -7
- datarobot_genai/nat/datarobot_llm_clients.py +45 -12
- datarobot_genai/nat/helpers.py +87 -0
- {datarobot_genai-0.2.11.dist-info → datarobot_genai-0.2.19.dist-info}/METADATA +1 -1
- {datarobot_genai-0.2.11.dist-info → datarobot_genai-0.2.19.dist-info}/RECORD +22 -19
- {datarobot_genai-0.2.11.dist-info → datarobot_genai-0.2.19.dist-info}/WHEEL +0 -0
- {datarobot_genai-0.2.11.dist-info → datarobot_genai-0.2.19.dist-info}/entry_points.txt +0 -0
- {datarobot_genai-0.2.11.dist-info → datarobot_genai-0.2.19.dist-info}/licenses/AUTHORS +0 -0
- {datarobot_genai-0.2.11.dist-info → datarobot_genai-0.2.19.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
126
|
-
url =
|
|
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
|
|
69
|
-
logger.error(f"
|
|
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
|
+
)
|