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.
- mcp_ticketer/__init__.py +27 -0
- mcp_ticketer/__version__.py +40 -0
- mcp_ticketer/adapters/__init__.py +8 -0
- mcp_ticketer/adapters/aitrackdown.py +396 -0
- mcp_ticketer/adapters/github.py +974 -0
- mcp_ticketer/adapters/jira.py +831 -0
- mcp_ticketer/adapters/linear.py +1355 -0
- mcp_ticketer/cache/__init__.py +5 -0
- mcp_ticketer/cache/memory.py +193 -0
- mcp_ticketer/cli/__init__.py +5 -0
- mcp_ticketer/cli/main.py +812 -0
- mcp_ticketer/cli/queue_commands.py +285 -0
- mcp_ticketer/cli/utils.py +523 -0
- mcp_ticketer/core/__init__.py +15 -0
- mcp_ticketer/core/adapter.py +211 -0
- mcp_ticketer/core/config.py +403 -0
- mcp_ticketer/core/http_client.py +430 -0
- mcp_ticketer/core/mappers.py +492 -0
- mcp_ticketer/core/models.py +111 -0
- mcp_ticketer/core/registry.py +128 -0
- mcp_ticketer/mcp/__init__.py +5 -0
- mcp_ticketer/mcp/server.py +459 -0
- mcp_ticketer/py.typed +0 -0
- mcp_ticketer/queue/__init__.py +7 -0
- mcp_ticketer/queue/__main__.py +6 -0
- mcp_ticketer/queue/manager.py +261 -0
- mcp_ticketer/queue/queue.py +357 -0
- mcp_ticketer/queue/run_worker.py +38 -0
- mcp_ticketer/queue/worker.py +425 -0
- mcp_ticketer-0.1.1.dist-info/METADATA +362 -0
- mcp_ticketer-0.1.1.dist-info/RECORD +35 -0
- mcp_ticketer-0.1.1.dist-info/WHEEL +5 -0
- mcp_ticketer-0.1.1.dist-info/entry_points.txt +3 -0
- mcp_ticketer-0.1.1.dist-info/licenses/LICENSE +21 -0
- mcp_ticketer-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -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)
|