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
mcp_ticketer/__init__.py
ADDED
|
@@ -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)
|