mcp-ticketer 0.1.21__py3-none-any.whl → 0.1.23__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of mcp-ticketer might be problematic. Click here for more details.
- mcp_ticketer/__init__.py +7 -7
- mcp_ticketer/__version__.py +4 -2
- mcp_ticketer/adapters/__init__.py +4 -4
- mcp_ticketer/adapters/aitrackdown.py +66 -49
- mcp_ticketer/adapters/github.py +192 -125
- mcp_ticketer/adapters/hybrid.py +99 -53
- mcp_ticketer/adapters/jira.py +161 -151
- mcp_ticketer/adapters/linear.py +396 -246
- mcp_ticketer/cache/__init__.py +1 -1
- mcp_ticketer/cache/memory.py +15 -16
- mcp_ticketer/cli/__init__.py +1 -1
- mcp_ticketer/cli/configure.py +69 -93
- mcp_ticketer/cli/discover.py +43 -35
- mcp_ticketer/cli/main.py +283 -298
- mcp_ticketer/cli/mcp_configure.py +39 -15
- mcp_ticketer/cli/migrate_config.py +11 -13
- mcp_ticketer/cli/queue_commands.py +21 -58
- mcp_ticketer/cli/utils.py +121 -66
- mcp_ticketer/core/__init__.py +2 -2
- mcp_ticketer/core/adapter.py +46 -39
- mcp_ticketer/core/config.py +128 -92
- mcp_ticketer/core/env_discovery.py +69 -37
- mcp_ticketer/core/http_client.py +57 -40
- mcp_ticketer/core/mappers.py +98 -54
- mcp_ticketer/core/models.py +38 -24
- mcp_ticketer/core/project_config.py +145 -80
- mcp_ticketer/core/registry.py +16 -16
- mcp_ticketer/mcp/__init__.py +1 -1
- mcp_ticketer/mcp/server.py +199 -145
- mcp_ticketer/queue/__init__.py +2 -2
- mcp_ticketer/queue/__main__.py +1 -1
- mcp_ticketer/queue/manager.py +30 -26
- mcp_ticketer/queue/queue.py +147 -85
- mcp_ticketer/queue/run_worker.py +2 -3
- mcp_ticketer/queue/worker.py +55 -40
- {mcp_ticketer-0.1.21.dist-info → mcp_ticketer-0.1.23.dist-info}/METADATA +1 -1
- mcp_ticketer-0.1.23.dist-info/RECORD +42 -0
- mcp_ticketer-0.1.21.dist-info/RECORD +0 -42
- {mcp_ticketer-0.1.21.dist-info → mcp_ticketer-0.1.23.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.1.21.dist-info → mcp_ticketer-0.1.23.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.1.21.dist-info → mcp_ticketer-0.1.23.dist-info}/licenses/LICENSE +0 -0
- {mcp_ticketer-0.1.21.dist-info → mcp_ticketer-0.1.23.dist-info}/top_level.txt +0 -0
mcp_ticketer/core/mappers.py
CHANGED
|
@@ -1,31 +1,31 @@
|
|
|
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, Generic, 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]):
|
|
18
17
|
"""Bidirectional dictionary for efficient lookups in both directions."""
|
|
19
18
|
|
|
20
|
-
def __init__(self, mapping:
|
|
19
|
+
def __init__(self, mapping: dict[T, U]):
|
|
21
20
|
"""Initialize with forward mapping.
|
|
22
21
|
|
|
23
22
|
Args:
|
|
24
23
|
mapping: Forward mapping dictionary
|
|
24
|
+
|
|
25
25
|
"""
|
|
26
|
-
self._forward:
|
|
27
|
-
self._reverse:
|
|
28
|
-
self._cache:
|
|
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
29
|
|
|
30
30
|
def get_forward(self, key: T, default: Optional[U] = None) -> Optional[U]:
|
|
31
31
|
"""Get value by forward key."""
|
|
@@ -43,15 +43,15 @@ class BiDirectionalDict(Generic[T, U]):
|
|
|
43
43
|
"""Check if reverse key exists."""
|
|
44
44
|
return key in self._reverse
|
|
45
45
|
|
|
46
|
-
def forward_keys(self) ->
|
|
46
|
+
def forward_keys(self) -> list[T]:
|
|
47
47
|
"""Get all forward keys."""
|
|
48
48
|
return list(self._forward.keys())
|
|
49
49
|
|
|
50
|
-
def reverse_keys(self) ->
|
|
50
|
+
def reverse_keys(self) -> list[U]:
|
|
51
51
|
"""Get all reverse keys."""
|
|
52
52
|
return list(self._reverse.keys())
|
|
53
53
|
|
|
54
|
-
def items(self) ->
|
|
54
|
+
def items(self) -> list[tuple[T, U]]:
|
|
55
55
|
"""Get all key-value pairs."""
|
|
56
56
|
return list(self._forward.items())
|
|
57
57
|
|
|
@@ -64,9 +64,10 @@ 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
|
-
self._cache:
|
|
70
|
+
self._cache: dict[str, Any] = {}
|
|
70
71
|
|
|
71
72
|
@abstractmethod
|
|
72
73
|
def get_mapping(self) -> BiDirectionalDict:
|
|
@@ -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"
|
|
@@ -208,7 +221,7 @@ class StateMapper(BaseMapper):
|
|
|
208
221
|
self._cache[cache_key] = result
|
|
209
222
|
return result
|
|
210
223
|
|
|
211
|
-
def get_available_states(self) ->
|
|
224
|
+
def get_available_states(self) -> list[str]:
|
|
212
225
|
"""Get all available adapter states."""
|
|
213
226
|
return self.get_mapping().reverse_keys()
|
|
214
227
|
|
|
@@ -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",
|
|
@@ -381,11 +418,11 @@ class PriorityMapper(BaseMapper):
|
|
|
381
418
|
self._cache[cache_key] = result
|
|
382
419
|
return result
|
|
383
420
|
|
|
384
|
-
def get_available_priorities(self) ->
|
|
421
|
+
def get_available_priorities(self) -> list[Any]:
|
|
385
422
|
"""Get all available adapter priorities."""
|
|
386
423
|
return self.get_mapping().reverse_keys()
|
|
387
424
|
|
|
388
|
-
def get_priority_labels(self, priority: Priority) ->
|
|
425
|
+
def get_priority_labels(self, priority: Priority) -> list[str]:
|
|
389
426
|
"""Get possible label names for a priority (GitHub-style).
|
|
390
427
|
|
|
391
428
|
Args:
|
|
@@ -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 []
|
|
@@ -407,7 +445,7 @@ class PriorityMapper(BaseMapper):
|
|
|
407
445
|
|
|
408
446
|
return priority_labels.get(priority, [])
|
|
409
447
|
|
|
410
|
-
def detect_priority_from_labels(self, labels:
|
|
448
|
+
def detect_priority_from_labels(self, labels: list[str]) -> Priority:
|
|
411
449
|
"""Detect priority from issue labels (GitHub-style).
|
|
412
450
|
|
|
413
451
|
Args:
|
|
@@ -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:
|
|
@@ -434,14 +478,12 @@ class PriorityMapper(BaseMapper):
|
|
|
434
478
|
class MapperRegistry:
|
|
435
479
|
"""Registry for managing mappers across different adapters."""
|
|
436
480
|
|
|
437
|
-
_state_mappers:
|
|
438
|
-
_priority_mappers:
|
|
481
|
+
_state_mappers: dict[str, StateMapper] = {}
|
|
482
|
+
_priority_mappers: dict[str, PriorityMapper] = {}
|
|
439
483
|
|
|
440
484
|
@classmethod
|
|
441
485
|
def get_state_mapper(
|
|
442
|
-
|
|
443
|
-
adapter_type: str,
|
|
444
|
-
custom_mappings: Optional[Dict[str, Any]] = None
|
|
486
|
+
cls, 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,17 +493,16 @@ 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
|
-
if cache_key not in
|
|
457
|
-
|
|
458
|
-
return
|
|
499
|
+
if cache_key not in cls._state_mappers:
|
|
500
|
+
cls._state_mappers[cache_key] = StateMapper(adapter_type, custom_mappings)
|
|
501
|
+
return cls._state_mappers[cache_key]
|
|
459
502
|
|
|
460
503
|
@classmethod
|
|
461
504
|
def get_priority_mapper(
|
|
462
|
-
|
|
463
|
-
adapter_type: str,
|
|
464
|
-
custom_mappings: Optional[Dict[str, Any]] = None
|
|
505
|
+
cls, 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,11 +512,14 @@ 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
|
-
if cache_key not in
|
|
477
|
-
|
|
478
|
-
|
|
518
|
+
if cache_key not in cls._priority_mappers:
|
|
519
|
+
cls._priority_mappers[cache_key] = PriorityMapper(
|
|
520
|
+
adapter_type, custom_mappings
|
|
521
|
+
)
|
|
522
|
+
return cls._priority_mappers[cache_key]
|
|
479
523
|
|
|
480
524
|
@classmethod
|
|
481
525
|
def clear_cache(cls) -> None:
|
|
@@ -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, 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"
|
|
@@ -34,7 +38,7 @@ class TicketState(str, Enum):
|
|
|
34
38
|
CLOSED = "closed"
|
|
35
39
|
|
|
36
40
|
@classmethod
|
|
37
|
-
def valid_transitions(cls) ->
|
|
41
|
+
def valid_transitions(cls) -> dict[str, list[str]]:
|
|
38
42
|
"""Define valid state transitions."""
|
|
39
43
|
return {
|
|
40
44
|
cls.OPEN: [cls.IN_PROGRESS, cls.WAITING, cls.BLOCKED, cls.CLOSED],
|
|
@@ -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")
|
|
@@ -61,30 +66,32 @@ class BaseTicket(BaseModel):
|
|
|
61
66
|
description: Optional[str] = Field(None, description="Detailed description")
|
|
62
67
|
state: TicketState = Field(TicketState.OPEN, description="Current state")
|
|
63
68
|
priority: Priority = Field(Priority.MEDIUM, description="Priority level")
|
|
64
|
-
tags:
|
|
69
|
+
tags: list[str] = Field(default_factory=list, description="Tags/labels")
|
|
65
70
|
created_at: Optional[datetime] = Field(None, description="Creation timestamp")
|
|
66
71
|
updated_at: Optional[datetime] = Field(None, description="Last update timestamp")
|
|
67
72
|
|
|
68
73
|
# Metadata for field mapping to different systems
|
|
69
|
-
metadata:
|
|
70
|
-
default_factory=dict,
|
|
71
|
-
description="System-specific metadata and field mappings"
|
|
74
|
+
metadata: dict[str, Any] = Field(
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
+
|
|
82
|
+
ticket_type: TicketType = Field(
|
|
83
|
+
default=TicketType.EPIC, frozen=True, description="Always EPIC type"
|
|
84
|
+
)
|
|
85
|
+
child_issues: list[str] = Field(
|
|
86
|
+
default_factory=list, description="IDs of child issues"
|
|
81
87
|
)
|
|
82
88
|
|
|
83
|
-
def validate_hierarchy(self) ->
|
|
89
|
+
def validate_hierarchy(self) -> list[str]:
|
|
84
90
|
"""Validate epic hierarchy rules.
|
|
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,11 +99,14 @@ 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")
|
|
99
|
-
children:
|
|
109
|
+
children: list[str] = Field(default_factory=list, description="Child task IDs")
|
|
100
110
|
|
|
101
111
|
# Additional fields common across systems
|
|
102
112
|
estimated_hours: Optional[float] = Field(None, description="Time estimate")
|
|
@@ -114,11 +124,12 @@ class Task(BaseTicket):
|
|
|
114
124
|
"""Check if this is a sub-task."""
|
|
115
125
|
return self.ticket_type in (TicketType.TASK, TicketType.SUBTASK)
|
|
116
126
|
|
|
117
|
-
def validate_hierarchy(self) ->
|
|
127
|
+
def validate_hierarchy(self) -> list[str]:
|
|
118
128
|
"""Validate ticket hierarchy rules.
|
|
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")
|
|
@@ -146,18 +160,18 @@ class Comment(BaseModel):
|
|
|
146
160
|
author: Optional[str] = Field(None, description="Comment author")
|
|
147
161
|
content: str = Field(..., min_length=1, description="Comment text")
|
|
148
162
|
created_at: Optional[datetime] = Field(None, description="Creation timestamp")
|
|
149
|
-
metadata:
|
|
150
|
-
default_factory=dict,
|
|
151
|
-
description="System-specific metadata"
|
|
163
|
+
metadata: dict[str, Any] = Field(
|
|
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
|
-
tags: Optional[
|
|
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")
|