mcp-ticketer 0.3.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.
Files changed (160) hide show
  1. mcp_ticketer/__init__.py +10 -10
  2. mcp_ticketer/__version__.py +3 -3
  3. mcp_ticketer/_version_scm.py +1 -0
  4. mcp_ticketer/adapters/__init__.py +2 -0
  5. mcp_ticketer/adapters/aitrackdown.py +930 -52
  6. mcp_ticketer/adapters/asana/__init__.py +15 -0
  7. mcp_ticketer/adapters/asana/adapter.py +1537 -0
  8. mcp_ticketer/adapters/asana/client.py +292 -0
  9. mcp_ticketer/adapters/asana/mappers.py +348 -0
  10. mcp_ticketer/adapters/asana/types.py +146 -0
  11. mcp_ticketer/adapters/github/__init__.py +26 -0
  12. mcp_ticketer/adapters/github/adapter.py +3229 -0
  13. mcp_ticketer/adapters/github/client.py +335 -0
  14. mcp_ticketer/adapters/github/mappers.py +797 -0
  15. mcp_ticketer/adapters/github/queries.py +692 -0
  16. mcp_ticketer/adapters/github/types.py +460 -0
  17. mcp_ticketer/adapters/hybrid.py +58 -16
  18. mcp_ticketer/adapters/jira/__init__.py +35 -0
  19. mcp_ticketer/adapters/jira/adapter.py +1351 -0
  20. mcp_ticketer/adapters/jira/client.py +271 -0
  21. mcp_ticketer/adapters/jira/mappers.py +246 -0
  22. mcp_ticketer/adapters/jira/queries.py +216 -0
  23. mcp_ticketer/adapters/jira/types.py +304 -0
  24. mcp_ticketer/adapters/linear/__init__.py +1 -1
  25. mcp_ticketer/adapters/linear/adapter.py +3810 -462
  26. mcp_ticketer/adapters/linear/client.py +312 -69
  27. mcp_ticketer/adapters/linear/mappers.py +305 -85
  28. mcp_ticketer/adapters/linear/queries.py +317 -17
  29. mcp_ticketer/adapters/linear/types.py +187 -64
  30. mcp_ticketer/adapters/linear.py +2 -2
  31. mcp_ticketer/analysis/__init__.py +56 -0
  32. mcp_ticketer/analysis/dependency_graph.py +255 -0
  33. mcp_ticketer/analysis/health_assessment.py +304 -0
  34. mcp_ticketer/analysis/orphaned.py +218 -0
  35. mcp_ticketer/analysis/project_status.py +594 -0
  36. mcp_ticketer/analysis/similarity.py +224 -0
  37. mcp_ticketer/analysis/staleness.py +266 -0
  38. mcp_ticketer/automation/__init__.py +11 -0
  39. mcp_ticketer/automation/project_updates.py +378 -0
  40. mcp_ticketer/cache/memory.py +9 -8
  41. mcp_ticketer/cli/adapter_diagnostics.py +91 -54
  42. mcp_ticketer/cli/auggie_configure.py +116 -15
  43. mcp_ticketer/cli/codex_configure.py +274 -82
  44. mcp_ticketer/cli/configure.py +1323 -151
  45. mcp_ticketer/cli/cursor_configure.py +314 -0
  46. mcp_ticketer/cli/diagnostics.py +209 -114
  47. mcp_ticketer/cli/discover.py +297 -26
  48. mcp_ticketer/cli/gemini_configure.py +119 -26
  49. mcp_ticketer/cli/init_command.py +880 -0
  50. mcp_ticketer/cli/install_mcp_server.py +418 -0
  51. mcp_ticketer/cli/instruction_commands.py +435 -0
  52. mcp_ticketer/cli/linear_commands.py +256 -130
  53. mcp_ticketer/cli/main.py +140 -1544
  54. mcp_ticketer/cli/mcp_configure.py +1013 -100
  55. mcp_ticketer/cli/mcp_server_commands.py +415 -0
  56. mcp_ticketer/cli/migrate_config.py +12 -8
  57. mcp_ticketer/cli/platform_commands.py +123 -0
  58. mcp_ticketer/cli/platform_detection.py +477 -0
  59. mcp_ticketer/cli/platform_installer.py +545 -0
  60. mcp_ticketer/cli/project_update_commands.py +350 -0
  61. mcp_ticketer/cli/python_detection.py +126 -0
  62. mcp_ticketer/cli/queue_commands.py +15 -15
  63. mcp_ticketer/cli/setup_command.py +794 -0
  64. mcp_ticketer/cli/simple_health.py +84 -59
  65. mcp_ticketer/cli/ticket_commands.py +1375 -0
  66. mcp_ticketer/cli/update_checker.py +313 -0
  67. mcp_ticketer/cli/utils.py +195 -72
  68. mcp_ticketer/core/__init__.py +64 -1
  69. mcp_ticketer/core/adapter.py +618 -18
  70. mcp_ticketer/core/config.py +77 -68
  71. mcp_ticketer/core/env_discovery.py +75 -16
  72. mcp_ticketer/core/env_loader.py +121 -97
  73. mcp_ticketer/core/exceptions.py +32 -24
  74. mcp_ticketer/core/http_client.py +26 -26
  75. mcp_ticketer/core/instructions.py +405 -0
  76. mcp_ticketer/core/label_manager.py +732 -0
  77. mcp_ticketer/core/mappers.py +42 -30
  78. mcp_ticketer/core/milestone_manager.py +252 -0
  79. mcp_ticketer/core/models.py +566 -19
  80. mcp_ticketer/core/onepassword_secrets.py +379 -0
  81. mcp_ticketer/core/priority_matcher.py +463 -0
  82. mcp_ticketer/core/project_config.py +189 -49
  83. mcp_ticketer/core/project_utils.py +281 -0
  84. mcp_ticketer/core/project_validator.py +376 -0
  85. mcp_ticketer/core/registry.py +3 -3
  86. mcp_ticketer/core/session_state.py +176 -0
  87. mcp_ticketer/core/state_matcher.py +592 -0
  88. mcp_ticketer/core/url_parser.py +425 -0
  89. mcp_ticketer/core/validators.py +69 -0
  90. mcp_ticketer/defaults/ticket_instructions.md +644 -0
  91. mcp_ticketer/mcp/__init__.py +29 -1
  92. mcp_ticketer/mcp/__main__.py +60 -0
  93. mcp_ticketer/mcp/server/__init__.py +25 -0
  94. mcp_ticketer/mcp/server/__main__.py +60 -0
  95. mcp_ticketer/mcp/server/constants.py +58 -0
  96. mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
  97. mcp_ticketer/mcp/server/dto.py +195 -0
  98. mcp_ticketer/mcp/server/main.py +1343 -0
  99. mcp_ticketer/mcp/server/response_builder.py +206 -0
  100. mcp_ticketer/mcp/server/routing.py +723 -0
  101. mcp_ticketer/mcp/server/server_sdk.py +151 -0
  102. mcp_ticketer/mcp/server/tools/__init__.py +69 -0
  103. mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
  104. mcp_ticketer/mcp/server/tools/attachment_tools.py +224 -0
  105. mcp_ticketer/mcp/server/tools/bulk_tools.py +330 -0
  106. mcp_ticketer/mcp/server/tools/comment_tools.py +152 -0
  107. mcp_ticketer/mcp/server/tools/config_tools.py +1564 -0
  108. mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
  109. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +942 -0
  110. mcp_ticketer/mcp/server/tools/instruction_tools.py +295 -0
  111. mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
  112. mcp_ticketer/mcp/server/tools/milestone_tools.py +338 -0
  113. mcp_ticketer/mcp/server/tools/pr_tools.py +150 -0
  114. mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
  115. mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
  116. mcp_ticketer/mcp/server/tools/search_tools.py +318 -0
  117. mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
  118. mcp_ticketer/mcp/server/tools/ticket_tools.py +1413 -0
  119. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +364 -0
  120. mcp_ticketer/queue/__init__.py +1 -0
  121. mcp_ticketer/queue/health_monitor.py +168 -136
  122. mcp_ticketer/queue/manager.py +78 -63
  123. mcp_ticketer/queue/queue.py +108 -21
  124. mcp_ticketer/queue/run_worker.py +2 -2
  125. mcp_ticketer/queue/ticket_registry.py +213 -155
  126. mcp_ticketer/queue/worker.py +96 -58
  127. mcp_ticketer/utils/__init__.py +5 -0
  128. mcp_ticketer/utils/token_utils.py +246 -0
  129. mcp_ticketer-2.2.9.dist-info/METADATA +1396 -0
  130. mcp_ticketer-2.2.9.dist-info/RECORD +158 -0
  131. mcp_ticketer-2.2.9.dist-info/top_level.txt +2 -0
  132. py_mcp_installer/examples/phase3_demo.py +178 -0
  133. py_mcp_installer/scripts/manage_version.py +54 -0
  134. py_mcp_installer/setup.py +6 -0
  135. py_mcp_installer/src/py_mcp_installer/__init__.py +153 -0
  136. py_mcp_installer/src/py_mcp_installer/command_builder.py +445 -0
  137. py_mcp_installer/src/py_mcp_installer/config_manager.py +541 -0
  138. py_mcp_installer/src/py_mcp_installer/exceptions.py +243 -0
  139. py_mcp_installer/src/py_mcp_installer/installation_strategy.py +617 -0
  140. py_mcp_installer/src/py_mcp_installer/installer.py +656 -0
  141. py_mcp_installer/src/py_mcp_installer/mcp_inspector.py +750 -0
  142. py_mcp_installer/src/py_mcp_installer/platform_detector.py +451 -0
  143. py_mcp_installer/src/py_mcp_installer/platforms/__init__.py +26 -0
  144. py_mcp_installer/src/py_mcp_installer/platforms/claude_code.py +225 -0
  145. py_mcp_installer/src/py_mcp_installer/platforms/codex.py +181 -0
  146. py_mcp_installer/src/py_mcp_installer/platforms/cursor.py +191 -0
  147. py_mcp_installer/src/py_mcp_installer/types.py +222 -0
  148. py_mcp_installer/src/py_mcp_installer/utils.py +463 -0
  149. py_mcp_installer/tests/__init__.py +0 -0
  150. py_mcp_installer/tests/platforms/__init__.py +0 -0
  151. py_mcp_installer/tests/test_platform_detector.py +17 -0
  152. mcp_ticketer/adapters/github.py +0 -1354
  153. mcp_ticketer/adapters/jira.py +0 -1011
  154. mcp_ticketer/mcp/server.py +0 -2030
  155. mcp_ticketer-0.3.0.dist-info/METADATA +0 -414
  156. mcp_ticketer-0.3.0.dist-info/RECORD +0 -59
  157. mcp_ticketer-0.3.0.dist-info/top_level.txt +0 -1
  158. {mcp_ticketer-0.3.0.dist-info → mcp_ticketer-2.2.9.dist-info}/WHEEL +0 -0
  159. {mcp_ticketer-0.3.0.dist-info → mcp_ticketer-2.2.9.dist-info}/entry_points.txt +0 -0
  160. {mcp_ticketer-0.3.0.dist-info → mcp_ticketer-2.2.9.dist-info}/licenses/LICENSE +0 -0
@@ -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)
@@ -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
- return False
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
- 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()
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(f"Added {len(env_vars)} environment variables from .env.local")
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
- # Give the process a moment to start
134
- import time
135
-
136
- time.sleep(0.5)
137
-
138
- # Verify process is running
139
- if not psutil.pid_exists(process.pid):
140
- logger.error("Worker process died immediately after starting")
141
- self._cleanup()
142
- return False
143
-
144
- logger.info(f"Started worker process with PID {process.pid}")
145
- return True
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) -> Optional[int]:
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 _get_python_executable(self) -> str:
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():
@@ -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,11 +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
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: Optional[Path] = None):
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: Optional[str] = None,
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) -> Optional[QueueItem]:
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
- (QueueStatus.PROCESSING.value, datetime.now().isoformat(), row[0], QueueStatus.PENDING.value),
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: Optional[str] = None,
243
- result: Optional[dict[str, Any]] = None,
244
- expected_status: Optional[QueueStatus] = None,
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(self, queue_id: str, expected_status: Optional[QueueStatus] = None) -> int:
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
- (QueueStatus.PENDING.value, queue_id, expected_status.value),
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) -> Optional[QueueItem]:
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: Optional[QueueStatus] = None, limit: int = 50
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
+ )
@@ -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()}")