mcp-ticketer 0.3.5__py3-none-any.whl → 0.12.0__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/__version__.py +3 -3
- mcp_ticketer/adapters/__init__.py +2 -0
- mcp_ticketer/adapters/aitrackdown.py +263 -14
- mcp_ticketer/adapters/asana/__init__.py +15 -0
- mcp_ticketer/adapters/asana/adapter.py +1308 -0
- mcp_ticketer/adapters/asana/client.py +292 -0
- mcp_ticketer/adapters/asana/mappers.py +334 -0
- mcp_ticketer/adapters/asana/types.py +146 -0
- mcp_ticketer/adapters/github.py +326 -109
- mcp_ticketer/adapters/hybrid.py +11 -11
- mcp_ticketer/adapters/jira.py +271 -25
- mcp_ticketer/adapters/linear/adapter.py +693 -39
- mcp_ticketer/adapters/linear/client.py +61 -9
- mcp_ticketer/adapters/linear/mappers.py +9 -3
- mcp_ticketer/adapters/linear/queries.py +9 -7
- mcp_ticketer/cache/memory.py +9 -8
- mcp_ticketer/cli/adapter_diagnostics.py +1 -1
- mcp_ticketer/cli/auggie_configure.py +104 -15
- mcp_ticketer/cli/codex_configure.py +188 -32
- mcp_ticketer/cli/configure.py +37 -48
- mcp_ticketer/cli/diagnostics.py +20 -18
- mcp_ticketer/cli/discover.py +292 -26
- mcp_ticketer/cli/gemini_configure.py +107 -26
- mcp_ticketer/cli/instruction_commands.py +429 -0
- mcp_ticketer/cli/linear_commands.py +105 -22
- mcp_ticketer/cli/main.py +1830 -435
- mcp_ticketer/cli/mcp_configure.py +296 -89
- mcp_ticketer/cli/migrate_config.py +12 -8
- mcp_ticketer/cli/platform_commands.py +123 -0
- mcp_ticketer/cli/platform_detection.py +412 -0
- mcp_ticketer/cli/python_detection.py +126 -0
- mcp_ticketer/cli/queue_commands.py +15 -15
- mcp_ticketer/cli/simple_health.py +1 -1
- mcp_ticketer/cli/ticket_commands.py +773 -0
- mcp_ticketer/cli/update_checker.py +313 -0
- mcp_ticketer/cli/utils.py +67 -62
- mcp_ticketer/core/__init__.py +14 -1
- mcp_ticketer/core/adapter.py +84 -15
- mcp_ticketer/core/config.py +44 -39
- mcp_ticketer/core/env_discovery.py +42 -12
- mcp_ticketer/core/env_loader.py +15 -14
- mcp_ticketer/core/exceptions.py +3 -3
- mcp_ticketer/core/http_client.py +26 -26
- mcp_ticketer/core/instructions.py +405 -0
- mcp_ticketer/core/mappers.py +11 -11
- mcp_ticketer/core/models.py +50 -20
- mcp_ticketer/core/onepassword_secrets.py +379 -0
- mcp_ticketer/core/project_config.py +57 -35
- mcp_ticketer/core/registry.py +3 -3
- mcp_ticketer/defaults/ticket_instructions.md +644 -0
- mcp_ticketer/mcp/__init__.py +29 -1
- mcp_ticketer/mcp/__main__.py +60 -0
- mcp_ticketer/mcp/server/__init__.py +25 -0
- mcp_ticketer/mcp/server/__main__.py +60 -0
- mcp_ticketer/mcp/{dto.py → server/dto.py} +32 -32
- mcp_ticketer/mcp/{server.py → server/main.py} +127 -74
- mcp_ticketer/mcp/{response_builder.py → server/response_builder.py} +2 -2
- mcp_ticketer/mcp/server/server_sdk.py +93 -0
- mcp_ticketer/mcp/server/tools/__init__.py +47 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +226 -0
- mcp_ticketer/mcp/server/tools/bulk_tools.py +273 -0
- mcp_ticketer/mcp/server/tools/comment_tools.py +90 -0
- mcp_ticketer/mcp/server/tools/config_tools.py +381 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +532 -0
- mcp_ticketer/mcp/server/tools/instruction_tools.py +293 -0
- mcp_ticketer/mcp/server/tools/pr_tools.py +154 -0
- mcp_ticketer/mcp/server/tools/search_tools.py +206 -0
- mcp_ticketer/mcp/server/tools/ticket_tools.py +430 -0
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +382 -0
- mcp_ticketer/queue/__init__.py +1 -0
- mcp_ticketer/queue/health_monitor.py +5 -4
- mcp_ticketer/queue/manager.py +15 -51
- mcp_ticketer/queue/queue.py +19 -19
- mcp_ticketer/queue/run_worker.py +1 -1
- mcp_ticketer/queue/ticket_registry.py +14 -14
- mcp_ticketer/queue/worker.py +16 -14
- {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/METADATA +168 -32
- mcp_ticketer-0.12.0.dist-info/RECORD +91 -0
- mcp_ticketer-0.3.5.dist-info/RECORD +0 -62
- /mcp_ticketer/mcp/{constants.py → server/constants.py} +0 -0
- {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/licenses/LICENSE +0 -0
- {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
"""User-specific ticket management tools.
|
|
2
|
+
|
|
3
|
+
This module provides tools for managing tickets from a user's perspective,
|
|
4
|
+
including listing assigned tickets and transitioning tickets through workflow
|
|
5
|
+
states with validation.
|
|
6
|
+
|
|
7
|
+
Design Decision: Workflow State Validation
|
|
8
|
+
------------------------------------------
|
|
9
|
+
State transitions are validated using TicketState.can_transition_to() to ensure
|
|
10
|
+
tickets follow the defined workflow. This prevents invalid state changes that
|
|
11
|
+
could break integrations or confuse team members.
|
|
12
|
+
|
|
13
|
+
Valid workflow transitions:
|
|
14
|
+
- OPEN → IN_PROGRESS, WAITING, BLOCKED, CLOSED
|
|
15
|
+
- IN_PROGRESS → READY, WAITING, BLOCKED, OPEN
|
|
16
|
+
- READY → TESTED, IN_PROGRESS, BLOCKED
|
|
17
|
+
- TESTED → DONE, IN_PROGRESS
|
|
18
|
+
- DONE → CLOSED
|
|
19
|
+
- WAITING/BLOCKED → OPEN, IN_PROGRESS, CLOSED
|
|
20
|
+
- CLOSED → (no transitions, terminal state)
|
|
21
|
+
|
|
22
|
+
Performance Considerations:
|
|
23
|
+
- get_my_tickets uses adapter's native filtering when available
|
|
24
|
+
- Falls back to client-side filtering for adapters without assignee filter
|
|
25
|
+
- State transition validation is O(1) lookup in predefined state machine
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from ....core.models import TicketState
|
|
32
|
+
from ....core.project_config import ConfigResolver, TicketerConfig
|
|
33
|
+
from ..server_sdk import get_adapter, mcp
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_config_resolver() -> ConfigResolver:
|
|
37
|
+
"""Get configuration resolver for current project.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
ConfigResolver instance for current working directory
|
|
41
|
+
|
|
42
|
+
"""
|
|
43
|
+
return ConfigResolver(project_path=Path.cwd())
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@mcp.tool()
|
|
47
|
+
async def get_my_tickets(
|
|
48
|
+
state: str | None = None,
|
|
49
|
+
limit: int = 10,
|
|
50
|
+
) -> dict[str, Any]:
|
|
51
|
+
"""Get tickets assigned to the configured default user.
|
|
52
|
+
|
|
53
|
+
Retrieves tickets assigned to the user specified in default_user configuration.
|
|
54
|
+
Requires default_user to be set via config_set_default_user().
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
state: Optional state filter - must be one of: open, in_progress, ready,
|
|
58
|
+
tested, done, closed, waiting, blocked
|
|
59
|
+
limit: Maximum number of tickets to return (default: 10, max: 100)
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Dictionary containing:
|
|
63
|
+
- status: "completed" or "error"
|
|
64
|
+
- tickets: List of ticket objects assigned to user
|
|
65
|
+
- count: Number of tickets returned
|
|
66
|
+
- user: User ID that was queried
|
|
67
|
+
- state_filter: State filter applied (if any)
|
|
68
|
+
- error: Error details (if failed)
|
|
69
|
+
|
|
70
|
+
Example:
|
|
71
|
+
>>> result = await get_my_tickets(state="in_progress", limit=5)
|
|
72
|
+
>>> print(result)
|
|
73
|
+
{
|
|
74
|
+
"status": "completed",
|
|
75
|
+
"tickets": [
|
|
76
|
+
{"id": "TICKET-1", "title": "Fix bug", "state": "in_progress"},
|
|
77
|
+
{"id": "TICKET-2", "title": "Add feature", "state": "in_progress"}
|
|
78
|
+
],
|
|
79
|
+
"count": 2,
|
|
80
|
+
"user": "user@example.com",
|
|
81
|
+
"state_filter": "in_progress"
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
Error Conditions:
|
|
85
|
+
- No default user configured: Returns error with setup instructions
|
|
86
|
+
- Invalid state: Returns error with valid state options
|
|
87
|
+
- Adapter query failure: Returns error with details
|
|
88
|
+
|
|
89
|
+
Usage Notes:
|
|
90
|
+
- Requires default_user to be set in configuration
|
|
91
|
+
- Use config_set_default_user() to configure the user first
|
|
92
|
+
- Limit is capped at 100 to prevent performance issues
|
|
93
|
+
|
|
94
|
+
"""
|
|
95
|
+
try:
|
|
96
|
+
# Validate limit
|
|
97
|
+
if limit > 100:
|
|
98
|
+
limit = 100
|
|
99
|
+
|
|
100
|
+
# Load configuration to get default user
|
|
101
|
+
resolver = get_config_resolver()
|
|
102
|
+
config = resolver.load_project_config() or TicketerConfig()
|
|
103
|
+
|
|
104
|
+
if not config.default_user:
|
|
105
|
+
return {
|
|
106
|
+
"status": "error",
|
|
107
|
+
"error": "No default user configured. Use config_set_default_user() to set a default user first.",
|
|
108
|
+
"setup_command": "config_set_default_user",
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
# Validate state if provided
|
|
112
|
+
state_filter = None
|
|
113
|
+
if state is not None:
|
|
114
|
+
try:
|
|
115
|
+
state_filter = TicketState(state.lower())
|
|
116
|
+
except ValueError:
|
|
117
|
+
valid_states = [s.value for s in TicketState]
|
|
118
|
+
return {
|
|
119
|
+
"status": "error",
|
|
120
|
+
"error": f"Invalid state '{state}'. Must be one of: {', '.join(valid_states)}",
|
|
121
|
+
"valid_states": valid_states,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
# Build filters
|
|
125
|
+
filters: dict[str, Any] = {"assignee": config.default_user}
|
|
126
|
+
if state_filter:
|
|
127
|
+
filters["state"] = state_filter
|
|
128
|
+
|
|
129
|
+
# Query adapter
|
|
130
|
+
adapter = get_adapter()
|
|
131
|
+
tickets = await adapter.list(limit=limit, offset=0, filters=filters)
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
"status": "completed",
|
|
135
|
+
"tickets": [ticket.model_dump() for ticket in tickets],
|
|
136
|
+
"count": len(tickets),
|
|
137
|
+
"user": config.default_user,
|
|
138
|
+
"state_filter": state if state else "all",
|
|
139
|
+
"limit": limit,
|
|
140
|
+
}
|
|
141
|
+
except Exception as e:
|
|
142
|
+
return {
|
|
143
|
+
"status": "error",
|
|
144
|
+
"error": f"Failed to retrieve tickets: {str(e)}",
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@mcp.tool()
|
|
149
|
+
async def get_available_transitions(ticket_id: str) -> dict[str, Any]:
|
|
150
|
+
"""Get valid next states for a ticket based on workflow rules.
|
|
151
|
+
|
|
152
|
+
Retrieves the ticket's current state and returns all valid target states
|
|
153
|
+
according to the defined workflow state machine. This helps AI agents and
|
|
154
|
+
users understand which state transitions are allowed.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
ticket_id: Unique identifier of the ticket
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Dictionary containing:
|
|
161
|
+
- status: "completed" or "error"
|
|
162
|
+
- ticket_id: ID of the queried ticket
|
|
163
|
+
- current_state: Current workflow state
|
|
164
|
+
- available_transitions: List of valid target states
|
|
165
|
+
- transition_descriptions: Human-readable descriptions of each transition
|
|
166
|
+
- error: Error details (if failed)
|
|
167
|
+
|
|
168
|
+
Example:
|
|
169
|
+
>>> result = await get_available_transitions("TICKET-123")
|
|
170
|
+
>>> print(result)
|
|
171
|
+
{
|
|
172
|
+
"status": "completed",
|
|
173
|
+
"ticket_id": "TICKET-123",
|
|
174
|
+
"current_state": "in_progress",
|
|
175
|
+
"available_transitions": ["ready", "waiting", "blocked", "open"],
|
|
176
|
+
"transition_descriptions": {
|
|
177
|
+
"ready": "Mark work as complete and ready for review",
|
|
178
|
+
"waiting": "Pause work while waiting for external dependency",
|
|
179
|
+
"blocked": "Work is blocked by an impediment",
|
|
180
|
+
"open": "Move back to backlog"
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
Error Conditions:
|
|
185
|
+
- Ticket not found: Returns error with ticket ID
|
|
186
|
+
- Adapter query failure: Returns error with details
|
|
187
|
+
- Terminal state (CLOSED): Returns empty transitions list
|
|
188
|
+
|
|
189
|
+
Usage Notes:
|
|
190
|
+
- CLOSED is a terminal state with no valid transitions
|
|
191
|
+
- Use this before ticket_transition() to validate intended state change
|
|
192
|
+
- Transition validation prevents workflow violations
|
|
193
|
+
|
|
194
|
+
"""
|
|
195
|
+
try:
|
|
196
|
+
# Get ticket from adapter
|
|
197
|
+
adapter = get_adapter()
|
|
198
|
+
ticket = await adapter.read(ticket_id)
|
|
199
|
+
|
|
200
|
+
if ticket is None:
|
|
201
|
+
return {
|
|
202
|
+
"status": "error",
|
|
203
|
+
"error": f"Ticket {ticket_id} not found",
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
# Get current state
|
|
207
|
+
current_state = ticket.state
|
|
208
|
+
|
|
209
|
+
# Get valid transitions from state machine
|
|
210
|
+
valid_transitions = TicketState.valid_transitions()
|
|
211
|
+
# Handle both TicketState enum and string values
|
|
212
|
+
if isinstance(current_state, str):
|
|
213
|
+
current_state = TicketState(current_state)
|
|
214
|
+
available = valid_transitions.get(current_state, [])
|
|
215
|
+
|
|
216
|
+
# Create human-readable descriptions
|
|
217
|
+
descriptions = {
|
|
218
|
+
TicketState.OPEN: "Move to backlog (not yet started)",
|
|
219
|
+
TicketState.IN_PROGRESS: "Begin active work on ticket",
|
|
220
|
+
TicketState.READY: "Mark as complete and ready for review/testing",
|
|
221
|
+
TicketState.TESTED: "Mark as tested and verified",
|
|
222
|
+
TicketState.DONE: "Mark as complete and accepted",
|
|
223
|
+
TicketState.WAITING: "Pause work while waiting for external dependency",
|
|
224
|
+
TicketState.BLOCKED: "Work is blocked by an impediment",
|
|
225
|
+
TicketState.CLOSED: "Close and archive ticket (final state)",
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
transition_descriptions = {
|
|
229
|
+
state.value: descriptions.get(state, "") for state in available
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
"status": "completed",
|
|
234
|
+
"ticket_id": ticket_id,
|
|
235
|
+
"current_state": current_state.value,
|
|
236
|
+
"available_transitions": [state.value for state in available],
|
|
237
|
+
"transition_descriptions": transition_descriptions,
|
|
238
|
+
"is_terminal": len(available) == 0,
|
|
239
|
+
}
|
|
240
|
+
except Exception as e:
|
|
241
|
+
return {
|
|
242
|
+
"status": "error",
|
|
243
|
+
"error": f"Failed to get available transitions: {str(e)}",
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@mcp.tool()
|
|
248
|
+
async def ticket_transition(
|
|
249
|
+
ticket_id: str,
|
|
250
|
+
to_state: str,
|
|
251
|
+
comment: str | None = None,
|
|
252
|
+
) -> dict[str, Any]:
|
|
253
|
+
"""Move ticket through workflow with validation and optional comment.
|
|
254
|
+
|
|
255
|
+
Transitions a ticket to a new state, validating the transition against the
|
|
256
|
+
defined workflow rules. Optionally adds a comment explaining the transition.
|
|
257
|
+
|
|
258
|
+
Workflow State Machine:
|
|
259
|
+
OPEN → IN_PROGRESS, WAITING, BLOCKED, CLOSED
|
|
260
|
+
IN_PROGRESS → READY, WAITING, BLOCKED, OPEN
|
|
261
|
+
READY → TESTED, IN_PROGRESS, BLOCKED
|
|
262
|
+
TESTED → DONE, IN_PROGRESS
|
|
263
|
+
DONE → CLOSED
|
|
264
|
+
WAITING → OPEN, IN_PROGRESS, CLOSED
|
|
265
|
+
BLOCKED → OPEN, IN_PROGRESS, CLOSED
|
|
266
|
+
CLOSED → (no transitions)
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
ticket_id: Unique identifier of the ticket to transition
|
|
270
|
+
to_state: Target state - must be valid for current state
|
|
271
|
+
comment: Optional comment explaining the transition reason
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
Dictionary containing:
|
|
275
|
+
- status: "completed" or "error"
|
|
276
|
+
- ticket: Updated ticket object with new state
|
|
277
|
+
- previous_state: State before transition
|
|
278
|
+
- new_state: State after transition
|
|
279
|
+
- comment_added: Whether a comment was added (if applicable)
|
|
280
|
+
- error: Error details (if failed)
|
|
281
|
+
|
|
282
|
+
Example:
|
|
283
|
+
>>> result = await ticket_transition(
|
|
284
|
+
... "TICKET-123",
|
|
285
|
+
... "ready",
|
|
286
|
+
... "Work complete, ready for code review"
|
|
287
|
+
... )
|
|
288
|
+
>>> print(result)
|
|
289
|
+
{
|
|
290
|
+
"status": "completed",
|
|
291
|
+
"ticket": {"id": "TICKET-123", "state": "ready", ...},
|
|
292
|
+
"previous_state": "in_progress",
|
|
293
|
+
"new_state": "ready",
|
|
294
|
+
"comment_added": True
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
Error Conditions:
|
|
298
|
+
- Ticket not found: Returns error with ticket ID
|
|
299
|
+
- Invalid transition: Returns error with valid options
|
|
300
|
+
- Invalid state name: Returns error with valid states
|
|
301
|
+
- Adapter update failure: Returns error with details
|
|
302
|
+
|
|
303
|
+
Usage Notes:
|
|
304
|
+
- Use get_available_transitions() first to see valid options
|
|
305
|
+
- Comments are adapter-dependent (some may not support them)
|
|
306
|
+
- Validation prevents workflow violations
|
|
307
|
+
- Terminal state (CLOSED) has no valid transitions
|
|
308
|
+
|
|
309
|
+
"""
|
|
310
|
+
try:
|
|
311
|
+
# Get ticket from adapter
|
|
312
|
+
adapter = get_adapter()
|
|
313
|
+
ticket = await adapter.read(ticket_id)
|
|
314
|
+
|
|
315
|
+
if ticket is None:
|
|
316
|
+
return {
|
|
317
|
+
"status": "error",
|
|
318
|
+
"error": f"Ticket {ticket_id} not found",
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
# Validate target state
|
|
322
|
+
try:
|
|
323
|
+
target_state = TicketState(to_state.lower())
|
|
324
|
+
except ValueError:
|
|
325
|
+
valid_states = [s.value for s in TicketState]
|
|
326
|
+
return {
|
|
327
|
+
"status": "error",
|
|
328
|
+
"error": f"Invalid state '{to_state}'. Must be one of: {', '.join(valid_states)}",
|
|
329
|
+
"valid_states": valid_states,
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
# Store current state for response
|
|
333
|
+
current_state = ticket.state
|
|
334
|
+
# Handle both TicketState enum and string values
|
|
335
|
+
if isinstance(current_state, str):
|
|
336
|
+
current_state = TicketState(current_state)
|
|
337
|
+
|
|
338
|
+
# Validate transition
|
|
339
|
+
if not current_state.can_transition_to(target_state):
|
|
340
|
+
valid_transitions = TicketState.valid_transitions().get(current_state, [])
|
|
341
|
+
valid_values = [s.value for s in valid_transitions]
|
|
342
|
+
return {
|
|
343
|
+
"status": "error",
|
|
344
|
+
"error": f"Invalid transition from '{current_state.value}' to '{target_state.value}'",
|
|
345
|
+
"current_state": current_state.value,
|
|
346
|
+
"valid_transitions": valid_values,
|
|
347
|
+
"message": f"Cannot transition from {current_state.value} to {target_state.value}. "
|
|
348
|
+
f"Valid transitions: {', '.join(valid_values) if valid_values else 'none (terminal state)'}",
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
# Update ticket state
|
|
352
|
+
updated = await adapter.update(ticket_id, {"state": target_state})
|
|
353
|
+
|
|
354
|
+
if updated is None:
|
|
355
|
+
return {
|
|
356
|
+
"status": "error",
|
|
357
|
+
"error": f"Failed to update ticket {ticket_id}",
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
# Add comment if provided and adapter supports it
|
|
361
|
+
comment_added = False
|
|
362
|
+
if comment and hasattr(adapter, "add_comment"):
|
|
363
|
+
try:
|
|
364
|
+
await adapter.add_comment(ticket_id, comment)
|
|
365
|
+
comment_added = True
|
|
366
|
+
except Exception:
|
|
367
|
+
# Log but don't fail the transition
|
|
368
|
+
comment_added = False
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
"status": "completed",
|
|
372
|
+
"ticket": updated.model_dump(),
|
|
373
|
+
"previous_state": current_state.value,
|
|
374
|
+
"new_state": target_state.value,
|
|
375
|
+
"comment_added": comment_added,
|
|
376
|
+
"message": f"Ticket {ticket_id} transitioned from {current_state.value} to {target_state.value}",
|
|
377
|
+
}
|
|
378
|
+
except Exception as e:
|
|
379
|
+
return {
|
|
380
|
+
"status": "error",
|
|
381
|
+
"error": f"Failed to transition ticket: {str(e)}",
|
|
382
|
+
}
|
mcp_ticketer/queue/__init__.py
CHANGED
|
@@ -4,7 +4,7 @@ import logging
|
|
|
4
4
|
import time
|
|
5
5
|
from datetime import datetime, timedelta
|
|
6
6
|
from enum import Enum
|
|
7
|
-
from typing import Any
|
|
7
|
+
from typing import Any
|
|
8
8
|
|
|
9
9
|
import psutil
|
|
10
10
|
|
|
@@ -30,8 +30,8 @@ class HealthAlert:
|
|
|
30
30
|
self,
|
|
31
31
|
level: HealthStatus,
|
|
32
32
|
message: str,
|
|
33
|
-
details:
|
|
34
|
-
timestamp:
|
|
33
|
+
details: dict[str, Any] | None = None,
|
|
34
|
+
timestamp: datetime | None = None,
|
|
35
35
|
):
|
|
36
36
|
self.level = level
|
|
37
37
|
self.message = message
|
|
@@ -39,6 +39,7 @@ class HealthAlert:
|
|
|
39
39
|
self.timestamp = timestamp or datetime.now()
|
|
40
40
|
|
|
41
41
|
def __str__(self) -> str:
|
|
42
|
+
"""Return string representation of alert."""
|
|
42
43
|
return f"[{self.level.upper()}] {self.message}"
|
|
43
44
|
|
|
44
45
|
|
|
@@ -52,7 +53,7 @@ class QueueHealthMonitor:
|
|
|
52
53
|
QUEUE_BACKLOG_WARNING = 10 # Warn if more than 10 pending items
|
|
53
54
|
QUEUE_BACKLOG_CRITICAL = 50 # Critical if more than 50 pending items
|
|
54
55
|
|
|
55
|
-
def __init__(self, queue:
|
|
56
|
+
def __init__(self, queue: Queue | None = None):
|
|
56
57
|
"""Initialize health monitor.
|
|
57
58
|
|
|
58
59
|
Args:
|
mcp_ticketer/queue/manager.py
CHANGED
|
@@ -4,14 +4,14 @@ import fcntl
|
|
|
4
4
|
import logging
|
|
5
5
|
import os
|
|
6
6
|
import subprocess
|
|
7
|
-
import sys
|
|
8
7
|
import time
|
|
9
8
|
from pathlib import Path
|
|
10
|
-
from typing import
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
11
10
|
|
|
12
11
|
import psutil
|
|
13
12
|
|
|
14
|
-
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
pass
|
|
15
15
|
|
|
16
16
|
logger = logging.getLogger(__name__)
|
|
17
17
|
|
|
@@ -19,8 +19,11 @@ logger = logging.getLogger(__name__)
|
|
|
19
19
|
class WorkerManager:
|
|
20
20
|
"""Manages worker process with file-based locking."""
|
|
21
21
|
|
|
22
|
-
def __init__(self):
|
|
22
|
+
def __init__(self) -> None:
|
|
23
23
|
"""Initialize worker manager."""
|
|
24
|
+
# Lazy import to avoid circular dependency
|
|
25
|
+
from .queue import Queue
|
|
26
|
+
|
|
24
27
|
self.lock_file = Path.home() / ".mcp-ticketer" / "worker.lock"
|
|
25
28
|
self.pid_file = Path.home() / ".mcp-ticketer" / "worker.pid"
|
|
26
29
|
self.lock_file.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -51,7 +54,7 @@ class WorkerManager:
|
|
|
51
54
|
# Lock already held
|
|
52
55
|
return False
|
|
53
56
|
|
|
54
|
-
def _release_lock(self):
|
|
57
|
+
def _release_lock(self) -> None:
|
|
55
58
|
"""Release worker lock."""
|
|
56
59
|
if hasattr(self, "lock_fd"):
|
|
57
60
|
fcntl.lockf(self.lock_fd, fcntl.LOCK_UN)
|
|
@@ -123,7 +126,10 @@ class WorkerManager:
|
|
|
123
126
|
try:
|
|
124
127
|
# Start worker in subprocess using the same Python executable as the CLI
|
|
125
128
|
# This ensures the worker can import mcp_ticketer modules
|
|
126
|
-
|
|
129
|
+
# Lazy import to avoid circular dependency
|
|
130
|
+
from ..cli.python_detection import get_mcp_ticketer_python
|
|
131
|
+
|
|
132
|
+
python_executable = get_mcp_ticketer_python()
|
|
127
133
|
cmd = [python_executable, "-m", "mcp_ticketer.queue.run_worker"]
|
|
128
134
|
|
|
129
135
|
# Prepare environment for subprocess
|
|
@@ -281,7 +287,7 @@ class WorkerManager:
|
|
|
281
287
|
is_running = self.is_running()
|
|
282
288
|
pid = self._get_pid() if is_running else None
|
|
283
289
|
|
|
284
|
-
status = {"running": is_running, "pid": pid}
|
|
290
|
+
status: dict[str, Any] = {"running": is_running, "pid": pid}
|
|
285
291
|
|
|
286
292
|
# Add process info if running
|
|
287
293
|
if is_running and pid:
|
|
@@ -304,7 +310,7 @@ class WorkerManager:
|
|
|
304
310
|
|
|
305
311
|
return status
|
|
306
312
|
|
|
307
|
-
def _get_pid(self) ->
|
|
313
|
+
def _get_pid(self) -> int | None:
|
|
308
314
|
"""Get worker PID from file.
|
|
309
315
|
|
|
310
316
|
Returns:
|
|
@@ -320,49 +326,7 @@ class WorkerManager:
|
|
|
320
326
|
except (OSError, ValueError):
|
|
321
327
|
return None
|
|
322
328
|
|
|
323
|
-
def
|
|
324
|
-
"""Get the correct Python executable for the worker subprocess.
|
|
325
|
-
|
|
326
|
-
This ensures the worker uses the same Python environment as the CLI,
|
|
327
|
-
which is critical for module imports to work correctly.
|
|
328
|
-
|
|
329
|
-
Returns:
|
|
330
|
-
Path to Python executable
|
|
331
|
-
|
|
332
|
-
"""
|
|
333
|
-
# First, try to detect if we're running in a pipx environment
|
|
334
|
-
# by checking if the current executable is in a pipx venv
|
|
335
|
-
current_executable = sys.executable
|
|
336
|
-
|
|
337
|
-
# Check if we're in a pipx venv (path contains /pipx/venvs/)
|
|
338
|
-
if "/pipx/venvs/" in current_executable:
|
|
339
|
-
logger.debug(f"Using pipx Python executable: {current_executable}")
|
|
340
|
-
return current_executable
|
|
341
|
-
|
|
342
|
-
# Check if we can find the mcp-ticketer executable and extract its Python
|
|
343
|
-
import shutil
|
|
344
|
-
|
|
345
|
-
mcp_ticketer_path = shutil.which("mcp-ticketer")
|
|
346
|
-
if mcp_ticketer_path:
|
|
347
|
-
try:
|
|
348
|
-
# Read the shebang line to get the Python executable
|
|
349
|
-
with open(mcp_ticketer_path) as f:
|
|
350
|
-
first_line = f.readline().strip()
|
|
351
|
-
if first_line.startswith("#!") and "python" in first_line:
|
|
352
|
-
python_path = first_line[2:].strip()
|
|
353
|
-
if os.path.exists(python_path):
|
|
354
|
-
logger.debug(
|
|
355
|
-
f"Using Python from mcp-ticketer shebang: {python_path}"
|
|
356
|
-
)
|
|
357
|
-
return python_path
|
|
358
|
-
except OSError:
|
|
359
|
-
pass
|
|
360
|
-
|
|
361
|
-
# Fallback to sys.executable
|
|
362
|
-
logger.debug(f"Using sys.executable as fallback: {current_executable}")
|
|
363
|
-
return current_executable
|
|
364
|
-
|
|
365
|
-
def _cleanup(self):
|
|
329
|
+
def _cleanup(self) -> None:
|
|
366
330
|
"""Clean up lock and PID files."""
|
|
367
331
|
self._release_lock()
|
|
368
332
|
if self.pid_file.exists():
|
mcp_ticketer/queue/queue.py
CHANGED
|
@@ -8,7 +8,7 @@ from dataclasses import asdict, dataclass
|
|
|
8
8
|
from datetime import datetime, timedelta
|
|
9
9
|
from enum import Enum
|
|
10
10
|
from pathlib import Path
|
|
11
|
-
from typing import Any
|
|
11
|
+
from typing import Any
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
class QueueStatus(str, Enum):
|
|
@@ -30,12 +30,12 @@ class QueueItem:
|
|
|
30
30
|
operation: str
|
|
31
31
|
status: QueueStatus
|
|
32
32
|
created_at: datetime
|
|
33
|
-
processed_at:
|
|
34
|
-
error_message:
|
|
33
|
+
processed_at: datetime | None = None
|
|
34
|
+
error_message: str | None = None
|
|
35
35
|
retry_count: int = 0
|
|
36
|
-
result:
|
|
37
|
-
project_dir:
|
|
38
|
-
adapter_config:
|
|
36
|
+
result: dict[str, Any] | None = None
|
|
37
|
+
project_dir: str | None = None
|
|
38
|
+
adapter_config: dict[str, Any] | None = None # Adapter configuration
|
|
39
39
|
|
|
40
40
|
def to_dict(self) -> dict:
|
|
41
41
|
"""Convert to dictionary for storage."""
|
|
@@ -67,7 +67,7 @@ class QueueItem:
|
|
|
67
67
|
class Queue:
|
|
68
68
|
"""Thread-safe SQLite queue for ticket operations."""
|
|
69
69
|
|
|
70
|
-
def __init__(self, db_path:
|
|
70
|
+
def __init__(self, db_path: Path | None = None):
|
|
71
71
|
"""Initialize queue with database connection.
|
|
72
72
|
|
|
73
73
|
Args:
|
|
@@ -83,7 +83,7 @@ class Queue:
|
|
|
83
83
|
self._lock = threading.Lock()
|
|
84
84
|
self._init_database()
|
|
85
85
|
|
|
86
|
-
def _init_database(self):
|
|
86
|
+
def _init_database(self) -> None:
|
|
87
87
|
"""Initialize database schema."""
|
|
88
88
|
with sqlite3.connect(self.db_path) as conn:
|
|
89
89
|
conn.execute(
|
|
@@ -139,8 +139,8 @@ class Queue:
|
|
|
139
139
|
ticket_data: dict[str, Any],
|
|
140
140
|
adapter: str,
|
|
141
141
|
operation: str,
|
|
142
|
-
project_dir:
|
|
143
|
-
adapter_config:
|
|
142
|
+
project_dir: str | None = None,
|
|
143
|
+
adapter_config: dict[str, Any] | None = None,
|
|
144
144
|
) -> str:
|
|
145
145
|
"""Add item to queue.
|
|
146
146
|
|
|
@@ -186,7 +186,7 @@ class Queue:
|
|
|
186
186
|
|
|
187
187
|
return queue_id
|
|
188
188
|
|
|
189
|
-
def get_next_pending(self) ->
|
|
189
|
+
def get_next_pending(self) -> QueueItem | None:
|
|
190
190
|
"""Get next pending item from queue atomically.
|
|
191
191
|
|
|
192
192
|
Returns:
|
|
@@ -251,9 +251,9 @@ class Queue:
|
|
|
251
251
|
self,
|
|
252
252
|
queue_id: str,
|
|
253
253
|
status: QueueStatus,
|
|
254
|
-
error_message:
|
|
255
|
-
result:
|
|
256
|
-
expected_status:
|
|
254
|
+
error_message: str | None = None,
|
|
255
|
+
result: dict[str, Any] | None = None,
|
|
256
|
+
expected_status: QueueStatus | None = None,
|
|
257
257
|
) -> bool:
|
|
258
258
|
"""Update queue item status atomically.
|
|
259
259
|
|
|
@@ -328,7 +328,7 @@ class Queue:
|
|
|
328
328
|
raise
|
|
329
329
|
|
|
330
330
|
def increment_retry(
|
|
331
|
-
self, queue_id: str, expected_status:
|
|
331
|
+
self, queue_id: str, expected_status: QueueStatus | None = None
|
|
332
332
|
) -> int:
|
|
333
333
|
"""Increment retry count and reset to pending atomically.
|
|
334
334
|
|
|
@@ -387,7 +387,7 @@ class Queue:
|
|
|
387
387
|
conn.rollback()
|
|
388
388
|
raise
|
|
389
389
|
|
|
390
|
-
def get_item(self, queue_id: str) ->
|
|
390
|
+
def get_item(self, queue_id: str) -> QueueItem | None:
|
|
391
391
|
"""Get specific queue item by ID.
|
|
392
392
|
|
|
393
393
|
Args:
|
|
@@ -409,7 +409,7 @@ class Queue:
|
|
|
409
409
|
return QueueItem.from_row(row) if row else None
|
|
410
410
|
|
|
411
411
|
def list_items(
|
|
412
|
-
self, status:
|
|
412
|
+
self, status: QueueStatus | None = None, limit: int = 50
|
|
413
413
|
) -> list[QueueItem]:
|
|
414
414
|
"""List queue items.
|
|
415
415
|
|
|
@@ -462,7 +462,7 @@ class Queue:
|
|
|
462
462
|
|
|
463
463
|
return cursor.fetchone()[0]
|
|
464
464
|
|
|
465
|
-
def cleanup_old(self, days: int = 7):
|
|
465
|
+
def cleanup_old(self, days: int = 7) -> None:
|
|
466
466
|
"""Clean up old completed/failed items.
|
|
467
467
|
|
|
468
468
|
Args:
|
|
@@ -487,7 +487,7 @@ class Queue:
|
|
|
487
487
|
)
|
|
488
488
|
conn.commit()
|
|
489
489
|
|
|
490
|
-
def reset_stuck_items(self, timeout_minutes: int = 30):
|
|
490
|
+
def reset_stuck_items(self, timeout_minutes: int = 30) -> None:
|
|
491
491
|
"""Reset items stuck in processing state.
|
|
492
492
|
|
|
493
493
|
Args:
|