mcp-ticketer 0.1.1__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 +27 -0
- mcp_ticketer/__version__.py +40 -0
- mcp_ticketer/adapters/__init__.py +8 -0
- mcp_ticketer/adapters/aitrackdown.py +396 -0
- mcp_ticketer/adapters/github.py +974 -0
- mcp_ticketer/adapters/jira.py +831 -0
- mcp_ticketer/adapters/linear.py +1355 -0
- mcp_ticketer/cache/__init__.py +5 -0
- mcp_ticketer/cache/memory.py +193 -0
- mcp_ticketer/cli/__init__.py +5 -0
- mcp_ticketer/cli/main.py +812 -0
- mcp_ticketer/cli/queue_commands.py +285 -0
- mcp_ticketer/cli/utils.py +523 -0
- mcp_ticketer/core/__init__.py +15 -0
- mcp_ticketer/core/adapter.py +211 -0
- mcp_ticketer/core/config.py +403 -0
- mcp_ticketer/core/http_client.py +430 -0
- mcp_ticketer/core/mappers.py +492 -0
- mcp_ticketer/core/models.py +111 -0
- mcp_ticketer/core/registry.py +128 -0
- mcp_ticketer/mcp/__init__.py +5 -0
- mcp_ticketer/mcp/server.py +459 -0
- mcp_ticketer/py.typed +0 -0
- mcp_ticketer/queue/__init__.py +7 -0
- mcp_ticketer/queue/__main__.py +6 -0
- mcp_ticketer/queue/manager.py +261 -0
- mcp_ticketer/queue/queue.py +357 -0
- mcp_ticketer/queue/run_worker.py +38 -0
- mcp_ticketer/queue/worker.py +425 -0
- mcp_ticketer-0.1.1.dist-info/METADATA +362 -0
- mcp_ticketer-0.1.1.dist-info/RECORD +35 -0
- mcp_ticketer-0.1.1.dist-info/WHEEL +5 -0
- mcp_ticketer-0.1.1.dist-info/entry_points.txt +3 -0
- mcp_ticketer-0.1.1.dist-info/licenses/LICENSE +21 -0
- mcp_ticketer-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
"""Centralized mapping utilities for state and priority conversions."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Dict, List, Optional, Any, TypeVar, Generic, Callable
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from enum import Enum
|
|
8
|
+
|
|
9
|
+
from .models import TicketState, Priority
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
T = TypeVar('T')
|
|
14
|
+
U = TypeVar('U')
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BiDirectionalDict(Generic[T, U]):
|
|
18
|
+
"""Bidirectional dictionary for efficient lookups in both directions."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, mapping: Dict[T, U]):
|
|
21
|
+
"""Initialize with forward mapping.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
mapping: Forward mapping dictionary
|
|
25
|
+
"""
|
|
26
|
+
self._forward: Dict[T, U] = mapping.copy()
|
|
27
|
+
self._reverse: Dict[U, T] = {v: k for k, v in mapping.items()}
|
|
28
|
+
self._cache: Dict[str, Any] = {}
|
|
29
|
+
|
|
30
|
+
def get_forward(self, key: T, default: Optional[U] = None) -> Optional[U]:
|
|
31
|
+
"""Get value by forward key."""
|
|
32
|
+
return self._forward.get(key, default)
|
|
33
|
+
|
|
34
|
+
def get_reverse(self, key: U, default: Optional[T] = None) -> Optional[T]:
|
|
35
|
+
"""Get value by reverse key."""
|
|
36
|
+
return self._reverse.get(key, default)
|
|
37
|
+
|
|
38
|
+
def contains_forward(self, key: T) -> bool:
|
|
39
|
+
"""Check if forward key exists."""
|
|
40
|
+
return key in self._forward
|
|
41
|
+
|
|
42
|
+
def contains_reverse(self, key: U) -> bool:
|
|
43
|
+
"""Check if reverse key exists."""
|
|
44
|
+
return key in self._reverse
|
|
45
|
+
|
|
46
|
+
def forward_keys(self) -> List[T]:
|
|
47
|
+
"""Get all forward keys."""
|
|
48
|
+
return list(self._forward.keys())
|
|
49
|
+
|
|
50
|
+
def reverse_keys(self) -> List[U]:
|
|
51
|
+
"""Get all reverse keys."""
|
|
52
|
+
return list(self._reverse.keys())
|
|
53
|
+
|
|
54
|
+
def items(self) -> List[tuple[T, U]]:
|
|
55
|
+
"""Get all key-value pairs."""
|
|
56
|
+
return list(self._forward.items())
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class BaseMapper(ABC):
|
|
60
|
+
"""Base class for mapping utilities."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, cache_size: int = 128):
|
|
63
|
+
"""Initialize mapper with caching.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
cache_size: Size of LRU cache for mapping results
|
|
67
|
+
"""
|
|
68
|
+
self.cache_size = cache_size
|
|
69
|
+
self._cache: Dict[str, Any] = {}
|
|
70
|
+
|
|
71
|
+
@abstractmethod
|
|
72
|
+
def get_mapping(self) -> BiDirectionalDict:
|
|
73
|
+
"""Get the bidirectional mapping."""
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
def clear_cache(self) -> None:
|
|
77
|
+
"""Clear the mapping cache."""
|
|
78
|
+
self._cache.clear()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class StateMapper(BaseMapper):
|
|
82
|
+
"""Universal state mapping utility."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, adapter_type: str, custom_mappings: Optional[Dict[str, Any]] = None):
|
|
85
|
+
"""Initialize state mapper.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
adapter_type: Type of adapter (github, jira, linear, etc.)
|
|
89
|
+
custom_mappings: Custom state mappings to override defaults
|
|
90
|
+
"""
|
|
91
|
+
super().__init__()
|
|
92
|
+
self.adapter_type = adapter_type
|
|
93
|
+
self.custom_mappings = custom_mappings or {}
|
|
94
|
+
self._mapping: Optional[BiDirectionalDict] = None
|
|
95
|
+
|
|
96
|
+
@lru_cache(maxsize=1)
|
|
97
|
+
def get_mapping(self) -> BiDirectionalDict:
|
|
98
|
+
"""Get cached bidirectional state mapping."""
|
|
99
|
+
if self._mapping is not None:
|
|
100
|
+
return self._mapping
|
|
101
|
+
|
|
102
|
+
# Default mappings by adapter type
|
|
103
|
+
default_mappings = {
|
|
104
|
+
"github": {
|
|
105
|
+
TicketState.OPEN: "open",
|
|
106
|
+
TicketState.IN_PROGRESS: "open", # Uses labels
|
|
107
|
+
TicketState.READY: "open", # Uses labels
|
|
108
|
+
TicketState.TESTED: "open", # Uses labels
|
|
109
|
+
TicketState.DONE: "closed",
|
|
110
|
+
TicketState.WAITING: "open", # Uses labels
|
|
111
|
+
TicketState.BLOCKED: "open", # Uses labels
|
|
112
|
+
TicketState.CLOSED: "closed",
|
|
113
|
+
},
|
|
114
|
+
"jira": {
|
|
115
|
+
TicketState.OPEN: "To Do",
|
|
116
|
+
TicketState.IN_PROGRESS: "In Progress",
|
|
117
|
+
TicketState.READY: "In Review",
|
|
118
|
+
TicketState.TESTED: "Testing",
|
|
119
|
+
TicketState.DONE: "Done",
|
|
120
|
+
TicketState.WAITING: "Waiting",
|
|
121
|
+
TicketState.BLOCKED: "Blocked",
|
|
122
|
+
TicketState.CLOSED: "Closed",
|
|
123
|
+
},
|
|
124
|
+
"linear": {
|
|
125
|
+
TicketState.OPEN: "backlog",
|
|
126
|
+
TicketState.IN_PROGRESS: "started",
|
|
127
|
+
TicketState.READY: "started", # Uses labels
|
|
128
|
+
TicketState.TESTED: "started", # Uses labels
|
|
129
|
+
TicketState.DONE: "completed",
|
|
130
|
+
TicketState.WAITING: "unstarted", # Uses labels
|
|
131
|
+
TicketState.BLOCKED: "unstarted", # Uses labels
|
|
132
|
+
TicketState.CLOSED: "canceled",
|
|
133
|
+
},
|
|
134
|
+
"aitrackdown": {
|
|
135
|
+
TicketState.OPEN: "open",
|
|
136
|
+
TicketState.IN_PROGRESS: "in-progress",
|
|
137
|
+
TicketState.READY: "ready",
|
|
138
|
+
TicketState.TESTED: "tested",
|
|
139
|
+
TicketState.DONE: "done",
|
|
140
|
+
TicketState.WAITING: "waiting",
|
|
141
|
+
TicketState.BLOCKED: "blocked",
|
|
142
|
+
TicketState.CLOSED: "closed",
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
mapping = default_mappings.get(self.adapter_type, {})
|
|
147
|
+
|
|
148
|
+
# Apply custom mappings
|
|
149
|
+
if self.custom_mappings:
|
|
150
|
+
mapping.update(self.custom_mappings)
|
|
151
|
+
|
|
152
|
+
self._mapping = BiDirectionalDict(mapping)
|
|
153
|
+
return self._mapping
|
|
154
|
+
|
|
155
|
+
def to_system_state(self, adapter_state: str) -> TicketState:
|
|
156
|
+
"""Convert adapter-specific state to universal state.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
adapter_state: State in adapter format
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
Universal ticket state
|
|
163
|
+
"""
|
|
164
|
+
cache_key = f"to_system_{adapter_state}"
|
|
165
|
+
if cache_key in self._cache:
|
|
166
|
+
return self._cache[cache_key]
|
|
167
|
+
|
|
168
|
+
mapping = self.get_mapping()
|
|
169
|
+
result = mapping.get_reverse(adapter_state)
|
|
170
|
+
|
|
171
|
+
if result is None:
|
|
172
|
+
# Fallback: try case-insensitive matching
|
|
173
|
+
adapter_state_lower = adapter_state.lower()
|
|
174
|
+
for universal_state, system_state in mapping.items():
|
|
175
|
+
if isinstance(system_state, str) and system_state.lower() == adapter_state_lower:
|
|
176
|
+
result = universal_state
|
|
177
|
+
break
|
|
178
|
+
|
|
179
|
+
if result is None:
|
|
180
|
+
logger.warning(f"Unknown {self.adapter_type} state: {adapter_state}, defaulting to OPEN")
|
|
181
|
+
result = TicketState.OPEN
|
|
182
|
+
|
|
183
|
+
self._cache[cache_key] = result
|
|
184
|
+
return result
|
|
185
|
+
|
|
186
|
+
def from_system_state(self, system_state: TicketState) -> str:
|
|
187
|
+
"""Convert universal state to adapter-specific state.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
system_state: Universal ticket state
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
State in adapter format
|
|
194
|
+
"""
|
|
195
|
+
cache_key = f"from_system_{system_state.value}"
|
|
196
|
+
if cache_key in self._cache:
|
|
197
|
+
return self._cache[cache_key]
|
|
198
|
+
|
|
199
|
+
mapping = self.get_mapping()
|
|
200
|
+
result = mapping.get_forward(system_state)
|
|
201
|
+
|
|
202
|
+
if result is None:
|
|
203
|
+
logger.warning(f"No {self.adapter_type} mapping for state: {system_state}, using default")
|
|
204
|
+
# Fallback to first available state
|
|
205
|
+
available_states = mapping.reverse_keys()
|
|
206
|
+
result = available_states[0] if available_states else "open"
|
|
207
|
+
|
|
208
|
+
self._cache[cache_key] = result
|
|
209
|
+
return result
|
|
210
|
+
|
|
211
|
+
def get_available_states(self) -> List[str]:
|
|
212
|
+
"""Get all available adapter states."""
|
|
213
|
+
return self.get_mapping().reverse_keys()
|
|
214
|
+
|
|
215
|
+
def supports_state_labels(self) -> bool:
|
|
216
|
+
"""Check if adapter uses labels for extended states."""
|
|
217
|
+
return self.adapter_type in ["github", "linear"]
|
|
218
|
+
|
|
219
|
+
def get_state_label(self, state: TicketState) -> Optional[str]:
|
|
220
|
+
"""Get label name for extended states that require labels.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
state: Universal ticket state
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
Label name if state requires a label, None otherwise
|
|
227
|
+
"""
|
|
228
|
+
if not self.supports_state_labels():
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
# States that require labels in GitHub and Linear
|
|
232
|
+
state_labels = {
|
|
233
|
+
TicketState.IN_PROGRESS: "in-progress",
|
|
234
|
+
TicketState.READY: "ready",
|
|
235
|
+
TicketState.TESTED: "tested",
|
|
236
|
+
TicketState.WAITING: "waiting",
|
|
237
|
+
TicketState.BLOCKED: "blocked",
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return state_labels.get(state)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class PriorityMapper(BaseMapper):
|
|
244
|
+
"""Universal priority mapping utility."""
|
|
245
|
+
|
|
246
|
+
def __init__(self, adapter_type: str, custom_mappings: Optional[Dict[str, Any]] = None):
|
|
247
|
+
"""Initialize priority mapper.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
adapter_type: Type of adapter (github, jira, linear, etc.)
|
|
251
|
+
custom_mappings: Custom priority mappings to override defaults
|
|
252
|
+
"""
|
|
253
|
+
super().__init__()
|
|
254
|
+
self.adapter_type = adapter_type
|
|
255
|
+
self.custom_mappings = custom_mappings or {}
|
|
256
|
+
self._mapping: Optional[BiDirectionalDict] = None
|
|
257
|
+
|
|
258
|
+
@lru_cache(maxsize=1)
|
|
259
|
+
def get_mapping(self) -> BiDirectionalDict:
|
|
260
|
+
"""Get cached bidirectional priority mapping."""
|
|
261
|
+
if self._mapping is not None:
|
|
262
|
+
return self._mapping
|
|
263
|
+
|
|
264
|
+
# Default mappings by adapter type
|
|
265
|
+
default_mappings = {
|
|
266
|
+
"github": {
|
|
267
|
+
Priority.CRITICAL: "P0",
|
|
268
|
+
Priority.HIGH: "P1",
|
|
269
|
+
Priority.MEDIUM: "P2",
|
|
270
|
+
Priority.LOW: "P3",
|
|
271
|
+
},
|
|
272
|
+
"jira": {
|
|
273
|
+
Priority.CRITICAL: "Highest",
|
|
274
|
+
Priority.HIGH: "High",
|
|
275
|
+
Priority.MEDIUM: "Medium",
|
|
276
|
+
Priority.LOW: "Low",
|
|
277
|
+
},
|
|
278
|
+
"linear": {
|
|
279
|
+
Priority.CRITICAL: 1,
|
|
280
|
+
Priority.HIGH: 2,
|
|
281
|
+
Priority.MEDIUM: 3,
|
|
282
|
+
Priority.LOW: 4,
|
|
283
|
+
},
|
|
284
|
+
"aitrackdown": {
|
|
285
|
+
Priority.CRITICAL: "critical",
|
|
286
|
+
Priority.HIGH: "high",
|
|
287
|
+
Priority.MEDIUM: "medium",
|
|
288
|
+
Priority.LOW: "low",
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
mapping = default_mappings.get(self.adapter_type, {})
|
|
293
|
+
|
|
294
|
+
# Apply custom mappings
|
|
295
|
+
if self.custom_mappings:
|
|
296
|
+
mapping.update(self.custom_mappings)
|
|
297
|
+
|
|
298
|
+
self._mapping = BiDirectionalDict(mapping)
|
|
299
|
+
return self._mapping
|
|
300
|
+
|
|
301
|
+
def to_system_priority(self, adapter_priority: Any) -> Priority:
|
|
302
|
+
"""Convert adapter-specific priority to universal priority.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
adapter_priority: Priority in adapter format
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
Universal priority
|
|
309
|
+
"""
|
|
310
|
+
cache_key = f"to_system_{adapter_priority}"
|
|
311
|
+
if cache_key in self._cache:
|
|
312
|
+
return self._cache[cache_key]
|
|
313
|
+
|
|
314
|
+
mapping = self.get_mapping()
|
|
315
|
+
result = mapping.get_reverse(adapter_priority)
|
|
316
|
+
|
|
317
|
+
if result is None:
|
|
318
|
+
# Fallback: try parsing different formats
|
|
319
|
+
if isinstance(adapter_priority, str):
|
|
320
|
+
adapter_priority_lower = adapter_priority.lower()
|
|
321
|
+
for universal_priority, system_priority in mapping.items():
|
|
322
|
+
if isinstance(system_priority, str) and system_priority.lower() == adapter_priority_lower:
|
|
323
|
+
result = universal_priority
|
|
324
|
+
break
|
|
325
|
+
# Check for common priority patterns
|
|
326
|
+
elif ("critical" in adapter_priority_lower or "urgent" in adapter_priority_lower or
|
|
327
|
+
"highest" in adapter_priority_lower or adapter_priority_lower in ["p0", "0"]):
|
|
328
|
+
result = Priority.CRITICAL
|
|
329
|
+
break
|
|
330
|
+
elif ("high" in adapter_priority_lower or adapter_priority_lower in ["p1", "1"]):
|
|
331
|
+
result = Priority.HIGH
|
|
332
|
+
break
|
|
333
|
+
elif ("low" in adapter_priority_lower or adapter_priority_lower in ["p3", "3", "lowest"]):
|
|
334
|
+
result = Priority.LOW
|
|
335
|
+
break
|
|
336
|
+
elif isinstance(adapter_priority, (int, float)):
|
|
337
|
+
# Handle numeric priorities (Linear-style)
|
|
338
|
+
if adapter_priority <= 1:
|
|
339
|
+
result = Priority.CRITICAL
|
|
340
|
+
elif adapter_priority == 2:
|
|
341
|
+
result = Priority.HIGH
|
|
342
|
+
elif adapter_priority >= 4:
|
|
343
|
+
result = Priority.LOW
|
|
344
|
+
else:
|
|
345
|
+
result = Priority.MEDIUM
|
|
346
|
+
|
|
347
|
+
if result is None:
|
|
348
|
+
logger.warning(f"Unknown {self.adapter_type} priority: {adapter_priority}, defaulting to MEDIUM")
|
|
349
|
+
result = Priority.MEDIUM
|
|
350
|
+
|
|
351
|
+
self._cache[cache_key] = result
|
|
352
|
+
return result
|
|
353
|
+
|
|
354
|
+
def from_system_priority(self, system_priority: Priority) -> Any:
|
|
355
|
+
"""Convert universal priority to adapter-specific priority.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
system_priority: Universal priority
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
Priority in adapter format
|
|
362
|
+
"""
|
|
363
|
+
cache_key = f"from_system_{system_priority.value}"
|
|
364
|
+
if cache_key in self._cache:
|
|
365
|
+
return self._cache[cache_key]
|
|
366
|
+
|
|
367
|
+
mapping = self.get_mapping()
|
|
368
|
+
result = mapping.get_forward(system_priority)
|
|
369
|
+
|
|
370
|
+
if result is None:
|
|
371
|
+
logger.warning(f"No {self.adapter_type} mapping for priority: {system_priority}")
|
|
372
|
+
# Fallback based on adapter type
|
|
373
|
+
fallback_mappings = {
|
|
374
|
+
"github": "P2",
|
|
375
|
+
"jira": "Medium",
|
|
376
|
+
"linear": 3,
|
|
377
|
+
"aitrackdown": "medium",
|
|
378
|
+
}
|
|
379
|
+
result = fallback_mappings.get(self.adapter_type, "medium")
|
|
380
|
+
|
|
381
|
+
self._cache[cache_key] = result
|
|
382
|
+
return result
|
|
383
|
+
|
|
384
|
+
def get_available_priorities(self) -> List[Any]:
|
|
385
|
+
"""Get all available adapter priorities."""
|
|
386
|
+
return self.get_mapping().reverse_keys()
|
|
387
|
+
|
|
388
|
+
def get_priority_labels(self, priority: Priority) -> List[str]:
|
|
389
|
+
"""Get possible label names for a priority (GitHub-style).
|
|
390
|
+
|
|
391
|
+
Args:
|
|
392
|
+
priority: Universal priority
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
List of possible label names
|
|
396
|
+
"""
|
|
397
|
+
if self.adapter_type != "github":
|
|
398
|
+
return []
|
|
399
|
+
|
|
400
|
+
# GitHub priority labels (including variations)
|
|
401
|
+
priority_labels = {
|
|
402
|
+
Priority.CRITICAL: ["P0", "critical", "urgent", "highest"],
|
|
403
|
+
Priority.HIGH: ["P1", "high"],
|
|
404
|
+
Priority.MEDIUM: ["P2", "medium"],
|
|
405
|
+
Priority.LOW: ["P3", "low", "lowest"],
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return priority_labels.get(priority, [])
|
|
409
|
+
|
|
410
|
+
def detect_priority_from_labels(self, labels: List[str]) -> Priority:
|
|
411
|
+
"""Detect priority from issue labels (GitHub-style).
|
|
412
|
+
|
|
413
|
+
Args:
|
|
414
|
+
labels: List of label names
|
|
415
|
+
|
|
416
|
+
Returns:
|
|
417
|
+
Detected priority
|
|
418
|
+
"""
|
|
419
|
+
if self.adapter_type != "github":
|
|
420
|
+
return Priority.MEDIUM
|
|
421
|
+
|
|
422
|
+
labels_lower = [label.lower() for label in labels]
|
|
423
|
+
|
|
424
|
+
# Check each priority level
|
|
425
|
+
for priority in [Priority.CRITICAL, Priority.HIGH, Priority.LOW, Priority.MEDIUM]:
|
|
426
|
+
priority_labels = self.get_priority_labels(priority)
|
|
427
|
+
for priority_label in priority_labels:
|
|
428
|
+
if priority_label.lower() in labels_lower:
|
|
429
|
+
return priority
|
|
430
|
+
|
|
431
|
+
return Priority.MEDIUM
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
class MapperRegistry:
|
|
435
|
+
"""Registry for managing mappers across different adapters."""
|
|
436
|
+
|
|
437
|
+
_state_mappers: Dict[str, StateMapper] = {}
|
|
438
|
+
_priority_mappers: Dict[str, PriorityMapper] = {}
|
|
439
|
+
|
|
440
|
+
@classmethod
|
|
441
|
+
def get_state_mapper(
|
|
442
|
+
self,
|
|
443
|
+
adapter_type: str,
|
|
444
|
+
custom_mappings: Optional[Dict[str, Any]] = None
|
|
445
|
+
) -> StateMapper:
|
|
446
|
+
"""Get or create state mapper for adapter type.
|
|
447
|
+
|
|
448
|
+
Args:
|
|
449
|
+
adapter_type: Adapter type
|
|
450
|
+
custom_mappings: Custom mappings
|
|
451
|
+
|
|
452
|
+
Returns:
|
|
453
|
+
State mapper instance
|
|
454
|
+
"""
|
|
455
|
+
cache_key = f"{adapter_type}_{hash(str(custom_mappings))}"
|
|
456
|
+
if cache_key not in self._state_mappers:
|
|
457
|
+
self._state_mappers[cache_key] = StateMapper(adapter_type, custom_mappings)
|
|
458
|
+
return self._state_mappers[cache_key]
|
|
459
|
+
|
|
460
|
+
@classmethod
|
|
461
|
+
def get_priority_mapper(
|
|
462
|
+
self,
|
|
463
|
+
adapter_type: str,
|
|
464
|
+
custom_mappings: Optional[Dict[str, Any]] = None
|
|
465
|
+
) -> PriorityMapper:
|
|
466
|
+
"""Get or create priority mapper for adapter type.
|
|
467
|
+
|
|
468
|
+
Args:
|
|
469
|
+
adapter_type: Adapter type
|
|
470
|
+
custom_mappings: Custom mappings
|
|
471
|
+
|
|
472
|
+
Returns:
|
|
473
|
+
Priority mapper instance
|
|
474
|
+
"""
|
|
475
|
+
cache_key = f"{adapter_type}_{hash(str(custom_mappings))}"
|
|
476
|
+
if cache_key not in self._priority_mappers:
|
|
477
|
+
self._priority_mappers[cache_key] = PriorityMapper(adapter_type, custom_mappings)
|
|
478
|
+
return self._priority_mappers[cache_key]
|
|
479
|
+
|
|
480
|
+
@classmethod
|
|
481
|
+
def clear_cache(cls) -> None:
|
|
482
|
+
"""Clear all mapper caches."""
|
|
483
|
+
for mapper in cls._state_mappers.values():
|
|
484
|
+
mapper.clear_cache()
|
|
485
|
+
for mapper in cls._priority_mappers.values():
|
|
486
|
+
mapper.clear_cache()
|
|
487
|
+
|
|
488
|
+
@classmethod
|
|
489
|
+
def reset(cls) -> None:
|
|
490
|
+
"""Reset all mappers."""
|
|
491
|
+
cls._state_mappers.clear()
|
|
492
|
+
cls._priority_mappers.clear()
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Simplified Universal Ticket models using Pydantic."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Optional, Dict, Any, List
|
|
6
|
+
from pydantic import BaseModel, Field, ConfigDict
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Priority(str, Enum):
|
|
10
|
+
"""Universal priority levels."""
|
|
11
|
+
LOW = "low"
|
|
12
|
+
MEDIUM = "medium"
|
|
13
|
+
HIGH = "high"
|
|
14
|
+
CRITICAL = "critical"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TicketState(str, Enum):
|
|
18
|
+
"""Universal ticket states with state machine abstraction."""
|
|
19
|
+
OPEN = "open"
|
|
20
|
+
IN_PROGRESS = "in_progress"
|
|
21
|
+
READY = "ready"
|
|
22
|
+
TESTED = "tested"
|
|
23
|
+
DONE = "done"
|
|
24
|
+
WAITING = "waiting"
|
|
25
|
+
BLOCKED = "blocked"
|
|
26
|
+
CLOSED = "closed"
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def valid_transitions(cls) -> Dict[str, List[str]]:
|
|
30
|
+
"""Define valid state transitions."""
|
|
31
|
+
return {
|
|
32
|
+
cls.OPEN: [cls.IN_PROGRESS, cls.WAITING, cls.BLOCKED, cls.CLOSED],
|
|
33
|
+
cls.IN_PROGRESS: [cls.READY, cls.WAITING, cls.BLOCKED, cls.OPEN],
|
|
34
|
+
cls.READY: [cls.TESTED, cls.IN_PROGRESS, cls.BLOCKED],
|
|
35
|
+
cls.TESTED: [cls.DONE, cls.IN_PROGRESS],
|
|
36
|
+
cls.DONE: [cls.CLOSED],
|
|
37
|
+
cls.WAITING: [cls.OPEN, cls.IN_PROGRESS, cls.CLOSED],
|
|
38
|
+
cls.BLOCKED: [cls.OPEN, cls.IN_PROGRESS, cls.CLOSED],
|
|
39
|
+
cls.CLOSED: [],
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
def can_transition_to(self, target: "TicketState") -> bool:
|
|
43
|
+
"""Check if transition to target state is valid."""
|
|
44
|
+
return target.value in self.valid_transitions().get(self, [])
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class BaseTicket(BaseModel):
|
|
48
|
+
"""Base model for all ticket types."""
|
|
49
|
+
model_config = ConfigDict(use_enum_values=True)
|
|
50
|
+
|
|
51
|
+
id: Optional[str] = Field(None, description="Unique identifier")
|
|
52
|
+
title: str = Field(..., min_length=1, description="Ticket title")
|
|
53
|
+
description: Optional[str] = Field(None, description="Detailed description")
|
|
54
|
+
state: TicketState = Field(TicketState.OPEN, description="Current state")
|
|
55
|
+
priority: Priority = Field(Priority.MEDIUM, description="Priority level")
|
|
56
|
+
tags: List[str] = Field(default_factory=list, description="Tags/labels")
|
|
57
|
+
created_at: Optional[datetime] = Field(None, description="Creation timestamp")
|
|
58
|
+
updated_at: Optional[datetime] = Field(None, description="Last update timestamp")
|
|
59
|
+
|
|
60
|
+
# Metadata for field mapping to different systems
|
|
61
|
+
metadata: Dict[str, Any] = Field(
|
|
62
|
+
default_factory=dict,
|
|
63
|
+
description="System-specific metadata and field mappings"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Epic(BaseTicket):
|
|
68
|
+
"""Epic - highest level container for work."""
|
|
69
|
+
ticket_type: str = Field(default="epic", frozen=True)
|
|
70
|
+
child_issues: List[str] = Field(
|
|
71
|
+
default_factory=list,
|
|
72
|
+
description="IDs of child issues"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Task(BaseTicket):
|
|
77
|
+
"""Task - individual work item."""
|
|
78
|
+
ticket_type: str = Field(default="task", frozen=True)
|
|
79
|
+
parent_issue: Optional[str] = Field(None, description="Parent issue ID")
|
|
80
|
+
parent_epic: Optional[str] = Field(None, description="Parent epic ID")
|
|
81
|
+
assignee: Optional[str] = Field(None, description="Assigned user")
|
|
82
|
+
|
|
83
|
+
# Additional fields common across systems
|
|
84
|
+
estimated_hours: Optional[float] = Field(None, description="Time estimate")
|
|
85
|
+
actual_hours: Optional[float] = Field(None, description="Actual time spent")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Comment(BaseModel):
|
|
89
|
+
"""Comment on a ticket."""
|
|
90
|
+
model_config = ConfigDict(use_enum_values=True)
|
|
91
|
+
|
|
92
|
+
id: Optional[str] = Field(None, description="Comment ID")
|
|
93
|
+
ticket_id: str = Field(..., description="Parent ticket ID")
|
|
94
|
+
author: Optional[str] = Field(None, description="Comment author")
|
|
95
|
+
content: str = Field(..., min_length=1, description="Comment text")
|
|
96
|
+
created_at: Optional[datetime] = Field(None, description="Creation timestamp")
|
|
97
|
+
metadata: Dict[str, Any] = Field(
|
|
98
|
+
default_factory=dict,
|
|
99
|
+
description="System-specific metadata"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class SearchQuery(BaseModel):
|
|
104
|
+
"""Search query parameters."""
|
|
105
|
+
query: Optional[str] = Field(None, description="Text search query")
|
|
106
|
+
state: Optional[TicketState] = Field(None, description="Filter by state")
|
|
107
|
+
priority: Optional[Priority] = Field(None, description="Filter by priority")
|
|
108
|
+
tags: Optional[List[str]] = Field(None, description="Filter by tags")
|
|
109
|
+
assignee: Optional[str] = Field(None, description="Filter by assignee")
|
|
110
|
+
limit: int = Field(10, gt=0, le=100, description="Maximum results")
|
|
111
|
+
offset: int = Field(0, ge=0, description="Result offset for pagination")
|