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.

Files changed (84) hide show
  1. mcp_ticketer/__version__.py +3 -3
  2. mcp_ticketer/adapters/__init__.py +2 -0
  3. mcp_ticketer/adapters/aitrackdown.py +263 -14
  4. mcp_ticketer/adapters/asana/__init__.py +15 -0
  5. mcp_ticketer/adapters/asana/adapter.py +1308 -0
  6. mcp_ticketer/adapters/asana/client.py +292 -0
  7. mcp_ticketer/adapters/asana/mappers.py +334 -0
  8. mcp_ticketer/adapters/asana/types.py +146 -0
  9. mcp_ticketer/adapters/github.py +326 -109
  10. mcp_ticketer/adapters/hybrid.py +11 -11
  11. mcp_ticketer/adapters/jira.py +271 -25
  12. mcp_ticketer/adapters/linear/adapter.py +693 -39
  13. mcp_ticketer/adapters/linear/client.py +61 -9
  14. mcp_ticketer/adapters/linear/mappers.py +9 -3
  15. mcp_ticketer/adapters/linear/queries.py +9 -7
  16. mcp_ticketer/cache/memory.py +9 -8
  17. mcp_ticketer/cli/adapter_diagnostics.py +1 -1
  18. mcp_ticketer/cli/auggie_configure.py +104 -15
  19. mcp_ticketer/cli/codex_configure.py +188 -32
  20. mcp_ticketer/cli/configure.py +37 -48
  21. mcp_ticketer/cli/diagnostics.py +20 -18
  22. mcp_ticketer/cli/discover.py +292 -26
  23. mcp_ticketer/cli/gemini_configure.py +107 -26
  24. mcp_ticketer/cli/instruction_commands.py +429 -0
  25. mcp_ticketer/cli/linear_commands.py +105 -22
  26. mcp_ticketer/cli/main.py +1830 -435
  27. mcp_ticketer/cli/mcp_configure.py +296 -89
  28. mcp_ticketer/cli/migrate_config.py +12 -8
  29. mcp_ticketer/cli/platform_commands.py +123 -0
  30. mcp_ticketer/cli/platform_detection.py +412 -0
  31. mcp_ticketer/cli/python_detection.py +126 -0
  32. mcp_ticketer/cli/queue_commands.py +15 -15
  33. mcp_ticketer/cli/simple_health.py +1 -1
  34. mcp_ticketer/cli/ticket_commands.py +773 -0
  35. mcp_ticketer/cli/update_checker.py +313 -0
  36. mcp_ticketer/cli/utils.py +67 -62
  37. mcp_ticketer/core/__init__.py +14 -1
  38. mcp_ticketer/core/adapter.py +84 -15
  39. mcp_ticketer/core/config.py +44 -39
  40. mcp_ticketer/core/env_discovery.py +42 -12
  41. mcp_ticketer/core/env_loader.py +15 -14
  42. mcp_ticketer/core/exceptions.py +3 -3
  43. mcp_ticketer/core/http_client.py +26 -26
  44. mcp_ticketer/core/instructions.py +405 -0
  45. mcp_ticketer/core/mappers.py +11 -11
  46. mcp_ticketer/core/models.py +50 -20
  47. mcp_ticketer/core/onepassword_secrets.py +379 -0
  48. mcp_ticketer/core/project_config.py +57 -35
  49. mcp_ticketer/core/registry.py +3 -3
  50. mcp_ticketer/defaults/ticket_instructions.md +644 -0
  51. mcp_ticketer/mcp/__init__.py +29 -1
  52. mcp_ticketer/mcp/__main__.py +60 -0
  53. mcp_ticketer/mcp/server/__init__.py +25 -0
  54. mcp_ticketer/mcp/server/__main__.py +60 -0
  55. mcp_ticketer/mcp/{dto.py → server/dto.py} +32 -32
  56. mcp_ticketer/mcp/{server.py → server/main.py} +127 -74
  57. mcp_ticketer/mcp/{response_builder.py → server/response_builder.py} +2 -2
  58. mcp_ticketer/mcp/server/server_sdk.py +93 -0
  59. mcp_ticketer/mcp/server/tools/__init__.py +47 -0
  60. mcp_ticketer/mcp/server/tools/attachment_tools.py +226 -0
  61. mcp_ticketer/mcp/server/tools/bulk_tools.py +273 -0
  62. mcp_ticketer/mcp/server/tools/comment_tools.py +90 -0
  63. mcp_ticketer/mcp/server/tools/config_tools.py +381 -0
  64. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +532 -0
  65. mcp_ticketer/mcp/server/tools/instruction_tools.py +293 -0
  66. mcp_ticketer/mcp/server/tools/pr_tools.py +154 -0
  67. mcp_ticketer/mcp/server/tools/search_tools.py +206 -0
  68. mcp_ticketer/mcp/server/tools/ticket_tools.py +430 -0
  69. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +382 -0
  70. mcp_ticketer/queue/__init__.py +1 -0
  71. mcp_ticketer/queue/health_monitor.py +5 -4
  72. mcp_ticketer/queue/manager.py +15 -51
  73. mcp_ticketer/queue/queue.py +19 -19
  74. mcp_ticketer/queue/run_worker.py +1 -1
  75. mcp_ticketer/queue/ticket_registry.py +14 -14
  76. mcp_ticketer/queue/worker.py +16 -14
  77. {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/METADATA +168 -32
  78. mcp_ticketer-0.12.0.dist-info/RECORD +91 -0
  79. mcp_ticketer-0.3.5.dist-info/RECORD +0 -62
  80. /mcp_ticketer/mcp/{constants.py → server/constants.py} +0 -0
  81. {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/WHEEL +0 -0
  82. {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/entry_points.txt +0 -0
  83. {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/licenses/LICENSE +0 -0
  84. {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
+ }
@@ -1,5 +1,6 @@
1
1
  """Async queue system for mcp-ticketer."""
2
2
 
3
+ # Import manager last to avoid circular import
3
4
  from .manager import WorkerManager
4
5
  from .queue import Queue, QueueItem, QueueStatus
5
6
  from .worker import Worker
@@ -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, Optional
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: Optional[dict[str, Any]] = None,
34
- timestamp: Optional[datetime] = None,
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: Optional[Queue] = None):
56
+ def __init__(self, queue: Queue | None = None):
56
57
  """Initialize health monitor.
57
58
 
58
59
  Args:
@@ -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 Any, Optional
9
+ from typing import TYPE_CHECKING, Any
11
10
 
12
11
  import psutil
13
12
 
14
- from .queue import Queue
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
- python_executable = self._get_python_executable()
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) -> Optional[int]:
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 _get_python_executable(self) -> str:
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():
@@ -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, Optional
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: Optional[datetime] = None
34
- error_message: Optional[str] = None
33
+ processed_at: datetime | None = None
34
+ error_message: str | None = None
35
35
  retry_count: int = 0
36
- result: Optional[dict[str, Any]] = None
37
- project_dir: Optional[str] = None
38
- adapter_config: Optional[dict[str, Any]] = None # Adapter configuration
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: Optional[Path] = None):
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: Optional[str] = None,
143
- adapter_config: Optional[dict[str, Any]] = None,
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) -> Optional[QueueItem]:
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: Optional[str] = None,
255
- result: Optional[dict[str, Any]] = None,
256
- expected_status: Optional[QueueStatus] = None,
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: Optional[QueueStatus] = None
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) -> Optional[QueueItem]:
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: Optional[QueueStatus] = None, limit: int = 50
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:
@@ -13,7 +13,7 @@ logging.basicConfig(
13
13
  logger = logging.getLogger(__name__)
14
14
 
15
15
 
16
- def main():
16
+ def main() -> None:
17
17
  """Run the worker process."""
18
18
  import os
19
19