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