mcp-ticketer 0.1.21__py3-none-any.whl → 0.1.23__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.
Potentially problematic release.
This version of mcp-ticketer might be problematic. Click here for more details.
- mcp_ticketer/__init__.py +7 -7
- mcp_ticketer/__version__.py +4 -2
- mcp_ticketer/adapters/__init__.py +4 -4
- mcp_ticketer/adapters/aitrackdown.py +66 -49
- mcp_ticketer/adapters/github.py +192 -125
- mcp_ticketer/adapters/hybrid.py +99 -53
- mcp_ticketer/adapters/jira.py +161 -151
- mcp_ticketer/adapters/linear.py +396 -246
- mcp_ticketer/cache/__init__.py +1 -1
- mcp_ticketer/cache/memory.py +15 -16
- mcp_ticketer/cli/__init__.py +1 -1
- mcp_ticketer/cli/configure.py +69 -93
- mcp_ticketer/cli/discover.py +43 -35
- mcp_ticketer/cli/main.py +283 -298
- mcp_ticketer/cli/mcp_configure.py +39 -15
- mcp_ticketer/cli/migrate_config.py +11 -13
- mcp_ticketer/cli/queue_commands.py +21 -58
- mcp_ticketer/cli/utils.py +121 -66
- mcp_ticketer/core/__init__.py +2 -2
- mcp_ticketer/core/adapter.py +46 -39
- mcp_ticketer/core/config.py +128 -92
- mcp_ticketer/core/env_discovery.py +69 -37
- mcp_ticketer/core/http_client.py +57 -40
- mcp_ticketer/core/mappers.py +98 -54
- mcp_ticketer/core/models.py +38 -24
- mcp_ticketer/core/project_config.py +145 -80
- mcp_ticketer/core/registry.py +16 -16
- mcp_ticketer/mcp/__init__.py +1 -1
- mcp_ticketer/mcp/server.py +199 -145
- mcp_ticketer/queue/__init__.py +2 -2
- mcp_ticketer/queue/__main__.py +1 -1
- mcp_ticketer/queue/manager.py +30 -26
- mcp_ticketer/queue/queue.py +147 -85
- mcp_ticketer/queue/run_worker.py +2 -3
- mcp_ticketer/queue/worker.py +55 -40
- {mcp_ticketer-0.1.21.dist-info → mcp_ticketer-0.1.23.dist-info}/METADATA +1 -1
- mcp_ticketer-0.1.23.dist-info/RECORD +42 -0
- mcp_ticketer-0.1.21.dist-info/RECORD +0 -42
- {mcp_ticketer-0.1.21.dist-info → mcp_ticketer-0.1.23.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.1.21.dist-info → mcp_ticketer-0.1.23.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.1.21.dist-info → mcp_ticketer-0.1.23.dist-info}/licenses/LICENSE +0 -0
- {mcp_ticketer-0.1.21.dist-info → mcp_ticketer-0.1.23.dist-info}/top_level.txt +0 -0
mcp_ticketer/adapters/jira.py
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
"""JIRA adapter implementation using REST API v3."""
|
|
2
2
|
|
|
3
|
-
import os
|
|
4
3
|
import asyncio
|
|
5
|
-
|
|
4
|
+
import builtins
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
6
7
|
from datetime import datetime
|
|
7
8
|
from enum import Enum
|
|
8
|
-
import
|
|
9
|
+
from typing import Any, Optional, Union
|
|
9
10
|
|
|
10
11
|
import httpx
|
|
11
12
|
from httpx import AsyncClient, HTTPStatusError, TimeoutException
|
|
12
13
|
|
|
13
14
|
from ..core.adapter import BaseAdapter
|
|
14
|
-
from ..core.models import
|
|
15
|
+
from ..core.models import Comment, Epic, Priority, SearchQuery, Task, TicketState
|
|
15
16
|
from ..core.registry import AdapterRegistry
|
|
16
17
|
|
|
17
18
|
logger = logging.getLogger(__name__)
|
|
@@ -19,6 +20,7 @@ logger = logging.getLogger(__name__)
|
|
|
19
20
|
|
|
20
21
|
class JiraIssueType(str, Enum):
|
|
21
22
|
"""Common JIRA issue types."""
|
|
23
|
+
|
|
22
24
|
EPIC = "Epic"
|
|
23
25
|
STORY = "Story"
|
|
24
26
|
TASK = "Task"
|
|
@@ -30,6 +32,7 @@ class JiraIssueType(str, Enum):
|
|
|
30
32
|
|
|
31
33
|
class JiraPriority(str, Enum):
|
|
32
34
|
"""Standard JIRA priority levels."""
|
|
35
|
+
|
|
33
36
|
HIGHEST = "Highest"
|
|
34
37
|
HIGH = "High"
|
|
35
38
|
MEDIUM = "Medium"
|
|
@@ -40,7 +43,7 @@ class JiraPriority(str, Enum):
|
|
|
40
43
|
class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
41
44
|
"""Adapter for JIRA using REST API v3."""
|
|
42
45
|
|
|
43
|
-
def __init__(self, config:
|
|
46
|
+
def __init__(self, config: dict[str, Any]):
|
|
44
47
|
"""Initialize JIRA adapter.
|
|
45
48
|
|
|
46
49
|
Args:
|
|
@@ -53,6 +56,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
53
56
|
- verify_ssl: Whether to verify SSL certificates (default: True)
|
|
54
57
|
- timeout: Request timeout in seconds (default: 30)
|
|
55
58
|
- max_retries: Maximum retry attempts (default: 3)
|
|
59
|
+
|
|
56
60
|
"""
|
|
57
61
|
super().__init__(config)
|
|
58
62
|
|
|
@@ -60,7 +64,9 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
60
64
|
self.server = config.get("server") or os.getenv("JIRA_SERVER", "")
|
|
61
65
|
self.email = config.get("email") or os.getenv("JIRA_EMAIL", "")
|
|
62
66
|
self.api_token = config.get("api_token") or os.getenv("JIRA_API_TOKEN", "")
|
|
63
|
-
self.project_key = config.get("project_key") or os.getenv(
|
|
67
|
+
self.project_key = config.get("project_key") or os.getenv(
|
|
68
|
+
"JIRA_PROJECT_KEY", ""
|
|
69
|
+
)
|
|
64
70
|
self.is_cloud = config.get("cloud", True)
|
|
65
71
|
self.verify_ssl = config.get("verify_ssl", True)
|
|
66
72
|
self.timeout = config.get("timeout", 30)
|
|
@@ -74,36 +80,50 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
74
80
|
self.server = self.server.rstrip("/")
|
|
75
81
|
|
|
76
82
|
# API base URL
|
|
77
|
-
self.api_base =
|
|
83
|
+
self.api_base = (
|
|
84
|
+
f"{self.server}/rest/api/3"
|
|
85
|
+
if self.is_cloud
|
|
86
|
+
else f"{self.server}/rest/api/2"
|
|
87
|
+
)
|
|
78
88
|
|
|
79
89
|
# HTTP client setup
|
|
80
90
|
self.auth = httpx.BasicAuth(self.email, self.api_token)
|
|
81
91
|
self.headers = {
|
|
82
92
|
"Accept": "application/json",
|
|
83
|
-
"Content-Type": "application/json"
|
|
93
|
+
"Content-Type": "application/json",
|
|
84
94
|
}
|
|
85
95
|
|
|
86
96
|
# Cache for workflow states and transitions
|
|
87
|
-
self._workflow_cache:
|
|
88
|
-
self._priority_cache:
|
|
89
|
-
self._issue_types_cache:
|
|
90
|
-
self._custom_fields_cache:
|
|
97
|
+
self._workflow_cache: dict[str, Any] = {}
|
|
98
|
+
self._priority_cache: list[dict[str, Any]] = []
|
|
99
|
+
self._issue_types_cache: dict[str, Any] = {}
|
|
100
|
+
self._custom_fields_cache: dict[str, Any] = {}
|
|
91
101
|
|
|
92
102
|
def validate_credentials(self) -> tuple[bool, str]:
|
|
93
103
|
"""Validate that required credentials are present.
|
|
94
104
|
|
|
95
105
|
Returns:
|
|
96
106
|
(is_valid, error_message) - Tuple of validation result and error message
|
|
107
|
+
|
|
97
108
|
"""
|
|
98
109
|
if not self.server:
|
|
99
|
-
return
|
|
110
|
+
return (
|
|
111
|
+
False,
|
|
112
|
+
"JIRA_SERVER is required but not found. Set it in .env.local or environment.",
|
|
113
|
+
)
|
|
100
114
|
if not self.email:
|
|
101
|
-
return
|
|
115
|
+
return (
|
|
116
|
+
False,
|
|
117
|
+
"JIRA_EMAIL is required but not found. Set it in .env.local or environment.",
|
|
118
|
+
)
|
|
102
119
|
if not self.api_token:
|
|
103
|
-
return
|
|
120
|
+
return (
|
|
121
|
+
False,
|
|
122
|
+
"JIRA_API_TOKEN is required but not found. Set it in .env.local or environment.",
|
|
123
|
+
)
|
|
104
124
|
return True, ""
|
|
105
125
|
|
|
106
|
-
def _get_state_mapping(self) ->
|
|
126
|
+
def _get_state_mapping(self) -> dict[TicketState, str]:
|
|
107
127
|
"""Map universal states to common JIRA workflow states."""
|
|
108
128
|
return {
|
|
109
129
|
TicketState.OPEN: "To Do",
|
|
@@ -122,17 +142,17 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
122
142
|
auth=self.auth,
|
|
123
143
|
headers=self.headers,
|
|
124
144
|
timeout=self.timeout,
|
|
125
|
-
verify=self.verify_ssl
|
|
145
|
+
verify=self.verify_ssl,
|
|
126
146
|
)
|
|
127
147
|
|
|
128
148
|
async def _make_request(
|
|
129
149
|
self,
|
|
130
150
|
method: str,
|
|
131
151
|
endpoint: str,
|
|
132
|
-
data: Optional[
|
|
133
|
-
params: Optional[
|
|
134
|
-
retry_count: int = 0
|
|
135
|
-
) ->
|
|
152
|
+
data: Optional[dict[str, Any]] = None,
|
|
153
|
+
params: Optional[dict[str, Any]] = None,
|
|
154
|
+
retry_count: int = 0,
|
|
155
|
+
) -> dict[str, Any]:
|
|
136
156
|
"""Make HTTP request to JIRA API with retry logic.
|
|
137
157
|
|
|
138
158
|
Args:
|
|
@@ -148,16 +168,14 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
148
168
|
Raises:
|
|
149
169
|
HTTPStatusError: On API errors
|
|
150
170
|
TimeoutException: On timeout
|
|
171
|
+
|
|
151
172
|
"""
|
|
152
173
|
url = f"{self.api_base}/{endpoint.lstrip('/')}"
|
|
153
174
|
|
|
154
175
|
async with await self._get_client() as client:
|
|
155
176
|
try:
|
|
156
177
|
response = await client.request(
|
|
157
|
-
method=method,
|
|
158
|
-
url=url,
|
|
159
|
-
json=data,
|
|
160
|
-
params=params
|
|
178
|
+
method=method, url=url, json=data, params=params
|
|
161
179
|
)
|
|
162
180
|
response.raise_for_status()
|
|
163
181
|
|
|
@@ -169,7 +187,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
169
187
|
|
|
170
188
|
except TimeoutException as e:
|
|
171
189
|
if retry_count < self.max_retries:
|
|
172
|
-
await asyncio.sleep(2
|
|
190
|
+
await asyncio.sleep(2**retry_count) # Exponential backoff
|
|
173
191
|
return await self._make_request(
|
|
174
192
|
method, endpoint, data, params, retry_count + 1
|
|
175
193
|
)
|
|
@@ -185,16 +203,20 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
185
203
|
)
|
|
186
204
|
|
|
187
205
|
# Log error details
|
|
188
|
-
logger.error(
|
|
206
|
+
logger.error(
|
|
207
|
+
f"JIRA API error: {e.response.status_code} - {e.response.text}"
|
|
208
|
+
)
|
|
189
209
|
raise e
|
|
190
210
|
|
|
191
|
-
async def _get_priorities(self) ->
|
|
211
|
+
async def _get_priorities(self) -> list[dict[str, Any]]:
|
|
192
212
|
"""Get available priorities from JIRA."""
|
|
193
213
|
if not self._priority_cache:
|
|
194
214
|
self._priority_cache = await self._make_request("GET", "priority")
|
|
195
215
|
return self._priority_cache
|
|
196
216
|
|
|
197
|
-
async def _get_issue_types(
|
|
217
|
+
async def _get_issue_types(
|
|
218
|
+
self, project_key: Optional[str] = None
|
|
219
|
+
) -> list[dict[str, Any]]:
|
|
198
220
|
"""Get available issue types for a project."""
|
|
199
221
|
key = project_key or self.project_key
|
|
200
222
|
if key not in self._issue_types_cache:
|
|
@@ -202,12 +224,12 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
202
224
|
self._issue_types_cache[key] = data.get("issueTypes", [])
|
|
203
225
|
return self._issue_types_cache[key]
|
|
204
226
|
|
|
205
|
-
async def _get_transitions(self, issue_key: str) ->
|
|
227
|
+
async def _get_transitions(self, issue_key: str) -> list[dict[str, Any]]:
|
|
206
228
|
"""Get available transitions for an issue."""
|
|
207
229
|
data = await self._make_request("GET", f"issue/{issue_key}/transitions")
|
|
208
230
|
return data.get("transitions", [])
|
|
209
231
|
|
|
210
|
-
async def _get_custom_fields(self) ->
|
|
232
|
+
async def _get_custom_fields(self) -> dict[str, str]:
|
|
211
233
|
"""Get custom field definitions."""
|
|
212
234
|
if not self._custom_fields_cache:
|
|
213
235
|
fields = await self._make_request("GET", "field")
|
|
@@ -253,45 +275,28 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
253
275
|
|
|
254
276
|
return "\n".join(lines)
|
|
255
277
|
|
|
256
|
-
def _convert_to_adf(self, text: str) ->
|
|
278
|
+
def _convert_to_adf(self, text: str) -> dict[str, Any]:
|
|
257
279
|
"""Convert plain text to Atlassian Document Format (ADF).
|
|
258
280
|
|
|
259
281
|
ADF is required for JIRA Cloud description fields.
|
|
260
282
|
This creates a simple document with paragraphs for each line.
|
|
261
283
|
"""
|
|
262
284
|
if not text:
|
|
263
|
-
return {
|
|
264
|
-
"type": "doc",
|
|
265
|
-
"version": 1,
|
|
266
|
-
"content": []
|
|
267
|
-
}
|
|
285
|
+
return {"type": "doc", "version": 1, "content": []}
|
|
268
286
|
|
|
269
287
|
# Split text into lines and create paragraphs
|
|
270
|
-
lines = text.split(
|
|
288
|
+
lines = text.split("\n")
|
|
271
289
|
content = []
|
|
272
290
|
|
|
273
291
|
for line in lines:
|
|
274
292
|
if line.strip(): # Non-empty line
|
|
275
|
-
content.append(
|
|
276
|
-
"type": "paragraph",
|
|
277
|
-
|
|
278
|
-
{
|
|
279
|
-
"type": "text",
|
|
280
|
-
"text": line
|
|
281
|
-
}
|
|
282
|
-
]
|
|
283
|
-
})
|
|
293
|
+
content.append(
|
|
294
|
+
{"type": "paragraph", "content": [{"type": "text", "text": line}]}
|
|
295
|
+
)
|
|
284
296
|
else: # Empty line becomes empty paragraph
|
|
285
|
-
content.append({
|
|
286
|
-
"type": "paragraph",
|
|
287
|
-
"content": []
|
|
288
|
-
})
|
|
297
|
+
content.append({"type": "paragraph", "content": []})
|
|
289
298
|
|
|
290
|
-
return {
|
|
291
|
-
"type": "doc",
|
|
292
|
-
"version": 1,
|
|
293
|
-
"content": content
|
|
294
|
-
}
|
|
299
|
+
return {"type": "doc", "version": 1, "content": content}
|
|
295
300
|
|
|
296
301
|
def _map_priority_to_jira(self, priority: Priority) -> str:
|
|
297
302
|
"""Map universal priority to JIRA priority."""
|
|
@@ -303,7 +308,9 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
303
308
|
}
|
|
304
309
|
return mapping.get(priority, JiraPriority.MEDIUM)
|
|
305
310
|
|
|
306
|
-
def _map_priority_from_jira(
|
|
311
|
+
def _map_priority_from_jira(
|
|
312
|
+
self, jira_priority: Optional[dict[str, Any]]
|
|
313
|
+
) -> Priority:
|
|
307
314
|
"""Map JIRA priority to universal priority."""
|
|
308
315
|
if not jira_priority:
|
|
309
316
|
return Priority.MEDIUM
|
|
@@ -319,7 +326,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
319
326
|
else:
|
|
320
327
|
return Priority.MEDIUM
|
|
321
328
|
|
|
322
|
-
def _map_state_from_jira(self, status:
|
|
329
|
+
def _map_state_from_jira(self, status: dict[str, Any]) -> TicketState:
|
|
323
330
|
"""Map JIRA status to universal state."""
|
|
324
331
|
if not status:
|
|
325
332
|
return TicketState.OPEN
|
|
@@ -353,7 +360,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
353
360
|
else:
|
|
354
361
|
return TicketState.OPEN
|
|
355
362
|
|
|
356
|
-
def _issue_to_ticket(self, issue:
|
|
363
|
+
def _issue_to_ticket(self, issue: dict[str, Any]) -> Union[Epic, Task]:
|
|
357
364
|
"""Convert JIRA issue to universal ticket model."""
|
|
358
365
|
fields = issue.get("fields", {})
|
|
359
366
|
|
|
@@ -375,12 +382,16 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
375
382
|
label.get("name", "") if isinstance(label, dict) else str(label)
|
|
376
383
|
for label in fields.get("labels", [])
|
|
377
384
|
],
|
|
378
|
-
"created_at":
|
|
379
|
-
fields.get("created", "").replace("Z", "+00:00")
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
385
|
+
"created_at": (
|
|
386
|
+
datetime.fromisoformat(fields.get("created", "").replace("Z", "+00:00"))
|
|
387
|
+
if fields.get("created")
|
|
388
|
+
else None
|
|
389
|
+
),
|
|
390
|
+
"updated_at": (
|
|
391
|
+
datetime.fromisoformat(fields.get("updated", "").replace("Z", "+00:00"))
|
|
392
|
+
if fields.get("updated")
|
|
393
|
+
else None
|
|
394
|
+
),
|
|
384
395
|
"metadata": {
|
|
385
396
|
"jira": {
|
|
386
397
|
"id": issue.get("id"),
|
|
@@ -393,7 +404,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
393
404
|
"fix_versions": fields.get("fixVersions", []),
|
|
394
405
|
"resolution": fields.get("resolution"),
|
|
395
406
|
}
|
|
396
|
-
}
|
|
407
|
+
},
|
|
397
408
|
}
|
|
398
409
|
|
|
399
410
|
if is_epic:
|
|
@@ -401,9 +412,8 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
401
412
|
return Epic(
|
|
402
413
|
**base_data,
|
|
403
414
|
child_issues=[
|
|
404
|
-
subtask.get("key")
|
|
405
|
-
|
|
406
|
-
]
|
|
415
|
+
subtask.get("key") for subtask in fields.get("subtasks", [])
|
|
416
|
+
],
|
|
407
417
|
)
|
|
408
418
|
else:
|
|
409
419
|
# Create Task
|
|
@@ -414,24 +424,34 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
414
424
|
**base_data,
|
|
415
425
|
parent_issue=parent.get("key") if parent else None,
|
|
416
426
|
parent_epic=epic_link if epic_link else None,
|
|
417
|
-
assignee=
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
)
|
|
422
|
-
|
|
423
|
-
"
|
|
424
|
-
|
|
427
|
+
assignee=(
|
|
428
|
+
fields.get("assignee", {}).get("displayName")
|
|
429
|
+
if fields.get("assignee")
|
|
430
|
+
else None
|
|
431
|
+
),
|
|
432
|
+
estimated_hours=(
|
|
433
|
+
fields.get("timetracking", {}).get("originalEstimateSeconds", 0)
|
|
434
|
+
/ 3600
|
|
435
|
+
if fields.get("timetracking")
|
|
436
|
+
else None
|
|
437
|
+
),
|
|
438
|
+
actual_hours=(
|
|
439
|
+
fields.get("timetracking", {}).get("timeSpentSeconds", 0) / 3600
|
|
440
|
+
if fields.get("timetracking")
|
|
441
|
+
else None
|
|
442
|
+
),
|
|
425
443
|
)
|
|
426
444
|
|
|
427
445
|
def _ticket_to_issue_fields(
|
|
428
|
-
self,
|
|
429
|
-
|
|
430
|
-
issue_type: Optional[str] = None
|
|
431
|
-
) -> Dict[str, Any]:
|
|
446
|
+
self, ticket: Union[Epic, Task], issue_type: Optional[str] = None
|
|
447
|
+
) -> dict[str, Any]:
|
|
432
448
|
"""Convert universal ticket to JIRA issue fields."""
|
|
433
449
|
# Convert description to ADF format for JIRA Cloud
|
|
434
|
-
description =
|
|
450
|
+
description = (
|
|
451
|
+
self._convert_to_adf(ticket.description or "")
|
|
452
|
+
if self.is_cloud
|
|
453
|
+
else (ticket.description or "")
|
|
454
|
+
)
|
|
435
455
|
|
|
436
456
|
fields = {
|
|
437
457
|
"summary": ticket.title,
|
|
@@ -480,11 +500,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
480
500
|
fields = self._ticket_to_issue_fields(ticket)
|
|
481
501
|
|
|
482
502
|
# Create issue
|
|
483
|
-
data = await self._make_request(
|
|
484
|
-
"POST",
|
|
485
|
-
"issue",
|
|
486
|
-
data={"fields": fields}
|
|
487
|
-
)
|
|
503
|
+
data = await self._make_request("POST", "issue", data={"fields": fields})
|
|
488
504
|
|
|
489
505
|
# Set the ID and fetch full issue data
|
|
490
506
|
ticket.id = data.get("key")
|
|
@@ -502,9 +518,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
502
518
|
|
|
503
519
|
try:
|
|
504
520
|
issue = await self._make_request(
|
|
505
|
-
"GET",
|
|
506
|
-
f"issue/{ticket_id}",
|
|
507
|
-
params={"expand": "renderedFields"}
|
|
521
|
+
"GET", f"issue/{ticket_id}", params={"expand": "renderedFields"}
|
|
508
522
|
)
|
|
509
523
|
return self._issue_to_ticket(issue)
|
|
510
524
|
except HTTPStatusError as e:
|
|
@@ -513,9 +527,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
513
527
|
raise
|
|
514
528
|
|
|
515
529
|
async def update(
|
|
516
|
-
self,
|
|
517
|
-
ticket_id: str,
|
|
518
|
-
updates: Dict[str, Any]
|
|
530
|
+
self, ticket_id: str, updates: dict[str, Any]
|
|
519
531
|
) -> Optional[Union[Epic, Task]]:
|
|
520
532
|
"""Update a JIRA issue."""
|
|
521
533
|
# Validate credentials before attempting operation
|
|
@@ -536,7 +548,9 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
536
548
|
if "description" in updates:
|
|
537
549
|
fields["description"] = updates["description"]
|
|
538
550
|
if "priority" in updates:
|
|
539
|
-
fields["priority"] = {
|
|
551
|
+
fields["priority"] = {
|
|
552
|
+
"name": self._map_priority_to_jira(updates["priority"])
|
|
553
|
+
}
|
|
540
554
|
if "tags" in updates:
|
|
541
555
|
fields["labels"] = updates["tags"]
|
|
542
556
|
if "assignee" in updates:
|
|
@@ -545,9 +559,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
545
559
|
# Apply update
|
|
546
560
|
if fields:
|
|
547
561
|
await self._make_request(
|
|
548
|
-
"PUT",
|
|
549
|
-
f"issue/{ticket_id}",
|
|
550
|
-
data={"fields": fields}
|
|
562
|
+
"PUT", f"issue/{ticket_id}", data={"fields": fields}
|
|
551
563
|
)
|
|
552
564
|
|
|
553
565
|
# Handle state transitions separately
|
|
@@ -573,11 +585,8 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
573
585
|
raise
|
|
574
586
|
|
|
575
587
|
async def list(
|
|
576
|
-
self,
|
|
577
|
-
|
|
578
|
-
offset: int = 0,
|
|
579
|
-
filters: Optional[Dict[str, Any]] = None
|
|
580
|
-
) -> List[Union[Epic, Task]]:
|
|
588
|
+
self, limit: int = 10, offset: int = 0, filters: Optional[dict[str, Any]] = None
|
|
589
|
+
) -> list[Union[Epic, Task]]:
|
|
581
590
|
"""List JIRA issues with pagination."""
|
|
582
591
|
# Build JQL query
|
|
583
592
|
jql_parts = []
|
|
@@ -608,15 +617,15 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
608
617
|
"startAt": offset,
|
|
609
618
|
"maxResults": limit,
|
|
610
619
|
"fields": ["*all"],
|
|
611
|
-
"expand": ["renderedFields"]
|
|
612
|
-
}
|
|
620
|
+
"expand": ["renderedFields"],
|
|
621
|
+
},
|
|
613
622
|
)
|
|
614
623
|
|
|
615
624
|
# Convert issues
|
|
616
625
|
issues = data.get("issues", [])
|
|
617
626
|
return [self._issue_to_ticket(issue) for issue in issues]
|
|
618
627
|
|
|
619
|
-
async def search(self, query: SearchQuery) ->
|
|
628
|
+
async def search(self, query: SearchQuery) -> builtins.list[Union[Epic, Task]]:
|
|
620
629
|
"""Search JIRA issues using JQL."""
|
|
621
630
|
# Build JQL query
|
|
622
631
|
jql_parts = []
|
|
@@ -658,8 +667,8 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
658
667
|
"startAt": query.offset,
|
|
659
668
|
"maxResults": query.limit,
|
|
660
669
|
"fields": ["*all"],
|
|
661
|
-
"expand": ["renderedFields"]
|
|
662
|
-
}
|
|
670
|
+
"expand": ["renderedFields"],
|
|
671
|
+
},
|
|
663
672
|
)
|
|
664
673
|
|
|
665
674
|
# Convert and return results
|
|
@@ -667,9 +676,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
667
676
|
return [self._issue_to_ticket(issue) for issue in issues]
|
|
668
677
|
|
|
669
678
|
async def transition_state(
|
|
670
|
-
self,
|
|
671
|
-
ticket_id: str,
|
|
672
|
-
target_state: TicketState
|
|
679
|
+
self, ticket_id: str, target_state: TicketState
|
|
673
680
|
) -> Optional[Union[Epic, Task]]:
|
|
674
681
|
"""Transition JIRA issue to a new state."""
|
|
675
682
|
# Get available transitions
|
|
@@ -688,10 +695,17 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
688
695
|
if not transition:
|
|
689
696
|
# Try to find by status category
|
|
690
697
|
for trans in transitions:
|
|
691
|
-
category =
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
698
|
+
category = (
|
|
699
|
+
trans.get("to", {}).get("statusCategory", {}).get("key", "").lower()
|
|
700
|
+
)
|
|
701
|
+
if (
|
|
702
|
+
(target_state == TicketState.DONE and category == "done")
|
|
703
|
+
or (
|
|
704
|
+
target_state == TicketState.IN_PROGRESS
|
|
705
|
+
and category == "indeterminate"
|
|
706
|
+
)
|
|
707
|
+
or (target_state == TicketState.OPEN and category == "new")
|
|
708
|
+
):
|
|
695
709
|
transition = trans
|
|
696
710
|
break
|
|
697
711
|
|
|
@@ -706,7 +720,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
706
720
|
await self._make_request(
|
|
707
721
|
"POST",
|
|
708
722
|
f"issue/{ticket_id}/transitions",
|
|
709
|
-
data={"transition": {"id": transition["id"]}}
|
|
723
|
+
data={"transition": {"id": transition["id"]}},
|
|
710
724
|
)
|
|
711
725
|
|
|
712
726
|
# Return updated issue
|
|
@@ -715,51 +729,39 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
715
729
|
async def add_comment(self, comment: Comment) -> Comment:
|
|
716
730
|
"""Add a comment to a JIRA issue."""
|
|
717
731
|
# Prepare comment data
|
|
718
|
-
data = {
|
|
719
|
-
"body": comment.content
|
|
720
|
-
}
|
|
732
|
+
data = {"body": comment.content}
|
|
721
733
|
|
|
722
734
|
# Add comment
|
|
723
735
|
result = await self._make_request(
|
|
724
|
-
"POST",
|
|
725
|
-
f"issue/{comment.ticket_id}/comment",
|
|
726
|
-
data=data
|
|
736
|
+
"POST", f"issue/{comment.ticket_id}/comment", data=data
|
|
727
737
|
)
|
|
728
738
|
|
|
729
739
|
# Update comment with JIRA data
|
|
730
740
|
comment.id = result.get("id")
|
|
731
|
-
comment.created_at =
|
|
732
|
-
result.get("created", "").replace("Z", "+00:00")
|
|
733
|
-
|
|
741
|
+
comment.created_at = (
|
|
742
|
+
datetime.fromisoformat(result.get("created", "").replace("Z", "+00:00"))
|
|
743
|
+
if result.get("created")
|
|
744
|
+
else datetime.now()
|
|
745
|
+
)
|
|
734
746
|
comment.author = result.get("author", {}).get("displayName", comment.author)
|
|
735
747
|
comment.metadata["jira"] = result
|
|
736
748
|
|
|
737
749
|
return comment
|
|
738
750
|
|
|
739
751
|
async def get_comments(
|
|
740
|
-
self,
|
|
741
|
-
|
|
742
|
-
limit: int = 10,
|
|
743
|
-
offset: int = 0
|
|
744
|
-
) -> List[Comment]:
|
|
752
|
+
self, ticket_id: str, limit: int = 10, offset: int = 0
|
|
753
|
+
) -> builtins.list[Comment]:
|
|
745
754
|
"""Get comments for a JIRA issue."""
|
|
746
755
|
# Fetch issue with comments
|
|
747
|
-
params = {
|
|
748
|
-
"expand": "comments",
|
|
749
|
-
"fields": "comment"
|
|
750
|
-
}
|
|
756
|
+
params = {"expand": "comments", "fields": "comment"}
|
|
751
757
|
|
|
752
|
-
issue = await self._make_request(
|
|
753
|
-
"GET",
|
|
754
|
-
f"issue/{ticket_id}",
|
|
755
|
-
params=params
|
|
756
|
-
)
|
|
758
|
+
issue = await self._make_request("GET", f"issue/{ticket_id}", params=params)
|
|
757
759
|
|
|
758
760
|
# Extract comments
|
|
759
761
|
comments_data = issue.get("fields", {}).get("comment", {}).get("comments", [])
|
|
760
762
|
|
|
761
763
|
# Apply pagination
|
|
762
|
-
paginated = comments_data[offset:offset + limit]
|
|
764
|
+
paginated = comments_data[offset : offset + limit]
|
|
763
765
|
|
|
764
766
|
# Convert to Comment objects
|
|
765
767
|
comments = []
|
|
@@ -769,16 +771,22 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
769
771
|
ticket_id=ticket_id,
|
|
770
772
|
author=comment_data.get("author", {}).get("displayName", "Unknown"),
|
|
771
773
|
content=comment_data.get("body", ""),
|
|
772
|
-
created_at=
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
774
|
+
created_at=(
|
|
775
|
+
datetime.fromisoformat(
|
|
776
|
+
comment_data.get("created", "").replace("Z", "+00:00")
|
|
777
|
+
)
|
|
778
|
+
if comment_data.get("created")
|
|
779
|
+
else None
|
|
780
|
+
),
|
|
781
|
+
metadata={"jira": comment_data},
|
|
776
782
|
)
|
|
777
783
|
comments.append(comment)
|
|
778
784
|
|
|
779
785
|
return comments
|
|
780
786
|
|
|
781
|
-
async def get_project_info(
|
|
787
|
+
async def get_project_info(
|
|
788
|
+
self, project_key: Optional[str] = None
|
|
789
|
+
) -> dict[str, Any]:
|
|
782
790
|
"""Get JIRA project information including workflows and fields."""
|
|
783
791
|
key = project_key or self.project_key
|
|
784
792
|
if not key:
|
|
@@ -798,7 +806,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
798
806
|
"custom_fields": custom_fields,
|
|
799
807
|
}
|
|
800
808
|
|
|
801
|
-
async def execute_jql(self, jql: str, limit: int = 50) ->
|
|
809
|
+
async def execute_jql(self, jql: str, limit: int = 50) -> builtins.list[Union[Epic, Task]]:
|
|
802
810
|
"""Execute a raw JQL query.
|
|
803
811
|
|
|
804
812
|
Args:
|
|
@@ -807,6 +815,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
807
815
|
|
|
808
816
|
Returns:
|
|
809
817
|
List of matching tickets
|
|
818
|
+
|
|
810
819
|
"""
|
|
811
820
|
data = await self._make_request(
|
|
812
821
|
"POST",
|
|
@@ -816,13 +825,13 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
816
825
|
"startAt": 0,
|
|
817
826
|
"maxResults": limit,
|
|
818
827
|
"fields": ["*all"],
|
|
819
|
-
}
|
|
828
|
+
},
|
|
820
829
|
)
|
|
821
830
|
|
|
822
831
|
issues = data.get("issues", [])
|
|
823
832
|
return [self._issue_to_ticket(issue) for issue in issues]
|
|
824
833
|
|
|
825
|
-
async def get_sprints(self, board_id: Optional[int] = None) ->
|
|
834
|
+
async def get_sprints(self, board_id: Optional[int] = None) -> builtins.list[dict[str, Any]]:
|
|
826
835
|
"""Get active sprints for a board (requires JIRA Software).
|
|
827
836
|
|
|
828
837
|
Args:
|
|
@@ -830,13 +839,14 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
830
839
|
|
|
831
840
|
Returns:
|
|
832
841
|
List of sprint information
|
|
842
|
+
|
|
833
843
|
"""
|
|
834
844
|
if not board_id:
|
|
835
845
|
# Try to find a board for the project
|
|
836
846
|
boards_data = await self._make_request(
|
|
837
847
|
"GET",
|
|
838
|
-
|
|
839
|
-
params={"projectKeyOrId": self.project_key}
|
|
848
|
+
"/rest/agile/1.0/board",
|
|
849
|
+
params={"projectKeyOrId": self.project_key},
|
|
840
850
|
)
|
|
841
851
|
boards = boards_data.get("values", [])
|
|
842
852
|
if not boards:
|
|
@@ -847,7 +857,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
847
857
|
sprints_data = await self._make_request(
|
|
848
858
|
"GET",
|
|
849
859
|
f"/rest/agile/1.0/board/{board_id}/sprint",
|
|
850
|
-
params={"state": "active,future"}
|
|
860
|
+
params={"state": "active,future"},
|
|
851
861
|
)
|
|
852
862
|
|
|
853
863
|
return sprints_data.get("values", [])
|
|
@@ -862,4 +872,4 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
862
872
|
|
|
863
873
|
|
|
864
874
|
# Register the adapter
|
|
865
|
-
AdapterRegistry.register("jira", JiraAdapter)
|
|
875
|
+
AdapterRegistry.register("jira", JiraAdapter)
|