mcp-ticketer 0.12.0__py3-none-any.whl → 2.2.13__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of mcp-ticketer might be problematic. Click here for more details.

Files changed (129) hide show
  1. mcp_ticketer/__init__.py +10 -10
  2. mcp_ticketer/__version__.py +1 -1
  3. mcp_ticketer/_version_scm.py +1 -0
  4. mcp_ticketer/adapters/aitrackdown.py +507 -6
  5. mcp_ticketer/adapters/asana/adapter.py +229 -0
  6. mcp_ticketer/adapters/asana/mappers.py +14 -0
  7. mcp_ticketer/adapters/github/__init__.py +26 -0
  8. mcp_ticketer/adapters/github/adapter.py +3229 -0
  9. mcp_ticketer/adapters/github/client.py +335 -0
  10. mcp_ticketer/adapters/github/mappers.py +797 -0
  11. mcp_ticketer/adapters/github/queries.py +692 -0
  12. mcp_ticketer/adapters/github/types.py +460 -0
  13. mcp_ticketer/adapters/hybrid.py +47 -5
  14. mcp_ticketer/adapters/jira/__init__.py +35 -0
  15. mcp_ticketer/adapters/jira/adapter.py +1351 -0
  16. mcp_ticketer/adapters/jira/client.py +271 -0
  17. mcp_ticketer/adapters/jira/mappers.py +246 -0
  18. mcp_ticketer/adapters/jira/queries.py +216 -0
  19. mcp_ticketer/adapters/jira/types.py +304 -0
  20. mcp_ticketer/adapters/linear/adapter.py +2730 -139
  21. mcp_ticketer/adapters/linear/client.py +175 -3
  22. mcp_ticketer/adapters/linear/mappers.py +203 -8
  23. mcp_ticketer/adapters/linear/queries.py +280 -3
  24. mcp_ticketer/adapters/linear/types.py +120 -4
  25. mcp_ticketer/analysis/__init__.py +56 -0
  26. mcp_ticketer/analysis/dependency_graph.py +255 -0
  27. mcp_ticketer/analysis/health_assessment.py +304 -0
  28. mcp_ticketer/analysis/orphaned.py +218 -0
  29. mcp_ticketer/analysis/project_status.py +594 -0
  30. mcp_ticketer/analysis/similarity.py +224 -0
  31. mcp_ticketer/analysis/staleness.py +266 -0
  32. mcp_ticketer/automation/__init__.py +11 -0
  33. mcp_ticketer/automation/project_updates.py +378 -0
  34. mcp_ticketer/cli/adapter_diagnostics.py +3 -1
  35. mcp_ticketer/cli/auggie_configure.py +17 -5
  36. mcp_ticketer/cli/codex_configure.py +97 -61
  37. mcp_ticketer/cli/configure.py +1288 -105
  38. mcp_ticketer/cli/cursor_configure.py +314 -0
  39. mcp_ticketer/cli/diagnostics.py +13 -12
  40. mcp_ticketer/cli/discover.py +5 -0
  41. mcp_ticketer/cli/gemini_configure.py +17 -5
  42. mcp_ticketer/cli/init_command.py +880 -0
  43. mcp_ticketer/cli/install_mcp_server.py +418 -0
  44. mcp_ticketer/cli/instruction_commands.py +6 -0
  45. mcp_ticketer/cli/main.py +267 -3175
  46. mcp_ticketer/cli/mcp_configure.py +821 -119
  47. mcp_ticketer/cli/mcp_server_commands.py +415 -0
  48. mcp_ticketer/cli/platform_detection.py +77 -12
  49. mcp_ticketer/cli/platform_installer.py +545 -0
  50. mcp_ticketer/cli/project_update_commands.py +350 -0
  51. mcp_ticketer/cli/setup_command.py +795 -0
  52. mcp_ticketer/cli/simple_health.py +12 -10
  53. mcp_ticketer/cli/ticket_commands.py +705 -103
  54. mcp_ticketer/cli/utils.py +113 -0
  55. mcp_ticketer/core/__init__.py +56 -6
  56. mcp_ticketer/core/adapter.py +533 -2
  57. mcp_ticketer/core/config.py +21 -21
  58. mcp_ticketer/core/exceptions.py +7 -1
  59. mcp_ticketer/core/label_manager.py +732 -0
  60. mcp_ticketer/core/mappers.py +31 -19
  61. mcp_ticketer/core/milestone_manager.py +252 -0
  62. mcp_ticketer/core/models.py +480 -0
  63. mcp_ticketer/core/onepassword_secrets.py +1 -1
  64. mcp_ticketer/core/priority_matcher.py +463 -0
  65. mcp_ticketer/core/project_config.py +132 -14
  66. mcp_ticketer/core/project_utils.py +281 -0
  67. mcp_ticketer/core/project_validator.py +376 -0
  68. mcp_ticketer/core/session_state.py +176 -0
  69. mcp_ticketer/core/state_matcher.py +625 -0
  70. mcp_ticketer/core/url_parser.py +425 -0
  71. mcp_ticketer/core/validators.py +69 -0
  72. mcp_ticketer/mcp/server/__main__.py +2 -1
  73. mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
  74. mcp_ticketer/mcp/server/main.py +106 -25
  75. mcp_ticketer/mcp/server/routing.py +723 -0
  76. mcp_ticketer/mcp/server/server_sdk.py +58 -0
  77. mcp_ticketer/mcp/server/tools/__init__.py +33 -11
  78. mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
  79. mcp_ticketer/mcp/server/tools/attachment_tools.py +5 -5
  80. mcp_ticketer/mcp/server/tools/bulk_tools.py +259 -202
  81. mcp_ticketer/mcp/server/tools/comment_tools.py +74 -12
  82. mcp_ticketer/mcp/server/tools/config_tools.py +1391 -145
  83. mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
  84. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +870 -460
  85. mcp_ticketer/mcp/server/tools/instruction_tools.py +7 -5
  86. mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
  87. mcp_ticketer/mcp/server/tools/milestone_tools.py +338 -0
  88. mcp_ticketer/mcp/server/tools/pr_tools.py +3 -7
  89. mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
  90. mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
  91. mcp_ticketer/mcp/server/tools/search_tools.py +209 -97
  92. mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
  93. mcp_ticketer/mcp/server/tools/ticket_tools.py +1107 -124
  94. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +218 -236
  95. mcp_ticketer/queue/queue.py +68 -0
  96. mcp_ticketer/queue/worker.py +1 -1
  97. mcp_ticketer/utils/__init__.py +5 -0
  98. mcp_ticketer/utils/token_utils.py +246 -0
  99. mcp_ticketer-2.2.13.dist-info/METADATA +1396 -0
  100. mcp_ticketer-2.2.13.dist-info/RECORD +158 -0
  101. mcp_ticketer-2.2.13.dist-info/top_level.txt +2 -0
  102. py_mcp_installer/examples/phase3_demo.py +178 -0
  103. py_mcp_installer/scripts/manage_version.py +54 -0
  104. py_mcp_installer/setup.py +6 -0
  105. py_mcp_installer/src/py_mcp_installer/__init__.py +153 -0
  106. py_mcp_installer/src/py_mcp_installer/command_builder.py +445 -0
  107. py_mcp_installer/src/py_mcp_installer/config_manager.py +541 -0
  108. py_mcp_installer/src/py_mcp_installer/exceptions.py +243 -0
  109. py_mcp_installer/src/py_mcp_installer/installation_strategy.py +617 -0
  110. py_mcp_installer/src/py_mcp_installer/installer.py +656 -0
  111. py_mcp_installer/src/py_mcp_installer/mcp_inspector.py +750 -0
  112. py_mcp_installer/src/py_mcp_installer/platform_detector.py +451 -0
  113. py_mcp_installer/src/py_mcp_installer/platforms/__init__.py +26 -0
  114. py_mcp_installer/src/py_mcp_installer/platforms/claude_code.py +225 -0
  115. py_mcp_installer/src/py_mcp_installer/platforms/codex.py +181 -0
  116. py_mcp_installer/src/py_mcp_installer/platforms/cursor.py +191 -0
  117. py_mcp_installer/src/py_mcp_installer/types.py +222 -0
  118. py_mcp_installer/src/py_mcp_installer/utils.py +463 -0
  119. py_mcp_installer/tests/__init__.py +0 -0
  120. py_mcp_installer/tests/platforms/__init__.py +0 -0
  121. py_mcp_installer/tests/test_platform_detector.py +17 -0
  122. mcp_ticketer/adapters/github.py +0 -1574
  123. mcp_ticketer/adapters/jira.py +0 -1258
  124. mcp_ticketer-0.12.0.dist-info/METADATA +0 -550
  125. mcp_ticketer-0.12.0.dist-info/RECORD +0 -91
  126. mcp_ticketer-0.12.0.dist-info/top_level.txt +0 -1
  127. {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.2.13.dist-info}/WHEEL +0 -0
  128. {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.2.13.dist-info}/entry_points.txt +0 -0
  129. {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.2.13.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,723 @@
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 validate_project_access(
641
+ self, project_url: str, test_connection: bool = True
642
+ ) -> dict[str, Any]:
643
+ """Validate project URL and test accessibility.
644
+
645
+ This method provides comprehensive validation for project URLs:
646
+ 1. Parses URL to extract platform and project ID
647
+ 2. Validates adapter configuration exists
648
+ 3. Validates adapter credentials
649
+ 4. Optionally tests project accessibility via API
650
+
651
+ Args:
652
+ project_url: Project URL to validate
653
+ test_connection: If True, test actual API connectivity (default: True)
654
+
655
+ Returns:
656
+ Validation result dictionary with:
657
+ - valid (bool): Whether validation passed
658
+ - platform (str): Detected platform
659
+ - project_id (str): Extracted project ID
660
+ - adapter_configured (bool): Whether adapter is configured
661
+ - error (str): Error message if validation failed
662
+ - suggestions (list): Suggested actions to resolve error
663
+
664
+ Examples:
665
+ >>> router = TicketRouter(...)
666
+ >>> result = await router.validate_project_access("https://linear.app/team/project/abc-123")
667
+ >>> if result["valid"]:
668
+ ... print(f"Project {result['project_id']} is accessible")
669
+ ... else:
670
+ ... print(f"Error: {result['error']}")
671
+
672
+ """
673
+ try:
674
+ # Import project validator
675
+ # Create validator (use router's config for consistency)
676
+ from pathlib import Path
677
+
678
+ from ...core.project_validator import ProjectValidator
679
+
680
+ validator = ProjectValidator(project_path=Path.cwd())
681
+
682
+ # Validate project URL
683
+ validation_result = validator.validate_project_url(
684
+ url=project_url, test_connection=test_connection
685
+ )
686
+
687
+ # Convert dataclass to dictionary
688
+ return {
689
+ "valid": validation_result.valid,
690
+ "platform": validation_result.platform,
691
+ "project_id": validation_result.project_id,
692
+ "adapter_configured": validation_result.adapter_configured,
693
+ "adapter_valid": validation_result.adapter_valid,
694
+ "error": validation_result.error,
695
+ "error_type": validation_result.error_type,
696
+ "suggestions": validation_result.suggestions,
697
+ "credential_errors": validation_result.credential_errors,
698
+ }
699
+
700
+ except Exception as e:
701
+ logger.error(f"Project validation failed: {e}")
702
+ return {
703
+ "valid": False,
704
+ "error": f"Validation failed with exception: {str(e)}",
705
+ "error_type": "validation_error",
706
+ }
707
+
708
+ async def close(self) -> None:
709
+ """Close all cached adapter connections.
710
+
711
+ This should be called when the router is no longer needed to clean up
712
+ any open connections or resources held by adapters.
713
+
714
+ """
715
+ for adapter_name, adapter in self._adapters.items():
716
+ try:
717
+ await adapter.close()
718
+ logger.debug(f"Closed adapter: {adapter_name}")
719
+ except Exception as e:
720
+ logger.warning(f"Error closing adapter {adapter_name}: {e}")
721
+
722
+ self._adapters.clear()
723
+ logger.info("Closed all adapter connections")