mcp-ticketer 0.1.11__py3-none-any.whl → 0.1.13__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of mcp-ticketer might be problematic. Click here for more details.

@@ -1,6 +1,6 @@
1
1
  """Version information for mcp-ticketer package."""
2
2
 
3
- __version__ = "0.1.11"
3
+ __version__ = "0.1.13"
4
4
  __version_info__ = tuple(int(part) for part in __version__.split("."))
5
5
 
6
6
  # Package metadata
@@ -4,5 +4,12 @@ from .aitrackdown import AITrackdownAdapter
4
4
  from .linear import LinearAdapter
5
5
  from .jira import JiraAdapter
6
6
  from .github import GitHubAdapter
7
+ from .hybrid import HybridAdapter
7
8
 
8
- __all__ = ["AITrackdownAdapter", "LinearAdapter", "JiraAdapter", "GitHubAdapter"]
9
+ __all__ = [
10
+ "AITrackdownAdapter",
11
+ "LinearAdapter",
12
+ "JiraAdapter",
13
+ "GitHubAdapter",
14
+ "HybridAdapter"
15
+ ]
@@ -168,7 +168,8 @@ class AITrackdownAdapter(BaseAdapter[Task]):
168
168
  """Create a new task."""
169
169
  # Generate ID if not provided
170
170
  if not ticket.id:
171
- timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
171
+ # Use microseconds to ensure uniqueness
172
+ timestamp = datetime.now().strftime("%Y%m%d%H%M%S%f")
172
173
  prefix = "epic" if isinstance(ticket, Epic) else "task"
173
174
  ticket.id = f"{prefix}-{timestamp}"
174
175
 
@@ -277,9 +278,9 @@ class AITrackdownAdapter(BaseAdapter[Task]):
277
278
  )
278
279
  tasks = [self._task_from_ai_ticket(t.__dict__) for t in tickets]
279
280
  else:
280
- # Direct file operation
281
+ # Direct file operation - read all files, filter, then paginate
281
282
  ticket_files = sorted(self.tickets_dir.glob("*.json"))
282
- for ticket_file in ticket_files[offset:offset + limit]:
283
+ for ticket_file in ticket_files:
283
284
  with open(ticket_file, "r") as f:
284
285
  ai_ticket = json.load(f)
285
286
  task = self._task_from_ai_ticket(ai_ticket)
@@ -303,7 +304,10 @@ class AITrackdownAdapter(BaseAdapter[Task]):
303
304
 
304
305
  tasks.append(task)
305
306
 
306
- return tasks[:limit]
307
+ # Apply pagination after filtering
308
+ tasks = tasks[offset:offset + limit]
309
+
310
+ return tasks
307
311
 
308
312
  async def search(self, query: SearchQuery) -> List[Task]:
309
313
  """Search tasks using query parameters."""
@@ -357,7 +361,7 @@ class AITrackdownAdapter(BaseAdapter[Task]):
357
361
  """Add comment to a task."""
358
362
  # Generate ID
359
363
  if not comment.id:
360
- timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
364
+ timestamp = datetime.now().strftime("%Y%m%d%H%M%S%f")
361
365
  comment.id = f"comment-{timestamp}"
362
366
 
363
367
  comment.created_at = datetime.now()
@@ -0,0 +1,505 @@
1
+ """Hybrid adapter for multi-platform ticket synchronization.
2
+
3
+ This adapter enables synchronization across multiple ticketing systems
4
+ (Linear, JIRA, GitHub, AITrackdown) with configurable sync strategies.
5
+ """
6
+
7
+ import asyncio
8
+ import logging
9
+ from typing import List, Optional, Dict, Any
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+ import json
13
+
14
+ from ..core.adapter import BaseAdapter
15
+ from ..core.models import Task, Epic, Comment, SearchQuery, TicketState, Priority
16
+ from ..core.registry import AdapterRegistry
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class HybridAdapter(BaseAdapter):
22
+ """Adapter that syncs tickets across multiple platforms.
23
+
24
+ Supports multiple synchronization strategies:
25
+ - PRIMARY_SOURCE: One adapter is source of truth, others are mirrors
26
+ - BIDIRECTIONAL: Two-way sync between adapters
27
+ - MIRROR: Clone tickets across all adapters
28
+
29
+ Maintains mapping between ticket IDs across different systems.
30
+ """
31
+
32
+ def __init__(self, config: Dict[str, Any]):
33
+ """Initialize hybrid adapter.
34
+
35
+ Args:
36
+ config: Hybrid configuration including:
37
+ - adapters: List of adapter configs
38
+ - primary_adapter: Name of primary adapter
39
+ - sync_strategy: Sync strategy (primary_source, bidirectional, mirror)
40
+ - mapping_file: Path to ID mapping file (optional)
41
+ """
42
+ super().__init__(config)
43
+
44
+ self.adapters: Dict[str, BaseAdapter] = {}
45
+ self.primary_adapter_name = config.get("primary_adapter")
46
+ self.sync_strategy = config.get("sync_strategy", "primary_source")
47
+
48
+ # Initialize all adapters
49
+ adapter_configs = config.get("adapter_configs", {})
50
+ for name, adapter_config in adapter_configs.items():
51
+ try:
52
+ adapter_type = adapter_config.get("adapter")
53
+ self.adapters[name] = AdapterRegistry.get_adapter(adapter_type, adapter_config)
54
+ logger.info(f"Initialized adapter: {name} ({adapter_type})")
55
+ except Exception as e:
56
+ logger.error(f"Failed to initialize adapter {name}: {e}")
57
+
58
+ if not self.adapters:
59
+ raise ValueError("No adapters successfully initialized")
60
+
61
+ if self.primary_adapter_name not in self.adapters:
62
+ raise ValueError(f"Primary adapter {self.primary_adapter_name} not found in adapters")
63
+
64
+ # Load or initialize ID mapping
65
+ self.mapping_file = Path(config.get("mapping_file", ".mcp-ticketer/hybrid_mapping.json"))
66
+ self.id_mapping = self._load_mapping()
67
+
68
+ def _get_state_mapping(self) -> Dict[TicketState, str]:
69
+ """Get state mapping from primary adapter."""
70
+ primary = self.adapters[self.primary_adapter_name]
71
+ return primary._get_state_mapping()
72
+
73
+ def _load_mapping(self) -> Dict[str, Dict[str, str]]:
74
+ """Load ID mapping from file.
75
+
76
+ Mapping format:
77
+ {
78
+ "ticket_uuid": {
79
+ "linear": "LIN-123",
80
+ "github": "456",
81
+ "jira": "PROJ-789"
82
+ }
83
+ }
84
+
85
+ Returns:
86
+ Dictionary mapping universal ticket IDs to adapter-specific IDs
87
+ """
88
+ if self.mapping_file.exists():
89
+ try:
90
+ with open(self.mapping_file, 'r') as f:
91
+ return json.load(f)
92
+ except Exception as e:
93
+ logger.error(f"Failed to load mapping file: {e}")
94
+
95
+ return {}
96
+
97
+ def _save_mapping(self) -> None:
98
+ """Save ID mapping to file."""
99
+ try:
100
+ self.mapping_file.parent.mkdir(parents=True, exist_ok=True)
101
+ with open(self.mapping_file, 'w') as f:
102
+ json.dump(self.id_mapping, f, indent=2)
103
+ except Exception as e:
104
+ logger.error(f"Failed to save mapping file: {e}")
105
+
106
+ def _store_ticket_mapping(self, universal_id: str, adapter_name: str, adapter_ticket_id: str) -> None:
107
+ """Store mapping between universal ID and adapter-specific ID.
108
+
109
+ Args:
110
+ universal_id: Universal ticket identifier
111
+ adapter_name: Name of adapter
112
+ adapter_ticket_id: Adapter-specific ticket ID
113
+ """
114
+ if universal_id not in self.id_mapping:
115
+ self.id_mapping[universal_id] = {}
116
+
117
+ self.id_mapping[universal_id][adapter_name] = adapter_ticket_id
118
+ self._save_mapping()
119
+
120
+ def _get_adapter_ticket_id(self, universal_id: str, adapter_name: str) -> Optional[str]:
121
+ """Get adapter-specific ticket ID from universal ID.
122
+
123
+ Args:
124
+ universal_id: Universal ticket identifier
125
+ adapter_name: Name of adapter
126
+
127
+ Returns:
128
+ Adapter-specific ticket ID or None
129
+ """
130
+ return self.id_mapping.get(universal_id, {}).get(adapter_name)
131
+
132
+ def _generate_universal_id(self) -> str:
133
+ """Generate a universal ticket ID.
134
+
135
+ Returns:
136
+ UUID-like universal ticket identifier
137
+ """
138
+ import uuid
139
+ return f"hybrid-{uuid.uuid4().hex[:12]}"
140
+
141
+ async def create(self, ticket: Task | Epic) -> Task | Epic:
142
+ """Create ticket in all configured adapters.
143
+
144
+ Args:
145
+ ticket: Ticket to create
146
+
147
+ Returns:
148
+ Created ticket with universal ID
149
+ """
150
+ universal_id = self._generate_universal_id()
151
+ results = []
152
+
153
+ # Create in primary adapter first
154
+ primary = self.adapters[self.primary_adapter_name]
155
+ try:
156
+ primary_ticket = await primary.create(ticket)
157
+ self._store_ticket_mapping(universal_id, self.primary_adapter_name, primary_ticket.id)
158
+ results.append((self.primary_adapter_name, primary_ticket))
159
+ logger.info(f"Created ticket in primary adapter {self.primary_adapter_name}: {primary_ticket.id}")
160
+ except Exception as e:
161
+ logger.error(f"Failed to create ticket in primary adapter {self.primary_adapter_name}: {e}")
162
+ raise
163
+
164
+ # Create in secondary adapters
165
+ for name, adapter in self.adapters.items():
166
+ if name == self.primary_adapter_name:
167
+ continue
168
+
169
+ try:
170
+ # Clone ticket for this adapter
171
+ adapter_ticket = await adapter.create(ticket)
172
+ self._store_ticket_mapping(universal_id, name, adapter_ticket.id)
173
+ results.append((name, adapter_ticket))
174
+ logger.info(f"Created ticket in adapter {name}: {adapter_ticket.id}")
175
+ except Exception as e:
176
+ logger.error(f"Failed to create ticket in adapter {name}: {e}")
177
+ # Continue with other adapters even if one fails
178
+
179
+ # Return primary ticket with cross-references in description
180
+ primary_ticket = results[0][1]
181
+ self._add_cross_references(primary_ticket, results)
182
+
183
+ # Set universal ID in ticket
184
+ primary_ticket.id = universal_id
185
+
186
+ return primary_ticket
187
+
188
+ def _add_cross_references(self, ticket: Task | Epic, results: List[tuple[str, Task | Epic]]) -> None:
189
+ """Add cross-references to ticket description.
190
+
191
+ Args:
192
+ ticket: Ticket to update
193
+ results: List of (adapter_name, ticket) tuples
194
+ """
195
+ cross_refs = "\n\n---\n**Cross-Platform References:**\n"
196
+ for adapter_name, adapter_ticket in results:
197
+ cross_refs += f"- {adapter_name}: {adapter_ticket.id}\n"
198
+
199
+ if ticket.description:
200
+ ticket.description += cross_refs
201
+ else:
202
+ ticket.description = cross_refs.strip()
203
+
204
+ async def read(self, ticket_id: str) -> Optional[Task | Epic]:
205
+ """Read ticket from primary adapter.
206
+
207
+ Args:
208
+ ticket_id: Universal or adapter-specific ticket ID
209
+
210
+ Returns:
211
+ Ticket if found, None otherwise
212
+ """
213
+ # Check if this is a universal ID
214
+ if ticket_id.startswith("hybrid-"):
215
+ # Get primary adapter ticket ID
216
+ primary_id = self._get_adapter_ticket_id(ticket_id, self.primary_adapter_name)
217
+ if not primary_id:
218
+ logger.warning(f"No primary ticket ID found for universal ID: {ticket_id}")
219
+ return None
220
+ ticket_id = primary_id
221
+
222
+ # Read from primary adapter
223
+ primary = self.adapters[self.primary_adapter_name]
224
+ return await primary.read(ticket_id)
225
+
226
+ async def update(self, ticket_id: str, updates: Dict[str, Any]) -> Optional[Task | Epic]:
227
+ """Update ticket across all adapters.
228
+
229
+ Args:
230
+ ticket_id: Universal or adapter-specific ticket ID
231
+ updates: Fields to update
232
+
233
+ Returns:
234
+ Updated ticket from primary adapter
235
+ """
236
+ universal_id = ticket_id
237
+ if not ticket_id.startswith("hybrid-"):
238
+ # Try to find universal ID by searching mapping
239
+ universal_id = self._find_universal_id(ticket_id)
240
+ if not universal_id:
241
+ logger.warning(f"No universal ID found for ticket: {ticket_id}")
242
+ # Fall back to primary adapter only
243
+ primary = self.adapters[self.primary_adapter_name]
244
+ return await primary.update(ticket_id, updates)
245
+
246
+ # Update in all adapters
247
+ results = []
248
+ for adapter_name, adapter in self.adapters.items():
249
+ adapter_ticket_id = self._get_adapter_ticket_id(universal_id, adapter_name)
250
+ if not adapter_ticket_id:
251
+ logger.warning(f"No ticket ID for adapter {adapter_name}")
252
+ continue
253
+
254
+ try:
255
+ updated_ticket = await adapter.update(adapter_ticket_id, updates)
256
+ results.append((adapter_name, updated_ticket))
257
+ logger.info(f"Updated ticket in adapter {adapter_name}: {adapter_ticket_id}")
258
+ except Exception as e:
259
+ logger.error(f"Failed to update ticket in adapter {adapter_name}: {e}")
260
+
261
+ # Return result from primary adapter
262
+ for adapter_name, ticket in results:
263
+ if adapter_name == self.primary_adapter_name:
264
+ return ticket
265
+
266
+ return None
267
+
268
+ def _find_universal_id(self, adapter_ticket_id: str) -> Optional[str]:
269
+ """Find universal ID for an adapter-specific ticket ID.
270
+
271
+ Args:
272
+ adapter_ticket_id: Adapter-specific ticket ID
273
+
274
+ Returns:
275
+ Universal ID if found, None otherwise
276
+ """
277
+ for universal_id, mapping in self.id_mapping.items():
278
+ if adapter_ticket_id in mapping.values():
279
+ return universal_id
280
+ return None
281
+
282
+ async def delete(self, ticket_id: str) -> bool:
283
+ """Delete ticket from all adapters.
284
+
285
+ Args:
286
+ ticket_id: Universal or adapter-specific ticket ID
287
+
288
+ Returns:
289
+ True if deleted from at least one adapter
290
+ """
291
+ universal_id = ticket_id
292
+ if not ticket_id.startswith("hybrid-"):
293
+ universal_id = self._find_universal_id(ticket_id)
294
+ if not universal_id:
295
+ # Fall back to primary adapter
296
+ primary = self.adapters[self.primary_adapter_name]
297
+ return await primary.delete(ticket_id)
298
+
299
+ # Delete from all adapters
300
+ success_count = 0
301
+ for adapter_name, adapter in self.adapters.items():
302
+ adapter_ticket_id = self._get_adapter_ticket_id(universal_id, adapter_name)
303
+ if not adapter_ticket_id:
304
+ continue
305
+
306
+ try:
307
+ if await adapter.delete(adapter_ticket_id):
308
+ success_count += 1
309
+ logger.info(f"Deleted ticket from adapter {adapter_name}: {adapter_ticket_id}")
310
+ except Exception as e:
311
+ logger.error(f"Failed to delete ticket from adapter {adapter_name}: {e}")
312
+
313
+ # Remove from mapping
314
+ if universal_id in self.id_mapping:
315
+ del self.id_mapping[universal_id]
316
+ self._save_mapping()
317
+
318
+ return success_count > 0
319
+
320
+ async def list(
321
+ self,
322
+ limit: int = 10,
323
+ offset: int = 0,
324
+ filters: Optional[Dict[str, Any]] = None
325
+ ) -> List[Task | Epic]:
326
+ """List tickets from primary adapter.
327
+
328
+ Args:
329
+ limit: Maximum number of tickets
330
+ offset: Skip this many tickets
331
+ filters: Optional filter criteria
332
+
333
+ Returns:
334
+ List of tickets from primary adapter
335
+ """
336
+ primary = self.adapters[self.primary_adapter_name]
337
+ return await primary.list(limit, offset, filters)
338
+
339
+ async def search(self, query: SearchQuery) -> List[Task | Epic]:
340
+ """Search tickets in primary adapter.
341
+
342
+ Args:
343
+ query: Search parameters
344
+
345
+ Returns:
346
+ List of tickets matching search criteria
347
+ """
348
+ primary = self.adapters[self.primary_adapter_name]
349
+ return await primary.search(query)
350
+
351
+ async def transition_state(
352
+ self,
353
+ ticket_id: str,
354
+ target_state: TicketState
355
+ ) -> Optional[Task | Epic]:
356
+ """Transition ticket state across all adapters.
357
+
358
+ Args:
359
+ ticket_id: Universal or adapter-specific ticket ID
360
+ target_state: Target state
361
+
362
+ Returns:
363
+ Updated ticket from primary adapter
364
+ """
365
+ universal_id = ticket_id
366
+ if not ticket_id.startswith("hybrid-"):
367
+ universal_id = self._find_universal_id(ticket_id)
368
+ if not universal_id:
369
+ # Fall back to primary adapter
370
+ primary = self.adapters[self.primary_adapter_name]
371
+ return await primary.transition_state(ticket_id, target_state)
372
+
373
+ # Transition in all adapters
374
+ results = []
375
+ for adapter_name, adapter in self.adapters.items():
376
+ adapter_ticket_id = self._get_adapter_ticket_id(universal_id, adapter_name)
377
+ if not adapter_ticket_id:
378
+ continue
379
+
380
+ try:
381
+ updated_ticket = await adapter.transition_state(adapter_ticket_id, target_state)
382
+ results.append((adapter_name, updated_ticket))
383
+ logger.info(f"Transitioned ticket in adapter {adapter_name}: {adapter_ticket_id}")
384
+ except Exception as e:
385
+ logger.error(f"Failed to transition ticket in adapter {adapter_name}: {e}")
386
+
387
+ # Return result from primary adapter
388
+ for adapter_name, ticket in results:
389
+ if adapter_name == self.primary_adapter_name:
390
+ return ticket
391
+
392
+ return None
393
+
394
+ async def add_comment(self, comment: Comment) -> Comment:
395
+ """Add comment to ticket in all adapters.
396
+
397
+ Args:
398
+ comment: Comment to add
399
+
400
+ Returns:
401
+ Created comment from primary adapter
402
+ """
403
+ universal_id = comment.ticket_id
404
+ if not comment.ticket_id.startswith("hybrid-"):
405
+ universal_id = self._find_universal_id(comment.ticket_id)
406
+ if not universal_id:
407
+ # Fall back to primary adapter
408
+ primary = self.adapters[self.primary_adapter_name]
409
+ return await primary.add_comment(comment)
410
+
411
+ # Add comment to all adapters
412
+ results = []
413
+ for adapter_name, adapter in self.adapters.items():
414
+ adapter_ticket_id = self._get_adapter_ticket_id(universal_id, adapter_name)
415
+ if not adapter_ticket_id:
416
+ continue
417
+
418
+ try:
419
+ # Clone comment with adapter-specific ticket ID
420
+ adapter_comment = Comment(
421
+ ticket_id=adapter_ticket_id,
422
+ content=comment.content,
423
+ author=comment.author
424
+ )
425
+ created_comment = await adapter.add_comment(adapter_comment)
426
+ results.append((adapter_name, created_comment))
427
+ logger.info(f"Added comment to adapter {adapter_name}: {adapter_ticket_id}")
428
+ except Exception as e:
429
+ logger.error(f"Failed to add comment to adapter {adapter_name}: {e}")
430
+
431
+ # Return result from primary adapter
432
+ for adapter_name, created_comment in results:
433
+ if adapter_name == self.primary_adapter_name:
434
+ return created_comment
435
+
436
+ # If no primary comment, return first successful one
437
+ if results:
438
+ return results[0][1]
439
+
440
+ raise RuntimeError("Failed to add comment to any adapter")
441
+
442
+ async def get_comments(
443
+ self,
444
+ ticket_id: str,
445
+ limit: int = 10,
446
+ offset: int = 0
447
+ ) -> List[Comment]:
448
+ """Get comments from primary adapter.
449
+
450
+ Args:
451
+ ticket_id: Universal or adapter-specific ticket ID
452
+ limit: Maximum number of comments
453
+ offset: Skip this many comments
454
+
455
+ Returns:
456
+ List of comments from primary adapter
457
+ """
458
+ if ticket_id.startswith("hybrid-"):
459
+ # Get primary adapter ticket ID
460
+ primary_id = self._get_adapter_ticket_id(ticket_id, self.primary_adapter_name)
461
+ if not primary_id:
462
+ return []
463
+ ticket_id = primary_id
464
+
465
+ primary = self.adapters[self.primary_adapter_name]
466
+ return await primary.get_comments(ticket_id, limit, offset)
467
+
468
+ async def close(self) -> None:
469
+ """Close all adapters and cleanup resources."""
470
+ for adapter in self.adapters.values():
471
+ try:
472
+ await adapter.close()
473
+ except Exception as e:
474
+ logger.error(f"Error closing adapter: {e}")
475
+
476
+ async def sync_status(self) -> Dict[str, Any]:
477
+ """Get synchronization status across all adapters.
478
+
479
+ Returns:
480
+ Dictionary with sync status information
481
+ """
482
+ status = {
483
+ "primary_adapter": self.primary_adapter_name,
484
+ "sync_strategy": self.sync_strategy,
485
+ "total_mapped_tickets": len(self.id_mapping),
486
+ "adapters": {}
487
+ }
488
+
489
+ for adapter_name, adapter in self.adapters.items():
490
+ try:
491
+ # Count tickets in this adapter
492
+ tickets = await adapter.list(limit=1000)
493
+ ticket_count = len(tickets)
494
+
495
+ status["adapters"][adapter_name] = {
496
+ "ticket_count": ticket_count,
497
+ "status": "connected"
498
+ }
499
+ except Exception as e:
500
+ status["adapters"][adapter_name] = {
501
+ "status": "error",
502
+ "error": str(e)
503
+ }
504
+
505
+ return status