mcp-ticketer 0.4.11__py3-none-any.whl → 2.0.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 +10 -10
- mcp_ticketer/__version__.py +3 -3
- mcp_ticketer/adapters/__init__.py +2 -0
- mcp_ticketer/adapters/aitrackdown.py +394 -9
- mcp_ticketer/adapters/asana/__init__.py +15 -0
- mcp_ticketer/adapters/asana/adapter.py +1416 -0
- mcp_ticketer/adapters/asana/client.py +292 -0
- mcp_ticketer/adapters/asana/mappers.py +348 -0
- mcp_ticketer/adapters/asana/types.py +146 -0
- mcp_ticketer/adapters/github.py +836 -105
- mcp_ticketer/adapters/hybrid.py +47 -5
- mcp_ticketer/adapters/jira.py +772 -1
- mcp_ticketer/adapters/linear/adapter.py +2293 -108
- mcp_ticketer/adapters/linear/client.py +146 -12
- mcp_ticketer/adapters/linear/mappers.py +105 -11
- mcp_ticketer/adapters/linear/queries.py +168 -1
- mcp_ticketer/adapters/linear/types.py +80 -4
- mcp_ticketer/analysis/__init__.py +56 -0
- mcp_ticketer/analysis/dependency_graph.py +255 -0
- mcp_ticketer/analysis/health_assessment.py +304 -0
- mcp_ticketer/analysis/orphaned.py +218 -0
- mcp_ticketer/analysis/project_status.py +594 -0
- mcp_ticketer/analysis/similarity.py +224 -0
- mcp_ticketer/analysis/staleness.py +266 -0
- mcp_ticketer/automation/__init__.py +11 -0
- mcp_ticketer/automation/project_updates.py +378 -0
- mcp_ticketer/cache/memory.py +3 -3
- mcp_ticketer/cli/adapter_diagnostics.py +4 -2
- mcp_ticketer/cli/auggie_configure.py +18 -6
- mcp_ticketer/cli/codex_configure.py +175 -60
- mcp_ticketer/cli/configure.py +884 -146
- mcp_ticketer/cli/cursor_configure.py +314 -0
- mcp_ticketer/cli/diagnostics.py +31 -28
- mcp_ticketer/cli/discover.py +293 -21
- mcp_ticketer/cli/gemini_configure.py +18 -6
- mcp_ticketer/cli/init_command.py +880 -0
- mcp_ticketer/cli/instruction_commands.py +435 -0
- mcp_ticketer/cli/linear_commands.py +99 -15
- mcp_ticketer/cli/main.py +109 -2055
- mcp_ticketer/cli/mcp_configure.py +673 -99
- mcp_ticketer/cli/mcp_server_commands.py +415 -0
- mcp_ticketer/cli/migrate_config.py +12 -8
- mcp_ticketer/cli/platform_commands.py +6 -6
- mcp_ticketer/cli/platform_detection.py +477 -0
- mcp_ticketer/cli/platform_installer.py +536 -0
- mcp_ticketer/cli/project_update_commands.py +350 -0
- mcp_ticketer/cli/queue_commands.py +15 -15
- mcp_ticketer/cli/setup_command.py +639 -0
- mcp_ticketer/cli/simple_health.py +13 -11
- mcp_ticketer/cli/ticket_commands.py +277 -36
- mcp_ticketer/cli/update_checker.py +313 -0
- mcp_ticketer/cli/utils.py +45 -41
- mcp_ticketer/core/__init__.py +35 -1
- mcp_ticketer/core/adapter.py +170 -5
- mcp_ticketer/core/config.py +38 -31
- mcp_ticketer/core/env_discovery.py +33 -3
- mcp_ticketer/core/env_loader.py +7 -6
- mcp_ticketer/core/exceptions.py +10 -4
- mcp_ticketer/core/http_client.py +10 -10
- mcp_ticketer/core/instructions.py +405 -0
- mcp_ticketer/core/label_manager.py +732 -0
- mcp_ticketer/core/mappers.py +32 -20
- mcp_ticketer/core/models.py +136 -1
- mcp_ticketer/core/onepassword_secrets.py +379 -0
- mcp_ticketer/core/priority_matcher.py +463 -0
- mcp_ticketer/core/project_config.py +148 -14
- mcp_ticketer/core/registry.py +1 -1
- mcp_ticketer/core/session_state.py +171 -0
- mcp_ticketer/core/state_matcher.py +592 -0
- mcp_ticketer/core/url_parser.py +425 -0
- mcp_ticketer/core/validators.py +69 -0
- mcp_ticketer/defaults/ticket_instructions.md +644 -0
- mcp_ticketer/mcp/__init__.py +2 -2
- mcp_ticketer/mcp/server/__init__.py +2 -2
- mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
- mcp_ticketer/mcp/server/main.py +187 -93
- mcp_ticketer/mcp/server/routing.py +655 -0
- mcp_ticketer/mcp/server/server_sdk.py +58 -0
- mcp_ticketer/mcp/server/tools/__init__.py +37 -9
- mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +65 -20
- mcp_ticketer/mcp/server/tools/bulk_tools.py +259 -202
- mcp_ticketer/mcp/server/tools/comment_tools.py +74 -12
- mcp_ticketer/mcp/server/tools/config_tools.py +1429 -0
- mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +878 -319
- mcp_ticketer/mcp/server/tools/instruction_tools.py +295 -0
- mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
- mcp_ticketer/mcp/server/tools/pr_tools.py +3 -7
- mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
- mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
- mcp_ticketer/mcp/server/tools/search_tools.py +180 -97
- mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
- mcp_ticketer/mcp/server/tools/ticket_tools.py +1182 -82
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +364 -0
- mcp_ticketer/queue/health_monitor.py +1 -0
- mcp_ticketer/queue/manager.py +4 -4
- mcp_ticketer/queue/queue.py +3 -3
- mcp_ticketer/queue/run_worker.py +1 -1
- mcp_ticketer/queue/ticket_registry.py +2 -2
- mcp_ticketer/queue/worker.py +15 -13
- mcp_ticketer/utils/__init__.py +5 -0
- mcp_ticketer/utils/token_utils.py +246 -0
- mcp_ticketer-2.0.1.dist-info/METADATA +1366 -0
- mcp_ticketer-2.0.1.dist-info/RECORD +122 -0
- mcp_ticketer-0.4.11.dist-info/METADATA +0 -496
- mcp_ticketer-0.4.11.dist-info/RECORD +0 -77
- {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/licenses/LICENSE +0 -0
- {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
"""Smart routing middleware for multi-platform ticket access.
|
|
2
|
+
|
|
3
|
+
This module provides intelligent routing of ticket operations to the appropriate
|
|
4
|
+
adapter based on URL detection or explicit adapter selection. It enables seamless
|
|
5
|
+
multi-platform ticket management within a single MCP session.
|
|
6
|
+
|
|
7
|
+
Architecture:
|
|
8
|
+
- TicketRouter: Main routing class that manages adapter selection and caching
|
|
9
|
+
- URL-based detection: Automatically routes based on ticket URL domains
|
|
10
|
+
- Plain ID fallback: Uses default adapter for non-URL ticket IDs
|
|
11
|
+
- Adapter caching: Lazy-loads and caches adapter instances for performance
|
|
12
|
+
|
|
13
|
+
Example:
|
|
14
|
+
>>> router = TicketRouter(
|
|
15
|
+
... default_adapter="linear",
|
|
16
|
+
... adapter_configs={
|
|
17
|
+
... "linear": {"api_key": "...", "team_id": "..."},
|
|
18
|
+
... "github": {"token": "...", "owner": "...", "repo": "..."},
|
|
19
|
+
... }
|
|
20
|
+
... )
|
|
21
|
+
>>> # Read ticket using URL (auto-detects adapter)
|
|
22
|
+
>>> ticket = await router.route_read("https://linear.app/team/issue/ABC-123")
|
|
23
|
+
>>> # Read ticket using plain ID (uses default adapter)
|
|
24
|
+
>>> ticket = await router.route_read("ABC-456")
|
|
25
|
+
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import logging
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from ...core.adapter import BaseAdapter
|
|
33
|
+
from ...core.registry import AdapterRegistry
|
|
34
|
+
from ...core.url_parser import extract_id_from_url, is_url
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class AdapterResult:
|
|
41
|
+
"""Result of adapter lookup operation.
|
|
42
|
+
|
|
43
|
+
This class represents both successful adapter retrieval and
|
|
44
|
+
unconfigured adapter scenarios, allowing tools to provide
|
|
45
|
+
helpful setup guidance instead of failing with errors.
|
|
46
|
+
|
|
47
|
+
Attributes:
|
|
48
|
+
status: "configured" or "unconfigured"
|
|
49
|
+
adapter: The adapter instance if configured, None otherwise
|
|
50
|
+
adapter_name: Name of the adapter
|
|
51
|
+
message: Human-readable status message
|
|
52
|
+
required_config: Dictionary of required config fields (if unconfigured)
|
|
53
|
+
setup_instructions: Command to configure the adapter (if unconfigured)
|
|
54
|
+
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
status: str
|
|
58
|
+
adapter: BaseAdapter | None
|
|
59
|
+
adapter_name: str
|
|
60
|
+
message: str
|
|
61
|
+
required_config: dict[str, str] | None = None
|
|
62
|
+
setup_instructions: str | None = None
|
|
63
|
+
|
|
64
|
+
def is_configured(self) -> bool:
|
|
65
|
+
"""Check if adapter is configured and ready to use."""
|
|
66
|
+
return self.status == "configured" and self.adapter is not None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class RouterError(Exception):
|
|
70
|
+
"""Raised when routing operations fail."""
|
|
71
|
+
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class TicketRouter:
|
|
76
|
+
"""Route ticket operations to appropriate adapter based on URL/ID.
|
|
77
|
+
|
|
78
|
+
This class provides intelligent routing for multi-platform ticket access:
|
|
79
|
+
- Detects adapter type from URLs automatically
|
|
80
|
+
- Falls back to default adapter for plain IDs
|
|
81
|
+
- Caches adapter instances for performance
|
|
82
|
+
- Supports dynamic adapter configuration
|
|
83
|
+
|
|
84
|
+
Attributes:
|
|
85
|
+
default_adapter: Name of default adapter for plain IDs
|
|
86
|
+
adapter_configs: Configuration dictionary for each adapter
|
|
87
|
+
_adapters: Cache of initialized adapter instances
|
|
88
|
+
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
# Configuration requirements for each adapter type
|
|
92
|
+
ADAPTER_CONFIG_SPECS = {
|
|
93
|
+
"linear": {
|
|
94
|
+
"api_key": "Linear API key (from linear.app/settings/api)",
|
|
95
|
+
"team_id": "Linear team UUID or team_key: Team key (e.g., 'BTA')",
|
|
96
|
+
},
|
|
97
|
+
"github": {
|
|
98
|
+
"token": "GitHub Personal Access Token (from github.com/settings/tokens)",
|
|
99
|
+
"owner": "Repository owner (username or organization)",
|
|
100
|
+
"repo": "Repository name",
|
|
101
|
+
},
|
|
102
|
+
"jira": {
|
|
103
|
+
"server": "JIRA server URL (e.g., https://company.atlassian.net)",
|
|
104
|
+
"email": "User email for authentication",
|
|
105
|
+
"api_token": "JIRA API token",
|
|
106
|
+
"project_key": "Default project key",
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
def __init__(
|
|
111
|
+
self, default_adapter: str, adapter_configs: dict[str, dict[str, Any]]
|
|
112
|
+
):
|
|
113
|
+
"""Initialize ticket router.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
default_adapter: Name of default adapter (e.g., "linear", "github")
|
|
117
|
+
adapter_configs: Dict mapping adapter names to their configurations
|
|
118
|
+
Example: {
|
|
119
|
+
"linear": {"api_key": "...", "team_id": "..."},
|
|
120
|
+
"github": {"token": "...", "owner": "...", "repo": "..."}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
Raises:
|
|
124
|
+
ValueError: If default_adapter is not in adapter_configs
|
|
125
|
+
|
|
126
|
+
"""
|
|
127
|
+
self.default_adapter = default_adapter
|
|
128
|
+
self.adapter_configs = adapter_configs
|
|
129
|
+
self._adapters: dict[str, BaseAdapter] = {}
|
|
130
|
+
|
|
131
|
+
# Validate default adapter
|
|
132
|
+
if default_adapter not in adapter_configs:
|
|
133
|
+
raise ValueError(
|
|
134
|
+
f"Default adapter '{default_adapter}' not found in adapter_configs. "
|
|
135
|
+
f"Available: {list(adapter_configs.keys())}"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
logger.info(f"Initialized TicketRouter with default adapter: {default_adapter}")
|
|
139
|
+
logger.debug(f"Configured adapters: {list(adapter_configs.keys())}")
|
|
140
|
+
|
|
141
|
+
def _detect_adapter_from_url(self, url: str) -> str:
|
|
142
|
+
"""Detect adapter type from URL domain.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
url: URL string to analyze
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Adapter type name (e.g., "linear", "github", "jira", "asana")
|
|
149
|
+
|
|
150
|
+
Raises:
|
|
151
|
+
RouterError: If adapter type cannot be detected from URL
|
|
152
|
+
|
|
153
|
+
"""
|
|
154
|
+
url_lower = url.lower()
|
|
155
|
+
|
|
156
|
+
if "linear.app" in url_lower:
|
|
157
|
+
return "linear"
|
|
158
|
+
elif "github.com" in url_lower:
|
|
159
|
+
return "github"
|
|
160
|
+
elif "atlassian.net" in url_lower or "/browse/" in url_lower:
|
|
161
|
+
return "jira"
|
|
162
|
+
elif "app.asana.com" in url_lower:
|
|
163
|
+
return "asana"
|
|
164
|
+
else:
|
|
165
|
+
raise RouterError(
|
|
166
|
+
f"Cannot detect adapter from URL: {url}. "
|
|
167
|
+
f"Supported platforms: Linear, GitHub, Jira, Asana"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def _normalize_ticket_id(self, ticket_id: str) -> tuple[str, str, str]:
|
|
171
|
+
"""Normalize ticket ID and determine adapter.
|
|
172
|
+
|
|
173
|
+
This method handles both URLs and plain IDs:
|
|
174
|
+
- URLs: Extracts ID and detects adapter from domain
|
|
175
|
+
- Plain IDs: Returns as-is with default adapter
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
ticket_id: Ticket ID or URL
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
Tuple of (normalized_id, adapter_name, source)
|
|
182
|
+
where source is "url", "default", or "configured"
|
|
183
|
+
|
|
184
|
+
Raises:
|
|
185
|
+
RouterError: If URL parsing fails or adapter detection fails
|
|
186
|
+
|
|
187
|
+
"""
|
|
188
|
+
# Check if input is a URL
|
|
189
|
+
if not is_url(ticket_id):
|
|
190
|
+
# Plain ID - use default adapter
|
|
191
|
+
logger.debug(
|
|
192
|
+
f"Using default adapter '{self.default_adapter}' for ID: {ticket_id}"
|
|
193
|
+
)
|
|
194
|
+
return ticket_id, self.default_adapter, "default"
|
|
195
|
+
|
|
196
|
+
# URL - detect adapter and extract ID
|
|
197
|
+
adapter_name = self._detect_adapter_from_url(ticket_id)
|
|
198
|
+
logger.debug(f"Detected adapter '{adapter_name}' from URL: {ticket_id}")
|
|
199
|
+
|
|
200
|
+
# Extract ID from URL
|
|
201
|
+
extracted_id, error = extract_id_from_url(ticket_id, adapter_type=adapter_name)
|
|
202
|
+
if error or not extracted_id:
|
|
203
|
+
raise RouterError(
|
|
204
|
+
f"Failed to extract ticket ID from URL: {ticket_id}. Error: {error}"
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
logger.debug(f"Extracted ticket ID '{extracted_id}' from URL")
|
|
208
|
+
return extracted_id, adapter_name, "url"
|
|
209
|
+
|
|
210
|
+
def _get_adapter(self, adapter_name: str) -> AdapterResult:
|
|
211
|
+
"""Get or create adapter instance with configuration status.
|
|
212
|
+
|
|
213
|
+
Returns a result object that indicates whether the adapter is configured
|
|
214
|
+
and ready to use, or provides setup instructions if not configured.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
adapter_name: Name of adapter to get
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
AdapterResult with configuration status and adapter (if available)
|
|
221
|
+
|
|
222
|
+
"""
|
|
223
|
+
# Return cached adapter if available
|
|
224
|
+
if adapter_name in self._adapters:
|
|
225
|
+
return AdapterResult(
|
|
226
|
+
status="configured",
|
|
227
|
+
adapter=self._adapters[adapter_name],
|
|
228
|
+
adapter_name=adapter_name,
|
|
229
|
+
message=f"{adapter_name.title()} adapter is configured and ready",
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# Check if adapter is configured
|
|
233
|
+
if adapter_name not in self.adapter_configs:
|
|
234
|
+
# Get config requirements for this adapter
|
|
235
|
+
required_config = self.ADAPTER_CONFIG_SPECS.get(
|
|
236
|
+
adapter_name,
|
|
237
|
+
{
|
|
238
|
+
"config": "Required configuration fields (check adapter documentation)"
|
|
239
|
+
},
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
return AdapterResult(
|
|
243
|
+
status="unconfigured",
|
|
244
|
+
adapter=None,
|
|
245
|
+
adapter_name=adapter_name,
|
|
246
|
+
message=f"{adapter_name.title()} adapter detected but not configured",
|
|
247
|
+
required_config=required_config,
|
|
248
|
+
setup_instructions=f"Run: mcp-ticketer configure {adapter_name}",
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
# Create and cache adapter
|
|
252
|
+
try:
|
|
253
|
+
config = self.adapter_configs[adapter_name]
|
|
254
|
+
adapter = AdapterRegistry.get_adapter(adapter_name, config)
|
|
255
|
+
self._adapters[adapter_name] = adapter
|
|
256
|
+
logger.info(f"Created and cached adapter: {adapter_name}")
|
|
257
|
+
|
|
258
|
+
return AdapterResult(
|
|
259
|
+
status="configured",
|
|
260
|
+
adapter=adapter,
|
|
261
|
+
adapter_name=adapter_name,
|
|
262
|
+
message=f"{adapter_name.title()} adapter configured successfully",
|
|
263
|
+
)
|
|
264
|
+
except Exception as e:
|
|
265
|
+
# Failed to create adapter - return unconfigured with error details
|
|
266
|
+
logger.error(f"Failed to create adapter '{adapter_name}': {e}")
|
|
267
|
+
|
|
268
|
+
return AdapterResult(
|
|
269
|
+
status="unconfigured",
|
|
270
|
+
adapter=None,
|
|
271
|
+
adapter_name=adapter_name,
|
|
272
|
+
message=f"Failed to initialize {adapter_name.title()} adapter: {str(e)}",
|
|
273
|
+
required_config=self.ADAPTER_CONFIG_SPECS.get(adapter_name, {}),
|
|
274
|
+
setup_instructions=f"Run: mcp-ticketer configure {adapter_name}",
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
def _build_adapter_metadata(
|
|
278
|
+
self,
|
|
279
|
+
adapter: BaseAdapter,
|
|
280
|
+
source: str,
|
|
281
|
+
original_input: str,
|
|
282
|
+
normalized_id: str,
|
|
283
|
+
) -> dict[str, Any]:
|
|
284
|
+
"""Build adapter metadata for MCP responses.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
adapter: The adapter that handled the operation
|
|
288
|
+
source: How the adapter was selected ("url", "default", "configured")
|
|
289
|
+
original_input: The original ticket ID or URL provided
|
|
290
|
+
normalized_id: The normalized ticket ID after extraction
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
Dictionary with adapter metadata fields
|
|
294
|
+
|
|
295
|
+
"""
|
|
296
|
+
metadata = {
|
|
297
|
+
"adapter": adapter.adapter_type,
|
|
298
|
+
"adapter_name": adapter.adapter_display_name,
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
# Add routing information if URL-based
|
|
302
|
+
if source == "url":
|
|
303
|
+
metadata.update(
|
|
304
|
+
{
|
|
305
|
+
"adapter_source": source,
|
|
306
|
+
"original_input": original_input,
|
|
307
|
+
"normalized_id": normalized_id,
|
|
308
|
+
}
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
return metadata
|
|
312
|
+
|
|
313
|
+
async def route_read(self, ticket_id: str) -> Any:
|
|
314
|
+
"""Route read operation to appropriate adapter.
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
ticket_id: Ticket ID or URL
|
|
318
|
+
|
|
319
|
+
Returns:
|
|
320
|
+
Ticket object from adapter, or dict with unconfigured status if adapter not set up
|
|
321
|
+
|
|
322
|
+
Raises:
|
|
323
|
+
RouterError: If routing or read operation fails
|
|
324
|
+
ValueError: If URL parsing fails
|
|
325
|
+
|
|
326
|
+
"""
|
|
327
|
+
try:
|
|
328
|
+
normalized_id, adapter_name, _ = self._normalize_ticket_id(ticket_id)
|
|
329
|
+
adapter_result = self._get_adapter(adapter_name)
|
|
330
|
+
|
|
331
|
+
# Check if adapter is configured
|
|
332
|
+
if not adapter_result.is_configured():
|
|
333
|
+
logger.warning(
|
|
334
|
+
f"Adapter '{adapter_name}' not configured for ticket: {ticket_id}"
|
|
335
|
+
)
|
|
336
|
+
return {
|
|
337
|
+
"status": "unconfigured",
|
|
338
|
+
"adapter_detected": adapter_name,
|
|
339
|
+
"message": adapter_result.message,
|
|
340
|
+
"required_config": adapter_result.required_config,
|
|
341
|
+
"setup_instructions": adapter_result.setup_instructions,
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
# Adapter is configured - proceed with read
|
|
345
|
+
adapter = adapter_result.adapter
|
|
346
|
+
logger.debug(
|
|
347
|
+
f"Routing read for '{normalized_id}' to {adapter_name} adapter"
|
|
348
|
+
)
|
|
349
|
+
return await adapter.read(normalized_id)
|
|
350
|
+
except ValueError:
|
|
351
|
+
# Re-raise ValueError without wrapping to preserve helpful user messages
|
|
352
|
+
# (e.g., Linear view URL detection error)
|
|
353
|
+
raise
|
|
354
|
+
except Exception as e:
|
|
355
|
+
raise RouterError(f"Failed to route read operation: {str(e)}") from e
|
|
356
|
+
|
|
357
|
+
async def route_update(self, ticket_id: str, updates: dict[str, Any]) -> Any:
|
|
358
|
+
"""Route update operation to appropriate adapter.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
ticket_id: Ticket ID or URL
|
|
362
|
+
updates: Dictionary of field updates
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
Updated ticket object from adapter, or dict with unconfigured status
|
|
366
|
+
|
|
367
|
+
Raises:
|
|
368
|
+
RouterError: If routing or update operation fails
|
|
369
|
+
ValueError: If URL parsing fails
|
|
370
|
+
|
|
371
|
+
"""
|
|
372
|
+
try:
|
|
373
|
+
normalized_id, adapter_name, _ = self._normalize_ticket_id(ticket_id)
|
|
374
|
+
adapter_result = self._get_adapter(adapter_name)
|
|
375
|
+
|
|
376
|
+
# Check if adapter is configured
|
|
377
|
+
if not adapter_result.is_configured():
|
|
378
|
+
logger.warning(
|
|
379
|
+
f"Adapter '{adapter_name}' not configured for ticket: {ticket_id}"
|
|
380
|
+
)
|
|
381
|
+
return {
|
|
382
|
+
"status": "unconfigured",
|
|
383
|
+
"adapter_detected": adapter_name,
|
|
384
|
+
"message": adapter_result.message,
|
|
385
|
+
"required_config": adapter_result.required_config,
|
|
386
|
+
"setup_instructions": adapter_result.setup_instructions,
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
# Adapter is configured - proceed with update
|
|
390
|
+
adapter = adapter_result.adapter
|
|
391
|
+
logger.debug(
|
|
392
|
+
f"Routing update for '{normalized_id}' to {adapter_name} adapter"
|
|
393
|
+
)
|
|
394
|
+
return await adapter.update(normalized_id, updates)
|
|
395
|
+
except ValueError:
|
|
396
|
+
# Re-raise ValueError without wrapping to preserve helpful user messages
|
|
397
|
+
# (e.g., Linear view URL detection error)
|
|
398
|
+
raise
|
|
399
|
+
except Exception as e:
|
|
400
|
+
raise RouterError(f"Failed to route update operation: {str(e)}") from e
|
|
401
|
+
|
|
402
|
+
async def route_delete(self, ticket_id: str) -> bool | dict[str, Any]:
|
|
403
|
+
"""Route delete operation to appropriate adapter.
|
|
404
|
+
|
|
405
|
+
Args:
|
|
406
|
+
ticket_id: Ticket ID or URL
|
|
407
|
+
|
|
408
|
+
Returns:
|
|
409
|
+
True if deletion was successful, or dict with unconfigured status
|
|
410
|
+
|
|
411
|
+
Raises:
|
|
412
|
+
RouterError: If routing or delete operation fails
|
|
413
|
+
ValueError: If URL parsing fails
|
|
414
|
+
|
|
415
|
+
"""
|
|
416
|
+
try:
|
|
417
|
+
normalized_id, adapter_name, _ = self._normalize_ticket_id(ticket_id)
|
|
418
|
+
adapter_result = self._get_adapter(adapter_name)
|
|
419
|
+
|
|
420
|
+
# Check if adapter is configured
|
|
421
|
+
if not adapter_result.is_configured():
|
|
422
|
+
logger.warning(
|
|
423
|
+
f"Adapter '{adapter_name}' not configured for ticket: {ticket_id}"
|
|
424
|
+
)
|
|
425
|
+
return {
|
|
426
|
+
"status": "unconfigured",
|
|
427
|
+
"adapter_detected": adapter_name,
|
|
428
|
+
"message": adapter_result.message,
|
|
429
|
+
"required_config": adapter_result.required_config,
|
|
430
|
+
"setup_instructions": adapter_result.setup_instructions,
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
# Adapter is configured - proceed with delete
|
|
434
|
+
adapter = adapter_result.adapter
|
|
435
|
+
logger.debug(
|
|
436
|
+
f"Routing delete for '{normalized_id}' to {adapter_name} adapter"
|
|
437
|
+
)
|
|
438
|
+
return await adapter.delete(normalized_id)
|
|
439
|
+
except ValueError:
|
|
440
|
+
# Re-raise ValueError without wrapping to preserve helpful user messages
|
|
441
|
+
# (e.g., Linear view URL detection error)
|
|
442
|
+
raise
|
|
443
|
+
except Exception as e:
|
|
444
|
+
raise RouterError(f"Failed to route delete operation: {str(e)}") from e
|
|
445
|
+
|
|
446
|
+
async def route_add_comment(self, ticket_id: str, comment: Any) -> Any:
|
|
447
|
+
"""Route comment addition to appropriate adapter.
|
|
448
|
+
|
|
449
|
+
Args:
|
|
450
|
+
ticket_id: Ticket ID or URL
|
|
451
|
+
comment: Comment object to add
|
|
452
|
+
|
|
453
|
+
Returns:
|
|
454
|
+
Created comment object from adapter, or dict with unconfigured status
|
|
455
|
+
|
|
456
|
+
Raises:
|
|
457
|
+
RouterError: If routing or comment operation fails
|
|
458
|
+
ValueError: If URL parsing fails
|
|
459
|
+
|
|
460
|
+
"""
|
|
461
|
+
try:
|
|
462
|
+
normalized_id, adapter_name, _ = self._normalize_ticket_id(ticket_id)
|
|
463
|
+
adapter_result = self._get_adapter(adapter_name)
|
|
464
|
+
|
|
465
|
+
# Check if adapter is configured
|
|
466
|
+
if not adapter_result.is_configured():
|
|
467
|
+
logger.warning(
|
|
468
|
+
f"Adapter '{adapter_name}' not configured for ticket: {ticket_id}"
|
|
469
|
+
)
|
|
470
|
+
return {
|
|
471
|
+
"status": "unconfigured",
|
|
472
|
+
"adapter_detected": adapter_name,
|
|
473
|
+
"message": adapter_result.message,
|
|
474
|
+
"required_config": adapter_result.required_config,
|
|
475
|
+
"setup_instructions": adapter_result.setup_instructions,
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
# Adapter is configured - proceed with add_comment
|
|
479
|
+
adapter = adapter_result.adapter
|
|
480
|
+
logger.debug(
|
|
481
|
+
f"Routing add_comment for '{normalized_id}' to {adapter_name} adapter"
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
# Update comment's ticket_id to use normalized ID
|
|
485
|
+
comment.ticket_id = normalized_id
|
|
486
|
+
return await adapter.add_comment(comment)
|
|
487
|
+
except ValueError:
|
|
488
|
+
# Re-raise ValueError without wrapping to preserve helpful user messages
|
|
489
|
+
# (e.g., Linear view URL detection error)
|
|
490
|
+
raise
|
|
491
|
+
except Exception as e:
|
|
492
|
+
raise RouterError(f"Failed to route add_comment operation: {str(e)}") from e
|
|
493
|
+
|
|
494
|
+
async def route_get_comments(
|
|
495
|
+
self, ticket_id: str, limit: int = 10, offset: int = 0
|
|
496
|
+
) -> list[Any] | dict[str, Any]:
|
|
497
|
+
"""Route get comments operation to appropriate adapter.
|
|
498
|
+
|
|
499
|
+
Args:
|
|
500
|
+
ticket_id: Ticket ID or URL
|
|
501
|
+
limit: Maximum number of comments to return
|
|
502
|
+
offset: Number of comments to skip
|
|
503
|
+
|
|
504
|
+
Returns:
|
|
505
|
+
List of comment objects from adapter, or dict with unconfigured status
|
|
506
|
+
|
|
507
|
+
Raises:
|
|
508
|
+
RouterError: If routing or get comments operation fails
|
|
509
|
+
ValueError: If URL parsing fails
|
|
510
|
+
|
|
511
|
+
"""
|
|
512
|
+
try:
|
|
513
|
+
normalized_id, adapter_name, _ = self._normalize_ticket_id(ticket_id)
|
|
514
|
+
adapter_result = self._get_adapter(adapter_name)
|
|
515
|
+
|
|
516
|
+
# Check if adapter is configured
|
|
517
|
+
if not adapter_result.is_configured():
|
|
518
|
+
logger.warning(
|
|
519
|
+
f"Adapter '{adapter_name}' not configured for ticket: {ticket_id}"
|
|
520
|
+
)
|
|
521
|
+
return {
|
|
522
|
+
"status": "unconfigured",
|
|
523
|
+
"adapter_detected": adapter_name,
|
|
524
|
+
"message": adapter_result.message,
|
|
525
|
+
"required_config": adapter_result.required_config,
|
|
526
|
+
"setup_instructions": adapter_result.setup_instructions,
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
# Adapter is configured - proceed with get_comments
|
|
530
|
+
adapter = adapter_result.adapter
|
|
531
|
+
logger.debug(
|
|
532
|
+
f"Routing get_comments for '{normalized_id}' to {adapter_name} adapter"
|
|
533
|
+
)
|
|
534
|
+
return await adapter.get_comments(normalized_id, limit=limit, offset=offset)
|
|
535
|
+
except ValueError:
|
|
536
|
+
# Re-raise ValueError without wrapping to preserve helpful user messages
|
|
537
|
+
# (e.g., Linear view URL detection error)
|
|
538
|
+
raise
|
|
539
|
+
except Exception as e:
|
|
540
|
+
raise RouterError(
|
|
541
|
+
f"Failed to route get_comments operation: {str(e)}"
|
|
542
|
+
) from e
|
|
543
|
+
|
|
544
|
+
async def route_list_issues_by_epic(
|
|
545
|
+
self, epic_id: str
|
|
546
|
+
) -> list[Any] | dict[str, Any]:
|
|
547
|
+
"""Route list issues by epic to appropriate adapter.
|
|
548
|
+
|
|
549
|
+
Args:
|
|
550
|
+
epic_id: Epic ID or URL
|
|
551
|
+
|
|
552
|
+
Returns:
|
|
553
|
+
List of issue objects from adapter, or dict with unconfigured status
|
|
554
|
+
|
|
555
|
+
Raises:
|
|
556
|
+
RouterError: If routing or list operation fails
|
|
557
|
+
ValueError: If URL parsing fails
|
|
558
|
+
|
|
559
|
+
"""
|
|
560
|
+
try:
|
|
561
|
+
normalized_id, adapter_name, _ = self._normalize_ticket_id(epic_id)
|
|
562
|
+
adapter_result = self._get_adapter(adapter_name)
|
|
563
|
+
|
|
564
|
+
# Check if adapter is configured
|
|
565
|
+
if not adapter_result.is_configured():
|
|
566
|
+
logger.warning(
|
|
567
|
+
f"Adapter '{adapter_name}' not configured for epic: {epic_id}"
|
|
568
|
+
)
|
|
569
|
+
return {
|
|
570
|
+
"status": "unconfigured",
|
|
571
|
+
"adapter_detected": adapter_name,
|
|
572
|
+
"message": adapter_result.message,
|
|
573
|
+
"required_config": adapter_result.required_config,
|
|
574
|
+
"setup_instructions": adapter_result.setup_instructions,
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
# Adapter is configured - proceed with list_issues_by_epic
|
|
578
|
+
adapter = adapter_result.adapter
|
|
579
|
+
logger.debug(
|
|
580
|
+
f"Routing list_issues_by_epic for '{normalized_id}' to {adapter_name} adapter"
|
|
581
|
+
)
|
|
582
|
+
return await adapter.list_issues_by_epic(normalized_id)
|
|
583
|
+
except ValueError:
|
|
584
|
+
# Re-raise ValueError without wrapping to preserve helpful user messages
|
|
585
|
+
# (e.g., Linear view URL detection error)
|
|
586
|
+
raise
|
|
587
|
+
except Exception as e:
|
|
588
|
+
raise RouterError(
|
|
589
|
+
f"Failed to route list_issues_by_epic operation: {str(e)}"
|
|
590
|
+
) from e
|
|
591
|
+
|
|
592
|
+
async def route_list_tasks_by_issue(
|
|
593
|
+
self, issue_id: str
|
|
594
|
+
) -> list[Any] | dict[str, Any]:
|
|
595
|
+
"""Route list tasks by issue to appropriate adapter.
|
|
596
|
+
|
|
597
|
+
Args:
|
|
598
|
+
issue_id: Issue ID or URL
|
|
599
|
+
|
|
600
|
+
Returns:
|
|
601
|
+
List of task objects from adapter, or dict with unconfigured status
|
|
602
|
+
|
|
603
|
+
Raises:
|
|
604
|
+
RouterError: If routing or list operation fails
|
|
605
|
+
ValueError: If URL parsing fails
|
|
606
|
+
|
|
607
|
+
"""
|
|
608
|
+
try:
|
|
609
|
+
normalized_id, adapter_name, _ = self._normalize_ticket_id(issue_id)
|
|
610
|
+
adapter_result = self._get_adapter(adapter_name)
|
|
611
|
+
|
|
612
|
+
# Check if adapter is configured
|
|
613
|
+
if not adapter_result.is_configured():
|
|
614
|
+
logger.warning(
|
|
615
|
+
f"Adapter '{adapter_name}' not configured for issue: {issue_id}"
|
|
616
|
+
)
|
|
617
|
+
return {
|
|
618
|
+
"status": "unconfigured",
|
|
619
|
+
"adapter_detected": adapter_name,
|
|
620
|
+
"message": adapter_result.message,
|
|
621
|
+
"required_config": adapter_result.required_config,
|
|
622
|
+
"setup_instructions": adapter_result.setup_instructions,
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
# Adapter is configured - proceed with list_tasks_by_issue
|
|
626
|
+
adapter = adapter_result.adapter
|
|
627
|
+
logger.debug(
|
|
628
|
+
f"Routing list_tasks_by_issue for '{normalized_id}' to {adapter_name} adapter"
|
|
629
|
+
)
|
|
630
|
+
return await adapter.list_tasks_by_issue(normalized_id)
|
|
631
|
+
except ValueError:
|
|
632
|
+
# Re-raise ValueError without wrapping to preserve helpful user messages
|
|
633
|
+
# (e.g., Linear view URL detection error)
|
|
634
|
+
raise
|
|
635
|
+
except Exception as e:
|
|
636
|
+
raise RouterError(
|
|
637
|
+
f"Failed to route list_tasks_by_issue operation: {str(e)}"
|
|
638
|
+
) from e
|
|
639
|
+
|
|
640
|
+
async def close(self) -> None:
|
|
641
|
+
"""Close all cached adapter connections.
|
|
642
|
+
|
|
643
|
+
This should be called when the router is no longer needed to clean up
|
|
644
|
+
any open connections or resources held by adapters.
|
|
645
|
+
|
|
646
|
+
"""
|
|
647
|
+
for adapter_name, adapter in self._adapters.items():
|
|
648
|
+
try:
|
|
649
|
+
await adapter.close()
|
|
650
|
+
logger.debug(f"Closed adapter: {adapter_name}")
|
|
651
|
+
except Exception as e:
|
|
652
|
+
logger.warning(f"Error closing adapter {adapter_name}: {e}")
|
|
653
|
+
|
|
654
|
+
self._adapters.clear()
|
|
655
|
+
logger.info("Closed all adapter connections")
|