mcp-ticketer 0.1.1__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,211 @@
1
+ """Base adapter abstract class for ticket systems."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import List, Optional, Dict, Any, TypeVar, Generic
5
+ from .models import Epic, Task, Comment, SearchQuery, TicketState
6
+
7
+ # Generic type for tickets
8
+ T = TypeVar("T", Epic, Task)
9
+
10
+
11
+ class BaseAdapter(ABC, Generic[T]):
12
+ """Abstract base class for all ticket system adapters."""
13
+
14
+ def __init__(self, config: Dict[str, Any]):
15
+ """Initialize adapter with configuration.
16
+
17
+ Args:
18
+ config: Adapter-specific configuration dictionary
19
+ """
20
+ self.config = config
21
+ self._state_mapping = self._get_state_mapping()
22
+
23
+ @abstractmethod
24
+ def _get_state_mapping(self) -> Dict[TicketState, str]:
25
+ """Get mapping from universal states to system-specific states.
26
+
27
+ Returns:
28
+ Dictionary mapping TicketState to system-specific state strings
29
+ """
30
+ pass
31
+
32
+ @abstractmethod
33
+ async def create(self, ticket: T) -> T:
34
+ """Create a new ticket.
35
+
36
+ Args:
37
+ ticket: Ticket to create (Epic or Task)
38
+
39
+ Returns:
40
+ Created ticket with ID populated
41
+ """
42
+ pass
43
+
44
+ @abstractmethod
45
+ async def read(self, ticket_id: str) -> Optional[T]:
46
+ """Read a ticket by ID.
47
+
48
+ Args:
49
+ ticket_id: Unique ticket identifier
50
+
51
+ Returns:
52
+ Ticket if found, None otherwise
53
+ """
54
+ pass
55
+
56
+ @abstractmethod
57
+ async def update(self, ticket_id: str, updates: Dict[str, Any]) -> Optional[T]:
58
+ """Update a ticket.
59
+
60
+ Args:
61
+ ticket_id: Ticket identifier
62
+ updates: Fields to update
63
+
64
+ Returns:
65
+ Updated ticket if successful, None otherwise
66
+ """
67
+ pass
68
+
69
+ @abstractmethod
70
+ async def delete(self, ticket_id: str) -> bool:
71
+ """Delete a ticket.
72
+
73
+ Args:
74
+ ticket_id: Ticket identifier
75
+
76
+ Returns:
77
+ True if deleted, False otherwise
78
+ """
79
+ pass
80
+
81
+ @abstractmethod
82
+ async def list(
83
+ self,
84
+ limit: int = 10,
85
+ offset: int = 0,
86
+ filters: Optional[Dict[str, Any]] = None
87
+ ) -> List[T]:
88
+ """List tickets with pagination and filters.
89
+
90
+ Args:
91
+ limit: Maximum number of tickets
92
+ offset: Skip this many tickets
93
+ filters: Optional filter criteria
94
+
95
+ Returns:
96
+ List of tickets matching criteria
97
+ """
98
+ pass
99
+
100
+ @abstractmethod
101
+ async def search(self, query: SearchQuery) -> List[T]:
102
+ """Search tickets using advanced query.
103
+
104
+ Args:
105
+ query: Search parameters
106
+
107
+ Returns:
108
+ List of tickets matching search criteria
109
+ """
110
+ pass
111
+
112
+ @abstractmethod
113
+ async def transition_state(
114
+ self,
115
+ ticket_id: str,
116
+ target_state: TicketState
117
+ ) -> Optional[T]:
118
+ """Transition ticket to a new state.
119
+
120
+ Args:
121
+ ticket_id: Ticket identifier
122
+ target_state: Target state
123
+
124
+ Returns:
125
+ Updated ticket if transition successful, None otherwise
126
+ """
127
+ pass
128
+
129
+ @abstractmethod
130
+ async def add_comment(self, comment: Comment) -> Comment:
131
+ """Add a comment to a ticket.
132
+
133
+ Args:
134
+ comment: Comment to add
135
+
136
+ Returns:
137
+ Created comment with ID populated
138
+ """
139
+ pass
140
+
141
+ @abstractmethod
142
+ async def get_comments(
143
+ self,
144
+ ticket_id: str,
145
+ limit: int = 10,
146
+ offset: int = 0
147
+ ) -> List[Comment]:
148
+ """Get comments for a ticket.
149
+
150
+ Args:
151
+ ticket_id: Ticket identifier
152
+ limit: Maximum number of comments
153
+ offset: Skip this many comments
154
+
155
+ Returns:
156
+ List of comments for the ticket
157
+ """
158
+ pass
159
+
160
+ def map_state_to_system(self, state: TicketState) -> str:
161
+ """Map universal state to system-specific state.
162
+
163
+ Args:
164
+ state: Universal ticket state
165
+
166
+ Returns:
167
+ System-specific state string
168
+ """
169
+ return self._state_mapping.get(state, state.value)
170
+
171
+ def map_state_from_system(self, system_state: str) -> TicketState:
172
+ """Map system-specific state to universal state.
173
+
174
+ Args:
175
+ system_state: System-specific state string
176
+
177
+ Returns:
178
+ Universal ticket state
179
+ """
180
+ reverse_mapping = {v: k for k, v in self._state_mapping.items()}
181
+ return reverse_mapping.get(system_state, TicketState.OPEN)
182
+
183
+ async def validate_transition(
184
+ self,
185
+ ticket_id: str,
186
+ target_state: TicketState
187
+ ) -> bool:
188
+ """Validate if state transition is allowed.
189
+
190
+ Args:
191
+ ticket_id: Ticket identifier
192
+ target_state: Target state
193
+
194
+ Returns:
195
+ True if transition is valid
196
+ """
197
+ ticket = await self.read(ticket_id)
198
+ if not ticket:
199
+ return False
200
+ # Handle case where state might be stored as string due to use_enum_values=True
201
+ current_state = ticket.state
202
+ if isinstance(current_state, str):
203
+ try:
204
+ current_state = TicketState(current_state)
205
+ except ValueError:
206
+ return False
207
+ return current_state.can_transition_to(target_state)
208
+
209
+ async def close(self) -> None:
210
+ """Close adapter and cleanup resources."""
211
+ pass
@@ -0,0 +1,403 @@
1
+ """Centralized configuration management with caching and validation."""
2
+
3
+ import os
4
+ import json
5
+ import logging
6
+ from typing import Dict, Any, Optional, List, Union
7
+ from pathlib import Path
8
+ from functools import lru_cache
9
+ import yaml
10
+ from pydantic import BaseModel, Field, validator, root_validator
11
+ from enum import Enum
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class AdapterType(str, Enum):
17
+ """Supported adapter types."""
18
+ GITHUB = "github"
19
+ JIRA = "jira"
20
+ LINEAR = "linear"
21
+ AITRACKDOWN = "aitrackdown"
22
+
23
+
24
+ class BaseAdapterConfig(BaseModel):
25
+ """Base configuration for all adapters."""
26
+ type: AdapterType
27
+ name: Optional[str] = None
28
+ enabled: bool = True
29
+ timeout: float = 30.0
30
+ max_retries: int = 3
31
+ rate_limit: Optional[Dict[str, Any]] = None
32
+
33
+
34
+ class GitHubConfig(BaseAdapterConfig):
35
+ """GitHub adapter configuration."""
36
+ 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')
40
+ api_url: str = "https://api.github.com"
41
+ use_projects_v2: bool = False
42
+ custom_priority_scheme: Optional[Dict[str, List[str]]] = None
43
+
44
+ @validator('token', pre=True, always=True)
45
+ def validate_token(cls, v):
46
+ if not v:
47
+ v = os.getenv('GITHUB_TOKEN')
48
+ if not v:
49
+ raise ValueError('GitHub token is required')
50
+ return v
51
+
52
+ @validator('owner', pre=True, always=True)
53
+ def validate_owner(cls, v):
54
+ if not v:
55
+ v = os.getenv('GITHUB_OWNER')
56
+ if not v:
57
+ raise ValueError('GitHub owner is required')
58
+ return v
59
+
60
+ @validator('repo', pre=True, always=True)
61
+ def validate_repo(cls, v):
62
+ if not v:
63
+ v = os.getenv('GITHUB_REPO')
64
+ if not v:
65
+ raise ValueError('GitHub repo is required')
66
+ return v
67
+
68
+
69
+ class JiraConfig(BaseAdapterConfig):
70
+ """JIRA adapter configuration."""
71
+ 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')
76
+ cloud: bool = True
77
+ verify_ssl: bool = True
78
+
79
+ @validator('server', pre=True, always=True)
80
+ def validate_server(cls, v):
81
+ if not v:
82
+ v = os.getenv('JIRA_SERVER')
83
+ if not v:
84
+ raise ValueError('JIRA server URL is required')
85
+ return v.rstrip('/')
86
+
87
+ @validator('email', pre=True, always=True)
88
+ def validate_email(cls, v):
89
+ if not v:
90
+ v = os.getenv('JIRA_EMAIL')
91
+ if not v:
92
+ raise ValueError('JIRA email is required')
93
+ return v
94
+
95
+ @validator('api_token', pre=True, always=True)
96
+ def validate_api_token(cls, v):
97
+ if not v:
98
+ v = os.getenv('JIRA_API_TOKEN')
99
+ if not v:
100
+ raise ValueError('JIRA API token is required')
101
+ return v
102
+
103
+
104
+ class LinearConfig(BaseAdapterConfig):
105
+ """Linear adapter configuration."""
106
+ type: AdapterType = AdapterType.LINEAR
107
+ api_key: Optional[str] = Field(None, env='LINEAR_API_KEY')
108
+ workspace: Optional[str] = None
109
+ team_key: str
110
+ api_url: str = "https://api.linear.app/graphql"
111
+
112
+ @validator('api_key', pre=True, always=True)
113
+ def validate_api_key(cls, v):
114
+ if not v:
115
+ v = os.getenv('LINEAR_API_KEY')
116
+ if not v:
117
+ raise ValueError('Linear API key is required')
118
+ return v
119
+
120
+
121
+ class AITrackdownConfig(BaseAdapterConfig):
122
+ """AITrackdown adapter configuration."""
123
+ type: AdapterType = AdapterType.AITRACKDOWN
124
+ # AITrackdown uses local storage, minimal config needed
125
+
126
+
127
+ class QueueConfig(BaseModel):
128
+ """Queue configuration."""
129
+ provider: str = "sqlite"
130
+ connection_string: Optional[str] = None
131
+ batch_size: int = 10
132
+ max_concurrent: int = 5
133
+ retry_attempts: int = 3
134
+ retry_delay: float = 1.0
135
+
136
+
137
+ class LoggingConfig(BaseModel):
138
+ """Logging configuration."""
139
+ level: str = "INFO"
140
+ format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
141
+ file: Optional[str] = None
142
+ max_size: str = "10MB"
143
+ backup_count: int = 5
144
+
145
+
146
+ class AppConfig(BaseModel):
147
+ """Main application configuration."""
148
+ adapters: Dict[str, Union[GitHubConfig, JiraConfig, LinearConfig, AITrackdownConfig]] = {}
149
+ queue: QueueConfig = QueueConfig()
150
+ logging: LoggingConfig = LoggingConfig()
151
+ cache_ttl: int = 300 # Cache TTL in seconds
152
+ default_adapter: Optional[str] = None
153
+
154
+ @root_validator
155
+ def validate_adapters(cls, values):
156
+ """Validate adapter configurations."""
157
+ adapters = values.get('adapters', {})
158
+
159
+ if not adapters:
160
+ logger.warning("No adapters configured")
161
+ return values
162
+
163
+ # Validate default adapter
164
+ default_adapter = values.get('default_adapter')
165
+ if default_adapter and default_adapter not in adapters:
166
+ raise ValueError(f"Default adapter '{default_adapter}' not found in adapters")
167
+
168
+ return values
169
+
170
+ def get_adapter_config(self, adapter_name: str) -> Optional[BaseAdapterConfig]:
171
+ """Get configuration for a specific adapter."""
172
+ return self.adapters.get(adapter_name)
173
+
174
+ def get_enabled_adapters(self) -> Dict[str, BaseAdapterConfig]:
175
+ """Get all enabled adapters."""
176
+ return {name: config for name, config in self.adapters.items() if config.enabled}
177
+
178
+
179
+ class ConfigurationManager:
180
+ """Centralized configuration management with caching and validation."""
181
+
182
+ _instance: Optional['ConfigurationManager'] = None
183
+ _config: Optional[AppConfig] = None
184
+ _config_file_paths: List[Path] = []
185
+
186
+ def __new__(cls) -> 'ConfigurationManager':
187
+ """Singleton pattern for global config access."""
188
+ if cls._instance is None:
189
+ cls._instance = super().__new__(cls)
190
+ return cls._instance
191
+
192
+ def __init__(self):
193
+ """Initialize configuration manager."""
194
+ if not hasattr(self, '_initialized'):
195
+ self._initialized = True
196
+ self._config_cache: Dict[str, Any] = {}
197
+ self._find_config_files()
198
+
199
+ def _find_config_files(self) -> None:
200
+ """Find configuration files in standard locations."""
201
+ 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"),
210
+ ]
211
+
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}")
214
+
215
+ @lru_cache(maxsize=1)
216
+ def load_config(self, config_file: Optional[Union[str, Path]] = None) -> AppConfig:
217
+ """Load and validate configuration from file and environment.
218
+
219
+ Args:
220
+ config_file: Optional specific config file path
221
+
222
+ Returns:
223
+ Validated application configuration
224
+ """
225
+ if self._config is not None and config_file is None:
226
+ return self._config
227
+
228
+ config_data = {}
229
+
230
+ # Load from file
231
+ if config_file:
232
+ config_path = Path(config_file)
233
+ if not config_path.exists():
234
+ raise FileNotFoundError(f"Configuration file not found: {config_path}")
235
+ config_data = self._load_config_file(config_path)
236
+ elif self._config_file_paths:
237
+ # Load from first available config file
238
+ config_data = self._load_config_file(self._config_file_paths[0])
239
+ logger.info(f"Loaded configuration from: {self._config_file_paths[0]}")
240
+
241
+ # Parse adapter configurations
242
+ if "adapters" in config_data:
243
+ parsed_adapters = {}
244
+ for name, adapter_config in config_data["adapters"].items():
245
+ adapter_type = adapter_config.get("type", "").lower()
246
+
247
+ if adapter_type == "github":
248
+ parsed_adapters[name] = GitHubConfig(**adapter_config)
249
+ elif adapter_type == "jira":
250
+ parsed_adapters[name] = JiraConfig(**adapter_config)
251
+ elif adapter_type == "linear":
252
+ parsed_adapters[name] = LinearConfig(**adapter_config)
253
+ elif adapter_type == "aitrackdown":
254
+ parsed_adapters[name] = AITrackdownConfig(**adapter_config)
255
+ else:
256
+ logger.warning(f"Unknown adapter type: {adapter_type} for adapter: {name}")
257
+
258
+ config_data["adapters"] = parsed_adapters
259
+
260
+ # Validate and create config
261
+ self._config = AppConfig(**config_data)
262
+ return self._config
263
+
264
+ def _load_config_file(self, config_path: Path) -> Dict[str, Any]:
265
+ """Load configuration from YAML or JSON file."""
266
+ try:
267
+ with open(config_path, 'r', encoding='utf-8') as file:
268
+ if config_path.suffix.lower() in ['.yaml', '.yml']:
269
+ return yaml.safe_load(file) or {}
270
+ elif config_path.suffix.lower() == '.json':
271
+ return json.load(file)
272
+ else:
273
+ # Try YAML first, then JSON
274
+ content = file.read()
275
+ try:
276
+ return yaml.safe_load(content) or {}
277
+ except yaml.YAMLError:
278
+ return json.loads(content)
279
+ except Exception as e:
280
+ logger.error(f"Error loading config file {config_path}: {e}")
281
+ return {}
282
+
283
+ def get_config(self) -> AppConfig:
284
+ """Get the current configuration."""
285
+ if self._config is None:
286
+ return self.load_config()
287
+ return self._config
288
+
289
+ def get_adapter_config(self, adapter_name: str) -> Optional[BaseAdapterConfig]:
290
+ """Get configuration for a specific adapter."""
291
+ config = self.get_config()
292
+ return config.get_adapter_config(adapter_name)
293
+
294
+ def get_enabled_adapters(self) -> Dict[str, BaseAdapterConfig]:
295
+ """Get all enabled adapter configurations."""
296
+ config = self.get_config()
297
+ return config.get_enabled_adapters()
298
+
299
+ def get_queue_config(self) -> QueueConfig:
300
+ """Get queue configuration."""
301
+ config = self.get_config()
302
+ return config.queue
303
+
304
+ def get_logging_config(self) -> LoggingConfig:
305
+ """Get logging configuration."""
306
+ config = self.get_config()
307
+ return config.logging
308
+
309
+ def reload_config(self, config_file: Optional[Union[str, Path]] = None) -> AppConfig:
310
+ """Reload configuration from file."""
311
+ # Clear cache
312
+ self.load_config.cache_clear()
313
+ self._config = None
314
+ self._config_cache.clear()
315
+
316
+ # Reload
317
+ return self.load_config(config_file)
318
+
319
+ def set_config_value(self, key: str, value: Any) -> None:
320
+ """Set a configuration value (for testing/runtime overrides)."""
321
+ self._config_cache[key] = value
322
+
323
+ def get_config_value(self, key: str, default: Any = None) -> Any:
324
+ """Get a configuration value with caching."""
325
+ if key in self._config_cache:
326
+ return self._config_cache[key]
327
+
328
+ # Parse nested keys like "queue.batch_size"
329
+ config = self.get_config()
330
+ parts = key.split('.')
331
+ value = config.dict()
332
+
333
+ for part in parts:
334
+ if isinstance(value, dict) and part in value:
335
+ value = value[part]
336
+ else:
337
+ return default
338
+
339
+ self._config_cache[key] = value
340
+ return value
341
+
342
+ def create_sample_config(self, output_path: Union[str, Path]) -> None:
343
+ """Create a sample configuration file."""
344
+ sample_config = {
345
+ "adapters": {
346
+ "github-main": {
347
+ "type": "github",
348
+ "token": "${GITHUB_TOKEN}",
349
+ "owner": "your-org",
350
+ "repo": "your-repo",
351
+ "enabled": True
352
+ },
353
+ "linear-dev": {
354
+ "type": "linear",
355
+ "api_key": "${LINEAR_API_KEY}",
356
+ "team_key": "DEV",
357
+ "enabled": True
358
+ },
359
+ "jira-support": {
360
+ "type": "jira",
361
+ "server": "https://your-org.atlassian.net",
362
+ "email": "${JIRA_EMAIL}",
363
+ "api_token": "${JIRA_API_TOKEN}",
364
+ "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"
376
+ },
377
+ "default_adapter": "github-main"
378
+ }
379
+
380
+ output_path = Path(output_path)
381
+ with open(output_path, 'w', encoding='utf-8') as file:
382
+ yaml.dump(sample_config, file, default_flow_style=False, indent=2)
383
+
384
+ logger.info(f"Sample configuration created at: {output_path}")
385
+
386
+
387
+ # Global configuration manager instance
388
+ config_manager = ConfigurationManager()
389
+
390
+
391
+ def get_config() -> AppConfig:
392
+ """Get the global configuration."""
393
+ return config_manager.get_config()
394
+
395
+
396
+ def get_adapter_config(adapter_name: str) -> Optional[BaseAdapterConfig]:
397
+ """Get configuration for a specific adapter."""
398
+ return config_manager.get_adapter_config(adapter_name)
399
+
400
+
401
+ def reload_config(config_file: Optional[Union[str, Path]] = None) -> AppConfig:
402
+ """Reload the global configuration."""
403
+ return config_manager.reload_config(config_file)