mcp-ticketer 0.1.21__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.21.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.21.dist-info/RECORD +0 -42
- {mcp_ticketer-0.1.21.dist-info → mcp_ticketer-0.1.22.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.1.21.dist-info → mcp_ticketer-0.1.22.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.1.21.dist-info → mcp_ticketer-0.1.22.dist-info}/licenses/LICENSE +0 -0
- {mcp_ticketer-0.1.21.dist-info → mcp_ticketer-0.1.22.dist-info}/top_level.txt +0 -0
mcp_ticketer/core/http_client.py
CHANGED
|
@@ -2,19 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import logging
|
|
5
|
-
from typing import Dict, Any, Optional, List, Union, Callable
|
|
6
|
-
from datetime import datetime, timedelta
|
|
7
|
-
from enum import Enum
|
|
8
5
|
import time
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any, Dict, List, Optional, Union
|
|
9
8
|
|
|
10
9
|
import httpx
|
|
11
|
-
from httpx import AsyncClient,
|
|
10
|
+
from httpx import AsyncClient, TimeoutException
|
|
12
11
|
|
|
13
12
|
logger = logging.getLogger(__name__)
|
|
14
13
|
|
|
15
14
|
|
|
16
15
|
class HTTPMethod(str, Enum):
|
|
17
16
|
"""HTTP methods."""
|
|
17
|
+
|
|
18
18
|
GET = "GET"
|
|
19
19
|
POST = "POST"
|
|
20
20
|
PUT = "PUT"
|
|
@@ -33,7 +33,7 @@ class RetryConfig:
|
|
|
33
33
|
exponential_base: float = 2.0,
|
|
34
34
|
jitter: bool = True,
|
|
35
35
|
retry_on_status: Optional[List[int]] = None,
|
|
36
|
-
retry_on_exceptions: Optional[List[type]] = None
|
|
36
|
+
retry_on_exceptions: Optional[List[type]] = None,
|
|
37
37
|
):
|
|
38
38
|
self.max_retries = max_retries
|
|
39
39
|
self.initial_delay = initial_delay
|
|
@@ -41,7 +41,11 @@ class RetryConfig:
|
|
|
41
41
|
self.exponential_base = exponential_base
|
|
42
42
|
self.jitter = jitter
|
|
43
43
|
self.retry_on_status = retry_on_status or [429, 502, 503, 504]
|
|
44
|
-
self.retry_on_exceptions = retry_on_exceptions or [
|
|
44
|
+
self.retry_on_exceptions = retry_on_exceptions or [
|
|
45
|
+
TimeoutException,
|
|
46
|
+
httpx.ConnectTimeout,
|
|
47
|
+
httpx.ReadTimeout,
|
|
48
|
+
]
|
|
45
49
|
|
|
46
50
|
|
|
47
51
|
class RateLimiter:
|
|
@@ -53,6 +57,7 @@ class RateLimiter:
|
|
|
53
57
|
Args:
|
|
54
58
|
max_requests: Maximum number of requests allowed
|
|
55
59
|
time_window: Time window in seconds
|
|
60
|
+
|
|
56
61
|
"""
|
|
57
62
|
self.max_requests = max_requests
|
|
58
63
|
self.time_window = time_window
|
|
@@ -69,7 +74,7 @@ class RateLimiter:
|
|
|
69
74
|
time_passed = now - self.last_update
|
|
70
75
|
self.tokens = min(
|
|
71
76
|
self.max_requests,
|
|
72
|
-
self.tokens + (time_passed / self.time_window) * self.max_requests
|
|
77
|
+
self.tokens + (time_passed / self.time_window) * self.max_requests,
|
|
73
78
|
)
|
|
74
79
|
self.last_update = now
|
|
75
80
|
|
|
@@ -95,7 +100,7 @@ class BaseHTTPClient:
|
|
|
95
100
|
retry_config: Optional[RetryConfig] = None,
|
|
96
101
|
rate_limiter: Optional[RateLimiter] = None,
|
|
97
102
|
verify_ssl: bool = True,
|
|
98
|
-
follow_redirects: bool = True
|
|
103
|
+
follow_redirects: bool = True,
|
|
99
104
|
):
|
|
100
105
|
"""Initialize HTTP client.
|
|
101
106
|
|
|
@@ -108,6 +113,7 @@ class BaseHTTPClient:
|
|
|
108
113
|
rate_limiter: Rate limiter instance
|
|
109
114
|
verify_ssl: Whether to verify SSL certificates
|
|
110
115
|
follow_redirects: Whether to follow redirects
|
|
116
|
+
|
|
111
117
|
"""
|
|
112
118
|
self.base_url = base_url.rstrip("/")
|
|
113
119
|
self.default_headers = headers or {}
|
|
@@ -137,11 +143,13 @@ class BaseHTTPClient:
|
|
|
137
143
|
auth=self.auth,
|
|
138
144
|
timeout=self.timeout,
|
|
139
145
|
verify=self.verify_ssl,
|
|
140
|
-
follow_redirects=self.follow_redirects
|
|
146
|
+
follow_redirects=self.follow_redirects,
|
|
141
147
|
)
|
|
142
148
|
return self._client
|
|
143
149
|
|
|
144
|
-
async def _calculate_delay(
|
|
150
|
+
async def _calculate_delay(
|
|
151
|
+
self, attempt: int, response: Optional[httpx.Response] = None
|
|
152
|
+
) -> float:
|
|
145
153
|
"""Calculate delay for retry attempt."""
|
|
146
154
|
if response and response.status_code == 429:
|
|
147
155
|
# Use Retry-After header if available
|
|
@@ -162,7 +170,8 @@ class BaseHTTPClient:
|
|
|
162
170
|
# Add jitter to prevent thundering herd
|
|
163
171
|
if self.retry_config.jitter:
|
|
164
172
|
import random
|
|
165
|
-
|
|
173
|
+
|
|
174
|
+
delay *= 0.5 + random.random() * 0.5
|
|
166
175
|
|
|
167
176
|
return delay
|
|
168
177
|
|
|
@@ -170,7 +179,7 @@ class BaseHTTPClient:
|
|
|
170
179
|
self,
|
|
171
180
|
exception: Exception,
|
|
172
181
|
response: Optional[httpx.Response] = None,
|
|
173
|
-
attempt: int = 1
|
|
182
|
+
attempt: int = 1,
|
|
174
183
|
) -> bool:
|
|
175
184
|
"""Determine if request should be retried."""
|
|
176
185
|
if attempt >= self.retry_config.max_retries:
|
|
@@ -197,7 +206,7 @@ class BaseHTTPClient:
|
|
|
197
206
|
headers: Optional[Dict[str, str]] = None,
|
|
198
207
|
timeout: Optional[float] = None,
|
|
199
208
|
retry_count: int = 0,
|
|
200
|
-
**kwargs
|
|
209
|
+
**kwargs,
|
|
201
210
|
) -> httpx.Response:
|
|
202
211
|
"""Make HTTP request with retry and rate limiting.
|
|
203
212
|
|
|
@@ -218,6 +227,7 @@ class BaseHTTPClient:
|
|
|
218
227
|
Raises:
|
|
219
228
|
HTTPStatusError: On HTTP errors
|
|
220
229
|
TimeoutException: On timeout
|
|
230
|
+
|
|
221
231
|
"""
|
|
222
232
|
# Rate limiting
|
|
223
233
|
if self.rate_limiter:
|
|
@@ -243,7 +253,7 @@ class BaseHTTPClient:
|
|
|
243
253
|
params=params,
|
|
244
254
|
headers=request_headers,
|
|
245
255
|
timeout=timeout or self.timeout,
|
|
246
|
-
**kwargs
|
|
256
|
+
**kwargs,
|
|
247
257
|
)
|
|
248
258
|
|
|
249
259
|
# Update stats
|
|
@@ -258,7 +268,7 @@ class BaseHTTPClient:
|
|
|
258
268
|
self.stats["errors"] += 1
|
|
259
269
|
|
|
260
270
|
# Check if we should retry
|
|
261
|
-
response = getattr(e,
|
|
271
|
+
response = getattr(e, "response", None)
|
|
262
272
|
if self._should_retry(e, response, retry_count + 1):
|
|
263
273
|
delay = await self._calculate_delay(retry_count + 1, response)
|
|
264
274
|
|
|
@@ -269,7 +279,15 @@ class BaseHTTPClient:
|
|
|
269
279
|
|
|
270
280
|
await asyncio.sleep(delay)
|
|
271
281
|
return await self.request(
|
|
272
|
-
method,
|
|
282
|
+
method,
|
|
283
|
+
endpoint,
|
|
284
|
+
data,
|
|
285
|
+
json,
|
|
286
|
+
params,
|
|
287
|
+
headers,
|
|
288
|
+
timeout,
|
|
289
|
+
retry_count + 1,
|
|
290
|
+
**kwargs,
|
|
273
291
|
)
|
|
274
292
|
|
|
275
293
|
# No more retries, re-raise the exception
|
|
@@ -364,6 +382,7 @@ class GitHubHTTPClient(BaseHTTPClient):
|
|
|
364
382
|
Args:
|
|
365
383
|
token: GitHub API token
|
|
366
384
|
api_url: GitHub API URL
|
|
385
|
+
|
|
367
386
|
"""
|
|
368
387
|
headers = {
|
|
369
388
|
"Authorization": f"Bearer {token}",
|
|
@@ -379,9 +398,8 @@ class GitHubHTTPClient(BaseHTTPClient):
|
|
|
379
398
|
headers=headers,
|
|
380
399
|
rate_limiter=rate_limiter,
|
|
381
400
|
retry_config=RetryConfig(
|
|
382
|
-
max_retries=3,
|
|
383
|
-
|
|
384
|
-
)
|
|
401
|
+
max_retries=3, retry_on_status=[429, 502, 503, 504, 522, 524]
|
|
402
|
+
),
|
|
385
403
|
)
|
|
386
404
|
|
|
387
405
|
|
|
@@ -394,7 +412,7 @@ class JiraHTTPClient(BaseHTTPClient):
|
|
|
394
412
|
api_token: str,
|
|
395
413
|
server_url: str,
|
|
396
414
|
is_cloud: bool = True,
|
|
397
|
-
verify_ssl: bool = True
|
|
415
|
+
verify_ssl: bool = True,
|
|
398
416
|
):
|
|
399
417
|
"""Initialize JIRA HTTP client.
|
|
400
418
|
|
|
@@ -404,13 +422,13 @@ class JiraHTTPClient(BaseHTTPClient):
|
|
|
404
422
|
server_url: JIRA server URL
|
|
405
423
|
is_cloud: Whether this is JIRA Cloud
|
|
406
424
|
verify_ssl: Whether to verify SSL certificates
|
|
425
|
+
|
|
407
426
|
"""
|
|
408
|
-
api_base =
|
|
427
|
+
api_base = (
|
|
428
|
+
f"{server_url}/rest/api/3" if is_cloud else f"{server_url}/rest/api/2"
|
|
429
|
+
)
|
|
409
430
|
|
|
410
|
-
headers = {
|
|
411
|
-
"Accept": "application/json",
|
|
412
|
-
"Content-Type": "application/json"
|
|
413
|
-
}
|
|
431
|
+
headers = {"Accept": "application/json", "Content-Type": "application/json"}
|
|
414
432
|
|
|
415
433
|
auth = httpx.BasicAuth(email, api_token)
|
|
416
434
|
|
|
@@ -424,7 +442,6 @@ class JiraHTTPClient(BaseHTTPClient):
|
|
|
424
442
|
rate_limiter=rate_limiter,
|
|
425
443
|
verify_ssl=verify_ssl,
|
|
426
444
|
retry_config=RetryConfig(
|
|
427
|
-
max_retries=3,
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
)
|
|
445
|
+
max_retries=3, retry_on_status=[429, 502, 503, 504]
|
|
446
|
+
),
|
|
447
|
+
)
|
mcp_ticketer/core/mappers.py
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
"""Centralized mapping utilities for state and priority conversions."""
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
|
-
from typing import Dict, List, Optional, Any, TypeVar, Generic, Callable
|
|
5
|
-
from functools import lru_cache
|
|
6
4
|
from abc import ABC, abstractmethod
|
|
7
|
-
from
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
from typing import Any, Dict, Generic, List, Optional, TypeVar
|
|
8
7
|
|
|
9
|
-
from .models import
|
|
8
|
+
from .models import Priority, TicketState
|
|
10
9
|
|
|
11
10
|
logger = logging.getLogger(__name__)
|
|
12
11
|
|
|
13
|
-
T = TypeVar(
|
|
14
|
-
U = TypeVar(
|
|
12
|
+
T = TypeVar("T")
|
|
13
|
+
U = TypeVar("U")
|
|
15
14
|
|
|
16
15
|
|
|
17
16
|
class BiDirectionalDict(Generic[T, U]):
|
|
@@ -22,6 +21,7 @@ class BiDirectionalDict(Generic[T, U]):
|
|
|
22
21
|
|
|
23
22
|
Args:
|
|
24
23
|
mapping: Forward mapping dictionary
|
|
24
|
+
|
|
25
25
|
"""
|
|
26
26
|
self._forward: Dict[T, U] = mapping.copy()
|
|
27
27
|
self._reverse: Dict[U, T] = {v: k for k, v in mapping.items()}
|
|
@@ -64,6 +64,7 @@ class BaseMapper(ABC):
|
|
|
64
64
|
|
|
65
65
|
Args:
|
|
66
66
|
cache_size: Size of LRU cache for mapping results
|
|
67
|
+
|
|
67
68
|
"""
|
|
68
69
|
self.cache_size = cache_size
|
|
69
70
|
self._cache: Dict[str, Any] = {}
|
|
@@ -81,12 +82,15 @@ class BaseMapper(ABC):
|
|
|
81
82
|
class StateMapper(BaseMapper):
|
|
82
83
|
"""Universal state mapping utility."""
|
|
83
84
|
|
|
84
|
-
def __init__(
|
|
85
|
+
def __init__(
|
|
86
|
+
self, adapter_type: str, custom_mappings: Optional[Dict[str, Any]] = None
|
|
87
|
+
):
|
|
85
88
|
"""Initialize state mapper.
|
|
86
89
|
|
|
87
90
|
Args:
|
|
88
91
|
adapter_type: Type of adapter (github, jira, linear, etc.)
|
|
89
92
|
custom_mappings: Custom state mappings to override defaults
|
|
93
|
+
|
|
90
94
|
"""
|
|
91
95
|
super().__init__()
|
|
92
96
|
self.adapter_type = adapter_type
|
|
@@ -104,11 +108,11 @@ class StateMapper(BaseMapper):
|
|
|
104
108
|
"github": {
|
|
105
109
|
TicketState.OPEN: "open",
|
|
106
110
|
TicketState.IN_PROGRESS: "open", # Uses labels
|
|
107
|
-
TicketState.READY: "open",
|
|
108
|
-
TicketState.TESTED: "open",
|
|
111
|
+
TicketState.READY: "open", # Uses labels
|
|
112
|
+
TicketState.TESTED: "open", # Uses labels
|
|
109
113
|
TicketState.DONE: "closed",
|
|
110
|
-
TicketState.WAITING: "open",
|
|
111
|
-
TicketState.BLOCKED: "open",
|
|
114
|
+
TicketState.WAITING: "open", # Uses labels
|
|
115
|
+
TicketState.BLOCKED: "open", # Uses labels
|
|
112
116
|
TicketState.CLOSED: "closed",
|
|
113
117
|
},
|
|
114
118
|
"jira": {
|
|
@@ -124,8 +128,8 @@ class StateMapper(BaseMapper):
|
|
|
124
128
|
"linear": {
|
|
125
129
|
TicketState.OPEN: "backlog",
|
|
126
130
|
TicketState.IN_PROGRESS: "started",
|
|
127
|
-
TicketState.READY: "started",
|
|
128
|
-
TicketState.TESTED: "started",
|
|
131
|
+
TicketState.READY: "started", # Uses labels
|
|
132
|
+
TicketState.TESTED: "started", # Uses labels
|
|
129
133
|
TicketState.DONE: "completed",
|
|
130
134
|
TicketState.WAITING: "unstarted", # Uses labels
|
|
131
135
|
TicketState.BLOCKED: "unstarted", # Uses labels
|
|
@@ -140,7 +144,7 @@ class StateMapper(BaseMapper):
|
|
|
140
144
|
TicketState.WAITING: "waiting",
|
|
141
145
|
TicketState.BLOCKED: "blocked",
|
|
142
146
|
TicketState.CLOSED: "closed",
|
|
143
|
-
}
|
|
147
|
+
},
|
|
144
148
|
}
|
|
145
149
|
|
|
146
150
|
mapping = default_mappings.get(self.adapter_type, {})
|
|
@@ -160,6 +164,7 @@ class StateMapper(BaseMapper):
|
|
|
160
164
|
|
|
161
165
|
Returns:
|
|
162
166
|
Universal ticket state
|
|
167
|
+
|
|
163
168
|
"""
|
|
164
169
|
cache_key = f"to_system_{adapter_state}"
|
|
165
170
|
if cache_key in self._cache:
|
|
@@ -172,12 +177,17 @@ class StateMapper(BaseMapper):
|
|
|
172
177
|
# Fallback: try case-insensitive matching
|
|
173
178
|
adapter_state_lower = adapter_state.lower()
|
|
174
179
|
for universal_state, system_state in mapping.items():
|
|
175
|
-
if
|
|
180
|
+
if (
|
|
181
|
+
isinstance(system_state, str)
|
|
182
|
+
and system_state.lower() == adapter_state_lower
|
|
183
|
+
):
|
|
176
184
|
result = universal_state
|
|
177
185
|
break
|
|
178
186
|
|
|
179
187
|
if result is None:
|
|
180
|
-
logger.warning(
|
|
188
|
+
logger.warning(
|
|
189
|
+
f"Unknown {self.adapter_type} state: {adapter_state}, defaulting to OPEN"
|
|
190
|
+
)
|
|
181
191
|
result = TicketState.OPEN
|
|
182
192
|
|
|
183
193
|
self._cache[cache_key] = result
|
|
@@ -191,6 +201,7 @@ class StateMapper(BaseMapper):
|
|
|
191
201
|
|
|
192
202
|
Returns:
|
|
193
203
|
State in adapter format
|
|
204
|
+
|
|
194
205
|
"""
|
|
195
206
|
cache_key = f"from_system_{system_state.value}"
|
|
196
207
|
if cache_key in self._cache:
|
|
@@ -200,7 +211,9 @@ class StateMapper(BaseMapper):
|
|
|
200
211
|
result = mapping.get_forward(system_state)
|
|
201
212
|
|
|
202
213
|
if result is None:
|
|
203
|
-
logger.warning(
|
|
214
|
+
logger.warning(
|
|
215
|
+
f"No {self.adapter_type} mapping for state: {system_state}, using default"
|
|
216
|
+
)
|
|
204
217
|
# Fallback to first available state
|
|
205
218
|
available_states = mapping.reverse_keys()
|
|
206
219
|
result = available_states[0] if available_states else "open"
|
|
@@ -224,6 +237,7 @@ class StateMapper(BaseMapper):
|
|
|
224
237
|
|
|
225
238
|
Returns:
|
|
226
239
|
Label name if state requires a label, None otherwise
|
|
240
|
+
|
|
227
241
|
"""
|
|
228
242
|
if not self.supports_state_labels():
|
|
229
243
|
return None
|
|
@@ -243,12 +257,15 @@ class StateMapper(BaseMapper):
|
|
|
243
257
|
class PriorityMapper(BaseMapper):
|
|
244
258
|
"""Universal priority mapping utility."""
|
|
245
259
|
|
|
246
|
-
def __init__(
|
|
260
|
+
def __init__(
|
|
261
|
+
self, adapter_type: str, custom_mappings: Optional[Dict[str, Any]] = None
|
|
262
|
+
):
|
|
247
263
|
"""Initialize priority mapper.
|
|
248
264
|
|
|
249
265
|
Args:
|
|
250
266
|
adapter_type: Type of adapter (github, jira, linear, etc.)
|
|
251
267
|
custom_mappings: Custom priority mappings to override defaults
|
|
268
|
+
|
|
252
269
|
"""
|
|
253
270
|
super().__init__()
|
|
254
271
|
self.adapter_type = adapter_type
|
|
@@ -286,7 +303,7 @@ class PriorityMapper(BaseMapper):
|
|
|
286
303
|
Priority.HIGH: "high",
|
|
287
304
|
Priority.MEDIUM: "medium",
|
|
288
305
|
Priority.LOW: "low",
|
|
289
|
-
}
|
|
306
|
+
},
|
|
290
307
|
}
|
|
291
308
|
|
|
292
309
|
mapping = default_mappings.get(self.adapter_type, {})
|
|
@@ -306,6 +323,7 @@ class PriorityMapper(BaseMapper):
|
|
|
306
323
|
|
|
307
324
|
Returns:
|
|
308
325
|
Universal priority
|
|
326
|
+
|
|
309
327
|
"""
|
|
310
328
|
cache_key = f"to_system_{adapter_priority}"
|
|
311
329
|
if cache_key in self._cache:
|
|
@@ -319,18 +337,32 @@ class PriorityMapper(BaseMapper):
|
|
|
319
337
|
if isinstance(adapter_priority, str):
|
|
320
338
|
adapter_priority_lower = adapter_priority.lower()
|
|
321
339
|
for universal_priority, system_priority in mapping.items():
|
|
322
|
-
if
|
|
340
|
+
if (
|
|
341
|
+
isinstance(system_priority, str)
|
|
342
|
+
and system_priority.lower() == adapter_priority_lower
|
|
343
|
+
):
|
|
323
344
|
result = universal_priority
|
|
324
345
|
break
|
|
325
346
|
# Check for common priority patterns
|
|
326
|
-
elif (
|
|
327
|
-
|
|
347
|
+
elif (
|
|
348
|
+
"critical" in adapter_priority_lower
|
|
349
|
+
or "urgent" in adapter_priority_lower
|
|
350
|
+
or "highest" in adapter_priority_lower
|
|
351
|
+
or adapter_priority_lower in ["p0", "0"]
|
|
352
|
+
):
|
|
328
353
|
result = Priority.CRITICAL
|
|
329
354
|
break
|
|
330
|
-
elif
|
|
355
|
+
elif "high" in adapter_priority_lower or adapter_priority_lower in [
|
|
356
|
+
"p1",
|
|
357
|
+
"1",
|
|
358
|
+
]:
|
|
331
359
|
result = Priority.HIGH
|
|
332
360
|
break
|
|
333
|
-
elif
|
|
361
|
+
elif "low" in adapter_priority_lower or adapter_priority_lower in [
|
|
362
|
+
"p3",
|
|
363
|
+
"3",
|
|
364
|
+
"lowest",
|
|
365
|
+
]:
|
|
334
366
|
result = Priority.LOW
|
|
335
367
|
break
|
|
336
368
|
elif isinstance(adapter_priority, (int, float)):
|
|
@@ -345,7 +377,9 @@ class PriorityMapper(BaseMapper):
|
|
|
345
377
|
result = Priority.MEDIUM
|
|
346
378
|
|
|
347
379
|
if result is None:
|
|
348
|
-
logger.warning(
|
|
380
|
+
logger.warning(
|
|
381
|
+
f"Unknown {self.adapter_type} priority: {adapter_priority}, defaulting to MEDIUM"
|
|
382
|
+
)
|
|
349
383
|
result = Priority.MEDIUM
|
|
350
384
|
|
|
351
385
|
self._cache[cache_key] = result
|
|
@@ -359,6 +393,7 @@ class PriorityMapper(BaseMapper):
|
|
|
359
393
|
|
|
360
394
|
Returns:
|
|
361
395
|
Priority in adapter format
|
|
396
|
+
|
|
362
397
|
"""
|
|
363
398
|
cache_key = f"from_system_{system_priority.value}"
|
|
364
399
|
if cache_key in self._cache:
|
|
@@ -368,7 +403,9 @@ class PriorityMapper(BaseMapper):
|
|
|
368
403
|
result = mapping.get_forward(system_priority)
|
|
369
404
|
|
|
370
405
|
if result is None:
|
|
371
|
-
logger.warning(
|
|
406
|
+
logger.warning(
|
|
407
|
+
f"No {self.adapter_type} mapping for priority: {system_priority}"
|
|
408
|
+
)
|
|
372
409
|
# Fallback based on adapter type
|
|
373
410
|
fallback_mappings = {
|
|
374
411
|
"github": "P2",
|
|
@@ -393,6 +430,7 @@ class PriorityMapper(BaseMapper):
|
|
|
393
430
|
|
|
394
431
|
Returns:
|
|
395
432
|
List of possible label names
|
|
433
|
+
|
|
396
434
|
"""
|
|
397
435
|
if self.adapter_type != "github":
|
|
398
436
|
return []
|
|
@@ -415,6 +453,7 @@ class PriorityMapper(BaseMapper):
|
|
|
415
453
|
|
|
416
454
|
Returns:
|
|
417
455
|
Detected priority
|
|
456
|
+
|
|
418
457
|
"""
|
|
419
458
|
if self.adapter_type != "github":
|
|
420
459
|
return Priority.MEDIUM
|
|
@@ -422,7 +461,12 @@ class PriorityMapper(BaseMapper):
|
|
|
422
461
|
labels_lower = [label.lower() for label in labels]
|
|
423
462
|
|
|
424
463
|
# Check each priority level
|
|
425
|
-
for priority in [
|
|
464
|
+
for priority in [
|
|
465
|
+
Priority.CRITICAL,
|
|
466
|
+
Priority.HIGH,
|
|
467
|
+
Priority.LOW,
|
|
468
|
+
Priority.MEDIUM,
|
|
469
|
+
]:
|
|
426
470
|
priority_labels = self.get_priority_labels(priority)
|
|
427
471
|
for priority_label in priority_labels:
|
|
428
472
|
if priority_label.lower() in labels_lower:
|
|
@@ -439,9 +483,7 @@ class MapperRegistry:
|
|
|
439
483
|
|
|
440
484
|
@classmethod
|
|
441
485
|
def get_state_mapper(
|
|
442
|
-
self,
|
|
443
|
-
adapter_type: str,
|
|
444
|
-
custom_mappings: Optional[Dict[str, Any]] = None
|
|
486
|
+
self, adapter_type: str, custom_mappings: Optional[Dict[str, Any]] = None
|
|
445
487
|
) -> StateMapper:
|
|
446
488
|
"""Get or create state mapper for adapter type.
|
|
447
489
|
|
|
@@ -451,6 +493,7 @@ class MapperRegistry:
|
|
|
451
493
|
|
|
452
494
|
Returns:
|
|
453
495
|
State mapper instance
|
|
496
|
+
|
|
454
497
|
"""
|
|
455
498
|
cache_key = f"{adapter_type}_{hash(str(custom_mappings))}"
|
|
456
499
|
if cache_key not in self._state_mappers:
|
|
@@ -459,9 +502,7 @@ class MapperRegistry:
|
|
|
459
502
|
|
|
460
503
|
@classmethod
|
|
461
504
|
def get_priority_mapper(
|
|
462
|
-
self,
|
|
463
|
-
adapter_type: str,
|
|
464
|
-
custom_mappings: Optional[Dict[str, Any]] = None
|
|
505
|
+
self, adapter_type: str, custom_mappings: Optional[Dict[str, Any]] = None
|
|
465
506
|
) -> PriorityMapper:
|
|
466
507
|
"""Get or create priority mapper for adapter type.
|
|
467
508
|
|
|
@@ -471,10 +512,13 @@ class MapperRegistry:
|
|
|
471
512
|
|
|
472
513
|
Returns:
|
|
473
514
|
Priority mapper instance
|
|
515
|
+
|
|
474
516
|
"""
|
|
475
517
|
cache_key = f"{adapter_type}_{hash(str(custom_mappings))}"
|
|
476
518
|
if cache_key not in self._priority_mappers:
|
|
477
|
-
self._priority_mappers[cache_key] = PriorityMapper(
|
|
519
|
+
self._priority_mappers[cache_key] = PriorityMapper(
|
|
520
|
+
adapter_type, custom_mappings
|
|
521
|
+
)
|
|
478
522
|
return self._priority_mappers[cache_key]
|
|
479
523
|
|
|
480
524
|
@classmethod
|
|
@@ -489,4 +533,4 @@ class MapperRegistry:
|
|
|
489
533
|
def reset(cls) -> None:
|
|
490
534
|
"""Reset all mappers."""
|
|
491
535
|
cls._state_mappers.clear()
|
|
492
|
-
cls._priority_mappers.clear()
|
|
536
|
+
cls._priority_mappers.clear()
|
mcp_ticketer/core/models.py
CHANGED
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
from datetime import datetime
|
|
4
4
|
from enum import Enum
|
|
5
|
-
from typing import
|
|
6
|
-
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
7
8
|
|
|
8
9
|
|
|
9
10
|
class Priority(str, Enum):
|
|
10
11
|
"""Universal priority levels."""
|
|
12
|
+
|
|
11
13
|
LOW = "low"
|
|
12
14
|
MEDIUM = "medium"
|
|
13
15
|
HIGH = "high"
|
|
@@ -16,14 +18,16 @@ class Priority(str, Enum):
|
|
|
16
18
|
|
|
17
19
|
class TicketType(str, Enum):
|
|
18
20
|
"""Ticket type hierarchy."""
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
|
|
22
|
+
EPIC = "epic" # Strategic level (Projects in Linear, Milestones in GitHub)
|
|
23
|
+
ISSUE = "issue" # Work item level (standard issues/tasks)
|
|
24
|
+
TASK = "task" # Sub-task level (sub-issues, checkboxes)
|
|
22
25
|
SUBTASK = "subtask" # Alias for task (for clarity)
|
|
23
26
|
|
|
24
27
|
|
|
25
28
|
class TicketState(str, Enum):
|
|
26
29
|
"""Universal ticket states with state machine abstraction."""
|
|
30
|
+
|
|
27
31
|
OPEN = "open"
|
|
28
32
|
IN_PROGRESS = "in_progress"
|
|
29
33
|
READY = "ready"
|
|
@@ -54,6 +58,7 @@ class TicketState(str, Enum):
|
|
|
54
58
|
|
|
55
59
|
class BaseTicket(BaseModel):
|
|
56
60
|
"""Base model for all ticket types."""
|
|
61
|
+
|
|
57
62
|
model_config = ConfigDict(use_enum_values=True)
|
|
58
63
|
|
|
59
64
|
id: Optional[str] = Field(None, description="Unique identifier")
|
|
@@ -67,17 +72,18 @@ class BaseTicket(BaseModel):
|
|
|
67
72
|
|
|
68
73
|
# Metadata for field mapping to different systems
|
|
69
74
|
metadata: Dict[str, Any] = Field(
|
|
70
|
-
default_factory=dict,
|
|
71
|
-
description="System-specific metadata and field mappings"
|
|
75
|
+
default_factory=dict, description="System-specific metadata and field mappings"
|
|
72
76
|
)
|
|
73
77
|
|
|
74
78
|
|
|
75
79
|
class Epic(BaseTicket):
|
|
76
80
|
"""Epic - highest level container for work (Projects in Linear, Milestones in GitHub)."""
|
|
77
|
-
|
|
81
|
+
|
|
82
|
+
ticket_type: TicketType = Field(
|
|
83
|
+
default=TicketType.EPIC, frozen=True, description="Always EPIC type"
|
|
84
|
+
)
|
|
78
85
|
child_issues: List[str] = Field(
|
|
79
|
-
default_factory=list,
|
|
80
|
-
description="IDs of child issues"
|
|
86
|
+
default_factory=list, description="IDs of child issues"
|
|
81
87
|
)
|
|
82
88
|
|
|
83
89
|
def validate_hierarchy(self) -> List[str]:
|
|
@@ -85,6 +91,7 @@ class Epic(BaseTicket):
|
|
|
85
91
|
|
|
86
92
|
Returns:
|
|
87
93
|
List of validation errors (empty if valid)
|
|
94
|
+
|
|
88
95
|
"""
|
|
89
96
|
# Epics don't have parents in our hierarchy
|
|
90
97
|
return []
|
|
@@ -92,7 +99,10 @@ class Epic(BaseTicket):
|
|
|
92
99
|
|
|
93
100
|
class Task(BaseTicket):
|
|
94
101
|
"""Task - individual work item (can be ISSUE or TASK type)."""
|
|
95
|
-
|
|
102
|
+
|
|
103
|
+
ticket_type: TicketType = Field(
|
|
104
|
+
default=TicketType.ISSUE, description="Ticket type in hierarchy"
|
|
105
|
+
)
|
|
96
106
|
parent_issue: Optional[str] = Field(None, description="Parent issue ID (for tasks)")
|
|
97
107
|
parent_epic: Optional[str] = Field(None, description="Parent epic ID (for issues)")
|
|
98
108
|
assignee: Optional[str] = Field(None, description="Assigned user")
|
|
@@ -119,6 +129,7 @@ class Task(BaseTicket):
|
|
|
119
129
|
|
|
120
130
|
Returns:
|
|
121
131
|
List of validation errors (empty if valid)
|
|
132
|
+
|
|
122
133
|
"""
|
|
123
134
|
errors = []
|
|
124
135
|
|
|
@@ -132,13 +143,16 @@ class Task(BaseTicket):
|
|
|
132
143
|
|
|
133
144
|
# Tasks should not have both parent_issue and parent_epic
|
|
134
145
|
if self.is_task() and self.parent_epic:
|
|
135
|
-
errors.append(
|
|
146
|
+
errors.append(
|
|
147
|
+
"Tasks should only have parent_issue, not parent_epic (epic comes from parent issue)"
|
|
148
|
+
)
|
|
136
149
|
|
|
137
150
|
return errors
|
|
138
151
|
|
|
139
152
|
|
|
140
153
|
class Comment(BaseModel):
|
|
141
154
|
"""Comment on a ticket."""
|
|
155
|
+
|
|
142
156
|
model_config = ConfigDict(use_enum_values=True)
|
|
143
157
|
|
|
144
158
|
id: Optional[str] = Field(None, description="Comment ID")
|
|
@@ -147,17 +161,17 @@ class Comment(BaseModel):
|
|
|
147
161
|
content: str = Field(..., min_length=1, description="Comment text")
|
|
148
162
|
created_at: Optional[datetime] = Field(None, description="Creation timestamp")
|
|
149
163
|
metadata: Dict[str, Any] = Field(
|
|
150
|
-
default_factory=dict,
|
|
151
|
-
description="System-specific metadata"
|
|
164
|
+
default_factory=dict, description="System-specific metadata"
|
|
152
165
|
)
|
|
153
166
|
|
|
154
167
|
|
|
155
168
|
class SearchQuery(BaseModel):
|
|
156
169
|
"""Search query parameters."""
|
|
170
|
+
|
|
157
171
|
query: Optional[str] = Field(None, description="Text search query")
|
|
158
172
|
state: Optional[TicketState] = Field(None, description="Filter by state")
|
|
159
173
|
priority: Optional[Priority] = Field(None, description="Filter by priority")
|
|
160
174
|
tags: Optional[List[str]] = Field(None, description="Filter by tags")
|
|
161
175
|
assignee: Optional[str] = Field(None, description="Filter by assignee")
|
|
162
176
|
limit: int = Field(10, gt=0, le=100, description="Maximum results")
|
|
163
|
-
offset: int = Field(0, ge=0, description="Result offset for pagination")
|
|
177
|
+
offset: int = Field(0, ge=0, description="Result offset for pagination")
|