mcp-ticketer 0.1.30__py3-none-any.whl → 1.2.11__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/__init__.py +10 -10
- mcp_ticketer/__version__.py +3 -3
- mcp_ticketer/adapters/__init__.py +2 -0
- mcp_ticketer/adapters/aitrackdown.py +796 -46
- mcp_ticketer/adapters/asana/__init__.py +15 -0
- mcp_ticketer/adapters/asana/adapter.py +1416 -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.py +879 -129
- mcp_ticketer/adapters/hybrid.py +11 -11
- mcp_ticketer/adapters/jira.py +973 -73
- mcp_ticketer/adapters/linear/__init__.py +24 -0
- mcp_ticketer/adapters/linear/adapter.py +2732 -0
- mcp_ticketer/adapters/linear/client.py +344 -0
- mcp_ticketer/adapters/linear/mappers.py +420 -0
- mcp_ticketer/adapters/linear/queries.py +479 -0
- mcp_ticketer/adapters/linear/types.py +360 -0
- mcp_ticketer/adapters/linear.py +10 -2315
- mcp_ticketer/analysis/__init__.py +23 -0
- mcp_ticketer/analysis/orphaned.py +218 -0
- mcp_ticketer/analysis/similarity.py +224 -0
- mcp_ticketer/analysis/staleness.py +266 -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 +888 -151
- mcp_ticketer/cli/diagnostics.py +400 -157
- 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/instruction_commands.py +435 -0
- mcp_ticketer/cli/linear_commands.py +616 -0
- mcp_ticketer/cli/main.py +203 -1165
- mcp_ticketer/cli/mcp_configure.py +474 -90
- 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 +418 -0
- mcp_ticketer/cli/platform_installer.py +513 -0
- mcp_ticketer/cli/python_detection.py +126 -0
- mcp_ticketer/cli/queue_commands.py +15 -15
- mcp_ticketer/cli/setup_command.py +639 -0
- mcp_ticketer/cli/simple_health.py +90 -65
- mcp_ticketer/cli/ticket_commands.py +1013 -0
- mcp_ticketer/cli/update_checker.py +313 -0
- mcp_ticketer/cli/utils.py +114 -66
- mcp_ticketer/core/__init__.py +24 -1
- mcp_ticketer/core/adapter.py +250 -16
- mcp_ticketer/core/config.py +145 -37
- mcp_ticketer/core/env_discovery.py +101 -22
- mcp_ticketer/core/env_loader.py +349 -0
- mcp_ticketer/core/exceptions.py +160 -0
- 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/models.py +280 -28
- mcp_ticketer/core/onepassword_secrets.py +379 -0
- mcp_ticketer/core/project_config.py +183 -49
- mcp_ticketer/core/registry.py +3 -3
- mcp_ticketer/core/session_state.py +171 -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 +655 -0
- mcp_ticketer/mcp/server/server_sdk.py +151 -0
- mcp_ticketer/mcp/server/tools/__init__.py +56 -0
- mcp_ticketer/mcp/server/tools/analysis_tools.py +495 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +226 -0
- mcp_ticketer/mcp/server/tools/bulk_tools.py +273 -0
- mcp_ticketer/mcp/server/tools/comment_tools.py +152 -0
- mcp_ticketer/mcp/server/tools/config_tools.py +1439 -0
- mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +921 -0
- mcp_ticketer/mcp/server/tools/instruction_tools.py +300 -0
- mcp_ticketer/mcp/server/tools/label_tools.py +948 -0
- mcp_ticketer/mcp/server/tools/pr_tools.py +152 -0
- mcp_ticketer/mcp/server/tools/search_tools.py +215 -0
- mcp_ticketer/mcp/server/tools/session_tools.py +170 -0
- mcp_ticketer/mcp/server/tools/ticket_tools.py +1268 -0
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +547 -0
- mcp_ticketer/queue/__init__.py +1 -0
- mcp_ticketer/queue/health_monitor.py +168 -136
- mcp_ticketer/queue/manager.py +95 -25
- mcp_ticketer/queue/queue.py +40 -21
- mcp_ticketer/queue/run_worker.py +6 -1
- mcp_ticketer/queue/ticket_registry.py +213 -155
- mcp_ticketer/queue/worker.py +109 -49
- mcp_ticketer-1.2.11.dist-info/METADATA +792 -0
- mcp_ticketer-1.2.11.dist-info/RECORD +110 -0
- mcp_ticketer/mcp/server.py +0 -1895
- mcp_ticketer-0.1.30.dist-info/METADATA +0 -413
- mcp_ticketer-0.1.30.dist-info/RECORD +0 -49
- {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/licenses/LICENSE +0 -0
- {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Unified Environment Loading System for MCP Ticketer.
|
|
3
|
+
|
|
4
|
+
This module provides a resilient environment loading system that:
|
|
5
|
+
1. Supports multiple naming conventions for each configuration key
|
|
6
|
+
2. Loads from multiple sources (.env.local, .env, environment variables)
|
|
7
|
+
3. Works consistently across CLI, worker processes, and MCP server
|
|
8
|
+
4. Provides fallback mechanisms for different key aliases
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class EnvKeyConfig:
|
|
22
|
+
"""Configuration for environment variable key aliases."""
|
|
23
|
+
|
|
24
|
+
primary_key: str
|
|
25
|
+
aliases: list[str]
|
|
26
|
+
description: str
|
|
27
|
+
required: bool = False
|
|
28
|
+
default: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class UnifiedEnvLoader:
|
|
32
|
+
"""Unified environment loader that handles multiple naming conventions.
|
|
33
|
+
|
|
34
|
+
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
|
+
# JIRA adapter keys
|
|
59
|
+
"jira_server": EnvKeyConfig(
|
|
60
|
+
primary_key="JIRA_SERVER",
|
|
61
|
+
aliases=["JIRA_URL", "JIRA_HOST", "JIRA_BASE_URL"],
|
|
62
|
+
description="JIRA server URL",
|
|
63
|
+
required=True,
|
|
64
|
+
),
|
|
65
|
+
"jira_email": EnvKeyConfig(
|
|
66
|
+
primary_key="JIRA_EMAIL",
|
|
67
|
+
aliases=["JIRA_USER", "JIRA_USERNAME", "JIRA_ACCESS_USER"],
|
|
68
|
+
description="JIRA user email",
|
|
69
|
+
required=True,
|
|
70
|
+
),
|
|
71
|
+
"jira_api_token": EnvKeyConfig(
|
|
72
|
+
primary_key="JIRA_API_TOKEN",
|
|
73
|
+
aliases=[
|
|
74
|
+
"JIRA_TOKEN",
|
|
75
|
+
"JIRA_ACCESS_TOKEN",
|
|
76
|
+
"JIRA_AUTH_TOKEN",
|
|
77
|
+
"JIRA_PASSWORD",
|
|
78
|
+
],
|
|
79
|
+
description="JIRA API token",
|
|
80
|
+
required=True,
|
|
81
|
+
),
|
|
82
|
+
"jira_project_key": EnvKeyConfig(
|
|
83
|
+
primary_key="JIRA_PROJECT_KEY",
|
|
84
|
+
aliases=["JIRA_PROJECT", "JIRA_PROJECT_ID"],
|
|
85
|
+
description="JIRA project key",
|
|
86
|
+
required=False,
|
|
87
|
+
),
|
|
88
|
+
# GitHub adapter keys
|
|
89
|
+
"github_token": EnvKeyConfig(
|
|
90
|
+
primary_key="GITHUB_TOKEN",
|
|
91
|
+
aliases=["GITHUB_ACCESS_TOKEN", "GITHUB_API_TOKEN", "GITHUB_AUTH_TOKEN"],
|
|
92
|
+
description="GitHub access token",
|
|
93
|
+
required=True,
|
|
94
|
+
),
|
|
95
|
+
"github_owner": EnvKeyConfig(
|
|
96
|
+
primary_key="GITHUB_OWNER",
|
|
97
|
+
aliases=["GITHUB_USER", "GITHUB_USERNAME", "GITHUB_ORG"],
|
|
98
|
+
description="GitHub repository owner",
|
|
99
|
+
required=True,
|
|
100
|
+
),
|
|
101
|
+
"github_repo": EnvKeyConfig(
|
|
102
|
+
primary_key="GITHUB_REPO",
|
|
103
|
+
aliases=["GITHUB_REPOSITORY", "GITHUB_REPO_NAME"],
|
|
104
|
+
description="GitHub repository name",
|
|
105
|
+
required=True,
|
|
106
|
+
),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
def __init__(self, project_root: Path | None = None):
|
|
110
|
+
"""Initialize the environment loader.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
project_root: Project root directory. If None, will auto-detect.
|
|
114
|
+
|
|
115
|
+
"""
|
|
116
|
+
self.project_root = project_root or self._find_project_root()
|
|
117
|
+
self._env_cache: dict[str, str] = {}
|
|
118
|
+
self._load_env_files()
|
|
119
|
+
|
|
120
|
+
def _find_project_root(self) -> Path:
|
|
121
|
+
"""Find the project root directory."""
|
|
122
|
+
current = Path.cwd()
|
|
123
|
+
|
|
124
|
+
# Look for common project indicators
|
|
125
|
+
indicators = [".mcp-ticketer", ".git", "pyproject.toml", "setup.py"]
|
|
126
|
+
|
|
127
|
+
while current != current.parent:
|
|
128
|
+
if any((current / indicator).exists() for indicator in indicators):
|
|
129
|
+
return current
|
|
130
|
+
current = current.parent
|
|
131
|
+
|
|
132
|
+
# Fallback to current directory
|
|
133
|
+
return Path.cwd()
|
|
134
|
+
|
|
135
|
+
def _load_env_files(self) -> None:
|
|
136
|
+
"""Load environment variables from .env files."""
|
|
137
|
+
env_files = [
|
|
138
|
+
self.project_root / ".env.local",
|
|
139
|
+
self.project_root / ".env",
|
|
140
|
+
Path.home() / ".mcp-ticketer" / ".env",
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
for env_file in env_files:
|
|
144
|
+
if env_file.exists():
|
|
145
|
+
logger.debug(f"Loading environment from: {env_file}")
|
|
146
|
+
self._load_env_file(env_file)
|
|
147
|
+
|
|
148
|
+
def _load_env_file(self, env_file: Path) -> None:
|
|
149
|
+
"""Load variables from a single .env file."""
|
|
150
|
+
try:
|
|
151
|
+
with open(env_file) as f:
|
|
152
|
+
for _line_num, line in enumerate(f, 1):
|
|
153
|
+
line = line.strip()
|
|
154
|
+
|
|
155
|
+
# Skip empty lines and comments
|
|
156
|
+
if not line or line.startswith("#"):
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
# Parse KEY=VALUE format
|
|
160
|
+
if "=" in line:
|
|
161
|
+
key, value = line.split("=", 1)
|
|
162
|
+
key = key.strip()
|
|
163
|
+
value = value.strip()
|
|
164
|
+
|
|
165
|
+
# Remove quotes if present
|
|
166
|
+
if value.startswith('"') and value.endswith('"'):
|
|
167
|
+
value = value[1:-1]
|
|
168
|
+
elif value.startswith("'") and value.endswith("'"):
|
|
169
|
+
value = value[1:-1]
|
|
170
|
+
|
|
171
|
+
# Only set if not already in environment
|
|
172
|
+
if key not in os.environ:
|
|
173
|
+
os.environ[key] = value
|
|
174
|
+
self._env_cache[key] = value
|
|
175
|
+
logger.debug(f"Loaded {key} from {env_file}")
|
|
176
|
+
|
|
177
|
+
except Exception as e:
|
|
178
|
+
logger.warning(f"Failed to load {env_file}: {e}")
|
|
179
|
+
|
|
180
|
+
def get_value(
|
|
181
|
+
self, config_key: str, config: dict[str, Any] | None = None
|
|
182
|
+
) -> str | None:
|
|
183
|
+
"""Get a configuration value using the key alias system.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
config_key: The configuration key (e.g., 'linear_api_key')
|
|
187
|
+
config: Optional configuration dictionary to check first
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
The value if found, None otherwise
|
|
191
|
+
|
|
192
|
+
"""
|
|
193
|
+
if config_key not in self.KEY_MAPPINGS:
|
|
194
|
+
logger.warning(f"Unknown configuration key: {config_key}")
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
key_config = self.KEY_MAPPINGS[config_key]
|
|
198
|
+
|
|
199
|
+
# 1. Check provided config dictionary first
|
|
200
|
+
if config:
|
|
201
|
+
# Check for the config key itself (without adapter prefix)
|
|
202
|
+
simple_key = (
|
|
203
|
+
config_key.split("_", 1)[1] if "_" in config_key else config_key
|
|
204
|
+
)
|
|
205
|
+
if simple_key in config:
|
|
206
|
+
value = config[simple_key]
|
|
207
|
+
if value:
|
|
208
|
+
logger.debug(f"Found {config_key} in config as {simple_key}")
|
|
209
|
+
return str(value)
|
|
210
|
+
|
|
211
|
+
# 2. Check environment variables (primary key first, then aliases)
|
|
212
|
+
all_keys = [key_config.primary_key] + key_config.aliases
|
|
213
|
+
|
|
214
|
+
for env_key in all_keys:
|
|
215
|
+
value = os.getenv(env_key)
|
|
216
|
+
if value:
|
|
217
|
+
logger.debug(f"Found {config_key} as {env_key}")
|
|
218
|
+
return value
|
|
219
|
+
|
|
220
|
+
# 3. Return default if available
|
|
221
|
+
if key_config.default:
|
|
222
|
+
logger.debug(f"Using default for {config_key}")
|
|
223
|
+
return key_config.default
|
|
224
|
+
|
|
225
|
+
# 4. Log if required key is missing
|
|
226
|
+
if key_config.required:
|
|
227
|
+
logger.warning(
|
|
228
|
+
f"Required configuration key {config_key} not found. Tried: {all_keys}"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
def get_adapter_config(
|
|
234
|
+
self, adapter_name: str, base_config: dict[str, Any] | None = None
|
|
235
|
+
) -> dict[str, Any]:
|
|
236
|
+
"""Get complete configuration for an adapter with environment variable resolution.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
adapter_name: Name of the adapter ('linear', 'jira', 'github')
|
|
240
|
+
base_config: Base configuration dictionary
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
Complete configuration with environment variables resolved
|
|
244
|
+
|
|
245
|
+
"""
|
|
246
|
+
config = base_config.copy() if base_config else {}
|
|
247
|
+
|
|
248
|
+
# Get adapter-specific keys
|
|
249
|
+
adapter_keys = [
|
|
250
|
+
key
|
|
251
|
+
for key in self.KEY_MAPPINGS.keys()
|
|
252
|
+
if key.startswith(f"{adapter_name}_")
|
|
253
|
+
]
|
|
254
|
+
|
|
255
|
+
for config_key in adapter_keys:
|
|
256
|
+
# Remove adapter prefix for the config key
|
|
257
|
+
simple_key = config_key.split("_", 1)[1]
|
|
258
|
+
|
|
259
|
+
# Only set if not already in config or if config value is empty
|
|
260
|
+
if simple_key not in config or not config[simple_key]:
|
|
261
|
+
value = self.get_value(config_key, config)
|
|
262
|
+
if value:
|
|
263
|
+
config[simple_key] = value
|
|
264
|
+
|
|
265
|
+
return config
|
|
266
|
+
|
|
267
|
+
def validate_adapter_config(
|
|
268
|
+
self, adapter_name: str, config: dict[str, Any]
|
|
269
|
+
) -> list[str]:
|
|
270
|
+
"""Validate that all required configuration is present for an adapter.
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
adapter_name: Name of the adapter
|
|
274
|
+
config: Configuration dictionary
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
List of missing required keys (empty if all required keys are present)
|
|
278
|
+
|
|
279
|
+
"""
|
|
280
|
+
missing_keys = []
|
|
281
|
+
adapter_keys = [
|
|
282
|
+
key
|
|
283
|
+
for key in self.KEY_MAPPINGS.keys()
|
|
284
|
+
if key.startswith(f"{adapter_name}_")
|
|
285
|
+
]
|
|
286
|
+
|
|
287
|
+
for config_key in adapter_keys:
|
|
288
|
+
key_config = self.KEY_MAPPINGS[config_key]
|
|
289
|
+
if key_config.required:
|
|
290
|
+
simple_key = config_key.split("_", 1)[1]
|
|
291
|
+
if simple_key not in config or not config[simple_key]:
|
|
292
|
+
missing_keys.append(f"{simple_key} ({key_config.description})")
|
|
293
|
+
|
|
294
|
+
return missing_keys
|
|
295
|
+
|
|
296
|
+
def get_debug_info(self) -> dict[str, Any]:
|
|
297
|
+
"""Get debug information about environment loading."""
|
|
298
|
+
return {
|
|
299
|
+
"project_root": str(self.project_root),
|
|
300
|
+
"env_files_checked": [
|
|
301
|
+
str(self.project_root / ".env.local"),
|
|
302
|
+
str(self.project_root / ".env"),
|
|
303
|
+
str(Path.home() / ".mcp-ticketer" / ".env"),
|
|
304
|
+
],
|
|
305
|
+
"loaded_keys": list(self._env_cache.keys()),
|
|
306
|
+
"available_configs": list(self.KEY_MAPPINGS.keys()),
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# Global instance
|
|
311
|
+
_env_loader: UnifiedEnvLoader | None = None
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def get_env_loader() -> UnifiedEnvLoader:
|
|
315
|
+
"""Get the global environment loader instance."""
|
|
316
|
+
global _env_loader
|
|
317
|
+
if _env_loader is None:
|
|
318
|
+
_env_loader = UnifiedEnvLoader()
|
|
319
|
+
return _env_loader
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def load_adapter_config(
|
|
323
|
+
adapter_name: str, base_config: dict[str, Any] | None = None
|
|
324
|
+
) -> dict[str, Any]:
|
|
325
|
+
"""Load adapter configuration with environment variables.
|
|
326
|
+
|
|
327
|
+
Args:
|
|
328
|
+
adapter_name: Name of the adapter ('linear', 'jira', 'github')
|
|
329
|
+
base_config: Base configuration dictionary
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
Complete configuration with environment variables resolved
|
|
333
|
+
|
|
334
|
+
"""
|
|
335
|
+
return get_env_loader().get_adapter_config(adapter_name, base_config)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def validate_adapter_config(adapter_name: str, config: dict[str, Any]) -> list[str]:
|
|
339
|
+
"""Validate adapter configuration.
|
|
340
|
+
|
|
341
|
+
Args:
|
|
342
|
+
adapter_name: Name of the adapter
|
|
343
|
+
config: Configuration dictionary
|
|
344
|
+
|
|
345
|
+
Returns:
|
|
346
|
+
List of missing required keys (empty if all required keys are present)
|
|
347
|
+
|
|
348
|
+
"""
|
|
349
|
+
return get_env_loader().validate_adapter_config(adapter_name, config)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Exception classes for MCP Ticketer.
|
|
2
|
+
|
|
3
|
+
Error Severity Classification:
|
|
4
|
+
CRITICAL - System-level issues (auth, config, network) → Always suggest diagnostics
|
|
5
|
+
MEDIUM - Resource issues (not found, permissions) → Suggest diagnostics
|
|
6
|
+
LOW - User input errors (validation, state transitions) → No diagnostics
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .models import TicketState
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MCPTicketerError(Exception):
|
|
17
|
+
"""Base exception for MCP Ticketer."""
|
|
18
|
+
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AdapterError(MCPTicketerError):
|
|
23
|
+
"""Base adapter error."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
message: str,
|
|
28
|
+
adapter_name: str,
|
|
29
|
+
original_error: Exception | None = None,
|
|
30
|
+
):
|
|
31
|
+
"""Initialize adapter error.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
message: Error message
|
|
35
|
+
adapter_name: Name of the adapter that raised the error
|
|
36
|
+
original_error: Original exception that caused this error
|
|
37
|
+
|
|
38
|
+
"""
|
|
39
|
+
super().__init__(message)
|
|
40
|
+
self.adapter_name = adapter_name
|
|
41
|
+
self.original_error = original_error
|
|
42
|
+
|
|
43
|
+
def __str__(self) -> str:
|
|
44
|
+
"""Return string representation of the error."""
|
|
45
|
+
base_msg = f"[{self.adapter_name}] {super().__str__()}"
|
|
46
|
+
if self.original_error:
|
|
47
|
+
base_msg += f" (caused by: {self.original_error})"
|
|
48
|
+
return base_msg
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class AuthenticationError(AdapterError):
|
|
52
|
+
"""Authentication failed with external service."""
|
|
53
|
+
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class RateLimitError(AdapterError):
|
|
58
|
+
"""Rate limit exceeded."""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
message: str,
|
|
63
|
+
adapter_name: str,
|
|
64
|
+
retry_after: int | None = None,
|
|
65
|
+
original_error: Exception | None = None,
|
|
66
|
+
):
|
|
67
|
+
"""Initialize rate limit error.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
message: Error message
|
|
71
|
+
adapter_name: Name of the adapter
|
|
72
|
+
retry_after: Seconds to wait before retrying
|
|
73
|
+
original_error: Original exception
|
|
74
|
+
|
|
75
|
+
"""
|
|
76
|
+
super().__init__(message, adapter_name, original_error)
|
|
77
|
+
self.retry_after = retry_after
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ValidationError(MCPTicketerError):
|
|
81
|
+
"""Data validation error."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, message: str, field: str | None = None, value: Any = None):
|
|
84
|
+
"""Initialize validation error.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
message: Error message
|
|
88
|
+
field: Field that failed validation
|
|
89
|
+
value: Value that failed validation
|
|
90
|
+
|
|
91
|
+
"""
|
|
92
|
+
super().__init__(message)
|
|
93
|
+
self.field = field
|
|
94
|
+
self.value = value
|
|
95
|
+
|
|
96
|
+
def __str__(self) -> str:
|
|
97
|
+
"""Return string representation of the error."""
|
|
98
|
+
base_msg = super().__str__()
|
|
99
|
+
if self.field:
|
|
100
|
+
base_msg += f" (field: {self.field})"
|
|
101
|
+
if self.value is not None:
|
|
102
|
+
base_msg += f" (value: {self.value})"
|
|
103
|
+
return base_msg
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class ConfigurationError(MCPTicketerError):
|
|
107
|
+
"""Configuration error."""
|
|
108
|
+
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class CacheError(MCPTicketerError):
|
|
113
|
+
"""Cache operation error."""
|
|
114
|
+
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class StateTransitionError(MCPTicketerError):
|
|
119
|
+
"""Invalid state transition."""
|
|
120
|
+
|
|
121
|
+
def __init__(self, message: str, from_state: TicketState, to_state: TicketState):
|
|
122
|
+
"""Initialize state transition error.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
message: Error message
|
|
126
|
+
from_state: Current state
|
|
127
|
+
to_state: Target state
|
|
128
|
+
|
|
129
|
+
"""
|
|
130
|
+
super().__init__(message)
|
|
131
|
+
self.from_state = from_state
|
|
132
|
+
self.to_state = to_state
|
|
133
|
+
|
|
134
|
+
def __str__(self) -> str:
|
|
135
|
+
"""Return string representation of the error."""
|
|
136
|
+
return f"{super().__str__()} ({self.from_state} -> {self.to_state})"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class NetworkError(AdapterError):
|
|
140
|
+
"""Network-related error."""
|
|
141
|
+
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class TimeoutError(AdapterError):
|
|
146
|
+
"""Request timeout error."""
|
|
147
|
+
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class NotFoundError(AdapterError):
|
|
152
|
+
"""Resource not found error."""
|
|
153
|
+
|
|
154
|
+
pass
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class PermissionError(AdapterError):
|
|
158
|
+
"""Permission denied error."""
|
|
159
|
+
|
|
160
|
+
pass
|
mcp_ticketer/core/http_client.py
CHANGED
|
@@ -4,7 +4,7 @@ import asyncio
|
|
|
4
4
|
import logging
|
|
5
5
|
import time
|
|
6
6
|
from enum import Enum
|
|
7
|
-
from typing import Any
|
|
7
|
+
from typing import Any
|
|
8
8
|
|
|
9
9
|
import httpx
|
|
10
10
|
from httpx import AsyncClient, TimeoutException
|
|
@@ -32,8 +32,8 @@ class RetryConfig:
|
|
|
32
32
|
max_delay: float = 60.0,
|
|
33
33
|
exponential_base: float = 2.0,
|
|
34
34
|
jitter: bool = True,
|
|
35
|
-
retry_on_status:
|
|
36
|
-
retry_on_exceptions:
|
|
35
|
+
retry_on_status: list[int] | None = None,
|
|
36
|
+
retry_on_exceptions: list[type] | None = None,
|
|
37
37
|
):
|
|
38
38
|
self.max_retries = max_retries
|
|
39
39
|
self.initial_delay = initial_delay
|
|
@@ -94,11 +94,11 @@ class BaseHTTPClient:
|
|
|
94
94
|
def __init__(
|
|
95
95
|
self,
|
|
96
96
|
base_url: str,
|
|
97
|
-
headers:
|
|
98
|
-
auth:
|
|
97
|
+
headers: dict[str, str] | None = None,
|
|
98
|
+
auth: httpx.Auth | tuple | None = None,
|
|
99
99
|
timeout: float = 30.0,
|
|
100
|
-
retry_config:
|
|
101
|
-
rate_limiter:
|
|
100
|
+
retry_config: RetryConfig | None = None,
|
|
101
|
+
rate_limiter: RateLimiter | None = None,
|
|
102
102
|
verify_ssl: bool = True,
|
|
103
103
|
follow_redirects: bool = True,
|
|
104
104
|
):
|
|
@@ -132,7 +132,7 @@ class BaseHTTPClient:
|
|
|
132
132
|
"errors": 0,
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
self._client:
|
|
135
|
+
self._client: AsyncClient | None = None
|
|
136
136
|
|
|
137
137
|
async def _get_client(self) -> AsyncClient:
|
|
138
138
|
"""Get or create HTTP client instance."""
|
|
@@ -148,7 +148,7 @@ class BaseHTTPClient:
|
|
|
148
148
|
return self._client
|
|
149
149
|
|
|
150
150
|
async def _calculate_delay(
|
|
151
|
-
self, attempt: int, response:
|
|
151
|
+
self, attempt: int, response: httpx.Response | None = None
|
|
152
152
|
) -> float:
|
|
153
153
|
"""Calculate delay for retry attempt."""
|
|
154
154
|
if response and response.status_code == 429:
|
|
@@ -178,7 +178,7 @@ class BaseHTTPClient:
|
|
|
178
178
|
def _should_retry(
|
|
179
179
|
self,
|
|
180
180
|
exception: Exception,
|
|
181
|
-
response:
|
|
181
|
+
response: httpx.Response | None = None,
|
|
182
182
|
attempt: int = 1,
|
|
183
183
|
) -> bool:
|
|
184
184
|
"""Determine if request should be retried."""
|
|
@@ -198,15 +198,15 @@ class BaseHTTPClient:
|
|
|
198
198
|
|
|
199
199
|
async def request(
|
|
200
200
|
self,
|
|
201
|
-
method:
|
|
201
|
+
method: HTTPMethod | str,
|
|
202
202
|
endpoint: str,
|
|
203
|
-
data:
|
|
204
|
-
json:
|
|
205
|
-
params:
|
|
206
|
-
headers:
|
|
207
|
-
timeout:
|
|
203
|
+
data: dict[str, Any] | None = None,
|
|
204
|
+
json: dict[str, Any] | None = None,
|
|
205
|
+
params: dict[str, Any] | None = None,
|
|
206
|
+
headers: dict[str, str] | None = None,
|
|
207
|
+
timeout: float | None = None,
|
|
208
208
|
retry_count: int = 0,
|
|
209
|
-
**kwargs,
|
|
209
|
+
**kwargs: Any,
|
|
210
210
|
) -> httpx.Response:
|
|
211
211
|
"""Make HTTP request with retry and rate limiting.
|
|
212
212
|
|
|
@@ -293,27 +293,27 @@ class BaseHTTPClient:
|
|
|
293
293
|
# No more retries, re-raise the exception
|
|
294
294
|
raise
|
|
295
295
|
|
|
296
|
-
async def get(self, endpoint: str, **kwargs) -> httpx.Response:
|
|
296
|
+
async def get(self, endpoint: str, **kwargs: Any) -> httpx.Response:
|
|
297
297
|
"""Make GET request."""
|
|
298
298
|
return await self.request(HTTPMethod.GET, endpoint, **kwargs)
|
|
299
299
|
|
|
300
|
-
async def post(self, endpoint: str, **kwargs) -> httpx.Response:
|
|
300
|
+
async def post(self, endpoint: str, **kwargs: Any) -> httpx.Response:
|
|
301
301
|
"""Make POST request."""
|
|
302
302
|
return await self.request(HTTPMethod.POST, endpoint, **kwargs)
|
|
303
303
|
|
|
304
|
-
async def put(self, endpoint: str, **kwargs) -> httpx.Response:
|
|
304
|
+
async def put(self, endpoint: str, **kwargs: Any) -> httpx.Response:
|
|
305
305
|
"""Make PUT request."""
|
|
306
306
|
return await self.request(HTTPMethod.PUT, endpoint, **kwargs)
|
|
307
307
|
|
|
308
|
-
async def patch(self, endpoint: str, **kwargs) -> httpx.Response:
|
|
308
|
+
async def patch(self, endpoint: str, **kwargs: Any) -> httpx.Response:
|
|
309
309
|
"""Make PATCH request."""
|
|
310
310
|
return await self.request(HTTPMethod.PATCH, endpoint, **kwargs)
|
|
311
311
|
|
|
312
|
-
async def delete(self, endpoint: str, **kwargs) -> httpx.Response:
|
|
312
|
+
async def delete(self, endpoint: str, **kwargs: Any) -> httpx.Response:
|
|
313
313
|
"""Make DELETE request."""
|
|
314
314
|
return await self.request(HTTPMethod.DELETE, endpoint, **kwargs)
|
|
315
315
|
|
|
316
|
-
async def get_json(self, endpoint: str, **kwargs) -> dict[str, Any]:
|
|
316
|
+
async def get_json(self, endpoint: str, **kwargs: Any) -> dict[str, Any]:
|
|
317
317
|
"""Make GET request and return JSON response."""
|
|
318
318
|
response = await self.get(endpoint, **kwargs)
|
|
319
319
|
|
|
@@ -323,7 +323,7 @@ class BaseHTTPClient:
|
|
|
323
323
|
|
|
324
324
|
return response.json()
|
|
325
325
|
|
|
326
|
-
async def post_json(self, endpoint: str, **kwargs) -> dict[str, Any]:
|
|
326
|
+
async def post_json(self, endpoint: str, **kwargs: Any) -> dict[str, Any]:
|
|
327
327
|
"""Make POST request and return JSON response."""
|
|
328
328
|
response = await self.post(endpoint, **kwargs)
|
|
329
329
|
|
|
@@ -333,7 +333,7 @@ class BaseHTTPClient:
|
|
|
333
333
|
|
|
334
334
|
return response.json()
|
|
335
335
|
|
|
336
|
-
async def put_json(self, endpoint: str, **kwargs) -> dict[str, Any]:
|
|
336
|
+
async def put_json(self, endpoint: str, **kwargs: Any) -> dict[str, Any]:
|
|
337
337
|
"""Make PUT request and return JSON response."""
|
|
338
338
|
response = await self.put(endpoint, **kwargs)
|
|
339
339
|
|
|
@@ -343,7 +343,7 @@ class BaseHTTPClient:
|
|
|
343
343
|
|
|
344
344
|
return response.json()
|
|
345
345
|
|
|
346
|
-
async def patch_json(self, endpoint: str, **kwargs) -> dict[str, Any]:
|
|
346
|
+
async def patch_json(self, endpoint: str, **kwargs: Any) -> dict[str, Any]:
|
|
347
347
|
"""Make PATCH request and return JSON response."""
|
|
348
348
|
response = await self.patch(endpoint, **kwargs)
|
|
349
349
|
|