mcp-ticketer 0.1.37__py3-none-any.whl → 0.1.39__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 +1 -1
- mcp_ticketer/adapters/github.py +27 -11
- mcp_ticketer/adapters/jira.py +185 -53
- mcp_ticketer/adapters/linear.py +78 -9
- mcp_ticketer/cli/linear_commands.py +490 -0
- mcp_ticketer/cli/main.py +102 -9
- mcp_ticketer/cli/simple_health.py +6 -6
- mcp_ticketer/cli/utils.py +6 -2
- mcp_ticketer/core/env_loader.py +325 -0
- mcp_ticketer/core/models.py +163 -10
- mcp_ticketer/mcp/server.py +4 -4
- mcp_ticketer/queue/manager.py +57 -2
- mcp_ticketer/queue/run_worker.py +5 -0
- mcp_ticketer/queue/worker.py +24 -2
- {mcp_ticketer-0.1.37.dist-info → mcp_ticketer-0.1.39.dist-info}/METADATA +1 -1
- {mcp_ticketer-0.1.37.dist-info → mcp_ticketer-0.1.39.dist-info}/RECORD +20 -18
- {mcp_ticketer-0.1.37.dist-info → mcp_ticketer-0.1.39.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.1.37.dist-info → mcp_ticketer-0.1.39.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.1.37.dist-info → mcp_ticketer-0.1.39.dist-info}/licenses/LICENSE +0 -0
- {mcp_ticketer-0.1.37.dist-info → mcp_ticketer-0.1.39.dist-info}/top_level.txt +0 -0
mcp_ticketer/cli/utils.py
CHANGED
|
@@ -210,10 +210,14 @@ class CommonPatterns:
|
|
|
210
210
|
config = CommonPatterns.load_config()
|
|
211
211
|
adapter_name = config.get("default_adapter", "aitrackdown")
|
|
212
212
|
|
|
213
|
-
# Add to queue
|
|
213
|
+
# Add to queue with explicit project directory
|
|
214
|
+
from pathlib import Path
|
|
214
215
|
queue = Queue()
|
|
215
216
|
queue_id = queue.add(
|
|
216
|
-
ticket_data=ticket_data,
|
|
217
|
+
ticket_data=ticket_data,
|
|
218
|
+
adapter=adapter_name,
|
|
219
|
+
operation=operation,
|
|
220
|
+
project_dir=str(Path.cwd()) # Explicitly pass current project directory
|
|
217
221
|
)
|
|
218
222
|
|
|
219
223
|
if show_progress:
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Unified Environment Loading System for MCP Ticketer.
|
|
4
|
+
|
|
5
|
+
This module provides a resilient environment loading system that:
|
|
6
|
+
1. Supports multiple naming conventions for each configuration key
|
|
7
|
+
2. Loads from multiple sources (.env.local, .env, environment variables)
|
|
8
|
+
3. Works consistently across CLI, worker processes, and MCP server
|
|
9
|
+
4. Provides fallback mechanisms for different key aliases
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import logging
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Dict, Any, List, Optional, Union
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class EnvKeyConfig:
|
|
23
|
+
"""Configuration for environment variable key aliases."""
|
|
24
|
+
primary_key: str
|
|
25
|
+
aliases: List[str]
|
|
26
|
+
description: str
|
|
27
|
+
required: bool = False
|
|
28
|
+
default: Optional[str] = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class UnifiedEnvLoader:
|
|
32
|
+
"""
|
|
33
|
+
Unified environment loader that handles multiple naming conventions
|
|
34
|
+
and provides consistent environment loading across all contexts.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
# Define key aliases for all adapters
|
|
38
|
+
KEY_MAPPINGS = {
|
|
39
|
+
# Linear adapter keys
|
|
40
|
+
"linear_api_key": EnvKeyConfig(
|
|
41
|
+
primary_key="LINEAR_API_KEY",
|
|
42
|
+
aliases=["LINEAR_TOKEN", "LINEAR_ACCESS_TOKEN", "LINEAR_AUTH_TOKEN"],
|
|
43
|
+
description="Linear API key",
|
|
44
|
+
required=True
|
|
45
|
+
),
|
|
46
|
+
"linear_team_id": EnvKeyConfig(
|
|
47
|
+
primary_key="LINEAR_TEAM_ID",
|
|
48
|
+
aliases=["LINEAR_TEAM_UUID", "LINEAR_TEAM_IDENTIFIER"],
|
|
49
|
+
description="Linear team ID (UUID)",
|
|
50
|
+
required=False
|
|
51
|
+
),
|
|
52
|
+
"linear_team_key": EnvKeyConfig(
|
|
53
|
+
primary_key="LINEAR_TEAM_KEY",
|
|
54
|
+
aliases=["LINEAR_TEAM_IDENTIFIER", "LINEAR_TEAM_NAME"],
|
|
55
|
+
description="Linear team key (short name)",
|
|
56
|
+
required=False
|
|
57
|
+
),
|
|
58
|
+
|
|
59
|
+
# JIRA adapter keys
|
|
60
|
+
"jira_server": EnvKeyConfig(
|
|
61
|
+
primary_key="JIRA_SERVER",
|
|
62
|
+
aliases=["JIRA_URL", "JIRA_HOST", "JIRA_BASE_URL"],
|
|
63
|
+
description="JIRA server URL",
|
|
64
|
+
required=True
|
|
65
|
+
),
|
|
66
|
+
"jira_email": EnvKeyConfig(
|
|
67
|
+
primary_key="JIRA_EMAIL",
|
|
68
|
+
aliases=["JIRA_USER", "JIRA_USERNAME", "JIRA_ACCESS_USER"],
|
|
69
|
+
description="JIRA user email",
|
|
70
|
+
required=True
|
|
71
|
+
),
|
|
72
|
+
"jira_api_token": EnvKeyConfig(
|
|
73
|
+
primary_key="JIRA_API_TOKEN",
|
|
74
|
+
aliases=["JIRA_TOKEN", "JIRA_ACCESS_TOKEN", "JIRA_AUTH_TOKEN", "JIRA_PASSWORD"],
|
|
75
|
+
description="JIRA API token",
|
|
76
|
+
required=True
|
|
77
|
+
),
|
|
78
|
+
"jira_project_key": EnvKeyConfig(
|
|
79
|
+
primary_key="JIRA_PROJECT_KEY",
|
|
80
|
+
aliases=["JIRA_PROJECT", "JIRA_PROJECT_ID"],
|
|
81
|
+
description="JIRA project key",
|
|
82
|
+
required=False
|
|
83
|
+
),
|
|
84
|
+
|
|
85
|
+
# GitHub adapter keys
|
|
86
|
+
"github_token": EnvKeyConfig(
|
|
87
|
+
primary_key="GITHUB_TOKEN",
|
|
88
|
+
aliases=["GITHUB_ACCESS_TOKEN", "GITHUB_API_TOKEN", "GITHUB_AUTH_TOKEN"],
|
|
89
|
+
description="GitHub access token",
|
|
90
|
+
required=True
|
|
91
|
+
),
|
|
92
|
+
"github_owner": EnvKeyConfig(
|
|
93
|
+
primary_key="GITHUB_OWNER",
|
|
94
|
+
aliases=["GITHUB_USER", "GITHUB_USERNAME", "GITHUB_ORG"],
|
|
95
|
+
description="GitHub repository owner",
|
|
96
|
+
required=True
|
|
97
|
+
),
|
|
98
|
+
"github_repo": EnvKeyConfig(
|
|
99
|
+
primary_key="GITHUB_REPO",
|
|
100
|
+
aliases=["GITHUB_REPOSITORY", "GITHUB_REPO_NAME"],
|
|
101
|
+
description="GitHub repository name",
|
|
102
|
+
required=True
|
|
103
|
+
),
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
def __init__(self, project_root: Optional[Path] = None):
|
|
107
|
+
"""Initialize the environment loader.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
project_root: Project root directory. If None, will auto-detect.
|
|
111
|
+
"""
|
|
112
|
+
self.project_root = project_root or self._find_project_root()
|
|
113
|
+
self._env_cache: Dict[str, str] = {}
|
|
114
|
+
self._load_env_files()
|
|
115
|
+
|
|
116
|
+
def _find_project_root(self) -> Path:
|
|
117
|
+
"""Find the project root directory."""
|
|
118
|
+
current = Path.cwd()
|
|
119
|
+
|
|
120
|
+
# Look for common project indicators
|
|
121
|
+
indicators = [".mcp-ticketer", ".git", "pyproject.toml", "setup.py"]
|
|
122
|
+
|
|
123
|
+
while current != current.parent:
|
|
124
|
+
if any((current / indicator).exists() for indicator in indicators):
|
|
125
|
+
return current
|
|
126
|
+
current = current.parent
|
|
127
|
+
|
|
128
|
+
# Fallback to current directory
|
|
129
|
+
return Path.cwd()
|
|
130
|
+
|
|
131
|
+
def _load_env_files(self):
|
|
132
|
+
"""Load environment variables from .env files."""
|
|
133
|
+
env_files = [
|
|
134
|
+
self.project_root / ".env.local",
|
|
135
|
+
self.project_root / ".env",
|
|
136
|
+
Path.home() / ".mcp-ticketer" / ".env",
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
for env_file in env_files:
|
|
140
|
+
if env_file.exists():
|
|
141
|
+
logger.debug(f"Loading environment from: {env_file}")
|
|
142
|
+
self._load_env_file(env_file)
|
|
143
|
+
|
|
144
|
+
def _load_env_file(self, env_file: Path):
|
|
145
|
+
"""Load variables from a single .env file."""
|
|
146
|
+
try:
|
|
147
|
+
with open(env_file, 'r') as f:
|
|
148
|
+
for line_num, line in enumerate(f, 1):
|
|
149
|
+
line = line.strip()
|
|
150
|
+
|
|
151
|
+
# Skip empty lines and comments
|
|
152
|
+
if not line or line.startswith('#'):
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
# Parse KEY=VALUE format
|
|
156
|
+
if '=' in line:
|
|
157
|
+
key, value = line.split('=', 1)
|
|
158
|
+
key = key.strip()
|
|
159
|
+
value = value.strip()
|
|
160
|
+
|
|
161
|
+
# Remove quotes if present
|
|
162
|
+
if value.startswith('"') and value.endswith('"'):
|
|
163
|
+
value = value[1:-1]
|
|
164
|
+
elif value.startswith("'") and value.endswith("'"):
|
|
165
|
+
value = value[1:-1]
|
|
166
|
+
|
|
167
|
+
# Only set if not already in environment
|
|
168
|
+
if key not in os.environ:
|
|
169
|
+
os.environ[key] = value
|
|
170
|
+
self._env_cache[key] = value
|
|
171
|
+
logger.debug(f"Loaded {key} from {env_file}")
|
|
172
|
+
|
|
173
|
+
except Exception as e:
|
|
174
|
+
logger.warning(f"Failed to load {env_file}: {e}")
|
|
175
|
+
|
|
176
|
+
def get_value(self, config_key: str, config: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
|
177
|
+
"""
|
|
178
|
+
Get a configuration value using the key alias system.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
config_key: The configuration key (e.g., 'linear_api_key')
|
|
182
|
+
config: Optional configuration dictionary to check first
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
The value if found, None otherwise
|
|
186
|
+
"""
|
|
187
|
+
if config_key not in self.KEY_MAPPINGS:
|
|
188
|
+
logger.warning(f"Unknown configuration key: {config_key}")
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
key_config = self.KEY_MAPPINGS[config_key]
|
|
192
|
+
|
|
193
|
+
# 1. Check provided config dictionary first
|
|
194
|
+
if config:
|
|
195
|
+
# Check for the config key itself (without adapter prefix)
|
|
196
|
+
simple_key = config_key.split('_', 1)[1] if '_' in config_key else config_key
|
|
197
|
+
if simple_key in config:
|
|
198
|
+
value = config[simple_key]
|
|
199
|
+
if value:
|
|
200
|
+
logger.debug(f"Found {config_key} in config as {simple_key}")
|
|
201
|
+
return str(value)
|
|
202
|
+
|
|
203
|
+
# 2. Check environment variables (primary key first, then aliases)
|
|
204
|
+
all_keys = [key_config.primary_key] + key_config.aliases
|
|
205
|
+
|
|
206
|
+
for env_key in all_keys:
|
|
207
|
+
value = os.getenv(env_key)
|
|
208
|
+
if value:
|
|
209
|
+
logger.debug(f"Found {config_key} as {env_key}")
|
|
210
|
+
return value
|
|
211
|
+
|
|
212
|
+
# 3. Return default if available
|
|
213
|
+
if key_config.default:
|
|
214
|
+
logger.debug(f"Using default for {config_key}")
|
|
215
|
+
return key_config.default
|
|
216
|
+
|
|
217
|
+
# 4. Log if required key is missing
|
|
218
|
+
if key_config.required:
|
|
219
|
+
logger.warning(f"Required configuration key {config_key} not found. Tried: {all_keys}")
|
|
220
|
+
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
def get_adapter_config(self, adapter_name: str, base_config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
224
|
+
"""
|
|
225
|
+
Get complete configuration for an adapter with environment variable resolution.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
adapter_name: Name of the adapter ('linear', 'jira', 'github')
|
|
229
|
+
base_config: Base configuration dictionary
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
Complete configuration with environment variables resolved
|
|
233
|
+
"""
|
|
234
|
+
config = base_config.copy() if base_config else {}
|
|
235
|
+
|
|
236
|
+
# Get adapter-specific keys
|
|
237
|
+
adapter_keys = [key for key in self.KEY_MAPPINGS.keys() if key.startswith(f"{adapter_name}_")]
|
|
238
|
+
|
|
239
|
+
for config_key in adapter_keys:
|
|
240
|
+
# Remove adapter prefix for the config key
|
|
241
|
+
simple_key = config_key.split('_', 1)[1]
|
|
242
|
+
|
|
243
|
+
# Only set if not already in config or if config value is empty
|
|
244
|
+
if simple_key not in config or not config[simple_key]:
|
|
245
|
+
value = self.get_value(config_key, config)
|
|
246
|
+
if value:
|
|
247
|
+
config[simple_key] = value
|
|
248
|
+
|
|
249
|
+
return config
|
|
250
|
+
|
|
251
|
+
def validate_adapter_config(self, adapter_name: str, config: Dict[str, Any]) -> List[str]:
|
|
252
|
+
"""
|
|
253
|
+
Validate that all required configuration is present for an adapter.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
adapter_name: Name of the adapter
|
|
257
|
+
config: Configuration dictionary
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
List of missing required keys (empty if all required keys are present)
|
|
261
|
+
"""
|
|
262
|
+
missing_keys = []
|
|
263
|
+
adapter_keys = [key for key in self.KEY_MAPPINGS.keys() if key.startswith(f"{adapter_name}_")]
|
|
264
|
+
|
|
265
|
+
for config_key in adapter_keys:
|
|
266
|
+
key_config = self.KEY_MAPPINGS[config_key]
|
|
267
|
+
if key_config.required:
|
|
268
|
+
simple_key = config_key.split('_', 1)[1]
|
|
269
|
+
if simple_key not in config or not config[simple_key]:
|
|
270
|
+
missing_keys.append(f"{simple_key} ({key_config.description})")
|
|
271
|
+
|
|
272
|
+
return missing_keys
|
|
273
|
+
|
|
274
|
+
def get_debug_info(self) -> Dict[str, Any]:
|
|
275
|
+
"""Get debug information about environment loading."""
|
|
276
|
+
return {
|
|
277
|
+
"project_root": str(self.project_root),
|
|
278
|
+
"env_files_checked": [
|
|
279
|
+
str(self.project_root / ".env.local"),
|
|
280
|
+
str(self.project_root / ".env"),
|
|
281
|
+
str(Path.home() / ".mcp-ticketer" / ".env"),
|
|
282
|
+
],
|
|
283
|
+
"loaded_keys": list(self._env_cache.keys()),
|
|
284
|
+
"available_configs": list(self.KEY_MAPPINGS.keys()),
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# Global instance
|
|
289
|
+
_env_loader: Optional[UnifiedEnvLoader] = None
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def get_env_loader() -> UnifiedEnvLoader:
|
|
293
|
+
"""Get the global environment loader instance."""
|
|
294
|
+
global _env_loader
|
|
295
|
+
if _env_loader is None:
|
|
296
|
+
_env_loader = UnifiedEnvLoader()
|
|
297
|
+
return _env_loader
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def load_adapter_config(adapter_name: str, base_config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
301
|
+
"""
|
|
302
|
+
Convenience function to load adapter configuration with environment variables.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
adapter_name: Name of the adapter ('linear', 'jira', 'github')
|
|
306
|
+
base_config: Base configuration dictionary
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
Complete configuration with environment variables resolved
|
|
310
|
+
"""
|
|
311
|
+
return get_env_loader().get_adapter_config(adapter_name, base_config)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def validate_adapter_config(adapter_name: str, config: Dict[str, Any]) -> List[str]:
|
|
315
|
+
"""
|
|
316
|
+
Convenience function to validate adapter configuration.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
adapter_name: Name of the adapter
|
|
320
|
+
config: Configuration dictionary
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
List of missing required keys (empty if all required keys are present)
|
|
324
|
+
"""
|
|
325
|
+
return get_env_loader().validate_adapter_config(adapter_name, config)
|
mcp_ticketer/core/models.py
CHANGED
|
@@ -1,4 +1,26 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Universal Ticket models using Pydantic.
|
|
2
|
+
|
|
3
|
+
This module defines the core data models for the MCP Ticketer system, providing
|
|
4
|
+
a unified interface across different ticket management platforms (Linear, JIRA,
|
|
5
|
+
GitHub, etc.).
|
|
6
|
+
|
|
7
|
+
The models follow a hierarchical structure:
|
|
8
|
+
- Epic: Strategic level containers (Projects in Linear, Epics in JIRA)
|
|
9
|
+
- Issue: Standard work items (Issues in GitHub, Stories in JIRA)
|
|
10
|
+
- Task: Sub-work items (Sub-issues in Linear, Sub-tasks in JIRA)
|
|
11
|
+
|
|
12
|
+
All models use Pydantic v2 for validation and serialization, ensuring type safety
|
|
13
|
+
and consistent data handling across adapters.
|
|
14
|
+
|
|
15
|
+
Example:
|
|
16
|
+
>>> from mcp_ticketer.core.models import Task, Priority, TicketState
|
|
17
|
+
>>> task = Task(
|
|
18
|
+
... title="Fix authentication bug",
|
|
19
|
+
... priority=Priority.HIGH,
|
|
20
|
+
... state=TicketState.IN_PROGRESS
|
|
21
|
+
... )
|
|
22
|
+
>>> print(task.model_dump_json())
|
|
23
|
+
"""
|
|
2
24
|
|
|
3
25
|
from datetime import datetime
|
|
4
26
|
from enum import Enum
|
|
@@ -8,7 +30,19 @@ from pydantic import BaseModel, ConfigDict, Field
|
|
|
8
30
|
|
|
9
31
|
|
|
10
32
|
class Priority(str, Enum):
|
|
11
|
-
"""Universal priority levels.
|
|
33
|
+
"""Universal priority levels for tickets.
|
|
34
|
+
|
|
35
|
+
These priority levels are mapped to platform-specific priorities:
|
|
36
|
+
- Linear: 1 (Critical), 2 (High), 3 (Medium), 4 (Low)
|
|
37
|
+
- JIRA: Highest, High, Medium, Low
|
|
38
|
+
- GitHub: P0/critical, P1/high, P2/medium, P3/low labels
|
|
39
|
+
|
|
40
|
+
Attributes:
|
|
41
|
+
LOW: Low priority, non-urgent work
|
|
42
|
+
MEDIUM: Standard priority, default for most work
|
|
43
|
+
HIGH: High priority, should be addressed soon
|
|
44
|
+
CRITICAL: Critical priority, urgent work requiring immediate attention
|
|
45
|
+
"""
|
|
12
46
|
|
|
13
47
|
LOW = "low"
|
|
14
48
|
MEDIUM = "medium"
|
|
@@ -17,7 +51,22 @@ class Priority(str, Enum):
|
|
|
17
51
|
|
|
18
52
|
|
|
19
53
|
class TicketType(str, Enum):
|
|
20
|
-
"""Ticket type hierarchy.
|
|
54
|
+
"""Ticket type hierarchy for organizing work.
|
|
55
|
+
|
|
56
|
+
Defines the three-level hierarchy used across all platforms:
|
|
57
|
+
|
|
58
|
+
Platform Mappings:
|
|
59
|
+
- Linear: Project (Epic) → Issue (Issue) → Sub-issue (Task)
|
|
60
|
+
- JIRA: Epic (Epic) → Story/Task (Issue) → Sub-task (Task)
|
|
61
|
+
- GitHub: Milestone (Epic) → Issue (Issue) → Checklist item (Task)
|
|
62
|
+
- Aitrackdown: Epic file → Issue file → Task reference
|
|
63
|
+
|
|
64
|
+
Attributes:
|
|
65
|
+
EPIC: Strategic level containers for large features or initiatives
|
|
66
|
+
ISSUE: Standard work items, the primary unit of work
|
|
67
|
+
TASK: Sub-work items, smaller pieces of an issue
|
|
68
|
+
SUBTASK: Alias for TASK for backward compatibility
|
|
69
|
+
"""
|
|
21
70
|
|
|
22
71
|
EPIC = "epic" # Strategic level (Projects in Linear, Milestones in GitHub)
|
|
23
72
|
ISSUE = "issue" # Work item level (standard issues/tasks)
|
|
@@ -26,7 +75,33 @@ class TicketType(str, Enum):
|
|
|
26
75
|
|
|
27
76
|
|
|
28
77
|
class TicketState(str, Enum):
|
|
29
|
-
"""Universal ticket states with state machine
|
|
78
|
+
"""Universal ticket states with workflow state machine.
|
|
79
|
+
|
|
80
|
+
Implements a standardized workflow that maps to different platform states:
|
|
81
|
+
|
|
82
|
+
State Flow:
|
|
83
|
+
OPEN → IN_PROGRESS → READY → TESTED → DONE → CLOSED
|
|
84
|
+
↓ ↓ ↓
|
|
85
|
+
CLOSED WAITING BLOCKED
|
|
86
|
+
↓ ↓
|
|
87
|
+
IN_PROGRESS ← IN_PROGRESS
|
|
88
|
+
|
|
89
|
+
Platform Mappings:
|
|
90
|
+
- Linear: Backlog (OPEN), Started (IN_PROGRESS), Completed (DONE), Canceled (CLOSED)
|
|
91
|
+
- JIRA: To Do (OPEN), In Progress (IN_PROGRESS), Done (DONE), etc.
|
|
92
|
+
- GitHub: open (OPEN), closed (CLOSED) + labels for extended states
|
|
93
|
+
- Aitrackdown: File-based state tracking
|
|
94
|
+
|
|
95
|
+
Attributes:
|
|
96
|
+
OPEN: Initial state, work not yet started
|
|
97
|
+
IN_PROGRESS: Work is actively being done
|
|
98
|
+
READY: Work is complete and ready for review/testing
|
|
99
|
+
TESTED: Work has been tested and verified
|
|
100
|
+
DONE: Work is complete and accepted
|
|
101
|
+
WAITING: Work is paused waiting for external dependency
|
|
102
|
+
BLOCKED: Work is blocked by an impediment
|
|
103
|
+
CLOSED: Final state, work is closed/archived
|
|
104
|
+
"""
|
|
30
105
|
|
|
31
106
|
OPEN = "open"
|
|
32
107
|
IN_PROGRESS = "in_progress"
|
|
@@ -39,7 +114,14 @@ class TicketState(str, Enum):
|
|
|
39
114
|
|
|
40
115
|
@classmethod
|
|
41
116
|
def valid_transitions(cls) -> dict[str, list[str]]:
|
|
42
|
-
"""Define valid state transitions.
|
|
117
|
+
"""Define valid state transitions for workflow enforcement.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Dictionary mapping each state to list of valid target states
|
|
121
|
+
|
|
122
|
+
Note:
|
|
123
|
+
CLOSED is a terminal state with no valid transitions
|
|
124
|
+
"""
|
|
43
125
|
return {
|
|
44
126
|
cls.OPEN: [cls.IN_PROGRESS, cls.WAITING, cls.BLOCKED, cls.CLOSED],
|
|
45
127
|
cls.IN_PROGRESS: [cls.READY, cls.WAITING, cls.BLOCKED, cls.OPEN],
|
|
@@ -52,12 +134,56 @@ class TicketState(str, Enum):
|
|
|
52
134
|
}
|
|
53
135
|
|
|
54
136
|
def can_transition_to(self, target: "TicketState") -> bool:
|
|
55
|
-
"""Check if transition to target state is valid.
|
|
137
|
+
"""Check if transition to target state is valid.
|
|
138
|
+
|
|
139
|
+
Validates state transitions according to the defined workflow rules.
|
|
140
|
+
This prevents invalid state changes and ensures workflow integrity.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
target: The state to transition to
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
True if the transition is valid, False otherwise
|
|
147
|
+
|
|
148
|
+
Example:
|
|
149
|
+
>>> state = TicketState.OPEN
|
|
150
|
+
>>> state.can_transition_to(TicketState.IN_PROGRESS)
|
|
151
|
+
True
|
|
152
|
+
>>> state.can_transition_to(TicketState.DONE)
|
|
153
|
+
False
|
|
154
|
+
"""
|
|
56
155
|
return target.value in self.valid_transitions().get(self, [])
|
|
57
156
|
|
|
58
157
|
|
|
59
158
|
class BaseTicket(BaseModel):
|
|
60
|
-
"""Base model for all ticket types.
|
|
159
|
+
"""Base model for all ticket types with universal field mapping.
|
|
160
|
+
|
|
161
|
+
Provides common fields and functionality shared across all ticket types
|
|
162
|
+
(Epic, Task, Comment). Uses Pydantic v2 for validation and serialization.
|
|
163
|
+
|
|
164
|
+
The metadata field allows adapters to store platform-specific information
|
|
165
|
+
while maintaining the universal interface.
|
|
166
|
+
|
|
167
|
+
Attributes:
|
|
168
|
+
id: Unique identifier assigned by the platform
|
|
169
|
+
title: Human-readable title (required, min 1 character)
|
|
170
|
+
description: Optional detailed description or body text
|
|
171
|
+
state: Current workflow state (defaults to OPEN)
|
|
172
|
+
priority: Priority level (defaults to MEDIUM)
|
|
173
|
+
tags: List of tags/labels for categorization
|
|
174
|
+
created_at: Timestamp when ticket was created
|
|
175
|
+
updated_at: Timestamp when ticket was last modified
|
|
176
|
+
metadata: Platform-specific data and field mappings
|
|
177
|
+
|
|
178
|
+
Example:
|
|
179
|
+
>>> ticket = BaseTicket(
|
|
180
|
+
... title="Fix login issue",
|
|
181
|
+
... description="Users cannot log in with SSO",
|
|
182
|
+
... priority=Priority.HIGH,
|
|
183
|
+
... tags=["bug", "authentication"]
|
|
184
|
+
... )
|
|
185
|
+
>>> ticket.state = TicketState.IN_PROGRESS
|
|
186
|
+
"""
|
|
61
187
|
|
|
62
188
|
model_config = ConfigDict(use_enum_values=True)
|
|
63
189
|
|
|
@@ -77,7 +203,32 @@ class BaseTicket(BaseModel):
|
|
|
77
203
|
|
|
78
204
|
|
|
79
205
|
class Epic(BaseTicket):
|
|
80
|
-
"""Epic - highest level container for work
|
|
206
|
+
"""Epic - highest level container for strategic work initiatives.
|
|
207
|
+
|
|
208
|
+
Epics represent large features, projects, or initiatives that contain
|
|
209
|
+
multiple related issues. They map to different concepts across platforms:
|
|
210
|
+
|
|
211
|
+
Platform Mappings:
|
|
212
|
+
- Linear: Projects (with issues as children)
|
|
213
|
+
- JIRA: Epics (with stories/tasks as children)
|
|
214
|
+
- GitHub: Milestones (with issues as children)
|
|
215
|
+
- Aitrackdown: Epic files (with issue references)
|
|
216
|
+
|
|
217
|
+
Epics sit at the top of the hierarchy and cannot have parent epics.
|
|
218
|
+
They can contain multiple child issues, which in turn can contain tasks.
|
|
219
|
+
|
|
220
|
+
Attributes:
|
|
221
|
+
ticket_type: Always TicketType.EPIC (frozen field)
|
|
222
|
+
child_issues: List of issue IDs that belong to this epic
|
|
223
|
+
|
|
224
|
+
Example:
|
|
225
|
+
>>> epic = Epic(
|
|
226
|
+
... title="User Authentication System",
|
|
227
|
+
... description="Complete overhaul of authentication",
|
|
228
|
+
... priority=Priority.HIGH
|
|
229
|
+
... )
|
|
230
|
+
>>> epic.child_issues = ["ISSUE-123", "ISSUE-124"]
|
|
231
|
+
"""
|
|
81
232
|
|
|
82
233
|
ticket_type: TicketType = Field(
|
|
83
234
|
default=TicketType.EPIC, frozen=True, description="Always EPIC type"
|
|
@@ -89,9 +240,11 @@ class Epic(BaseTicket):
|
|
|
89
240
|
def validate_hierarchy(self) -> list[str]:
|
|
90
241
|
"""Validate epic hierarchy rules.
|
|
91
242
|
|
|
92
|
-
|
|
93
|
-
|
|
243
|
+
Epics are at the top of the hierarchy and have no parent constraints.
|
|
244
|
+
This method is provided for consistency with other ticket types.
|
|
94
245
|
|
|
246
|
+
Returns:
|
|
247
|
+
Empty list (epics have no hierarchy constraints)
|
|
95
248
|
"""
|
|
96
249
|
# Epics don't have parents in our hierarchy
|
|
97
250
|
return []
|
mcp_ticketer/mcp/server.py
CHANGED
|
@@ -1745,10 +1745,10 @@ async def _handle_system_health(self, arguments: dict[str, Any]) -> dict[str, An
|
|
|
1745
1745
|
|
|
1746
1746
|
# Check queue system
|
|
1747
1747
|
try:
|
|
1748
|
-
from ..queue.manager import
|
|
1749
|
-
|
|
1750
|
-
worker_status =
|
|
1751
|
-
stats =
|
|
1748
|
+
from ..queue.manager import WorkerManager
|
|
1749
|
+
worker_manager = WorkerManager()
|
|
1750
|
+
worker_status = worker_manager.get_status()
|
|
1751
|
+
stats = worker_manager.queue.get_stats()
|
|
1752
1752
|
|
|
1753
1753
|
total = stats.get("total", 0)
|
|
1754
1754
|
failed = stats.get("failed", 0)
|
mcp_ticketer/queue/manager.py
CHANGED
|
@@ -99,8 +99,23 @@ class WorkerManager:
|
|
|
99
99
|
return False
|
|
100
100
|
|
|
101
101
|
try:
|
|
102
|
-
# Start worker in subprocess
|
|
103
|
-
|
|
102
|
+
# Start worker in subprocess using the same Python executable as the CLI
|
|
103
|
+
# This ensures the worker can import mcp_ticketer modules
|
|
104
|
+
python_executable = self._get_python_executable()
|
|
105
|
+
cmd = [python_executable, "-m", "mcp_ticketer.queue.run_worker"]
|
|
106
|
+
|
|
107
|
+
# Prepare environment for subprocess
|
|
108
|
+
# Ensure the subprocess gets the same environment as the parent
|
|
109
|
+
subprocess_env = os.environ.copy()
|
|
110
|
+
|
|
111
|
+
# Explicitly load environment variables from .env.local if it exists
|
|
112
|
+
env_file = Path.cwd() / ".env.local"
|
|
113
|
+
if env_file.exists():
|
|
114
|
+
logger.debug(f"Loading environment from {env_file} for subprocess")
|
|
115
|
+
from dotenv import dotenv_values
|
|
116
|
+
env_vars = dotenv_values(env_file)
|
|
117
|
+
subprocess_env.update(env_vars)
|
|
118
|
+
logger.debug(f"Added {len(env_vars)} environment variables from .env.local")
|
|
104
119
|
|
|
105
120
|
# Start as background process
|
|
106
121
|
process = subprocess.Popen(
|
|
@@ -108,6 +123,8 @@ class WorkerManager:
|
|
|
108
123
|
stdout=subprocess.DEVNULL,
|
|
109
124
|
stderr=subprocess.DEVNULL,
|
|
110
125
|
start_new_session=True,
|
|
126
|
+
env=subprocess_env, # Pass environment explicitly
|
|
127
|
+
cwd=str(Path.cwd()), # Ensure correct working directory
|
|
111
128
|
)
|
|
112
129
|
|
|
113
130
|
# Save PID
|
|
@@ -256,6 +273,44 @@ class WorkerManager:
|
|
|
256
273
|
except (OSError, ValueError):
|
|
257
274
|
return None
|
|
258
275
|
|
|
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
|
+
|
|
259
314
|
def _cleanup(self):
|
|
260
315
|
"""Clean up lock and PID files."""
|
|
261
316
|
self._release_lock()
|
mcp_ticketer/queue/run_worker.py
CHANGED
|
@@ -15,7 +15,12 @@ logger = logging.getLogger(__name__)
|
|
|
15
15
|
|
|
16
16
|
def main():
|
|
17
17
|
"""Run the worker process."""
|
|
18
|
+
import sys
|
|
19
|
+
import os
|
|
18
20
|
logger.info("Starting standalone worker process")
|
|
21
|
+
logger.info(f"Worker Python executable: {sys.executable}")
|
|
22
|
+
logger.info(f"Worker working directory: {os.getcwd()}")
|
|
23
|
+
logger.info(f"Worker Python path: {sys.path[:3]}...") # Show first 3 entries
|
|
19
24
|
|
|
20
25
|
try:
|
|
21
26
|
# Create queue and worker
|