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.

@@ -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")