mcp-ticketer 0.2.0__py3-none-any.whl → 2.2.9__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.
- mcp_ticketer/__init__.py +10 -10
- mcp_ticketer/__version__.py +3 -3
- mcp_ticketer/_version_scm.py +1 -0
- mcp_ticketer/adapters/__init__.py +2 -0
- mcp_ticketer/adapters/aitrackdown.py +930 -52
- mcp_ticketer/adapters/asana/__init__.py +15 -0
- mcp_ticketer/adapters/asana/adapter.py +1537 -0
- mcp_ticketer/adapters/asana/client.py +292 -0
- mcp_ticketer/adapters/asana/mappers.py +348 -0
- mcp_ticketer/adapters/asana/types.py +146 -0
- mcp_ticketer/adapters/github/__init__.py +26 -0
- mcp_ticketer/adapters/github/adapter.py +3229 -0
- mcp_ticketer/adapters/github/client.py +335 -0
- mcp_ticketer/adapters/github/mappers.py +797 -0
- mcp_ticketer/adapters/github/queries.py +692 -0
- mcp_ticketer/adapters/github/types.py +460 -0
- mcp_ticketer/adapters/hybrid.py +58 -16
- mcp_ticketer/adapters/jira/__init__.py +35 -0
- mcp_ticketer/adapters/jira/adapter.py +1351 -0
- mcp_ticketer/adapters/jira/client.py +271 -0
- mcp_ticketer/adapters/jira/mappers.py +246 -0
- mcp_ticketer/adapters/jira/queries.py +216 -0
- mcp_ticketer/adapters/jira/types.py +304 -0
- mcp_ticketer/adapters/linear/__init__.py +1 -1
- mcp_ticketer/adapters/linear/adapter.py +3810 -462
- mcp_ticketer/adapters/linear/client.py +312 -69
- mcp_ticketer/adapters/linear/mappers.py +305 -85
- mcp_ticketer/adapters/linear/queries.py +317 -17
- mcp_ticketer/adapters/linear/types.py +187 -64
- mcp_ticketer/adapters/linear.py +2 -2
- mcp_ticketer/analysis/__init__.py +56 -0
- mcp_ticketer/analysis/dependency_graph.py +255 -0
- mcp_ticketer/analysis/health_assessment.py +304 -0
- mcp_ticketer/analysis/orphaned.py +218 -0
- mcp_ticketer/analysis/project_status.py +594 -0
- mcp_ticketer/analysis/similarity.py +224 -0
- mcp_ticketer/analysis/staleness.py +266 -0
- mcp_ticketer/automation/__init__.py +11 -0
- mcp_ticketer/automation/project_updates.py +378 -0
- mcp_ticketer/cache/memory.py +9 -8
- mcp_ticketer/cli/adapter_diagnostics.py +421 -0
- mcp_ticketer/cli/auggie_configure.py +116 -15
- mcp_ticketer/cli/codex_configure.py +274 -82
- mcp_ticketer/cli/configure.py +1323 -151
- mcp_ticketer/cli/cursor_configure.py +314 -0
- mcp_ticketer/cli/diagnostics.py +209 -114
- mcp_ticketer/cli/discover.py +297 -26
- mcp_ticketer/cli/gemini_configure.py +119 -26
- mcp_ticketer/cli/init_command.py +880 -0
- mcp_ticketer/cli/install_mcp_server.py +418 -0
- mcp_ticketer/cli/instruction_commands.py +435 -0
- mcp_ticketer/cli/linear_commands.py +256 -130
- mcp_ticketer/cli/main.py +140 -1284
- mcp_ticketer/cli/mcp_configure.py +1013 -100
- mcp_ticketer/cli/mcp_server_commands.py +415 -0
- mcp_ticketer/cli/migrate_config.py +12 -8
- mcp_ticketer/cli/platform_commands.py +123 -0
- mcp_ticketer/cli/platform_detection.py +477 -0
- mcp_ticketer/cli/platform_installer.py +545 -0
- mcp_ticketer/cli/project_update_commands.py +350 -0
- mcp_ticketer/cli/python_detection.py +126 -0
- mcp_ticketer/cli/queue_commands.py +15 -15
- mcp_ticketer/cli/setup_command.py +794 -0
- mcp_ticketer/cli/simple_health.py +84 -59
- mcp_ticketer/cli/ticket_commands.py +1375 -0
- mcp_ticketer/cli/update_checker.py +313 -0
- mcp_ticketer/cli/utils.py +195 -72
- mcp_ticketer/core/__init__.py +64 -1
- mcp_ticketer/core/adapter.py +618 -18
- mcp_ticketer/core/config.py +77 -68
- mcp_ticketer/core/env_discovery.py +75 -16
- mcp_ticketer/core/env_loader.py +121 -97
- mcp_ticketer/core/exceptions.py +32 -24
- mcp_ticketer/core/http_client.py +26 -26
- mcp_ticketer/core/instructions.py +405 -0
- mcp_ticketer/core/label_manager.py +732 -0
- mcp_ticketer/core/mappers.py +42 -30
- mcp_ticketer/core/milestone_manager.py +252 -0
- mcp_ticketer/core/models.py +566 -19
- mcp_ticketer/core/onepassword_secrets.py +379 -0
- mcp_ticketer/core/priority_matcher.py +463 -0
- mcp_ticketer/core/project_config.py +189 -49
- mcp_ticketer/core/project_utils.py +281 -0
- mcp_ticketer/core/project_validator.py +376 -0
- mcp_ticketer/core/registry.py +3 -3
- mcp_ticketer/core/session_state.py +176 -0
- mcp_ticketer/core/state_matcher.py +592 -0
- mcp_ticketer/core/url_parser.py +425 -0
- mcp_ticketer/core/validators.py +69 -0
- 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/server/constants.py +58 -0
- mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
- mcp_ticketer/mcp/server/dto.py +195 -0
- mcp_ticketer/mcp/server/main.py +1343 -0
- mcp_ticketer/mcp/server/response_builder.py +206 -0
- mcp_ticketer/mcp/server/routing.py +723 -0
- mcp_ticketer/mcp/server/server_sdk.py +151 -0
- mcp_ticketer/mcp/server/tools/__init__.py +69 -0
- mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +224 -0
- mcp_ticketer/mcp/server/tools/bulk_tools.py +330 -0
- mcp_ticketer/mcp/server/tools/comment_tools.py +152 -0
- mcp_ticketer/mcp/server/tools/config_tools.py +1564 -0
- mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +942 -0
- mcp_ticketer/mcp/server/tools/instruction_tools.py +295 -0
- mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
- mcp_ticketer/mcp/server/tools/milestone_tools.py +338 -0
- mcp_ticketer/mcp/server/tools/pr_tools.py +150 -0
- mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
- mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
- mcp_ticketer/mcp/server/tools/search_tools.py +318 -0
- mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
- mcp_ticketer/mcp/server/tools/ticket_tools.py +1413 -0
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +364 -0
- mcp_ticketer/queue/__init__.py +1 -0
- mcp_ticketer/queue/health_monitor.py +168 -136
- mcp_ticketer/queue/manager.py +78 -63
- mcp_ticketer/queue/queue.py +108 -21
- mcp_ticketer/queue/run_worker.py +2 -2
- mcp_ticketer/queue/ticket_registry.py +213 -155
- mcp_ticketer/queue/worker.py +96 -58
- mcp_ticketer/utils/__init__.py +5 -0
- mcp_ticketer/utils/token_utils.py +246 -0
- mcp_ticketer-2.2.9.dist-info/METADATA +1396 -0
- mcp_ticketer-2.2.9.dist-info/RECORD +158 -0
- mcp_ticketer-2.2.9.dist-info/top_level.txt +2 -0
- py_mcp_installer/examples/phase3_demo.py +178 -0
- py_mcp_installer/scripts/manage_version.py +54 -0
- py_mcp_installer/setup.py +6 -0
- py_mcp_installer/src/py_mcp_installer/__init__.py +153 -0
- py_mcp_installer/src/py_mcp_installer/command_builder.py +445 -0
- py_mcp_installer/src/py_mcp_installer/config_manager.py +541 -0
- py_mcp_installer/src/py_mcp_installer/exceptions.py +243 -0
- py_mcp_installer/src/py_mcp_installer/installation_strategy.py +617 -0
- py_mcp_installer/src/py_mcp_installer/installer.py +656 -0
- py_mcp_installer/src/py_mcp_installer/mcp_inspector.py +750 -0
- py_mcp_installer/src/py_mcp_installer/platform_detector.py +451 -0
- py_mcp_installer/src/py_mcp_installer/platforms/__init__.py +26 -0
- py_mcp_installer/src/py_mcp_installer/platforms/claude_code.py +225 -0
- py_mcp_installer/src/py_mcp_installer/platforms/codex.py +181 -0
- py_mcp_installer/src/py_mcp_installer/platforms/cursor.py +191 -0
- py_mcp_installer/src/py_mcp_installer/types.py +222 -0
- py_mcp_installer/src/py_mcp_installer/utils.py +463 -0
- py_mcp_installer/tests/__init__.py +0 -0
- py_mcp_installer/tests/platforms/__init__.py +0 -0
- py_mcp_installer/tests/test_platform_detector.py +17 -0
- mcp_ticketer/adapters/github.py +0 -1354
- mcp_ticketer/adapters/jira.py +0 -1011
- mcp_ticketer/mcp/server.py +0 -1895
- mcp_ticketer-0.2.0.dist-info/METADATA +0 -414
- mcp_ticketer-0.2.0.dist-info/RECORD +0 -58
- mcp_ticketer-0.2.0.dist-info/top_level.txt +0 -1
- {mcp_ticketer-0.2.0.dist-info → mcp_ticketer-2.2.9.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.2.0.dist-info → mcp_ticketer-2.2.9.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.2.0.dist-info → mcp_ticketer-2.2.9.dist-info}/licenses/LICENSE +0 -0
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)
|
|
@@ -81,12 +84,22 @@ class WorkerManager:
|
|
|
81
84
|
# Try to start worker
|
|
82
85
|
return self.start()
|
|
83
86
|
|
|
84
|
-
def start(self) -> bool:
|
|
87
|
+
def start(self, timeout: float = 30.0) -> bool:
|
|
85
88
|
"""Start the worker process.
|
|
86
89
|
|
|
90
|
+
Args:
|
|
91
|
+
timeout: Maximum seconds to wait for worker to become fully operational (default: 30)
|
|
92
|
+
|
|
87
93
|
Returns:
|
|
88
94
|
True if started successfully, False otherwise
|
|
89
95
|
|
|
96
|
+
Raises:
|
|
97
|
+
TimeoutError: If worker doesn't start within timeout period
|
|
98
|
+
|
|
99
|
+
Note:
|
|
100
|
+
If timeout occurs, a worker may already be running in another process.
|
|
101
|
+
Check for stale lock files or processes using `get_status()`.
|
|
102
|
+
|
|
90
103
|
"""
|
|
91
104
|
# Check if already running
|
|
92
105
|
if self.is_running():
|
|
@@ -96,12 +109,27 @@ class WorkerManager:
|
|
|
96
109
|
# Try to acquire lock
|
|
97
110
|
if not self._acquire_lock():
|
|
98
111
|
logger.warning("Could not acquire lock - another worker may be running")
|
|
99
|
-
|
|
112
|
+
# Wait briefly to see if worker becomes operational
|
|
113
|
+
start_time = time.time()
|
|
114
|
+
while time.time() - start_time < timeout:
|
|
115
|
+
if self.is_running():
|
|
116
|
+
logger.info("Worker started by another process")
|
|
117
|
+
return True
|
|
118
|
+
time.sleep(1)
|
|
119
|
+
|
|
120
|
+
raise TimeoutError(
|
|
121
|
+
f"Worker start timed out after {timeout}s. "
|
|
122
|
+
f"Lock is held but worker is not running. "
|
|
123
|
+
f"Check for stale lock files or zombie processes."
|
|
124
|
+
)
|
|
100
125
|
|
|
101
126
|
try:
|
|
102
127
|
# Start worker in subprocess using the same Python executable as the CLI
|
|
103
128
|
# This ensures the worker can import mcp_ticketer modules
|
|
104
|
-
|
|
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()
|
|
105
133
|
cmd = [python_executable, "-m", "mcp_ticketer.queue.run_worker"]
|
|
106
134
|
|
|
107
135
|
# Prepare environment for subprocess
|
|
@@ -113,9 +141,12 @@ class WorkerManager:
|
|
|
113
141
|
if env_file.exists():
|
|
114
142
|
logger.debug(f"Loading environment from {env_file} for subprocess")
|
|
115
143
|
from dotenv import dotenv_values
|
|
144
|
+
|
|
116
145
|
env_vars = dotenv_values(env_file)
|
|
117
146
|
subprocess_env.update(env_vars)
|
|
118
|
-
logger.debug(
|
|
147
|
+
logger.debug(
|
|
148
|
+
f"Added {len(env_vars)} environment variables from .env.local"
|
|
149
|
+
)
|
|
119
150
|
|
|
120
151
|
# Start as background process
|
|
121
152
|
process = subprocess.Popen(
|
|
@@ -130,20 +161,42 @@ class WorkerManager:
|
|
|
130
161
|
# Save PID
|
|
131
162
|
self.pid_file.write_text(str(process.pid))
|
|
132
163
|
|
|
133
|
-
#
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
164
|
+
# Wait for worker to become fully operational with timeout
|
|
165
|
+
start_time = time.time()
|
|
166
|
+
while time.time() - start_time < timeout:
|
|
167
|
+
# Check if process exists
|
|
168
|
+
if not psutil.pid_exists(process.pid):
|
|
169
|
+
logger.error("Worker process died during startup")
|
|
170
|
+
self._cleanup()
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
# Check if worker is fully running (not just process exists)
|
|
174
|
+
if self.is_running():
|
|
175
|
+
elapsed = time.time() - start_time
|
|
176
|
+
logger.info(
|
|
177
|
+
f"Worker started successfully in {elapsed:.1f}s with PID {process.pid}"
|
|
178
|
+
)
|
|
179
|
+
return True
|
|
180
|
+
|
|
181
|
+
# Log progress for long startups
|
|
182
|
+
elapsed = time.time() - start_time
|
|
183
|
+
if elapsed > 5 and int(elapsed) % 5 == 0:
|
|
184
|
+
logger.debug(
|
|
185
|
+
f"Waiting for worker to start, elapsed: {elapsed:.1f}s"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
time.sleep(0.5)
|
|
189
|
+
|
|
190
|
+
# Timeout reached
|
|
191
|
+
raise TimeoutError(
|
|
192
|
+
f"Worker start timed out after {timeout}s. "
|
|
193
|
+
f"Process spawned (PID {process.pid}) but worker did not become operational. "
|
|
194
|
+
f"Check worker logs for startup errors."
|
|
195
|
+
)
|
|
146
196
|
|
|
197
|
+
except TimeoutError:
|
|
198
|
+
# Re-raise timeout errors with context preserved
|
|
199
|
+
raise
|
|
147
200
|
except Exception as e:
|
|
148
201
|
logger.error(f"Failed to start worker: {e}")
|
|
149
202
|
self._release_lock()
|
|
@@ -234,7 +287,7 @@ class WorkerManager:
|
|
|
234
287
|
is_running = self.is_running()
|
|
235
288
|
pid = self._get_pid() if is_running else None
|
|
236
289
|
|
|
237
|
-
status = {"running": is_running, "pid": pid}
|
|
290
|
+
status: dict[str, Any] = {"running": is_running, "pid": pid}
|
|
238
291
|
|
|
239
292
|
# Add process info if running
|
|
240
293
|
if is_running and pid:
|
|
@@ -257,7 +310,7 @@ class WorkerManager:
|
|
|
257
310
|
|
|
258
311
|
return status
|
|
259
312
|
|
|
260
|
-
def _get_pid(self) ->
|
|
313
|
+
def _get_pid(self) -> int | None:
|
|
261
314
|
"""Get worker PID from file.
|
|
262
315
|
|
|
263
316
|
Returns:
|
|
@@ -273,45 +326,7 @@ class WorkerManager:
|
|
|
273
326
|
except (OSError, ValueError):
|
|
274
327
|
return None
|
|
275
328
|
|
|
276
|
-
def
|
|
277
|
-
"""Get the correct Python executable for the worker subprocess.
|
|
278
|
-
|
|
279
|
-
This ensures the worker uses the same Python environment as the CLI,
|
|
280
|
-
which is critical for module imports to work correctly.
|
|
281
|
-
|
|
282
|
-
Returns:
|
|
283
|
-
Path to Python executable
|
|
284
|
-
"""
|
|
285
|
-
# First, try to detect if we're running in a pipx environment
|
|
286
|
-
# by checking if the current executable is in a pipx venv
|
|
287
|
-
current_executable = sys.executable
|
|
288
|
-
|
|
289
|
-
# Check if we're in a pipx venv (path contains /pipx/venvs/)
|
|
290
|
-
if "/pipx/venvs/" in current_executable:
|
|
291
|
-
logger.debug(f"Using pipx Python executable: {current_executable}")
|
|
292
|
-
return current_executable
|
|
293
|
-
|
|
294
|
-
# Check if we can find the mcp-ticketer executable and extract its Python
|
|
295
|
-
import shutil
|
|
296
|
-
mcp_ticketer_path = shutil.which("mcp-ticketer")
|
|
297
|
-
if mcp_ticketer_path:
|
|
298
|
-
try:
|
|
299
|
-
# Read the shebang line to get the Python executable
|
|
300
|
-
with open(mcp_ticketer_path, 'r') as f:
|
|
301
|
-
first_line = f.readline().strip()
|
|
302
|
-
if first_line.startswith("#!") and "python" in first_line:
|
|
303
|
-
python_path = first_line[2:].strip()
|
|
304
|
-
if os.path.exists(python_path):
|
|
305
|
-
logger.debug(f"Using Python from mcp-ticketer shebang: {python_path}")
|
|
306
|
-
return python_path
|
|
307
|
-
except (OSError, IOError):
|
|
308
|
-
pass
|
|
309
|
-
|
|
310
|
-
# Fallback to sys.executable
|
|
311
|
-
logger.debug(f"Using sys.executable as fallback: {current_executable}")
|
|
312
|
-
return current_executable
|
|
313
|
-
|
|
314
|
-
def _cleanup(self):
|
|
329
|
+
def _cleanup(self) -> None:
|
|
315
330
|
"""Clean up lock and PID files."""
|
|
316
331
|
self._release_lock()
|
|
317
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,11 +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:
|
|
36
|
+
result: dict[str, Any] | None = None
|
|
37
|
+
project_dir: str | None = None
|
|
38
|
+
adapter_config: dict[str, Any] | None = None # Adapter configuration
|
|
38
39
|
|
|
39
40
|
def to_dict(self) -> dict:
|
|
40
41
|
"""Convert to dictionary for storage."""
|
|
@@ -59,13 +60,14 @@ class QueueItem:
|
|
|
59
60
|
retry_count=row[8],
|
|
60
61
|
result=json.loads(row[9]) if row[9] else None,
|
|
61
62
|
project_dir=row[10] if len(row) > 10 else None,
|
|
63
|
+
adapter_config=json.loads(row[11]) if len(row) > 11 and row[11] else None,
|
|
62
64
|
)
|
|
63
65
|
|
|
64
66
|
|
|
65
67
|
class Queue:
|
|
66
68
|
"""Thread-safe SQLite queue for ticket operations."""
|
|
67
69
|
|
|
68
|
-
def __init__(self, db_path:
|
|
70
|
+
def __init__(self, db_path: Path | None = None):
|
|
69
71
|
"""Initialize queue with database connection.
|
|
70
72
|
|
|
71
73
|
Args:
|
|
@@ -81,7 +83,7 @@ class Queue:
|
|
|
81
83
|
self._lock = threading.Lock()
|
|
82
84
|
self._init_database()
|
|
83
85
|
|
|
84
|
-
def _init_database(self):
|
|
86
|
+
def _init_database(self) -> None:
|
|
85
87
|
"""Initialize database schema."""
|
|
86
88
|
with sqlite3.connect(self.db_path) as conn:
|
|
87
89
|
conn.execute(
|
|
@@ -127,6 +129,8 @@ class Queue:
|
|
|
127
129
|
columns = [row[1] for row in cursor.fetchall()]
|
|
128
130
|
if "project_dir" not in columns:
|
|
129
131
|
conn.execute("ALTER TABLE queue ADD COLUMN project_dir TEXT")
|
|
132
|
+
if "adapter_config" not in columns:
|
|
133
|
+
conn.execute("ALTER TABLE queue ADD COLUMN adapter_config TEXT")
|
|
130
134
|
|
|
131
135
|
conn.commit()
|
|
132
136
|
|
|
@@ -135,7 +139,8 @@ class Queue:
|
|
|
135
139
|
ticket_data: dict[str, Any],
|
|
136
140
|
adapter: str,
|
|
137
141
|
operation: str,
|
|
138
|
-
project_dir:
|
|
142
|
+
project_dir: str | None = None,
|
|
143
|
+
adapter_config: dict[str, Any] | None = None,
|
|
139
144
|
) -> str:
|
|
140
145
|
"""Add item to queue.
|
|
141
146
|
|
|
@@ -144,6 +149,7 @@ class Queue:
|
|
|
144
149
|
adapter: Name of the adapter to use
|
|
145
150
|
operation: Operation to perform (create, update, delete, etc.)
|
|
146
151
|
project_dir: Project directory for config resolution (defaults to current directory)
|
|
152
|
+
adapter_config: Adapter configuration to use (optional, for explicit config passing)
|
|
147
153
|
|
|
148
154
|
Returns:
|
|
149
155
|
Queue ID for tracking
|
|
@@ -161,8 +167,8 @@ class Queue:
|
|
|
161
167
|
"""
|
|
162
168
|
INSERT INTO queue (
|
|
163
169
|
id, ticket_data, adapter, operation,
|
|
164
|
-
status, created_at, retry_count, project_dir
|
|
165
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
170
|
+
status, created_at, retry_count, project_dir, adapter_config
|
|
171
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
166
172
|
""",
|
|
167
173
|
(
|
|
168
174
|
queue_id,
|
|
@@ -173,13 +179,14 @@ class Queue:
|
|
|
173
179
|
datetime.now().isoformat(),
|
|
174
180
|
0,
|
|
175
181
|
project_dir,
|
|
182
|
+
json.dumps(adapter_config) if adapter_config else None,
|
|
176
183
|
),
|
|
177
184
|
)
|
|
178
185
|
conn.commit()
|
|
179
186
|
|
|
180
187
|
return queue_id
|
|
181
188
|
|
|
182
|
-
def get_next_pending(self) ->
|
|
189
|
+
def get_next_pending(self) -> QueueItem | None:
|
|
183
190
|
"""Get next pending item from queue atomically.
|
|
184
191
|
|
|
185
192
|
Returns:
|
|
@@ -211,7 +218,12 @@ class Queue:
|
|
|
211
218
|
SET status = ?, processed_at = ?
|
|
212
219
|
WHERE id = ? AND status = ?
|
|
213
220
|
""",
|
|
214
|
-
(
|
|
221
|
+
(
|
|
222
|
+
QueueStatus.PROCESSING.value,
|
|
223
|
+
datetime.now().isoformat(),
|
|
224
|
+
row[0],
|
|
225
|
+
QueueStatus.PENDING.value,
|
|
226
|
+
),
|
|
215
227
|
)
|
|
216
228
|
|
|
217
229
|
# Check if update was successful (prevents race conditions)
|
|
@@ -239,9 +251,9 @@ class Queue:
|
|
|
239
251
|
self,
|
|
240
252
|
queue_id: str,
|
|
241
253
|
status: QueueStatus,
|
|
242
|
-
error_message:
|
|
243
|
-
result:
|
|
244
|
-
expected_status:
|
|
254
|
+
error_message: str | None = None,
|
|
255
|
+
result: dict[str, Any] | None = None,
|
|
256
|
+
expected_status: QueueStatus | None = None,
|
|
245
257
|
) -> bool:
|
|
246
258
|
"""Update queue item status atomically.
|
|
247
259
|
|
|
@@ -254,6 +266,7 @@ class Queue:
|
|
|
254
266
|
|
|
255
267
|
Returns:
|
|
256
268
|
True if update was successful, False if item was in unexpected state
|
|
269
|
+
|
|
257
270
|
"""
|
|
258
271
|
with self._lock:
|
|
259
272
|
with sqlite3.connect(self.db_path) as conn:
|
|
@@ -314,7 +327,9 @@ class Queue:
|
|
|
314
327
|
conn.rollback()
|
|
315
328
|
raise
|
|
316
329
|
|
|
317
|
-
def increment_retry(
|
|
330
|
+
def increment_retry(
|
|
331
|
+
self, queue_id: str, expected_status: QueueStatus | None = None
|
|
332
|
+
) -> int:
|
|
318
333
|
"""Increment retry count and reset to pending atomically.
|
|
319
334
|
|
|
320
335
|
Args:
|
|
@@ -340,7 +355,11 @@ class Queue:
|
|
|
340
355
|
WHERE id = ? AND status = ?
|
|
341
356
|
RETURNING retry_count
|
|
342
357
|
""",
|
|
343
|
-
(
|
|
358
|
+
(
|
|
359
|
+
QueueStatus.PENDING.value,
|
|
360
|
+
queue_id,
|
|
361
|
+
expected_status.value,
|
|
362
|
+
),
|
|
344
363
|
)
|
|
345
364
|
else:
|
|
346
365
|
# Regular increment
|
|
@@ -368,7 +387,7 @@ class Queue:
|
|
|
368
387
|
conn.rollback()
|
|
369
388
|
raise
|
|
370
389
|
|
|
371
|
-
def get_item(self, queue_id: str) ->
|
|
390
|
+
def get_item(self, queue_id: str) -> QueueItem | None:
|
|
372
391
|
"""Get specific queue item by ID.
|
|
373
392
|
|
|
374
393
|
Args:
|
|
@@ -390,7 +409,7 @@ class Queue:
|
|
|
390
409
|
return QueueItem.from_row(row) if row else None
|
|
391
410
|
|
|
392
411
|
def list_items(
|
|
393
|
-
self, status:
|
|
412
|
+
self, status: QueueStatus | None = None, limit: int = 50
|
|
394
413
|
) -> list[QueueItem]:
|
|
395
414
|
"""List queue items.
|
|
396
415
|
|
|
@@ -443,7 +462,7 @@ class Queue:
|
|
|
443
462
|
|
|
444
463
|
return cursor.fetchone()[0]
|
|
445
464
|
|
|
446
|
-
def cleanup_old(self, days: int = 7):
|
|
465
|
+
def cleanup_old(self, days: int = 7) -> None:
|
|
447
466
|
"""Clean up old completed/failed items.
|
|
448
467
|
|
|
449
468
|
Args:
|
|
@@ -468,7 +487,7 @@ class Queue:
|
|
|
468
487
|
)
|
|
469
488
|
conn.commit()
|
|
470
489
|
|
|
471
|
-
def reset_stuck_items(self, timeout_minutes: int = 30):
|
|
490
|
+
def reset_stuck_items(self, timeout_minutes: int = 30) -> None:
|
|
472
491
|
"""Reset items stuck in processing state.
|
|
473
492
|
|
|
474
493
|
Args:
|
|
@@ -516,3 +535,71 @@ class Queue:
|
|
|
516
535
|
stats[status] = count
|
|
517
536
|
|
|
518
537
|
return stats
|
|
538
|
+
|
|
539
|
+
def poll_until_complete(
|
|
540
|
+
self,
|
|
541
|
+
queue_id: str,
|
|
542
|
+
timeout: float = 30.0,
|
|
543
|
+
poll_interval: float = 0.5,
|
|
544
|
+
) -> QueueItem:
|
|
545
|
+
"""Poll queue item until completion or timeout.
|
|
546
|
+
|
|
547
|
+
This enables synchronous waiting for queue operations, useful for:
|
|
548
|
+
- CLI commands that need immediate results
|
|
549
|
+
- Testing that requires actual ticket IDs
|
|
550
|
+
- GitHub adapter operations requiring ticket numbers
|
|
551
|
+
|
|
552
|
+
Args:
|
|
553
|
+
queue_id: Queue ID to poll (e.g., "Q-9E7B5050")
|
|
554
|
+
timeout: Maximum seconds to wait (default: 30.0)
|
|
555
|
+
poll_interval: Seconds between polls (default: 0.5)
|
|
556
|
+
|
|
557
|
+
Returns:
|
|
558
|
+
Completed QueueItem with result data
|
|
559
|
+
|
|
560
|
+
Raises:
|
|
561
|
+
TimeoutError: If operation doesn't complete within timeout
|
|
562
|
+
RuntimeError: If operation fails or queue item not found
|
|
563
|
+
|
|
564
|
+
Example:
|
|
565
|
+
>>> queue = Queue()
|
|
566
|
+
>>> queue_id = queue.add(ticket_data={...}, adapter="github", operation="create")
|
|
567
|
+
>>> # Start worker to process the queue
|
|
568
|
+
>>> completed_item = queue.poll_until_complete(queue_id, timeout=30)
|
|
569
|
+
>>> ticket_id = completed_item.result["id"]
|
|
570
|
+
|
|
571
|
+
"""
|
|
572
|
+
import time
|
|
573
|
+
|
|
574
|
+
start_time = time.time()
|
|
575
|
+
elapsed = 0.0
|
|
576
|
+
|
|
577
|
+
while elapsed < timeout:
|
|
578
|
+
item = self.get_item(queue_id)
|
|
579
|
+
|
|
580
|
+
if item is None:
|
|
581
|
+
raise RuntimeError(f"Queue item not found: {queue_id}")
|
|
582
|
+
|
|
583
|
+
if item.status == QueueStatus.COMPLETED:
|
|
584
|
+
if item.result is None:
|
|
585
|
+
raise RuntimeError(
|
|
586
|
+
f"Queue operation completed but has no result data: {queue_id}"
|
|
587
|
+
)
|
|
588
|
+
return item
|
|
589
|
+
|
|
590
|
+
elif item.status == QueueStatus.FAILED:
|
|
591
|
+
error_msg = item.error_message or "Unknown error"
|
|
592
|
+
raise RuntimeError(
|
|
593
|
+
f"Queue operation failed: {error_msg} (queue_id: {queue_id})"
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
# Still pending or processing - wait and retry
|
|
597
|
+
time.sleep(poll_interval)
|
|
598
|
+
elapsed = time.time() - start_time
|
|
599
|
+
|
|
600
|
+
# Timeout reached
|
|
601
|
+
final_item = self.get_item(queue_id)
|
|
602
|
+
final_status = final_item.status.value if final_item else "UNKNOWN"
|
|
603
|
+
raise TimeoutError(
|
|
604
|
+
f"Queue operation timed out after {timeout}s (status: {final_status}, queue_id: {queue_id})"
|
|
605
|
+
)
|
mcp_ticketer/queue/run_worker.py
CHANGED
|
@@ -13,10 +13,10 @@ 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
|
-
import sys
|
|
19
18
|
import os
|
|
19
|
+
|
|
20
20
|
logger.info("Starting standalone worker process")
|
|
21
21
|
logger.info(f"Worker Python executable: {sys.executable}")
|
|
22
22
|
logger.info(f"Worker working directory: {os.getcwd()}")
|