mcp-ticketer 0.1.21__py3-none-any.whl → 0.1.22__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 +7 -7
- mcp_ticketer/__version__.py +4 -2
- mcp_ticketer/adapters/__init__.py +4 -4
- mcp_ticketer/adapters/aitrackdown.py +54 -38
- mcp_ticketer/adapters/github.py +175 -109
- mcp_ticketer/adapters/hybrid.py +90 -45
- mcp_ticketer/adapters/jira.py +139 -130
- mcp_ticketer/adapters/linear.py +374 -225
- mcp_ticketer/cache/__init__.py +1 -1
- mcp_ticketer/cache/memory.py +14 -15
- mcp_ticketer/cli/__init__.py +1 -1
- mcp_ticketer/cli/configure.py +69 -93
- mcp_ticketer/cli/discover.py +43 -35
- mcp_ticketer/cli/main.py +250 -293
- mcp_ticketer/cli/mcp_configure.py +39 -15
- mcp_ticketer/cli/migrate_config.py +10 -12
- mcp_ticketer/cli/queue_commands.py +21 -58
- mcp_ticketer/cli/utils.py +115 -60
- mcp_ticketer/core/__init__.py +2 -2
- mcp_ticketer/core/adapter.py +36 -30
- mcp_ticketer/core/config.py +113 -77
- mcp_ticketer/core/env_discovery.py +51 -19
- mcp_ticketer/core/http_client.py +46 -29
- mcp_ticketer/core/mappers.py +79 -35
- mcp_ticketer/core/models.py +29 -15
- mcp_ticketer/core/project_config.py +131 -66
- mcp_ticketer/core/registry.py +12 -12
- mcp_ticketer/mcp/__init__.py +1 -1
- mcp_ticketer/mcp/server.py +183 -129
- mcp_ticketer/queue/__init__.py +2 -2
- mcp_ticketer/queue/__main__.py +1 -1
- mcp_ticketer/queue/manager.py +29 -25
- mcp_ticketer/queue/queue.py +144 -82
- mcp_ticketer/queue/run_worker.py +2 -3
- mcp_ticketer/queue/worker.py +48 -33
- {mcp_ticketer-0.1.21.dist-info → mcp_ticketer-0.1.22.dist-info}/METADATA +1 -1
- mcp_ticketer-0.1.22.dist-info/RECORD +42 -0
- mcp_ticketer-0.1.21.dist-info/RECORD +0 -42
- {mcp_ticketer-0.1.21.dist-info → mcp_ticketer-0.1.22.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.1.21.dist-info → mcp_ticketer-0.1.22.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.1.21.dist-info → mcp_ticketer-0.1.22.dist-info}/licenses/LICENSE +0 -0
- {mcp_ticketer-0.1.21.dist-info → mcp_ticketer-0.1.22.dist-info}/top_level.txt +0 -0
mcp_ticketer/core/config.py
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
"""Centralized configuration management with caching and validation."""
|
|
2
2
|
|
|
3
|
-
import os
|
|
4
3
|
import json
|
|
5
4
|
import logging
|
|
6
|
-
|
|
7
|
-
from
|
|
5
|
+
import os
|
|
6
|
+
from enum import Enum
|
|
8
7
|
from functools import lru_cache
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, List, Optional, Union
|
|
10
|
+
|
|
9
11
|
import yaml
|
|
10
|
-
from pydantic import BaseModel, Field,
|
|
11
|
-
from enum import Enum
|
|
12
|
+
from pydantic import BaseModel, Field, root_validator, validator
|
|
12
13
|
|
|
13
14
|
logger = logging.getLogger(__name__)
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
class AdapterType(str, Enum):
|
|
17
18
|
"""Supported adapter types."""
|
|
19
|
+
|
|
18
20
|
GITHUB = "github"
|
|
19
21
|
JIRA = "jira"
|
|
20
22
|
LINEAR = "linear"
|
|
@@ -23,6 +25,7 @@ class AdapterType(str, Enum):
|
|
|
23
25
|
|
|
24
26
|
class BaseAdapterConfig(BaseModel):
|
|
25
27
|
"""Base configuration for all adapters."""
|
|
28
|
+
|
|
26
29
|
type: AdapterType
|
|
27
30
|
name: Optional[str] = None
|
|
28
31
|
enabled: bool = True
|
|
@@ -33,99 +36,104 @@ class BaseAdapterConfig(BaseModel):
|
|
|
33
36
|
|
|
34
37
|
class GitHubConfig(BaseAdapterConfig):
|
|
35
38
|
"""GitHub adapter configuration."""
|
|
39
|
+
|
|
36
40
|
type: AdapterType = AdapterType.GITHUB
|
|
37
|
-
token: Optional[str] = Field(None, env=
|
|
38
|
-
owner: Optional[str] = Field(None, env=
|
|
39
|
-
repo: Optional[str] = Field(None, env=
|
|
41
|
+
token: Optional[str] = Field(None, env="GITHUB_TOKEN")
|
|
42
|
+
owner: Optional[str] = Field(None, env="GITHUB_OWNER")
|
|
43
|
+
repo: Optional[str] = Field(None, env="GITHUB_REPO")
|
|
40
44
|
api_url: str = "https://api.github.com"
|
|
41
45
|
use_projects_v2: bool = False
|
|
42
46
|
custom_priority_scheme: Optional[Dict[str, List[str]]] = None
|
|
43
47
|
|
|
44
|
-
@validator(
|
|
48
|
+
@validator("token", pre=True, always=True)
|
|
45
49
|
def validate_token(cls, v):
|
|
46
50
|
if not v:
|
|
47
|
-
v = os.getenv(
|
|
51
|
+
v = os.getenv("GITHUB_TOKEN")
|
|
48
52
|
if not v:
|
|
49
|
-
raise ValueError(
|
|
53
|
+
raise ValueError("GitHub token is required")
|
|
50
54
|
return v
|
|
51
55
|
|
|
52
|
-
@validator(
|
|
56
|
+
@validator("owner", pre=True, always=True)
|
|
53
57
|
def validate_owner(cls, v):
|
|
54
58
|
if not v:
|
|
55
|
-
v = os.getenv(
|
|
59
|
+
v = os.getenv("GITHUB_OWNER")
|
|
56
60
|
if not v:
|
|
57
|
-
raise ValueError(
|
|
61
|
+
raise ValueError("GitHub owner is required")
|
|
58
62
|
return v
|
|
59
63
|
|
|
60
|
-
@validator(
|
|
64
|
+
@validator("repo", pre=True, always=True)
|
|
61
65
|
def validate_repo(cls, v):
|
|
62
66
|
if not v:
|
|
63
|
-
v = os.getenv(
|
|
67
|
+
v = os.getenv("GITHUB_REPO")
|
|
64
68
|
if not v:
|
|
65
|
-
raise ValueError(
|
|
69
|
+
raise ValueError("GitHub repo is required")
|
|
66
70
|
return v
|
|
67
71
|
|
|
68
72
|
|
|
69
73
|
class JiraConfig(BaseAdapterConfig):
|
|
70
74
|
"""JIRA adapter configuration."""
|
|
75
|
+
|
|
71
76
|
type: AdapterType = AdapterType.JIRA
|
|
72
|
-
server: Optional[str] = Field(None, env=
|
|
73
|
-
email: Optional[str] = Field(None, env=
|
|
74
|
-
api_token: Optional[str] = Field(None, env=
|
|
75
|
-
project_key: Optional[str] = Field(None, env=
|
|
77
|
+
server: Optional[str] = Field(None, env="JIRA_SERVER")
|
|
78
|
+
email: Optional[str] = Field(None, env="JIRA_EMAIL")
|
|
79
|
+
api_token: Optional[str] = Field(None, env="JIRA_API_TOKEN")
|
|
80
|
+
project_key: Optional[str] = Field(None, env="JIRA_PROJECT_KEY")
|
|
76
81
|
cloud: bool = True
|
|
77
82
|
verify_ssl: bool = True
|
|
78
83
|
|
|
79
|
-
@validator(
|
|
84
|
+
@validator("server", pre=True, always=True)
|
|
80
85
|
def validate_server(cls, v):
|
|
81
86
|
if not v:
|
|
82
|
-
v = os.getenv(
|
|
87
|
+
v = os.getenv("JIRA_SERVER")
|
|
83
88
|
if not v:
|
|
84
|
-
raise ValueError(
|
|
85
|
-
return v.rstrip(
|
|
89
|
+
raise ValueError("JIRA server URL is required")
|
|
90
|
+
return v.rstrip("/")
|
|
86
91
|
|
|
87
|
-
@validator(
|
|
92
|
+
@validator("email", pre=True, always=True)
|
|
88
93
|
def validate_email(cls, v):
|
|
89
94
|
if not v:
|
|
90
|
-
v = os.getenv(
|
|
95
|
+
v = os.getenv("JIRA_EMAIL")
|
|
91
96
|
if not v:
|
|
92
|
-
raise ValueError(
|
|
97
|
+
raise ValueError("JIRA email is required")
|
|
93
98
|
return v
|
|
94
99
|
|
|
95
|
-
@validator(
|
|
100
|
+
@validator("api_token", pre=True, always=True)
|
|
96
101
|
def validate_api_token(cls, v):
|
|
97
102
|
if not v:
|
|
98
|
-
v = os.getenv(
|
|
103
|
+
v = os.getenv("JIRA_API_TOKEN")
|
|
99
104
|
if not v:
|
|
100
|
-
raise ValueError(
|
|
105
|
+
raise ValueError("JIRA API token is required")
|
|
101
106
|
return v
|
|
102
107
|
|
|
103
108
|
|
|
104
109
|
class LinearConfig(BaseAdapterConfig):
|
|
105
110
|
"""Linear adapter configuration."""
|
|
111
|
+
|
|
106
112
|
type: AdapterType = AdapterType.LINEAR
|
|
107
|
-
api_key: Optional[str] = Field(None, env=
|
|
113
|
+
api_key: Optional[str] = Field(None, env="LINEAR_API_KEY")
|
|
108
114
|
workspace: Optional[str] = None
|
|
109
115
|
team_key: str
|
|
110
116
|
api_url: str = "https://api.linear.app/graphql"
|
|
111
117
|
|
|
112
|
-
@validator(
|
|
118
|
+
@validator("api_key", pre=True, always=True)
|
|
113
119
|
def validate_api_key(cls, v):
|
|
114
120
|
if not v:
|
|
115
|
-
v = os.getenv(
|
|
121
|
+
v = os.getenv("LINEAR_API_KEY")
|
|
116
122
|
if not v:
|
|
117
|
-
raise ValueError(
|
|
123
|
+
raise ValueError("Linear API key is required")
|
|
118
124
|
return v
|
|
119
125
|
|
|
120
126
|
|
|
121
127
|
class AITrackdownConfig(BaseAdapterConfig):
|
|
122
128
|
"""AITrackdown adapter configuration."""
|
|
129
|
+
|
|
123
130
|
type: AdapterType = AdapterType.AITRACKDOWN
|
|
124
131
|
# AITrackdown uses local storage, minimal config needed
|
|
125
132
|
|
|
126
133
|
|
|
127
134
|
class QueueConfig(BaseModel):
|
|
128
135
|
"""Queue configuration."""
|
|
136
|
+
|
|
129
137
|
provider: str = "sqlite"
|
|
130
138
|
connection_string: Optional[str] = None
|
|
131
139
|
batch_size: int = 10
|
|
@@ -136,6 +144,7 @@ class QueueConfig(BaseModel):
|
|
|
136
144
|
|
|
137
145
|
class LoggingConfig(BaseModel):
|
|
138
146
|
"""Logging configuration."""
|
|
147
|
+
|
|
139
148
|
level: str = "INFO"
|
|
140
149
|
format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
141
150
|
file: Optional[str] = None
|
|
@@ -145,25 +154,30 @@ class LoggingConfig(BaseModel):
|
|
|
145
154
|
|
|
146
155
|
class AppConfig(BaseModel):
|
|
147
156
|
"""Main application configuration."""
|
|
148
|
-
|
|
157
|
+
|
|
158
|
+
adapters: Dict[
|
|
159
|
+
str, Union[GitHubConfig, JiraConfig, LinearConfig, AITrackdownConfig]
|
|
160
|
+
] = {}
|
|
149
161
|
queue: QueueConfig = QueueConfig()
|
|
150
162
|
logging: LoggingConfig = LoggingConfig()
|
|
151
163
|
cache_ttl: int = 300 # Cache TTL in seconds
|
|
152
164
|
default_adapter: Optional[str] = None
|
|
153
165
|
|
|
154
|
-
@root_validator
|
|
166
|
+
@root_validator(skip_on_failure=True)
|
|
155
167
|
def validate_adapters(cls, values):
|
|
156
168
|
"""Validate adapter configurations."""
|
|
157
|
-
adapters = values.get(
|
|
169
|
+
adapters = values.get("adapters", {})
|
|
158
170
|
|
|
159
171
|
if not adapters:
|
|
160
172
|
logger.warning("No adapters configured")
|
|
161
173
|
return values
|
|
162
174
|
|
|
163
175
|
# Validate default adapter
|
|
164
|
-
default_adapter = values.get(
|
|
176
|
+
default_adapter = values.get("default_adapter")
|
|
165
177
|
if default_adapter and default_adapter not in adapters:
|
|
166
|
-
raise ValueError(
|
|
178
|
+
raise ValueError(
|
|
179
|
+
f"Default adapter '{default_adapter}' not found in adapters"
|
|
180
|
+
)
|
|
167
181
|
|
|
168
182
|
return values
|
|
169
183
|
|
|
@@ -173,17 +187,19 @@ class AppConfig(BaseModel):
|
|
|
173
187
|
|
|
174
188
|
def get_enabled_adapters(self) -> Dict[str, BaseAdapterConfig]:
|
|
175
189
|
"""Get all enabled adapters."""
|
|
176
|
-
return {
|
|
190
|
+
return {
|
|
191
|
+
name: config for name, config in self.adapters.items() if config.enabled
|
|
192
|
+
}
|
|
177
193
|
|
|
178
194
|
|
|
179
195
|
class ConfigurationManager:
|
|
180
196
|
"""Centralized configuration management with caching and validation."""
|
|
181
197
|
|
|
182
|
-
_instance: Optional[
|
|
198
|
+
_instance: Optional["ConfigurationManager"] = None
|
|
183
199
|
_config: Optional[AppConfig] = None
|
|
184
200
|
_config_file_paths: List[Path] = []
|
|
185
201
|
|
|
186
|
-
def __new__(cls) ->
|
|
202
|
+
def __new__(cls) -> "ConfigurationManager":
|
|
187
203
|
"""Singleton pattern for global config access."""
|
|
188
204
|
if cls._instance is None:
|
|
189
205
|
cls._instance = super().__new__(cls)
|
|
@@ -191,26 +207,48 @@ class ConfigurationManager:
|
|
|
191
207
|
|
|
192
208
|
def __init__(self):
|
|
193
209
|
"""Initialize configuration manager."""
|
|
194
|
-
if not hasattr(self,
|
|
210
|
+
if not hasattr(self, "_initialized"):
|
|
195
211
|
self._initialized = True
|
|
196
212
|
self._config_cache: Dict[str, Any] = {}
|
|
197
213
|
self._find_config_files()
|
|
198
214
|
|
|
199
215
|
def _find_config_files(self) -> None:
|
|
200
|
-
"""Find configuration files in
|
|
216
|
+
"""Find configuration files in project-local directory ONLY.
|
|
217
|
+
|
|
218
|
+
SECURITY: This method ONLY searches in the current project directory
|
|
219
|
+
to prevent configuration leakage across projects. It will NEVER read
|
|
220
|
+
from user home directory or system-wide locations.
|
|
221
|
+
"""
|
|
222
|
+
# ONLY search in current project directory, never external locations
|
|
201
223
|
possible_paths = [
|
|
202
|
-
Path.cwd() / "mcp-ticketer.
|
|
203
|
-
Path.cwd() / "mcp-ticketer.
|
|
204
|
-
Path.cwd() / "
|
|
205
|
-
Path.cwd() / "config.
|
|
206
|
-
Path.
|
|
207
|
-
Path.home() / ".mcp-ticketer.yml",
|
|
208
|
-
Path("/etc/mcp-ticketer/config.yaml"),
|
|
209
|
-
Path("/etc/mcp-ticketer/config.yml"),
|
|
224
|
+
Path.cwd() / ".mcp-ticketer" / "config.json", # Primary JSON config
|
|
225
|
+
Path.cwd() / "mcp-ticketer.yaml", # Alternative YAML
|
|
226
|
+
Path.cwd() / "mcp-ticketer.yml", # Alternative YML
|
|
227
|
+
Path.cwd() / "config.yaml", # Generic YAML
|
|
228
|
+
Path.cwd() / "config.yml", # Generic YML
|
|
210
229
|
]
|
|
211
230
|
|
|
212
|
-
|
|
213
|
-
|
|
231
|
+
# Validate all paths are within project (security check)
|
|
232
|
+
validated_paths = []
|
|
233
|
+
for path in possible_paths:
|
|
234
|
+
if path.exists():
|
|
235
|
+
try:
|
|
236
|
+
if path.resolve().is_relative_to(Path.cwd().resolve()):
|
|
237
|
+
validated_paths.append(path)
|
|
238
|
+
else:
|
|
239
|
+
logger.warning(
|
|
240
|
+
f"Security: Ignoring config file outside project: {path}"
|
|
241
|
+
)
|
|
242
|
+
except (ValueError, RuntimeError):
|
|
243
|
+
# is_relative_to may raise ValueError in some cases
|
|
244
|
+
# Skip this file if we can't validate it
|
|
245
|
+
logger.warning(f"Could not validate config file path: {path}")
|
|
246
|
+
|
|
247
|
+
self._config_file_paths = validated_paths
|
|
248
|
+
if self._config_file_paths:
|
|
249
|
+
logger.debug(f"Found project-local config files: {self._config_file_paths}")
|
|
250
|
+
else:
|
|
251
|
+
logger.debug("No project-local config files found, will use defaults")
|
|
214
252
|
|
|
215
253
|
@lru_cache(maxsize=1)
|
|
216
254
|
def load_config(self, config_file: Optional[Union[str, Path]] = None) -> AppConfig:
|
|
@@ -221,6 +259,7 @@ class ConfigurationManager:
|
|
|
221
259
|
|
|
222
260
|
Returns:
|
|
223
261
|
Validated application configuration
|
|
262
|
+
|
|
224
263
|
"""
|
|
225
264
|
if self._config is not None and config_file is None:
|
|
226
265
|
return self._config
|
|
@@ -253,7 +292,9 @@ class ConfigurationManager:
|
|
|
253
292
|
elif adapter_type == "aitrackdown":
|
|
254
293
|
parsed_adapters[name] = AITrackdownConfig(**adapter_config)
|
|
255
294
|
else:
|
|
256
|
-
logger.warning(
|
|
295
|
+
logger.warning(
|
|
296
|
+
f"Unknown adapter type: {adapter_type} for adapter: {name}"
|
|
297
|
+
)
|
|
257
298
|
|
|
258
299
|
config_data["adapters"] = parsed_adapters
|
|
259
300
|
|
|
@@ -264,10 +305,10 @@ class ConfigurationManager:
|
|
|
264
305
|
def _load_config_file(self, config_path: Path) -> Dict[str, Any]:
|
|
265
306
|
"""Load configuration from YAML or JSON file."""
|
|
266
307
|
try:
|
|
267
|
-
with open(config_path,
|
|
268
|
-
if config_path.suffix.lower() in [
|
|
308
|
+
with open(config_path, encoding="utf-8") as file:
|
|
309
|
+
if config_path.suffix.lower() in [".yaml", ".yml"]:
|
|
269
310
|
return yaml.safe_load(file) or {}
|
|
270
|
-
elif config_path.suffix.lower() ==
|
|
311
|
+
elif config_path.suffix.lower() == ".json":
|
|
271
312
|
return json.load(file)
|
|
272
313
|
else:
|
|
273
314
|
# Try YAML first, then JSON
|
|
@@ -306,7 +347,9 @@ class ConfigurationManager:
|
|
|
306
347
|
config = self.get_config()
|
|
307
348
|
return config.logging
|
|
308
349
|
|
|
309
|
-
def reload_config(
|
|
350
|
+
def reload_config(
|
|
351
|
+
self, config_file: Optional[Union[str, Path]] = None
|
|
352
|
+
) -> AppConfig:
|
|
310
353
|
"""Reload configuration from file."""
|
|
311
354
|
# Clear cache
|
|
312
355
|
self.load_config.cache_clear()
|
|
@@ -327,7 +370,7 @@ class ConfigurationManager:
|
|
|
327
370
|
|
|
328
371
|
# Parse nested keys like "queue.batch_size"
|
|
329
372
|
config = self.get_config()
|
|
330
|
-
parts = key.split(
|
|
373
|
+
parts = key.split(".")
|
|
331
374
|
value = config.dict()
|
|
332
375
|
|
|
333
376
|
for part in parts:
|
|
@@ -348,13 +391,13 @@ class ConfigurationManager:
|
|
|
348
391
|
"token": "${GITHUB_TOKEN}",
|
|
349
392
|
"owner": "your-org",
|
|
350
393
|
"repo": "your-repo",
|
|
351
|
-
"enabled": True
|
|
394
|
+
"enabled": True,
|
|
352
395
|
},
|
|
353
396
|
"linear-dev": {
|
|
354
397
|
"type": "linear",
|
|
355
398
|
"api_key": "${LINEAR_API_KEY}",
|
|
356
399
|
"team_key": "DEV",
|
|
357
|
-
"enabled": True
|
|
400
|
+
"enabled": True,
|
|
358
401
|
},
|
|
359
402
|
"jira-support": {
|
|
360
403
|
"type": "jira",
|
|
@@ -362,23 +405,16 @@ class ConfigurationManager:
|
|
|
362
405
|
"email": "${JIRA_EMAIL}",
|
|
363
406
|
"api_token": "${JIRA_API_TOKEN}",
|
|
364
407
|
"project_key": "SUPPORT",
|
|
365
|
-
"enabled": False
|
|
366
|
-
}
|
|
367
|
-
},
|
|
368
|
-
"queue": {
|
|
369
|
-
"provider": "sqlite",
|
|
370
|
-
"batch_size": 10,
|
|
371
|
-
"max_concurrent": 5
|
|
372
|
-
},
|
|
373
|
-
"logging": {
|
|
374
|
-
"level": "INFO",
|
|
375
|
-
"file": "mcp-ticketer.log"
|
|
408
|
+
"enabled": False,
|
|
409
|
+
},
|
|
376
410
|
},
|
|
377
|
-
"
|
|
411
|
+
"queue": {"provider": "sqlite", "batch_size": 10, "max_concurrent": 5},
|
|
412
|
+
"logging": {"level": "INFO", "file": "mcp-ticketer.log"},
|
|
413
|
+
"default_adapter": "github-main",
|
|
378
414
|
}
|
|
379
415
|
|
|
380
416
|
output_path = Path(output_path)
|
|
381
|
-
with open(output_path,
|
|
417
|
+
with open(output_path, "w", encoding="utf-8") as file:
|
|
382
418
|
yaml.dump(sample_config, file, default_flow_style=False, indent=2)
|
|
383
419
|
|
|
384
420
|
logger.info(f"Sample configuration created at: {output_path}")
|
|
@@ -400,4 +436,4 @@ def get_adapter_config(adapter_name: str) -> Optional[BaseAdapterConfig]:
|
|
|
400
436
|
|
|
401
437
|
def reload_config(config_file: Optional[Union[str, Path]] = None) -> AppConfig:
|
|
402
438
|
"""Reload the global configuration."""
|
|
403
|
-
return config_manager.reload_config(config_file)
|
|
439
|
+
return config_manager.reload_config(config_file)
|
|
@@ -8,14 +8,14 @@ environment files, including:
|
|
|
8
8
|
- Security validation
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
|
-
import os
|
|
12
11
|
import logging
|
|
13
|
-
from pathlib import Path
|
|
14
|
-
from typing import Dict, Any, Optional, List, Tuple
|
|
15
12
|
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Dict, List, Optional
|
|
15
|
+
|
|
16
16
|
from dotenv import dotenv_values
|
|
17
17
|
|
|
18
|
-
from .project_config import AdapterType
|
|
18
|
+
from .project_config import AdapterType
|
|
19
19
|
|
|
20
20
|
logger = logging.getLogger(__name__)
|
|
21
21
|
|
|
@@ -130,9 +130,7 @@ class DiscoveryResult:
|
|
|
130
130
|
|
|
131
131
|
# Sort by: complete configs first, then by confidence
|
|
132
132
|
sorted_adapters = sorted(
|
|
133
|
-
self.adapters,
|
|
134
|
-
key=lambda a: (a.is_complete(), a.confidence),
|
|
135
|
-
reverse=True
|
|
133
|
+
self.adapters, key=lambda a: (a.is_complete(), a.confidence), reverse=True
|
|
136
134
|
)
|
|
137
135
|
return sorted_adapters[0]
|
|
138
136
|
|
|
@@ -160,6 +158,7 @@ class EnvDiscovery:
|
|
|
160
158
|
|
|
161
159
|
Args:
|
|
162
160
|
project_path: Path to project root (defaults to cwd)
|
|
161
|
+
|
|
163
162
|
"""
|
|
164
163
|
self.project_path = project_path or Path.cwd()
|
|
165
164
|
|
|
@@ -168,6 +167,7 @@ class EnvDiscovery:
|
|
|
168
167
|
|
|
169
168
|
Returns:
|
|
170
169
|
DiscoveryResult with found adapters and warnings
|
|
170
|
+
|
|
171
171
|
"""
|
|
172
172
|
result = DiscoveryResult()
|
|
173
173
|
|
|
@@ -179,19 +179,27 @@ class EnvDiscovery:
|
|
|
179
179
|
return result
|
|
180
180
|
|
|
181
181
|
# Detect adapters
|
|
182
|
-
linear_adapter = self._detect_linear(
|
|
182
|
+
linear_adapter = self._detect_linear(
|
|
183
|
+
env_vars, result.env_files_found[0] if result.env_files_found else ".env"
|
|
184
|
+
)
|
|
183
185
|
if linear_adapter:
|
|
184
186
|
result.adapters.append(linear_adapter)
|
|
185
187
|
|
|
186
|
-
github_adapter = self._detect_github(
|
|
188
|
+
github_adapter = self._detect_github(
|
|
189
|
+
env_vars, result.env_files_found[0] if result.env_files_found else ".env"
|
|
190
|
+
)
|
|
187
191
|
if github_adapter:
|
|
188
192
|
result.adapters.append(github_adapter)
|
|
189
193
|
|
|
190
|
-
jira_adapter = self._detect_jira(
|
|
194
|
+
jira_adapter = self._detect_jira(
|
|
195
|
+
env_vars, result.env_files_found[0] if result.env_files_found else ".env"
|
|
196
|
+
)
|
|
191
197
|
if jira_adapter:
|
|
192
198
|
result.adapters.append(jira_adapter)
|
|
193
199
|
|
|
194
|
-
aitrackdown_adapter = self._detect_aitrackdown(
|
|
200
|
+
aitrackdown_adapter = self._detect_aitrackdown(
|
|
201
|
+
env_vars, result.env_files_found[0] if result.env_files_found else ".env"
|
|
202
|
+
)
|
|
195
203
|
if aitrackdown_adapter:
|
|
196
204
|
result.adapters.append(aitrackdown_adapter)
|
|
197
205
|
|
|
@@ -209,6 +217,7 @@ class EnvDiscovery:
|
|
|
209
217
|
|
|
210
218
|
Returns:
|
|
211
219
|
Merged dictionary of environment variables
|
|
220
|
+
|
|
212
221
|
"""
|
|
213
222
|
merged_env: Dict[str, str] = {}
|
|
214
223
|
|
|
@@ -229,7 +238,9 @@ class EnvDiscovery:
|
|
|
229
238
|
|
|
230
239
|
return merged_env
|
|
231
240
|
|
|
232
|
-
def _find_key_value(
|
|
241
|
+
def _find_key_value(
|
|
242
|
+
self, env_vars: Dict[str, str], patterns: List[str]
|
|
243
|
+
) -> Optional[str]:
|
|
233
244
|
"""Find first matching key value from patterns.
|
|
234
245
|
|
|
235
246
|
Args:
|
|
@@ -238,13 +249,16 @@ class EnvDiscovery:
|
|
|
238
249
|
|
|
239
250
|
Returns:
|
|
240
251
|
Value if found, None otherwise
|
|
252
|
+
|
|
241
253
|
"""
|
|
242
254
|
for pattern in patterns:
|
|
243
255
|
if pattern in env_vars and env_vars[pattern]:
|
|
244
256
|
return env_vars[pattern]
|
|
245
257
|
return None
|
|
246
258
|
|
|
247
|
-
def _detect_linear(
|
|
259
|
+
def _detect_linear(
|
|
260
|
+
self, env_vars: Dict[str, str], found_in: str
|
|
261
|
+
) -> Optional[DiscoveredAdapter]:
|
|
248
262
|
"""Detect Linear adapter configuration.
|
|
249
263
|
|
|
250
264
|
Args:
|
|
@@ -253,6 +267,7 @@ class EnvDiscovery:
|
|
|
253
267
|
|
|
254
268
|
Returns:
|
|
255
269
|
DiscoveredAdapter if Linear config detected, None otherwise
|
|
270
|
+
|
|
256
271
|
"""
|
|
257
272
|
api_key = self._find_key_value(env_vars, LINEAR_KEY_PATTERNS)
|
|
258
273
|
|
|
@@ -289,7 +304,9 @@ class EnvDiscovery:
|
|
|
289
304
|
found_in=found_in,
|
|
290
305
|
)
|
|
291
306
|
|
|
292
|
-
def _detect_github(
|
|
307
|
+
def _detect_github(
|
|
308
|
+
self, env_vars: Dict[str, str], found_in: str
|
|
309
|
+
) -> Optional[DiscoveredAdapter]:
|
|
293
310
|
"""Detect GitHub adapter configuration.
|
|
294
311
|
|
|
295
312
|
Args:
|
|
@@ -298,6 +315,7 @@ class EnvDiscovery:
|
|
|
298
315
|
|
|
299
316
|
Returns:
|
|
300
317
|
DiscoveredAdapter if GitHub config detected, None otherwise
|
|
318
|
+
|
|
301
319
|
"""
|
|
302
320
|
token = self._find_key_value(env_vars, GITHUB_TOKEN_PATTERNS)
|
|
303
321
|
|
|
@@ -345,7 +363,9 @@ class EnvDiscovery:
|
|
|
345
363
|
found_in=found_in,
|
|
346
364
|
)
|
|
347
365
|
|
|
348
|
-
def _detect_jira(
|
|
366
|
+
def _detect_jira(
|
|
367
|
+
self, env_vars: Dict[str, str], found_in: str
|
|
368
|
+
) -> Optional[DiscoveredAdapter]:
|
|
349
369
|
"""Detect JIRA adapter configuration.
|
|
350
370
|
|
|
351
371
|
Args:
|
|
@@ -354,6 +374,7 @@ class EnvDiscovery:
|
|
|
354
374
|
|
|
355
375
|
Returns:
|
|
356
376
|
DiscoveredAdapter if JIRA config detected, None otherwise
|
|
377
|
+
|
|
357
378
|
"""
|
|
358
379
|
api_token = self._find_key_value(env_vars, JIRA_TOKEN_PATTERNS)
|
|
359
380
|
|
|
@@ -398,7 +419,9 @@ class EnvDiscovery:
|
|
|
398
419
|
found_in=found_in,
|
|
399
420
|
)
|
|
400
421
|
|
|
401
|
-
def _detect_aitrackdown(
|
|
422
|
+
def _detect_aitrackdown(
|
|
423
|
+
self, env_vars: Dict[str, str], found_in: str
|
|
424
|
+
) -> Optional[DiscoveredAdapter]:
|
|
402
425
|
"""Detect AITrackdown adapter configuration.
|
|
403
426
|
|
|
404
427
|
Args:
|
|
@@ -407,6 +430,7 @@ class EnvDiscovery:
|
|
|
407
430
|
|
|
408
431
|
Returns:
|
|
409
432
|
DiscoveredAdapter if AITrackdown config detected, None otherwise
|
|
433
|
+
|
|
410
434
|
"""
|
|
411
435
|
base_path = self._find_key_value(env_vars, AITRACKDOWN_PATH_PATTERNS)
|
|
412
436
|
|
|
@@ -440,6 +464,7 @@ class EnvDiscovery:
|
|
|
440
464
|
|
|
441
465
|
Returns:
|
|
442
466
|
List of security warnings
|
|
467
|
+
|
|
443
468
|
"""
|
|
444
469
|
warnings: List[str] = []
|
|
445
470
|
|
|
@@ -460,7 +485,7 @@ class EnvDiscovery:
|
|
|
460
485
|
# Check if .gitignore exists and has .env patterns
|
|
461
486
|
if gitignore_path.exists():
|
|
462
487
|
try:
|
|
463
|
-
with open(gitignore_path
|
|
488
|
+
with open(gitignore_path) as f:
|
|
464
489
|
gitignore_content = f.read()
|
|
465
490
|
if ".env" not in gitignore_content:
|
|
466
491
|
warnings.append(
|
|
@@ -479,6 +504,7 @@ class EnvDiscovery:
|
|
|
479
504
|
|
|
480
505
|
Returns:
|
|
481
506
|
True if file is tracked in git, False otherwise
|
|
507
|
+
|
|
482
508
|
"""
|
|
483
509
|
import subprocess
|
|
484
510
|
|
|
@@ -506,6 +532,7 @@ class EnvDiscovery:
|
|
|
506
532
|
|
|
507
533
|
Returns:
|
|
508
534
|
List of validation warnings
|
|
535
|
+
|
|
509
536
|
"""
|
|
510
537
|
warnings: List[str] = []
|
|
511
538
|
|
|
@@ -522,12 +549,16 @@ class EnvDiscovery:
|
|
|
522
549
|
|
|
523
550
|
# Validate token prefix
|
|
524
551
|
if token and not token.startswith(("ghp_", "gho_", "ghu_", "ghs_", "ghr_")):
|
|
525
|
-
warnings.append(
|
|
552
|
+
warnings.append(
|
|
553
|
+
"⚠️ GitHub token doesn't match expected format (should start with ghp_, gho_, etc.)"
|
|
554
|
+
)
|
|
526
555
|
|
|
527
556
|
elif adapter.adapter_type == AdapterType.JIRA.value:
|
|
528
557
|
server = adapter.config.get("server", "")
|
|
529
558
|
if server and not server.startswith(("http://", "https://")):
|
|
530
|
-
warnings.append(
|
|
559
|
+
warnings.append(
|
|
560
|
+
"⚠️ JIRA server URL should start with http:// or https://"
|
|
561
|
+
)
|
|
531
562
|
|
|
532
563
|
email = adapter.config.get("email", "")
|
|
533
564
|
if email and "@" not in email:
|
|
@@ -550,6 +581,7 @@ def discover_config(project_path: Optional[Path] = None) -> DiscoveryResult:
|
|
|
550
581
|
|
|
551
582
|
Returns:
|
|
552
583
|
DiscoveryResult with found adapters and warnings
|
|
584
|
+
|
|
553
585
|
"""
|
|
554
586
|
discovery = EnvDiscovery(project_path)
|
|
555
587
|
return discovery.discover()
|