mcp-ticketer 0.1.21__py3-none-any.whl → 0.1.23__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.

Files changed (42) hide show
  1. mcp_ticketer/__init__.py +7 -7
  2. mcp_ticketer/__version__.py +4 -2
  3. mcp_ticketer/adapters/__init__.py +4 -4
  4. mcp_ticketer/adapters/aitrackdown.py +66 -49
  5. mcp_ticketer/adapters/github.py +192 -125
  6. mcp_ticketer/adapters/hybrid.py +99 -53
  7. mcp_ticketer/adapters/jira.py +161 -151
  8. mcp_ticketer/adapters/linear.py +396 -246
  9. mcp_ticketer/cache/__init__.py +1 -1
  10. mcp_ticketer/cache/memory.py +15 -16
  11. mcp_ticketer/cli/__init__.py +1 -1
  12. mcp_ticketer/cli/configure.py +69 -93
  13. mcp_ticketer/cli/discover.py +43 -35
  14. mcp_ticketer/cli/main.py +283 -298
  15. mcp_ticketer/cli/mcp_configure.py +39 -15
  16. mcp_ticketer/cli/migrate_config.py +11 -13
  17. mcp_ticketer/cli/queue_commands.py +21 -58
  18. mcp_ticketer/cli/utils.py +121 -66
  19. mcp_ticketer/core/__init__.py +2 -2
  20. mcp_ticketer/core/adapter.py +46 -39
  21. mcp_ticketer/core/config.py +128 -92
  22. mcp_ticketer/core/env_discovery.py +69 -37
  23. mcp_ticketer/core/http_client.py +57 -40
  24. mcp_ticketer/core/mappers.py +98 -54
  25. mcp_ticketer/core/models.py +38 -24
  26. mcp_ticketer/core/project_config.py +145 -80
  27. mcp_ticketer/core/registry.py +16 -16
  28. mcp_ticketer/mcp/__init__.py +1 -1
  29. mcp_ticketer/mcp/server.py +199 -145
  30. mcp_ticketer/queue/__init__.py +2 -2
  31. mcp_ticketer/queue/__main__.py +1 -1
  32. mcp_ticketer/queue/manager.py +30 -26
  33. mcp_ticketer/queue/queue.py +147 -85
  34. mcp_ticketer/queue/run_worker.py +2 -3
  35. mcp_ticketer/queue/worker.py +55 -40
  36. {mcp_ticketer-0.1.21.dist-info → mcp_ticketer-0.1.23.dist-info}/METADATA +1 -1
  37. mcp_ticketer-0.1.23.dist-info/RECORD +42 -0
  38. mcp_ticketer-0.1.21.dist-info/RECORD +0 -42
  39. {mcp_ticketer-0.1.21.dist-info → mcp_ticketer-0.1.23.dist-info}/WHEEL +0 -0
  40. {mcp_ticketer-0.1.21.dist-info → mcp_ticketer-0.1.23.dist-info}/entry_points.txt +0 -0
  41. {mcp_ticketer-0.1.21.dist-info → mcp_ticketer-0.1.23.dist-info}/licenses/LICENSE +0 -0
  42. {mcp_ticketer-0.1.21.dist-info → mcp_ticketer-0.1.23.dist-info}/top_level.txt +0 -0
@@ -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
- from typing import Dict, Any, Optional, List, Union
7
- from pathlib import Path
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, Optional, Union
10
+
9
11
  import yaml
10
- from pydantic import BaseModel, Field, validator, root_validator
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,109 +25,115 @@ 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
29
32
  timeout: float = 30.0
30
33
  max_retries: int = 3
31
- rate_limit: Optional[Dict[str, Any]] = None
34
+ rate_limit: Optional[dict[str, Any]] = None
32
35
 
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='GITHUB_TOKEN')
38
- owner: Optional[str] = Field(None, env='GITHUB_OWNER')
39
- repo: Optional[str] = Field(None, env='GITHUB_REPO')
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
- custom_priority_scheme: Optional[Dict[str, List[str]]] = None
46
+ custom_priority_scheme: Optional[dict[str, list[str]]] = None
43
47
 
44
- @validator('token', pre=True, always=True)
45
- def validate_token(cls, v):
48
+ @validator("token", pre=True, always=True)
49
+ def validate_token(self, v):
46
50
  if not v:
47
- v = os.getenv('GITHUB_TOKEN')
51
+ v = os.getenv("GITHUB_TOKEN")
48
52
  if not v:
49
- raise ValueError('GitHub token is required')
53
+ raise ValueError("GitHub token is required")
50
54
  return v
51
55
 
52
- @validator('owner', pre=True, always=True)
53
- def validate_owner(cls, v):
56
+ @validator("owner", pre=True, always=True)
57
+ def validate_owner(self, v):
54
58
  if not v:
55
- v = os.getenv('GITHUB_OWNER')
59
+ v = os.getenv("GITHUB_OWNER")
56
60
  if not v:
57
- raise ValueError('GitHub owner is required')
61
+ raise ValueError("GitHub owner is required")
58
62
  return v
59
63
 
60
- @validator('repo', pre=True, always=True)
61
- def validate_repo(cls, v):
64
+ @validator("repo", pre=True, always=True)
65
+ def validate_repo(self, v):
62
66
  if not v:
63
- v = os.getenv('GITHUB_REPO')
67
+ v = os.getenv("GITHUB_REPO")
64
68
  if not v:
65
- raise ValueError('GitHub repo is required')
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='JIRA_SERVER')
73
- email: Optional[str] = Field(None, env='JIRA_EMAIL')
74
- api_token: Optional[str] = Field(None, env='JIRA_API_TOKEN')
75
- project_key: Optional[str] = Field(None, env='JIRA_PROJECT_KEY')
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('server', pre=True, always=True)
80
- def validate_server(cls, v):
84
+ @validator("server", pre=True, always=True)
85
+ def validate_server(self, v):
81
86
  if not v:
82
- v = os.getenv('JIRA_SERVER')
87
+ v = os.getenv("JIRA_SERVER")
83
88
  if not v:
84
- raise ValueError('JIRA server URL is required')
85
- return v.rstrip('/')
89
+ raise ValueError("JIRA server URL is required")
90
+ return v.rstrip("/")
86
91
 
87
- @validator('email', pre=True, always=True)
88
- def validate_email(cls, v):
92
+ @validator("email", pre=True, always=True)
93
+ def validate_email(self, v):
89
94
  if not v:
90
- v = os.getenv('JIRA_EMAIL')
95
+ v = os.getenv("JIRA_EMAIL")
91
96
  if not v:
92
- raise ValueError('JIRA email is required')
97
+ raise ValueError("JIRA email is required")
93
98
  return v
94
99
 
95
- @validator('api_token', pre=True, always=True)
96
- def validate_api_token(cls, v):
100
+ @validator("api_token", pre=True, always=True)
101
+ def validate_api_token(self, v):
97
102
  if not v:
98
- v = os.getenv('JIRA_API_TOKEN')
103
+ v = os.getenv("JIRA_API_TOKEN")
99
104
  if not v:
100
- raise ValueError('JIRA API token is required')
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='LINEAR_API_KEY')
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('api_key', pre=True, always=True)
113
- def validate_api_key(cls, v):
118
+ @validator("api_key", pre=True, always=True)
119
+ def validate_api_key(self, v):
114
120
  if not v:
115
- v = os.getenv('LINEAR_API_KEY')
121
+ v = os.getenv("LINEAR_API_KEY")
116
122
  if not v:
117
- raise ValueError('Linear API key is required')
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
- adapters: Dict[str, Union[GitHubConfig, JiraConfig, LinearConfig, AITrackdownConfig]] = {}
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
155
- def validate_adapters(cls, values):
166
+ @root_validator(skip_on_failure=True)
167
+ def validate_adapters(self, values):
156
168
  """Validate adapter configurations."""
157
- adapters = values.get('adapters', {})
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('default_adapter')
176
+ default_adapter = values.get("default_adapter")
165
177
  if default_adapter and default_adapter not in adapters:
166
- raise ValueError(f"Default adapter '{default_adapter}' not found in adapters")
178
+ raise ValueError(
179
+ f"Default adapter '{default_adapter}' not found in adapters"
180
+ )
167
181
 
168
182
  return values
169
183
 
@@ -171,19 +185,21 @@ class AppConfig(BaseModel):
171
185
  """Get configuration for a specific adapter."""
172
186
  return self.adapters.get(adapter_name)
173
187
 
174
- def get_enabled_adapters(self) -> Dict[str, BaseAdapterConfig]:
188
+ def get_enabled_adapters(self) -> dict[str, BaseAdapterConfig]:
175
189
  """Get all enabled adapters."""
176
- return {name: config for name, config in self.adapters.items() if config.enabled}
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['ConfigurationManager'] = None
198
+ _instance: Optional["ConfigurationManager"] = None
183
199
  _config: Optional[AppConfig] = None
184
- _config_file_paths: List[Path] = []
200
+ _config_file_paths: list[Path] = []
185
201
 
186
- def __new__(cls) -> 'ConfigurationManager':
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, '_initialized'):
210
+ if not hasattr(self, "_initialized"):
195
211
  self._initialized = True
196
- self._config_cache: Dict[str, Any] = {}
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 standard locations."""
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.yaml",
203
- Path.cwd() / "mcp-ticketer.yml",
204
- Path.cwd() / "config.yaml",
205
- Path.cwd() / "config.yml",
206
- Path.home() / ".mcp-ticketer.yaml",
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
- self._config_file_paths = [path for path in possible_paths if path.exists()]
213
- logger.debug(f"Found config files: {self._config_file_paths}")
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(f"Unknown adapter type: {adapter_type} for adapter: {name}")
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
 
@@ -261,13 +302,13 @@ class ConfigurationManager:
261
302
  self._config = AppConfig(**config_data)
262
303
  return self._config
263
304
 
264
- def _load_config_file(self, config_path: Path) -> Dict[str, Any]:
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, 'r', encoding='utf-8') as file:
268
- if config_path.suffix.lower() in ['.yaml', '.yml']:
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() == '.json':
311
+ elif config_path.suffix.lower() == ".json":
271
312
  return json.load(file)
272
313
  else:
273
314
  # Try YAML first, then JSON
@@ -291,7 +332,7 @@ class ConfigurationManager:
291
332
  config = self.get_config()
292
333
  return config.get_adapter_config(adapter_name)
293
334
 
294
- def get_enabled_adapters(self) -> Dict[str, BaseAdapterConfig]:
335
+ def get_enabled_adapters(self) -> dict[str, BaseAdapterConfig]:
295
336
  """Get all enabled adapter configurations."""
296
337
  config = self.get_config()
297
338
  return config.get_enabled_adapters()
@@ -306,7 +347,9 @@ class ConfigurationManager:
306
347
  config = self.get_config()
307
348
  return config.logging
308
349
 
309
- def reload_config(self, config_file: Optional[Union[str, Path]] = None) -> AppConfig:
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
- "default_adapter": "github-main"
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, 'w', encoding='utf-8') as file:
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)