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,27 @@
1
+ """MCP Ticketer - Universal ticket management interface."""
2
+
3
+ from .__version__ import (
4
+ __version__,
5
+ __version_info__,
6
+ __title__,
7
+ __description__,
8
+ __author__,
9
+ __author_email__,
10
+ __license__,
11
+ __copyright__,
12
+ get_version,
13
+ get_user_agent,
14
+ )
15
+
16
+ __all__ = [
17
+ "__version__",
18
+ "__version_info__",
19
+ "__title__",
20
+ "__description__",
21
+ "__author__",
22
+ "__author_email__",
23
+ "__license__",
24
+ "__copyright__",
25
+ "get_version",
26
+ "get_user_agent",
27
+ ]
@@ -0,0 +1,40 @@
1
+ """Version information for mcp-ticketer package."""
2
+
3
+ __version__ = "0.1.1"
4
+ __version_info__ = tuple(int(part) for part in __version__.split("."))
5
+
6
+ # Package metadata
7
+ __title__ = "mcp-ticketer"
8
+ __description__ = "Universal ticket management interface for AI agents"
9
+ __author__ = "MCP Ticketer Team"
10
+ __author_email__ = "support@mcp-ticketer.io"
11
+ __license__ = "MIT"
12
+ __copyright__ = "2025 MCP Ticketer Team"
13
+
14
+ # Build metadata
15
+ __build__ = None # Will be set during CI/CD builds
16
+ __commit__ = None # Will be set during CI/CD builds
17
+ __build_date__ = None # Will be set during CI/CD builds
18
+
19
+ # Feature flags
20
+ __features__ = {
21
+ "jira": True,
22
+ "linear": True,
23
+ "github": True,
24
+ "mcp_server": True,
25
+ "cli": True,
26
+ "queue_system": True,
27
+ }
28
+
29
+ def get_version():
30
+ """Return the full version string with build metadata if available."""
31
+ version = __version__
32
+ if __build__:
33
+ version += f"+{__build__}"
34
+ if __commit__:
35
+ version += f".{__commit__[:7]}"
36
+ return version
37
+
38
+ def get_user_agent():
39
+ """Return a user agent string for API requests."""
40
+ return f"{__title__}/{__version__}"
@@ -0,0 +1,8 @@
1
+ """Adapter implementations for various ticket systems."""
2
+
3
+ from .aitrackdown import AITrackdownAdapter
4
+ from .linear import LinearAdapter
5
+ from .jira import JiraAdapter
6
+ from .github import GitHubAdapter
7
+
8
+ __all__ = ["AITrackdownAdapter", "LinearAdapter", "JiraAdapter", "GitHubAdapter"]
@@ -0,0 +1,396 @@
1
+ """AI-Trackdown adapter implementation."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import List, Optional, Dict, Any, Union
6
+ from datetime import datetime
7
+
8
+ from ..core.adapter import BaseAdapter
9
+ from ..core.models import Epic, Task, Comment, SearchQuery, TicketState, Priority
10
+ from ..core.registry import AdapterRegistry
11
+
12
+ # Import ai-trackdown-pytools when available
13
+ try:
14
+ from ai_trackdown_pytools import AITrackdown, Ticket as AITicket
15
+ HAS_AITRACKDOWN = True
16
+ except ImportError:
17
+ HAS_AITRACKDOWN = False
18
+ AITrackdown = None
19
+ AITicket = None
20
+
21
+
22
+ class AITrackdownAdapter(BaseAdapter[Task]):
23
+ """Adapter for AI-Trackdown ticket system."""
24
+
25
+ def __init__(self, config: Dict[str, Any]):
26
+ """Initialize AI-Trackdown adapter.
27
+
28
+ Args:
29
+ config: Configuration with 'base_path' for tickets directory
30
+ """
31
+ super().__init__(config)
32
+ self.base_path = Path(config.get("base_path", ".aitrackdown"))
33
+ self.tickets_dir = self.base_path / "tickets"
34
+
35
+ # Initialize AI-Trackdown if available
36
+ if HAS_AITRACKDOWN:
37
+ self.tracker = AITrackdown(str(self.base_path))
38
+ else:
39
+ # Fallback to direct file operations
40
+ self.tracker = None
41
+ self.tickets_dir.mkdir(parents=True, exist_ok=True)
42
+
43
+ def _get_state_mapping(self) -> Dict[TicketState, str]:
44
+ """Map universal states to AI-Trackdown states."""
45
+ return {
46
+ TicketState.OPEN: "open",
47
+ TicketState.IN_PROGRESS: "in-progress",
48
+ TicketState.READY: "ready",
49
+ TicketState.TESTED: "tested",
50
+ TicketState.DONE: "done",
51
+ TicketState.WAITING: "waiting",
52
+ TicketState.BLOCKED: "blocked",
53
+ TicketState.CLOSED: "closed",
54
+ }
55
+
56
+ def _priority_to_ai(self, priority: Union[Priority, str]) -> str:
57
+ """Convert universal priority to AI-Trackdown priority."""
58
+ if isinstance(priority, Priority):
59
+ return priority.value
60
+ return priority # Already a string due to use_enum_values=True
61
+
62
+ def _priority_from_ai(self, ai_priority: str) -> Priority:
63
+ """Convert AI-Trackdown priority to universal priority."""
64
+ try:
65
+ return Priority(ai_priority.lower())
66
+ except ValueError:
67
+ return Priority.MEDIUM
68
+
69
+ def _task_from_ai_ticket(self, ai_ticket: Dict[str, Any]) -> Task:
70
+ """Convert AI-Trackdown ticket to universal Task."""
71
+ return Task(
72
+ id=ai_ticket.get("id"),
73
+ title=ai_ticket.get("title", ""),
74
+ description=ai_ticket.get("description"),
75
+ state=self.map_state_from_system(ai_ticket.get("status", "open")),
76
+ priority=self._priority_from_ai(ai_ticket.get("priority", "medium")),
77
+ tags=ai_ticket.get("tags", []),
78
+ parent_issue=ai_ticket.get("parent_issue"),
79
+ parent_epic=ai_ticket.get("parent_epic"),
80
+ assignee=ai_ticket.get("assignee"),
81
+ created_at=datetime.fromisoformat(ai_ticket["created_at"])
82
+ if "created_at" in ai_ticket else None,
83
+ updated_at=datetime.fromisoformat(ai_ticket["updated_at"])
84
+ if "updated_at" in ai_ticket else None,
85
+ metadata={"ai_trackdown": ai_ticket},
86
+ )
87
+
88
+ def _epic_from_ai_ticket(self, ai_ticket: Dict[str, Any]) -> Epic:
89
+ """Convert AI-Trackdown ticket to universal Epic."""
90
+ return Epic(
91
+ id=ai_ticket.get("id"),
92
+ title=ai_ticket.get("title", ""),
93
+ description=ai_ticket.get("description"),
94
+ state=self.map_state_from_system(ai_ticket.get("status", "open")),
95
+ priority=self._priority_from_ai(ai_ticket.get("priority", "medium")),
96
+ tags=ai_ticket.get("tags", []),
97
+ child_issues=ai_ticket.get("child_issues", []),
98
+ created_at=datetime.fromisoformat(ai_ticket["created_at"])
99
+ if "created_at" in ai_ticket and ai_ticket["created_at"] else None,
100
+ updated_at=datetime.fromisoformat(ai_ticket["updated_at"])
101
+ if "updated_at" in ai_ticket and ai_ticket["updated_at"] else None,
102
+ metadata={"ai_trackdown": ai_ticket},
103
+ )
104
+
105
+ def _task_to_ai_ticket(self, task: Task) -> Dict[str, Any]:
106
+ """Convert universal Task to AI-Trackdown ticket."""
107
+ # Handle enum values that may be stored as strings due to use_enum_values=True
108
+ state_value = task.state
109
+ if isinstance(task.state, TicketState):
110
+ state_value = self._get_state_mapping()[task.state]
111
+ elif isinstance(task.state, str):
112
+ # Already a string, map to AI-Trackdown format if needed
113
+ state_value = task.state.replace('_', '-') # Convert snake_case to kebab-case
114
+
115
+ return {
116
+ "id": task.id,
117
+ "title": task.title,
118
+ "description": task.description,
119
+ "status": state_value,
120
+ "priority": self._priority_to_ai(task.priority),
121
+ "tags": task.tags,
122
+ "parent_issue": task.parent_issue,
123
+ "parent_epic": task.parent_epic,
124
+ "assignee": task.assignee,
125
+ "created_at": task.created_at.isoformat() if task.created_at else None,
126
+ "updated_at": task.updated_at.isoformat() if task.updated_at else None,
127
+ "type": "task",
128
+ }
129
+
130
+ def _epic_to_ai_ticket(self, epic: Epic) -> Dict[str, Any]:
131
+ """Convert universal Epic to AI-Trackdown ticket."""
132
+ # Handle enum values that may be stored as strings due to use_enum_values=True
133
+ state_value = epic.state
134
+ if isinstance(epic.state, TicketState):
135
+ state_value = self._get_state_mapping()[epic.state]
136
+ elif isinstance(epic.state, str):
137
+ # Already a string, map to AI-Trackdown format if needed
138
+ state_value = epic.state.replace('_', '-') # Convert snake_case to kebab-case
139
+
140
+ return {
141
+ "id": epic.id,
142
+ "title": epic.title,
143
+ "description": epic.description,
144
+ "status": state_value,
145
+ "priority": self._priority_to_ai(epic.priority),
146
+ "tags": epic.tags,
147
+ "child_issues": epic.child_issues,
148
+ "created_at": epic.created_at.isoformat() if epic.created_at else None,
149
+ "updated_at": epic.updated_at.isoformat() if epic.updated_at else None,
150
+ "type": "epic",
151
+ }
152
+
153
+ def _read_ticket_file(self, ticket_id: str) -> Optional[Dict[str, Any]]:
154
+ """Read ticket from file system."""
155
+ ticket_file = self.tickets_dir / f"{ticket_id}.json"
156
+ if ticket_file.exists():
157
+ with open(ticket_file, "r") as f:
158
+ return json.load(f)
159
+ return None
160
+
161
+ def _write_ticket_file(self, ticket_id: str, data: Dict[str, Any]) -> None:
162
+ """Write ticket to file system."""
163
+ ticket_file = self.tickets_dir / f"{ticket_id}.json"
164
+ with open(ticket_file, "w") as f:
165
+ json.dump(data, f, indent=2, default=str)
166
+
167
+ async def create(self, ticket: Union[Task, Epic]) -> Union[Task, Epic]:
168
+ """Create a new task."""
169
+ # Generate ID if not provided
170
+ if not ticket.id:
171
+ timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
172
+ prefix = "epic" if isinstance(ticket, Epic) else "task"
173
+ ticket.id = f"{prefix}-{timestamp}"
174
+
175
+ # Set timestamps
176
+ now = datetime.now()
177
+ ticket.created_at = now
178
+ ticket.updated_at = now
179
+
180
+ # Convert to AI-Trackdown format
181
+ if isinstance(ticket, Epic):
182
+ ai_ticket = self._epic_to_ai_ticket(ticket)
183
+ else:
184
+ ai_ticket = self._task_to_ai_ticket(ticket)
185
+
186
+ if self.tracker:
187
+ # Use AI-Trackdown library
188
+ created = self.tracker.create_ticket(
189
+ title=ticket.title,
190
+ description=ticket.description,
191
+ priority=ai_ticket["priority"],
192
+ tags=ticket.tags,
193
+ ticket_type="task",
194
+ )
195
+ ticket.id = created.id
196
+ else:
197
+ # Direct file operation
198
+ self._write_ticket_file(ticket.id, ai_ticket)
199
+
200
+ return ticket
201
+
202
+ async def read(self, ticket_id: str) -> Optional[Union[Task, Epic]]:
203
+ """Read a task by ID."""
204
+ if self.tracker:
205
+ ai_ticket = self.tracker.get_ticket(ticket_id)
206
+ if ai_ticket:
207
+ return self._task_from_ai_ticket(ai_ticket.__dict__)
208
+ else:
209
+ ai_ticket = self._read_ticket_file(ticket_id)
210
+ if ai_ticket:
211
+ if ai_ticket.get("type") == "epic":
212
+ return self._epic_from_ai_ticket(ai_ticket)
213
+ else:
214
+ return self._task_from_ai_ticket(ai_ticket)
215
+ return None
216
+
217
+ async def update(
218
+ self,
219
+ ticket_id: str,
220
+ updates: Union[Dict[str, Any], Task]
221
+ ) -> Optional[Task]:
222
+ """Update a task."""
223
+ # Read existing ticket
224
+ existing = await self.read(ticket_id)
225
+ if not existing:
226
+ return None
227
+
228
+ # Apply updates
229
+ if isinstance(updates, Task):
230
+ # If updates is a Task object, copy all fields except frozen ones
231
+ for field in updates.__fields__:
232
+ if field not in ['ticket_type'] and hasattr(updates, field) and getattr(updates, field) is not None:
233
+ setattr(existing, field, getattr(updates, field))
234
+ else:
235
+ # If updates is a dictionary
236
+ for key, value in updates.items():
237
+ if hasattr(existing, key):
238
+ setattr(existing, key, value)
239
+
240
+ existing.updated_at = datetime.now()
241
+
242
+ # Write back
243
+ ai_ticket = self._task_to_ai_ticket(existing)
244
+ if self.tracker:
245
+ self.tracker.update_ticket(ticket_id, **updates)
246
+ else:
247
+ self._write_ticket_file(ticket_id, ai_ticket)
248
+
249
+ return existing
250
+
251
+ async def delete(self, ticket_id: str) -> bool:
252
+ """Delete a task."""
253
+ if self.tracker:
254
+ return self.tracker.delete_ticket(ticket_id)
255
+ else:
256
+ ticket_file = self.tickets_dir / f"{ticket_id}.json"
257
+ if ticket_file.exists():
258
+ ticket_file.unlink()
259
+ return True
260
+ return False
261
+
262
+ async def list(
263
+ self,
264
+ limit: int = 10,
265
+ offset: int = 0,
266
+ filters: Optional[Dict[str, Any]] = None
267
+ ) -> List[Task]:
268
+ """List tasks with pagination."""
269
+ tasks = []
270
+
271
+ if self.tracker:
272
+ # Use AI-Trackdown library
273
+ tickets = self.tracker.list_tickets(
274
+ status=filters.get("state") if filters else None,
275
+ limit=limit,
276
+ offset=offset,
277
+ )
278
+ tasks = [self._task_from_ai_ticket(t.__dict__) for t in tickets]
279
+ else:
280
+ # Direct file operation
281
+ ticket_files = sorted(self.tickets_dir.glob("*.json"))
282
+ for ticket_file in ticket_files[offset:offset + limit]:
283
+ with open(ticket_file, "r") as f:
284
+ ai_ticket = json.load(f)
285
+ task = self._task_from_ai_ticket(ai_ticket)
286
+
287
+ # Apply filters
288
+ if filters:
289
+ if "state" in filters:
290
+ filter_state = filters["state"]
291
+ # Handle state comparison - task.state might be string, filter_state might be enum
292
+ if isinstance(filter_state, TicketState):
293
+ filter_state = filter_state.value
294
+ if task.state != filter_state:
295
+ continue
296
+ if "priority" in filters:
297
+ filter_priority = filters["priority"]
298
+ # Handle priority comparison
299
+ if isinstance(filter_priority, Priority):
300
+ filter_priority = filter_priority.value
301
+ if task.priority != filter_priority:
302
+ continue
303
+
304
+ tasks.append(task)
305
+
306
+ return tasks[:limit]
307
+
308
+ async def search(self, query: SearchQuery) -> List[Task]:
309
+ """Search tasks using query parameters."""
310
+ filters = {}
311
+ if query.state:
312
+ filters["state"] = query.state
313
+ if query.priority:
314
+ filters["priority"] = query.priority
315
+
316
+ # Get all matching tasks
317
+ all_tasks = await self.list(limit=100, filters=filters)
318
+
319
+ # Additional filtering
320
+ results = []
321
+ for task in all_tasks:
322
+ # Text search in title and description
323
+ if query.query:
324
+ search_text = query.query.lower()
325
+ if (search_text not in (task.title or "").lower() and
326
+ search_text not in (task.description or "").lower()):
327
+ continue
328
+
329
+ # Tag filtering
330
+ if query.tags:
331
+ if not any(tag in task.tags for tag in query.tags):
332
+ continue
333
+
334
+ # Assignee filtering
335
+ if query.assignee and task.assignee != query.assignee:
336
+ continue
337
+
338
+ results.append(task)
339
+
340
+ # Apply pagination
341
+ return results[query.offset:query.offset + query.limit]
342
+
343
+ async def transition_state(
344
+ self,
345
+ ticket_id: str,
346
+ target_state: TicketState
347
+ ) -> Optional[Task]:
348
+ """Transition task to new state."""
349
+ # Validate transition
350
+ if not await self.validate_transition(ticket_id, target_state):
351
+ return None
352
+
353
+ # Update state
354
+ return await self.update(ticket_id, {"state": target_state})
355
+
356
+ async def add_comment(self, comment: Comment) -> Comment:
357
+ """Add comment to a task."""
358
+ # Generate ID
359
+ if not comment.id:
360
+ timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
361
+ comment.id = f"comment-{timestamp}"
362
+
363
+ comment.created_at = datetime.now()
364
+
365
+ # Store comment (simplified - in real implementation would be linked to ticket)
366
+ comment_file = self.base_path / "comments" / f"{comment.id}.json"
367
+ comment_file.parent.mkdir(parents=True, exist_ok=True)
368
+
369
+ with open(comment_file, "w") as f:
370
+ json.dump(comment.model_dump(), f, indent=2, default=str)
371
+
372
+ return comment
373
+
374
+ async def get_comments(
375
+ self,
376
+ ticket_id: str,
377
+ limit: int = 10,
378
+ offset: int = 0
379
+ ) -> List[Comment]:
380
+ """Get comments for a task."""
381
+ comments = []
382
+ comments_dir = self.base_path / "comments"
383
+
384
+ if comments_dir.exists():
385
+ comment_files = sorted(comments_dir.glob("*.json"))
386
+ for comment_file in comment_files[offset:offset + limit]:
387
+ with open(comment_file, "r") as f:
388
+ data = json.load(f)
389
+ if data.get("ticket_id") == ticket_id:
390
+ comments.append(Comment(**data))
391
+
392
+ return comments[:limit]
393
+
394
+
395
+ # Register the adapter
396
+ AdapterRegistry.register("aitrackdown", AITrackdownAdapter)