mcp-ticketer 0.12.0__py3-none-any.whl → 2.2.13__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 +10 -10
- mcp_ticketer/__version__.py +1 -1
- mcp_ticketer/_version_scm.py +1 -0
- mcp_ticketer/adapters/aitrackdown.py +507 -6
- mcp_ticketer/adapters/asana/adapter.py +229 -0
- mcp_ticketer/adapters/asana/mappers.py +14 -0
- mcp_ticketer/adapters/github/__init__.py +26 -0
- mcp_ticketer/adapters/github/adapter.py +3229 -0
- mcp_ticketer/adapters/github/client.py +335 -0
- mcp_ticketer/adapters/github/mappers.py +797 -0
- mcp_ticketer/adapters/github/queries.py +692 -0
- mcp_ticketer/adapters/github/types.py +460 -0
- mcp_ticketer/adapters/hybrid.py +47 -5
- mcp_ticketer/adapters/jira/__init__.py +35 -0
- mcp_ticketer/adapters/jira/adapter.py +1351 -0
- mcp_ticketer/adapters/jira/client.py +271 -0
- mcp_ticketer/adapters/jira/mappers.py +246 -0
- mcp_ticketer/adapters/jira/queries.py +216 -0
- mcp_ticketer/adapters/jira/types.py +304 -0
- mcp_ticketer/adapters/linear/adapter.py +2730 -139
- mcp_ticketer/adapters/linear/client.py +175 -3
- mcp_ticketer/adapters/linear/mappers.py +203 -8
- mcp_ticketer/adapters/linear/queries.py +280 -3
- mcp_ticketer/adapters/linear/types.py +120 -4
- mcp_ticketer/analysis/__init__.py +56 -0
- mcp_ticketer/analysis/dependency_graph.py +255 -0
- mcp_ticketer/analysis/health_assessment.py +304 -0
- mcp_ticketer/analysis/orphaned.py +218 -0
- mcp_ticketer/analysis/project_status.py +594 -0
- mcp_ticketer/analysis/similarity.py +224 -0
- mcp_ticketer/analysis/staleness.py +266 -0
- mcp_ticketer/automation/__init__.py +11 -0
- mcp_ticketer/automation/project_updates.py +378 -0
- mcp_ticketer/cli/adapter_diagnostics.py +3 -1
- mcp_ticketer/cli/auggie_configure.py +17 -5
- mcp_ticketer/cli/codex_configure.py +97 -61
- mcp_ticketer/cli/configure.py +1288 -105
- mcp_ticketer/cli/cursor_configure.py +314 -0
- mcp_ticketer/cli/diagnostics.py +13 -12
- mcp_ticketer/cli/discover.py +5 -0
- mcp_ticketer/cli/gemini_configure.py +17 -5
- mcp_ticketer/cli/init_command.py +880 -0
- mcp_ticketer/cli/install_mcp_server.py +418 -0
- mcp_ticketer/cli/instruction_commands.py +6 -0
- mcp_ticketer/cli/main.py +267 -3175
- mcp_ticketer/cli/mcp_configure.py +821 -119
- mcp_ticketer/cli/mcp_server_commands.py +415 -0
- mcp_ticketer/cli/platform_detection.py +77 -12
- mcp_ticketer/cli/platform_installer.py +545 -0
- mcp_ticketer/cli/project_update_commands.py +350 -0
- mcp_ticketer/cli/setup_command.py +795 -0
- mcp_ticketer/cli/simple_health.py +12 -10
- mcp_ticketer/cli/ticket_commands.py +705 -103
- mcp_ticketer/cli/utils.py +113 -0
- mcp_ticketer/core/__init__.py +56 -6
- mcp_ticketer/core/adapter.py +533 -2
- mcp_ticketer/core/config.py +21 -21
- mcp_ticketer/core/exceptions.py +7 -1
- mcp_ticketer/core/label_manager.py +732 -0
- mcp_ticketer/core/mappers.py +31 -19
- mcp_ticketer/core/milestone_manager.py +252 -0
- mcp_ticketer/core/models.py +480 -0
- mcp_ticketer/core/onepassword_secrets.py +1 -1
- mcp_ticketer/core/priority_matcher.py +463 -0
- mcp_ticketer/core/project_config.py +132 -14
- mcp_ticketer/core/project_utils.py +281 -0
- mcp_ticketer/core/project_validator.py +376 -0
- mcp_ticketer/core/session_state.py +176 -0
- mcp_ticketer/core/state_matcher.py +625 -0
- mcp_ticketer/core/url_parser.py +425 -0
- mcp_ticketer/core/validators.py +69 -0
- mcp_ticketer/mcp/server/__main__.py +2 -1
- mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
- mcp_ticketer/mcp/server/main.py +106 -25
- mcp_ticketer/mcp/server/routing.py +723 -0
- mcp_ticketer/mcp/server/server_sdk.py +58 -0
- mcp_ticketer/mcp/server/tools/__init__.py +33 -11
- mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +5 -5
- mcp_ticketer/mcp/server/tools/bulk_tools.py +259 -202
- mcp_ticketer/mcp/server/tools/comment_tools.py +74 -12
- mcp_ticketer/mcp/server/tools/config_tools.py +1391 -145
- mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +870 -460
- mcp_ticketer/mcp/server/tools/instruction_tools.py +7 -5
- mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
- mcp_ticketer/mcp/server/tools/milestone_tools.py +338 -0
- mcp_ticketer/mcp/server/tools/pr_tools.py +3 -7
- mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
- mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
- mcp_ticketer/mcp/server/tools/search_tools.py +209 -97
- mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
- mcp_ticketer/mcp/server/tools/ticket_tools.py +1107 -124
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +218 -236
- mcp_ticketer/queue/queue.py +68 -0
- mcp_ticketer/queue/worker.py +1 -1
- mcp_ticketer/utils/__init__.py +5 -0
- mcp_ticketer/utils/token_utils.py +246 -0
- mcp_ticketer-2.2.13.dist-info/METADATA +1396 -0
- mcp_ticketer-2.2.13.dist-info/RECORD +158 -0
- mcp_ticketer-2.2.13.dist-info/top_level.txt +2 -0
- py_mcp_installer/examples/phase3_demo.py +178 -0
- py_mcp_installer/scripts/manage_version.py +54 -0
- py_mcp_installer/setup.py +6 -0
- py_mcp_installer/src/py_mcp_installer/__init__.py +153 -0
- py_mcp_installer/src/py_mcp_installer/command_builder.py +445 -0
- py_mcp_installer/src/py_mcp_installer/config_manager.py +541 -0
- py_mcp_installer/src/py_mcp_installer/exceptions.py +243 -0
- py_mcp_installer/src/py_mcp_installer/installation_strategy.py +617 -0
- py_mcp_installer/src/py_mcp_installer/installer.py +656 -0
- py_mcp_installer/src/py_mcp_installer/mcp_inspector.py +750 -0
- py_mcp_installer/src/py_mcp_installer/platform_detector.py +451 -0
- py_mcp_installer/src/py_mcp_installer/platforms/__init__.py +26 -0
- py_mcp_installer/src/py_mcp_installer/platforms/claude_code.py +225 -0
- py_mcp_installer/src/py_mcp_installer/platforms/codex.py +181 -0
- py_mcp_installer/src/py_mcp_installer/platforms/cursor.py +191 -0
- py_mcp_installer/src/py_mcp_installer/types.py +222 -0
- py_mcp_installer/src/py_mcp_installer/utils.py +463 -0
- py_mcp_installer/tests/__init__.py +0 -0
- py_mcp_installer/tests/platforms/__init__.py +0 -0
- py_mcp_installer/tests/test_platform_detector.py +17 -0
- mcp_ticketer/adapters/github.py +0 -1574
- mcp_ticketer/adapters/jira.py +0 -1258
- mcp_ticketer-0.12.0.dist-info/METADATA +0 -550
- mcp_ticketer-0.12.0.dist-info/RECORD +0 -91
- mcp_ticketer-0.12.0.dist-info/top_level.txt +0 -1
- {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.2.13.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.2.13.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.2.13.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""HTTP client for Jira REST API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
from httpx import AsyncClient, HTTPStatusError, TimeoutException
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class JiraClient:
|
|
16
|
+
"""HTTP client for JIRA REST API with authentication and retry logic."""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
server: str,
|
|
21
|
+
email: str,
|
|
22
|
+
api_token: str,
|
|
23
|
+
is_cloud: bool = True,
|
|
24
|
+
verify_ssl: bool = True,
|
|
25
|
+
timeout: int = 30,
|
|
26
|
+
max_retries: int = 3,
|
|
27
|
+
):
|
|
28
|
+
"""Initialize JIRA API client.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
----
|
|
32
|
+
server: JIRA server URL (e.g., https://company.atlassian.net)
|
|
33
|
+
email: User email for authentication
|
|
34
|
+
api_token: API token for authentication
|
|
35
|
+
is_cloud: Whether this is JIRA Cloud (default: True)
|
|
36
|
+
verify_ssl: Whether to verify SSL certificates (default: True)
|
|
37
|
+
timeout: Request timeout in seconds (default: 30)
|
|
38
|
+
max_retries: Maximum retry attempts (default: 3)
|
|
39
|
+
|
|
40
|
+
"""
|
|
41
|
+
# Clean up server URL
|
|
42
|
+
self.server = server.rstrip("/")
|
|
43
|
+
|
|
44
|
+
# API base URL
|
|
45
|
+
self.api_base = (
|
|
46
|
+
f"{self.server}/rest/api/3" if is_cloud else f"{self.server}/rest/api/2"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Configuration
|
|
50
|
+
self.email = email
|
|
51
|
+
self.api_token = api_token
|
|
52
|
+
self.verify_ssl = verify_ssl
|
|
53
|
+
self.timeout = timeout
|
|
54
|
+
self.max_retries = max_retries
|
|
55
|
+
|
|
56
|
+
# HTTP client setup
|
|
57
|
+
self.auth = httpx.BasicAuth(self.email, self.api_token)
|
|
58
|
+
self.headers = {
|
|
59
|
+
"Accept": "application/json",
|
|
60
|
+
"Content-Type": "application/json",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async def _get_client(self) -> AsyncClient:
|
|
64
|
+
"""Get configured async HTTP client.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
-------
|
|
68
|
+
Configured AsyncClient instance
|
|
69
|
+
|
|
70
|
+
"""
|
|
71
|
+
return AsyncClient(
|
|
72
|
+
auth=self.auth,
|
|
73
|
+
headers=self.headers,
|
|
74
|
+
timeout=self.timeout,
|
|
75
|
+
verify=self.verify_ssl,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
async def request(
|
|
79
|
+
self,
|
|
80
|
+
method: str,
|
|
81
|
+
endpoint: str,
|
|
82
|
+
data: dict[str, Any] | None = None,
|
|
83
|
+
params: dict[str, Any] | None = None,
|
|
84
|
+
retry_count: int = 0,
|
|
85
|
+
) -> dict[str, Any]:
|
|
86
|
+
"""Make HTTP request to JIRA API with retry logic.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
----
|
|
90
|
+
method: HTTP method (GET, POST, PUT, DELETE)
|
|
91
|
+
endpoint: API endpoint (relative to api_base)
|
|
92
|
+
data: Request body data
|
|
93
|
+
params: Query parameters
|
|
94
|
+
retry_count: Current retry attempt
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
-------
|
|
98
|
+
Response data as dictionary
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
------
|
|
102
|
+
HTTPStatusError: On API errors
|
|
103
|
+
TimeoutException: On timeout
|
|
104
|
+
|
|
105
|
+
"""
|
|
106
|
+
url = f"{self.api_base}/{endpoint.lstrip('/')}"
|
|
107
|
+
|
|
108
|
+
async with await self._get_client() as client:
|
|
109
|
+
try:
|
|
110
|
+
response = await client.request(
|
|
111
|
+
method=method, url=url, json=data, params=params
|
|
112
|
+
)
|
|
113
|
+
response.raise_for_status()
|
|
114
|
+
|
|
115
|
+
# Handle empty responses
|
|
116
|
+
if response.status_code == 204:
|
|
117
|
+
return {}
|
|
118
|
+
|
|
119
|
+
return response.json()
|
|
120
|
+
|
|
121
|
+
except TimeoutException as e:
|
|
122
|
+
if retry_count < self.max_retries:
|
|
123
|
+
await asyncio.sleep(2**retry_count) # Exponential backoff
|
|
124
|
+
return await self.request(
|
|
125
|
+
method, endpoint, data, params, retry_count + 1
|
|
126
|
+
)
|
|
127
|
+
raise e
|
|
128
|
+
|
|
129
|
+
except HTTPStatusError as e:
|
|
130
|
+
# Handle rate limiting
|
|
131
|
+
if e.response.status_code == 429 and retry_count < self.max_retries:
|
|
132
|
+
retry_after = int(e.response.headers.get("Retry-After", 5))
|
|
133
|
+
await asyncio.sleep(retry_after)
|
|
134
|
+
return await self.request(
|
|
135
|
+
method, endpoint, data, params, retry_count + 1
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Log error details
|
|
139
|
+
logger.error(
|
|
140
|
+
f"JIRA API error: {e.response.status_code} - {e.response.text}"
|
|
141
|
+
)
|
|
142
|
+
raise e
|
|
143
|
+
|
|
144
|
+
async def get(
|
|
145
|
+
self,
|
|
146
|
+
endpoint: str,
|
|
147
|
+
params: dict[str, Any] | None = None,
|
|
148
|
+
) -> dict[str, Any]:
|
|
149
|
+
"""Make GET request.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
----
|
|
153
|
+
endpoint: API endpoint
|
|
154
|
+
params: Query parameters
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
-------
|
|
158
|
+
Response data
|
|
159
|
+
|
|
160
|
+
"""
|
|
161
|
+
return await self.request("GET", endpoint, params=params)
|
|
162
|
+
|
|
163
|
+
async def post(
|
|
164
|
+
self,
|
|
165
|
+
endpoint: str,
|
|
166
|
+
data: dict[str, Any] | None = None,
|
|
167
|
+
params: dict[str, Any] | None = None,
|
|
168
|
+
) -> dict[str, Any]:
|
|
169
|
+
"""Make POST request.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
----
|
|
173
|
+
endpoint: API endpoint
|
|
174
|
+
data: Request body data
|
|
175
|
+
params: Query parameters
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
-------
|
|
179
|
+
Response data
|
|
180
|
+
|
|
181
|
+
"""
|
|
182
|
+
return await self.request("POST", endpoint, data=data, params=params)
|
|
183
|
+
|
|
184
|
+
async def put(
|
|
185
|
+
self,
|
|
186
|
+
endpoint: str,
|
|
187
|
+
data: dict[str, Any] | None = None,
|
|
188
|
+
) -> dict[str, Any]:
|
|
189
|
+
"""Make PUT request.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
----
|
|
193
|
+
endpoint: API endpoint
|
|
194
|
+
data: Request body data
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
-------
|
|
198
|
+
Response data
|
|
199
|
+
|
|
200
|
+
"""
|
|
201
|
+
return await self.request("PUT", endpoint, data=data)
|
|
202
|
+
|
|
203
|
+
async def delete(
|
|
204
|
+
self,
|
|
205
|
+
endpoint: str,
|
|
206
|
+
) -> dict[str, Any]:
|
|
207
|
+
"""Make DELETE request.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
----
|
|
211
|
+
endpoint: API endpoint
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
-------
|
|
215
|
+
Response data (usually empty)
|
|
216
|
+
|
|
217
|
+
"""
|
|
218
|
+
return await self.request("DELETE", endpoint)
|
|
219
|
+
|
|
220
|
+
async def upload_file(
|
|
221
|
+
self,
|
|
222
|
+
endpoint: str,
|
|
223
|
+
file_path: str,
|
|
224
|
+
filename: str,
|
|
225
|
+
) -> dict[str, Any]:
|
|
226
|
+
"""Upload file with multipart/form-data.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
----
|
|
230
|
+
endpoint: API endpoint
|
|
231
|
+
file_path: Path to file to upload
|
|
232
|
+
filename: Name for the uploaded file
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
-------
|
|
236
|
+
Response data
|
|
237
|
+
|
|
238
|
+
"""
|
|
239
|
+
# JIRA requires special header for attachment upload
|
|
240
|
+
headers = {
|
|
241
|
+
"X-Atlassian-Token": "no-check",
|
|
242
|
+
# Don't set Content-Type - let httpx handle multipart
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
url = f"{self.api_base}/{endpoint.lstrip('/')}"
|
|
246
|
+
|
|
247
|
+
# Prepare multipart file upload
|
|
248
|
+
with open(file_path, "rb") as f:
|
|
249
|
+
files = {"file": (filename, f, "application/octet-stream")}
|
|
250
|
+
|
|
251
|
+
# Use existing client infrastructure
|
|
252
|
+
async with await self._get_client() as client:
|
|
253
|
+
response = await client.post(
|
|
254
|
+
url, files=files, headers={**self.headers, **headers}
|
|
255
|
+
)
|
|
256
|
+
response.raise_for_status()
|
|
257
|
+
return response.json()
|
|
258
|
+
|
|
259
|
+
def get_browse_url(self, issue_key: str) -> str:
|
|
260
|
+
"""Get browser URL for an issue.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
----
|
|
264
|
+
issue_key: Issue key (e.g., PROJ-123)
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
-------
|
|
268
|
+
Full URL to view issue in browser
|
|
269
|
+
|
|
270
|
+
"""
|
|
271
|
+
return f"{self.server}/browse/{issue_key}"
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Data transformation functions for mapping between Jira and universal models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ...core.models import Epic, Priority, Task
|
|
8
|
+
from .types import (
|
|
9
|
+
JiraIssueType,
|
|
10
|
+
convert_from_adf,
|
|
11
|
+
convert_to_adf,
|
|
12
|
+
map_priority_from_jira,
|
|
13
|
+
map_priority_to_jira,
|
|
14
|
+
map_state_from_jira,
|
|
15
|
+
parse_jira_datetime,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def issue_to_ticket(
|
|
20
|
+
issue: dict[str, Any],
|
|
21
|
+
server: str,
|
|
22
|
+
) -> Epic | Task:
|
|
23
|
+
"""Convert JIRA issue to universal ticket model.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
----
|
|
27
|
+
issue: JIRA issue dictionary from API
|
|
28
|
+
server: JIRA server URL for constructing browse URLs
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
-------
|
|
32
|
+
Epic or Task object depending on issue type
|
|
33
|
+
|
|
34
|
+
"""
|
|
35
|
+
fields = issue.get("fields", {})
|
|
36
|
+
|
|
37
|
+
# Determine ticket type
|
|
38
|
+
issue_type = fields.get("issuetype", {}).get("name", "").lower()
|
|
39
|
+
is_epic = "epic" in issue_type
|
|
40
|
+
|
|
41
|
+
# Extract common fields
|
|
42
|
+
# Convert ADF description back to plain text if needed
|
|
43
|
+
description = convert_from_adf(fields.get("description", ""))
|
|
44
|
+
|
|
45
|
+
base_data = {
|
|
46
|
+
"id": issue.get("key"),
|
|
47
|
+
"title": fields.get("summary", ""),
|
|
48
|
+
"description": description,
|
|
49
|
+
"state": map_state_from_jira(fields.get("status", {})),
|
|
50
|
+
"priority": map_priority_from_jira(fields.get("priority")),
|
|
51
|
+
"tags": [
|
|
52
|
+
label.get("name", "") if isinstance(label, dict) else str(label)
|
|
53
|
+
for label in fields.get("labels", [])
|
|
54
|
+
],
|
|
55
|
+
"created_at": parse_jira_datetime(fields.get("created")),
|
|
56
|
+
"updated_at": parse_jira_datetime(fields.get("updated")),
|
|
57
|
+
"metadata": {
|
|
58
|
+
"jira": {
|
|
59
|
+
"id": issue.get("id"),
|
|
60
|
+
"key": issue.get("key"),
|
|
61
|
+
"self": issue.get("self"),
|
|
62
|
+
"url": f"{server}/browse/{issue.get('key')}",
|
|
63
|
+
"issue_type": fields.get("issuetype", {}),
|
|
64
|
+
"project": fields.get("project", {}),
|
|
65
|
+
"components": fields.get("components", []),
|
|
66
|
+
"fix_versions": fields.get("fixVersions", []),
|
|
67
|
+
"resolution": fields.get("resolution"),
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if is_epic:
|
|
73
|
+
# Create Epic
|
|
74
|
+
return Epic(
|
|
75
|
+
**base_data,
|
|
76
|
+
child_issues=[subtask.get("key") for subtask in fields.get("subtasks", [])],
|
|
77
|
+
)
|
|
78
|
+
else:
|
|
79
|
+
# Create Task
|
|
80
|
+
parent = fields.get("parent", {})
|
|
81
|
+
epic_link = fields.get("customfield_10014") # Common epic link field
|
|
82
|
+
|
|
83
|
+
return Task(
|
|
84
|
+
**base_data,
|
|
85
|
+
parent_issue=parent.get("key") if parent else None,
|
|
86
|
+
parent_epic=epic_link if epic_link else None,
|
|
87
|
+
assignee=(
|
|
88
|
+
fields.get("assignee", {}).get("displayName")
|
|
89
|
+
if fields.get("assignee")
|
|
90
|
+
else None
|
|
91
|
+
),
|
|
92
|
+
estimated_hours=(
|
|
93
|
+
fields.get("timetracking", {}).get("originalEstimateSeconds", 0) / 3600
|
|
94
|
+
if fields.get("timetracking")
|
|
95
|
+
else None
|
|
96
|
+
),
|
|
97
|
+
actual_hours=(
|
|
98
|
+
fields.get("timetracking", {}).get("timeSpentSeconds", 0) / 3600
|
|
99
|
+
if fields.get("timetracking")
|
|
100
|
+
else None
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def ticket_to_issue_fields(
|
|
106
|
+
ticket: Epic | Task,
|
|
107
|
+
issue_type: str | None = None,
|
|
108
|
+
is_cloud: bool = True,
|
|
109
|
+
project_key: str | None = None,
|
|
110
|
+
) -> dict[str, Any]:
|
|
111
|
+
"""Convert universal ticket to JIRA issue fields.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
----
|
|
115
|
+
ticket: Epic or Task object
|
|
116
|
+
issue_type: Optional issue type override
|
|
117
|
+
is_cloud: Whether this is JIRA Cloud (affects description format)
|
|
118
|
+
project_key: Project key for new issues
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
-------
|
|
122
|
+
Dictionary of JIRA issue fields
|
|
123
|
+
|
|
124
|
+
"""
|
|
125
|
+
# Convert description to ADF format for JIRA Cloud
|
|
126
|
+
description = (
|
|
127
|
+
convert_to_adf(ticket.description or "")
|
|
128
|
+
if is_cloud
|
|
129
|
+
else (ticket.description or "")
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
fields = {
|
|
133
|
+
"summary": ticket.title,
|
|
134
|
+
"description": description,
|
|
135
|
+
"labels": ticket.tags,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
# Only add priority for Tasks, not Epics (some JIRA configurations don't allow priority on Epics)
|
|
139
|
+
if isinstance(ticket, Task):
|
|
140
|
+
fields["priority"] = {"name": map_priority_to_jira(ticket.priority)}
|
|
141
|
+
|
|
142
|
+
# Add project if creating new issue
|
|
143
|
+
if not ticket.id and project_key:
|
|
144
|
+
fields["project"] = {"key": project_key}
|
|
145
|
+
|
|
146
|
+
# Set issue type
|
|
147
|
+
if issue_type:
|
|
148
|
+
fields["issuetype"] = {"name": issue_type}
|
|
149
|
+
elif isinstance(ticket, Epic):
|
|
150
|
+
fields["issuetype"] = {"name": JiraIssueType.EPIC}
|
|
151
|
+
else:
|
|
152
|
+
fields["issuetype"] = {"name": JiraIssueType.TASK}
|
|
153
|
+
|
|
154
|
+
# Add task-specific fields
|
|
155
|
+
if isinstance(ticket, Task):
|
|
156
|
+
if ticket.assignee:
|
|
157
|
+
# Note: Need to resolve user account ID
|
|
158
|
+
fields["assignee"] = {"accountId": ticket.assignee}
|
|
159
|
+
|
|
160
|
+
if ticket.parent_issue:
|
|
161
|
+
fields["parent"] = {"key": ticket.parent_issue}
|
|
162
|
+
|
|
163
|
+
# Time tracking
|
|
164
|
+
if ticket.estimated_hours:
|
|
165
|
+
fields["timetracking"] = {
|
|
166
|
+
"originalEstimate": f"{int(ticket.estimated_hours)}h"
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return fields
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def map_update_fields(
|
|
173
|
+
updates: dict[str, Any],
|
|
174
|
+
is_cloud: bool = True,
|
|
175
|
+
) -> dict[str, Any]:
|
|
176
|
+
"""Map update dictionary to JIRA field updates.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
----
|
|
180
|
+
updates: Dictionary of field updates
|
|
181
|
+
is_cloud: Whether this is JIRA Cloud
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
-------
|
|
185
|
+
Dictionary of JIRA field updates
|
|
186
|
+
|
|
187
|
+
"""
|
|
188
|
+
fields = {}
|
|
189
|
+
|
|
190
|
+
if "title" in updates:
|
|
191
|
+
fields["summary"] = updates["title"]
|
|
192
|
+
if "description" in updates:
|
|
193
|
+
fields["description"] = updates["description"]
|
|
194
|
+
if "priority" in updates:
|
|
195
|
+
fields["priority"] = {"name": map_priority_to_jira(updates["priority"])}
|
|
196
|
+
if "tags" in updates:
|
|
197
|
+
fields["labels"] = updates["tags"]
|
|
198
|
+
if "assignee" in updates:
|
|
199
|
+
fields["assignee"] = {"accountId": updates["assignee"]}
|
|
200
|
+
|
|
201
|
+
return fields
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def map_epic_update_fields(
|
|
205
|
+
updates: dict[str, Any],
|
|
206
|
+
) -> dict[str, Any]:
|
|
207
|
+
"""Map epic update dictionary to JIRA field updates.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
----
|
|
211
|
+
updates: Dictionary with fields to update:
|
|
212
|
+
- title: Epic title (maps to summary)
|
|
213
|
+
- description: Epic description (auto-converted to ADF)
|
|
214
|
+
- state: TicketState value (transitions via workflow)
|
|
215
|
+
- tags: List of labels
|
|
216
|
+
- priority: Priority level
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
-------
|
|
220
|
+
Dictionary of JIRA field updates
|
|
221
|
+
|
|
222
|
+
"""
|
|
223
|
+
fields = {}
|
|
224
|
+
|
|
225
|
+
# Map title to summary
|
|
226
|
+
if "title" in updates:
|
|
227
|
+
fields["summary"] = updates["title"]
|
|
228
|
+
|
|
229
|
+
# Convert description to ADF format
|
|
230
|
+
if "description" in updates:
|
|
231
|
+
fields["description"] = convert_to_adf(updates["description"])
|
|
232
|
+
|
|
233
|
+
# Map tags to labels
|
|
234
|
+
if "tags" in updates:
|
|
235
|
+
fields["labels"] = updates["tags"]
|
|
236
|
+
|
|
237
|
+
# Map priority (some JIRA configs allow priority on Epics)
|
|
238
|
+
if "priority" in updates:
|
|
239
|
+
priority_value = updates["priority"]
|
|
240
|
+
if isinstance(priority_value, Priority):
|
|
241
|
+
fields["priority"] = {"name": map_priority_to_jira(priority_value)}
|
|
242
|
+
else:
|
|
243
|
+
# String priority passed directly
|
|
244
|
+
fields["priority"] = {"name": priority_value}
|
|
245
|
+
|
|
246
|
+
return fields
|