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

@@ -0,0 +1,553 @@
1
+ """Project-level configuration management with hierarchical resolution.
2
+
3
+ This module provides a comprehensive configuration system that supports:
4
+ - Project-specific configurations (.mcp-ticketer/config.json in project root)
5
+ - Global configurations (~/.mcp-ticketer/config.json)
6
+ - Environment variable overrides
7
+ - CLI flag overrides
8
+ - Hybrid mode for multi-platform synchronization
9
+ """
10
+
11
+ import os
12
+ import json
13
+ import logging
14
+ from typing import Dict, Any, Optional, List
15
+ from pathlib import Path
16
+ from dataclasses import dataclass, field, asdict
17
+ from enum import Enum
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class AdapterType(str, Enum):
23
+ """Supported adapter types."""
24
+ AITRACKDOWN = "aitrackdown"
25
+ LINEAR = "linear"
26
+ JIRA = "jira"
27
+ GITHUB = "github"
28
+
29
+
30
+ class SyncStrategy(str, Enum):
31
+ """Hybrid mode synchronization strategies."""
32
+ PRIMARY_SOURCE = "primary_source" # One adapter is source of truth
33
+ BIDIRECTIONAL = "bidirectional" # Two-way sync between adapters
34
+ MIRROR = "mirror" # Clone tickets across all adapters
35
+
36
+
37
+ @dataclass
38
+ class AdapterConfig:
39
+ """Base configuration for a single adapter instance."""
40
+ adapter: str
41
+ enabled: bool = True
42
+
43
+ # Common fields (not all adapters use all fields)
44
+ api_key: Optional[str] = None
45
+ token: Optional[str] = None
46
+
47
+ # Linear-specific
48
+ team_id: Optional[str] = None
49
+ team_key: Optional[str] = None
50
+ workspace: Optional[str] = None
51
+
52
+ # JIRA-specific
53
+ server: Optional[str] = None
54
+ email: Optional[str] = None
55
+ api_token: Optional[str] = None
56
+ project_key: Optional[str] = None
57
+
58
+ # GitHub-specific
59
+ owner: Optional[str] = None
60
+ repo: Optional[str] = None
61
+
62
+ # AITrackdown-specific
63
+ base_path: Optional[str] = None
64
+
65
+ # Project ID (can be used by any adapter for scoping)
66
+ project_id: Optional[str] = None
67
+
68
+ # Additional adapter-specific configuration
69
+ additional_config: Dict[str, Any] = field(default_factory=dict)
70
+
71
+ def to_dict(self) -> Dict[str, Any]:
72
+ """Convert to dictionary, filtering None values."""
73
+ result = {}
74
+ for key, value in asdict(self).items():
75
+ if value is not None:
76
+ result[key] = value
77
+ return result
78
+
79
+ @classmethod
80
+ def from_dict(cls, data: Dict[str, Any]) -> 'AdapterConfig':
81
+ """Create from dictionary."""
82
+ # Extract known fields
83
+ known_fields = {
84
+ 'adapter', 'enabled', 'api_key', 'token', 'team_id', 'team_key',
85
+ 'workspace', 'server', 'email', 'api_token', 'project_key',
86
+ 'owner', 'repo', 'base_path', 'project_id'
87
+ }
88
+
89
+ kwargs = {}
90
+ additional = {}
91
+
92
+ for key, value in data.items():
93
+ if key in known_fields:
94
+ kwargs[key] = value
95
+ elif key != 'additional_config':
96
+ additional[key] = value
97
+
98
+ # Merge explicit additional_config
99
+ if 'additional_config' in data:
100
+ additional.update(data['additional_config'])
101
+
102
+ kwargs['additional_config'] = additional
103
+ return cls(**kwargs)
104
+
105
+
106
+ @dataclass
107
+ class ProjectConfig:
108
+ """Configuration for a specific project."""
109
+ adapter: str
110
+ api_key: Optional[str] = None
111
+ project_id: Optional[str] = None
112
+ team_id: Optional[str] = None
113
+ additional_config: Dict[str, Any] = field(default_factory=dict)
114
+
115
+ def to_dict(self) -> Dict[str, Any]:
116
+ """Convert to dictionary."""
117
+ return {k: v for k, v in asdict(self).items() if v is not None}
118
+
119
+ @classmethod
120
+ def from_dict(cls, data: Dict[str, Any]) -> 'ProjectConfig':
121
+ """Create from dictionary."""
122
+ return cls(**{k: v for k, v in data.items() if k in cls.__annotations__})
123
+
124
+
125
+ @dataclass
126
+ class HybridConfig:
127
+ """Configuration for hybrid mode (multi-adapter sync)."""
128
+ enabled: bool = False
129
+ adapters: List[str] = field(default_factory=list)
130
+ primary_adapter: Optional[str] = None
131
+ sync_strategy: SyncStrategy = SyncStrategy.PRIMARY_SOURCE
132
+
133
+ def to_dict(self) -> Dict[str, Any]:
134
+ """Convert to dictionary."""
135
+ result = asdict(self)
136
+ result['sync_strategy'] = self.sync_strategy.value
137
+ return result
138
+
139
+ @classmethod
140
+ def from_dict(cls, data: Dict[str, Any]) -> 'HybridConfig':
141
+ """Create from dictionary."""
142
+ data = data.copy()
143
+ if 'sync_strategy' in data:
144
+ data['sync_strategy'] = SyncStrategy(data['sync_strategy'])
145
+ return cls(**data)
146
+
147
+
148
+ @dataclass
149
+ class TicketerConfig:
150
+ """Complete ticketer configuration with hierarchical resolution."""
151
+ default_adapter: str = "aitrackdown"
152
+ project_configs: Dict[str, ProjectConfig] = field(default_factory=dict)
153
+ adapters: Dict[str, AdapterConfig] = field(default_factory=dict)
154
+ hybrid_mode: Optional[HybridConfig] = None
155
+
156
+ def to_dict(self) -> Dict[str, Any]:
157
+ """Convert to dictionary for JSON serialization."""
158
+ return {
159
+ "default_adapter": self.default_adapter,
160
+ "project_configs": {
161
+ path: config.to_dict()
162
+ for path, config in self.project_configs.items()
163
+ },
164
+ "adapters": {
165
+ name: config.to_dict()
166
+ for name, config in self.adapters.items()
167
+ },
168
+ "hybrid_mode": self.hybrid_mode.to_dict() if self.hybrid_mode else None
169
+ }
170
+
171
+ @classmethod
172
+ def from_dict(cls, data: Dict[str, Any]) -> 'TicketerConfig':
173
+ """Create from dictionary."""
174
+ # Parse project configs
175
+ project_configs = {}
176
+ if "project_configs" in data:
177
+ for path, config_data in data["project_configs"].items():
178
+ project_configs[path] = ProjectConfig.from_dict(config_data)
179
+
180
+ # Parse adapter configs
181
+ adapters = {}
182
+ if "adapters" in data:
183
+ for name, adapter_data in data["adapters"].items():
184
+ adapters[name] = AdapterConfig.from_dict(adapter_data)
185
+
186
+ # Parse hybrid config
187
+ hybrid_mode = None
188
+ if "hybrid_mode" in data and data["hybrid_mode"]:
189
+ hybrid_mode = HybridConfig.from_dict(data["hybrid_mode"])
190
+
191
+ return cls(
192
+ default_adapter=data.get("default_adapter", "aitrackdown"),
193
+ project_configs=project_configs,
194
+ adapters=adapters,
195
+ hybrid_mode=hybrid_mode
196
+ )
197
+
198
+
199
+ class ConfigValidator:
200
+ """Validate adapter configurations."""
201
+
202
+ @staticmethod
203
+ def validate_linear_config(config: Dict[str, Any]) -> tuple[bool, Optional[str]]:
204
+ """Validate Linear adapter configuration.
205
+
206
+ Returns:
207
+ Tuple of (is_valid, error_message)
208
+ """
209
+ required = ["api_key"]
210
+ for field in required:
211
+ if field not in config or not config[field]:
212
+ return False, f"Linear config missing required field: {field}"
213
+
214
+ # Warn if team_id is missing but don't fail
215
+ if not config.get("team_id"):
216
+ logger.warning("Linear config missing team_id - may be required for some operations")
217
+
218
+ return True, None
219
+
220
+ @staticmethod
221
+ def validate_github_config(config: Dict[str, Any]) -> tuple[bool, Optional[str]]:
222
+ """Validate GitHub adapter configuration.
223
+
224
+ Returns:
225
+ Tuple of (is_valid, error_message)
226
+ """
227
+ # token or api_key (aliases)
228
+ has_token = config.get("token") or config.get("api_key")
229
+ if not has_token:
230
+ return False, "GitHub config missing required field: token or api_key"
231
+
232
+ # project_id can be "owner/repo" format
233
+ if config.get("project_id"):
234
+ if "/" in config["project_id"]:
235
+ parts = config["project_id"].split("/")
236
+ if len(parts) == 2:
237
+ # Extract owner and repo from project_id
238
+ return True, None
239
+
240
+ # Otherwise need explicit owner and repo
241
+ required = ["owner", "repo"]
242
+ for field in required:
243
+ if field not in config or not config[field]:
244
+ return False, f"GitHub config missing required field: {field}"
245
+
246
+ return True, None
247
+
248
+ @staticmethod
249
+ def validate_jira_config(config: Dict[str, Any]) -> tuple[bool, Optional[str]]:
250
+ """Validate JIRA adapter configuration.
251
+
252
+ Returns:
253
+ Tuple of (is_valid, error_message)
254
+ """
255
+ required = ["server", "email", "api_token"]
256
+ for field in required:
257
+ if field not in config or not config[field]:
258
+ return False, f"JIRA config missing required field: {field}"
259
+
260
+ # Validate server URL format
261
+ server = config["server"]
262
+ if not server.startswith(("http://", "https://")):
263
+ return False, "JIRA server must be a valid URL (http:// or https://)"
264
+
265
+ return True, None
266
+
267
+ @staticmethod
268
+ def validate_aitrackdown_config(config: Dict[str, Any]) -> tuple[bool, Optional[str]]:
269
+ """Validate AITrackdown adapter configuration.
270
+
271
+ Returns:
272
+ Tuple of (is_valid, error_message)
273
+ """
274
+ # AITrackdown has minimal requirements
275
+ # base_path is optional (defaults to .aitrackdown)
276
+ return True, None
277
+
278
+ @classmethod
279
+ def validate(cls, adapter_type: str, config: Dict[str, Any]) -> tuple[bool, Optional[str]]:
280
+ """Validate configuration for any adapter type.
281
+
282
+ Args:
283
+ adapter_type: Type of adapter
284
+ config: Configuration dictionary
285
+
286
+ Returns:
287
+ Tuple of (is_valid, error_message)
288
+ """
289
+ validators = {
290
+ AdapterType.LINEAR.value: cls.validate_linear_config,
291
+ AdapterType.GITHUB.value: cls.validate_github_config,
292
+ AdapterType.JIRA.value: cls.validate_jira_config,
293
+ AdapterType.AITRACKDOWN.value: cls.validate_aitrackdown_config,
294
+ }
295
+
296
+ validator = validators.get(adapter_type)
297
+ if not validator:
298
+ return False, f"Unknown adapter type: {adapter_type}"
299
+
300
+ return validator(config)
301
+
302
+
303
+ class ConfigResolver:
304
+ """Resolve configuration from multiple sources with hierarchical precedence."""
305
+
306
+ # Global config location
307
+ GLOBAL_CONFIG_PATH = Path.home() / ".mcp-ticketer" / "config.json"
308
+
309
+ # Project config location (relative to project root)
310
+ PROJECT_CONFIG_SUBPATH = ".mcp-ticketer" / Path("config.json")
311
+
312
+ def __init__(self, project_path: Optional[Path] = None):
313
+ """Initialize config resolver.
314
+
315
+ Args:
316
+ project_path: Path to project root (defaults to cwd)
317
+ """
318
+ self.project_path = project_path or Path.cwd()
319
+ self._global_config: Optional[TicketerConfig] = None
320
+ self._project_config: Optional[TicketerConfig] = None
321
+
322
+ def load_global_config(self) -> TicketerConfig:
323
+ """Load global configuration from ~/.mcp-ticketer/config.json."""
324
+ if self.GLOBAL_CONFIG_PATH.exists():
325
+ try:
326
+ with open(self.GLOBAL_CONFIG_PATH, 'r') as f:
327
+ data = json.load(f)
328
+ return TicketerConfig.from_dict(data)
329
+ except Exception as e:
330
+ logger.error(f"Failed to load global config: {e}")
331
+
332
+ # Return default config
333
+ return TicketerConfig()
334
+
335
+ def load_project_config(self, project_path: Optional[Path] = None) -> Optional[TicketerConfig]:
336
+ """Load project-specific configuration.
337
+
338
+ Args:
339
+ project_path: Path to project root (defaults to self.project_path)
340
+
341
+ Returns:
342
+ Project config if exists, None otherwise
343
+ """
344
+ proj_path = project_path or self.project_path
345
+ config_path = proj_path / self.PROJECT_CONFIG_SUBPATH
346
+
347
+ if config_path.exists():
348
+ try:
349
+ with open(config_path, 'r') as f:
350
+ data = json.load(f)
351
+ return TicketerConfig.from_dict(data)
352
+ except Exception as e:
353
+ logger.error(f"Failed to load project config from {config_path}: {e}")
354
+
355
+ return None
356
+
357
+ def save_global_config(self, config: TicketerConfig) -> None:
358
+ """Save global configuration.
359
+
360
+ Args:
361
+ config: Configuration to save
362
+ """
363
+ self.GLOBAL_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
364
+ with open(self.GLOBAL_CONFIG_PATH, 'w') as f:
365
+ json.dump(config.to_dict(), f, indent=2)
366
+ logger.info(f"Saved global config to {self.GLOBAL_CONFIG_PATH}")
367
+
368
+ def save_project_config(self, config: TicketerConfig, project_path: Optional[Path] = None) -> None:
369
+ """Save project-specific configuration.
370
+
371
+ Args:
372
+ config: Configuration to save
373
+ project_path: Path to project root (defaults to self.project_path)
374
+ """
375
+ proj_path = project_path or self.project_path
376
+ config_path = proj_path / self.PROJECT_CONFIG_SUBPATH
377
+
378
+ config_path.parent.mkdir(parents=True, exist_ok=True)
379
+ with open(config_path, 'w') as f:
380
+ json.dump(config.to_dict(), f, indent=2)
381
+ logger.info(f"Saved project config to {config_path}")
382
+
383
+ def resolve_adapter_config(
384
+ self,
385
+ adapter_name: Optional[str] = None,
386
+ cli_overrides: Optional[Dict[str, Any]] = None
387
+ ) -> Dict[str, Any]:
388
+ """Resolve adapter configuration with hierarchical precedence.
389
+
390
+ Precedence (highest to lowest):
391
+ 1. CLI overrides
392
+ 2. Environment variables
393
+ 3. Project-specific config
394
+ 4. Global config
395
+
396
+ Args:
397
+ adapter_name: Name of adapter to configure (defaults to default_adapter)
398
+ cli_overrides: CLI flag overrides
399
+
400
+ Returns:
401
+ Resolved configuration dictionary
402
+ """
403
+ # Load configs
404
+ global_config = self.load_global_config()
405
+ project_config = self.load_project_config()
406
+
407
+ # Determine which adapter to use
408
+ if adapter_name:
409
+ target_adapter = adapter_name
410
+ elif project_config and project_config.default_adapter:
411
+ target_adapter = project_config.default_adapter
412
+ else:
413
+ target_adapter = global_config.default_adapter
414
+
415
+ # Start with empty config
416
+ resolved_config = {"adapter": target_adapter}
417
+
418
+ # 1. Apply global adapter config
419
+ if target_adapter in global_config.adapters:
420
+ global_adapter_config = global_config.adapters[target_adapter].to_dict()
421
+ resolved_config.update(global_adapter_config)
422
+
423
+ # 2. Apply project-specific config if exists
424
+ if project_config:
425
+ # Check if this project has specific adapter config
426
+ project_path_str = str(self.project_path)
427
+ if project_path_str in project_config.project_configs:
428
+ proj_adapter_config = project_config.project_configs[project_path_str].to_dict()
429
+ resolved_config.update(proj_adapter_config)
430
+
431
+ # Also check if project has adapter-level overrides
432
+ if target_adapter in project_config.adapters:
433
+ proj_global_adapter_config = project_config.adapters[target_adapter].to_dict()
434
+ resolved_config.update(proj_global_adapter_config)
435
+
436
+ # 3. Apply environment variable overrides
437
+ env_overrides = self._get_env_overrides(target_adapter)
438
+ resolved_config.update(env_overrides)
439
+
440
+ # 4. Apply CLI overrides
441
+ if cli_overrides:
442
+ resolved_config.update(cli_overrides)
443
+
444
+ return resolved_config
445
+
446
+ def _get_env_overrides(self, adapter_type: str) -> Dict[str, Any]:
447
+ """Get configuration overrides from environment variables.
448
+
449
+ Args:
450
+ adapter_type: Type of adapter
451
+
452
+ Returns:
453
+ Dictionary of overrides from environment
454
+ """
455
+ overrides = {}
456
+
457
+ # Override adapter type
458
+ if os.getenv("MCP_TICKETER_ADAPTER"):
459
+ overrides["adapter"] = os.getenv("MCP_TICKETER_ADAPTER")
460
+
461
+ # Common overrides
462
+ if os.getenv("MCP_TICKETER_API_KEY"):
463
+ overrides["api_key"] = os.getenv("MCP_TICKETER_API_KEY")
464
+
465
+ # Adapter-specific overrides
466
+ if adapter_type == AdapterType.LINEAR.value:
467
+ if os.getenv("MCP_TICKETER_LINEAR_API_KEY"):
468
+ overrides["api_key"] = os.getenv("MCP_TICKETER_LINEAR_API_KEY")
469
+ if os.getenv("MCP_TICKETER_LINEAR_TEAM_ID"):
470
+ overrides["team_id"] = os.getenv("MCP_TICKETER_LINEAR_TEAM_ID")
471
+ if os.getenv("LINEAR_API_KEY"):
472
+ overrides["api_key"] = os.getenv("LINEAR_API_KEY")
473
+
474
+ elif adapter_type == AdapterType.GITHUB.value:
475
+ if os.getenv("MCP_TICKETER_GITHUB_TOKEN"):
476
+ overrides["token"] = os.getenv("MCP_TICKETER_GITHUB_TOKEN")
477
+ if os.getenv("GITHUB_TOKEN"):
478
+ overrides["token"] = os.getenv("GITHUB_TOKEN")
479
+ if os.getenv("MCP_TICKETER_GITHUB_OWNER"):
480
+ overrides["owner"] = os.getenv("MCP_TICKETER_GITHUB_OWNER")
481
+ if os.getenv("MCP_TICKETER_GITHUB_REPO"):
482
+ overrides["repo"] = os.getenv("MCP_TICKETER_GITHUB_REPO")
483
+
484
+ elif adapter_type == AdapterType.JIRA.value:
485
+ if os.getenv("MCP_TICKETER_JIRA_SERVER"):
486
+ overrides["server"] = os.getenv("MCP_TICKETER_JIRA_SERVER")
487
+ if os.getenv("MCP_TICKETER_JIRA_EMAIL"):
488
+ overrides["email"] = os.getenv("MCP_TICKETER_JIRA_EMAIL")
489
+ if os.getenv("MCP_TICKETER_JIRA_TOKEN"):
490
+ overrides["api_token"] = os.getenv("MCP_TICKETER_JIRA_TOKEN")
491
+ if os.getenv("JIRA_SERVER"):
492
+ overrides["server"] = os.getenv("JIRA_SERVER")
493
+ if os.getenv("JIRA_EMAIL"):
494
+ overrides["email"] = os.getenv("JIRA_EMAIL")
495
+ if os.getenv("JIRA_API_TOKEN"):
496
+ overrides["api_token"] = os.getenv("JIRA_API_TOKEN")
497
+
498
+ elif adapter_type == AdapterType.AITRACKDOWN.value:
499
+ if os.getenv("MCP_TICKETER_AITRACKDOWN_BASE_PATH"):
500
+ overrides["base_path"] = os.getenv("MCP_TICKETER_AITRACKDOWN_BASE_PATH")
501
+
502
+ # Hybrid mode
503
+ if os.getenv("MCP_TICKETER_HYBRID_MODE"):
504
+ overrides["hybrid_mode_enabled"] = os.getenv("MCP_TICKETER_HYBRID_MODE").lower() == "true"
505
+ if os.getenv("MCP_TICKETER_HYBRID_ADAPTERS"):
506
+ overrides["hybrid_adapters"] = os.getenv("MCP_TICKETER_HYBRID_ADAPTERS").split(",")
507
+
508
+ return overrides
509
+
510
+ def get_hybrid_config(self) -> Optional[HybridConfig]:
511
+ """Get hybrid mode configuration if enabled.
512
+
513
+ Returns:
514
+ HybridConfig if hybrid mode is enabled, None otherwise
515
+ """
516
+ # Check environment first
517
+ if os.getenv("MCP_TICKETER_HYBRID_MODE", "").lower() == "true":
518
+ adapters = os.getenv("MCP_TICKETER_HYBRID_ADAPTERS", "").split(",")
519
+ return HybridConfig(
520
+ enabled=True,
521
+ adapters=[a.strip() for a in adapters if a.strip()]
522
+ )
523
+
524
+ # Check project config
525
+ project_config = self.load_project_config()
526
+ if project_config and project_config.hybrid_mode and project_config.hybrid_mode.enabled:
527
+ return project_config.hybrid_mode
528
+
529
+ # Check global config
530
+ global_config = self.load_global_config()
531
+ if global_config.hybrid_mode and global_config.hybrid_mode.enabled:
532
+ return global_config.hybrid_mode
533
+
534
+ return None
535
+
536
+
537
+ # Singleton instance for global access
538
+ _default_resolver: Optional[ConfigResolver] = None
539
+
540
+
541
+ def get_config_resolver(project_path: Optional[Path] = None) -> ConfigResolver:
542
+ """Get the global config resolver instance.
543
+
544
+ Args:
545
+ project_path: Path to project root (defaults to cwd)
546
+
547
+ Returns:
548
+ ConfigResolver instance
549
+ """
550
+ global _default_resolver
551
+ if _default_resolver is None or project_path is not None:
552
+ _default_resolver = ConfigResolver(project_path)
553
+ return _default_resolver