mcp-ticketer 0.2.0__py3-none-any.whl → 2.2.9__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.
Files changed (160) hide show
  1. mcp_ticketer/__init__.py +10 -10
  2. mcp_ticketer/__version__.py +3 -3
  3. mcp_ticketer/_version_scm.py +1 -0
  4. mcp_ticketer/adapters/__init__.py +2 -0
  5. mcp_ticketer/adapters/aitrackdown.py +930 -52
  6. mcp_ticketer/adapters/asana/__init__.py +15 -0
  7. mcp_ticketer/adapters/asana/adapter.py +1537 -0
  8. mcp_ticketer/adapters/asana/client.py +292 -0
  9. mcp_ticketer/adapters/asana/mappers.py +348 -0
  10. mcp_ticketer/adapters/asana/types.py +146 -0
  11. mcp_ticketer/adapters/github/__init__.py +26 -0
  12. mcp_ticketer/adapters/github/adapter.py +3229 -0
  13. mcp_ticketer/adapters/github/client.py +335 -0
  14. mcp_ticketer/adapters/github/mappers.py +797 -0
  15. mcp_ticketer/adapters/github/queries.py +692 -0
  16. mcp_ticketer/adapters/github/types.py +460 -0
  17. mcp_ticketer/adapters/hybrid.py +58 -16
  18. mcp_ticketer/adapters/jira/__init__.py +35 -0
  19. mcp_ticketer/adapters/jira/adapter.py +1351 -0
  20. mcp_ticketer/adapters/jira/client.py +271 -0
  21. mcp_ticketer/adapters/jira/mappers.py +246 -0
  22. mcp_ticketer/adapters/jira/queries.py +216 -0
  23. mcp_ticketer/adapters/jira/types.py +304 -0
  24. mcp_ticketer/adapters/linear/__init__.py +1 -1
  25. mcp_ticketer/adapters/linear/adapter.py +3810 -462
  26. mcp_ticketer/adapters/linear/client.py +312 -69
  27. mcp_ticketer/adapters/linear/mappers.py +305 -85
  28. mcp_ticketer/adapters/linear/queries.py +317 -17
  29. mcp_ticketer/adapters/linear/types.py +187 -64
  30. mcp_ticketer/adapters/linear.py +2 -2
  31. mcp_ticketer/analysis/__init__.py +56 -0
  32. mcp_ticketer/analysis/dependency_graph.py +255 -0
  33. mcp_ticketer/analysis/health_assessment.py +304 -0
  34. mcp_ticketer/analysis/orphaned.py +218 -0
  35. mcp_ticketer/analysis/project_status.py +594 -0
  36. mcp_ticketer/analysis/similarity.py +224 -0
  37. mcp_ticketer/analysis/staleness.py +266 -0
  38. mcp_ticketer/automation/__init__.py +11 -0
  39. mcp_ticketer/automation/project_updates.py +378 -0
  40. mcp_ticketer/cache/memory.py +9 -8
  41. mcp_ticketer/cli/adapter_diagnostics.py +421 -0
  42. mcp_ticketer/cli/auggie_configure.py +116 -15
  43. mcp_ticketer/cli/codex_configure.py +274 -82
  44. mcp_ticketer/cli/configure.py +1323 -151
  45. mcp_ticketer/cli/cursor_configure.py +314 -0
  46. mcp_ticketer/cli/diagnostics.py +209 -114
  47. mcp_ticketer/cli/discover.py +297 -26
  48. mcp_ticketer/cli/gemini_configure.py +119 -26
  49. mcp_ticketer/cli/init_command.py +880 -0
  50. mcp_ticketer/cli/install_mcp_server.py +418 -0
  51. mcp_ticketer/cli/instruction_commands.py +435 -0
  52. mcp_ticketer/cli/linear_commands.py +256 -130
  53. mcp_ticketer/cli/main.py +140 -1284
  54. mcp_ticketer/cli/mcp_configure.py +1013 -100
  55. mcp_ticketer/cli/mcp_server_commands.py +415 -0
  56. mcp_ticketer/cli/migrate_config.py +12 -8
  57. mcp_ticketer/cli/platform_commands.py +123 -0
  58. mcp_ticketer/cli/platform_detection.py +477 -0
  59. mcp_ticketer/cli/platform_installer.py +545 -0
  60. mcp_ticketer/cli/project_update_commands.py +350 -0
  61. mcp_ticketer/cli/python_detection.py +126 -0
  62. mcp_ticketer/cli/queue_commands.py +15 -15
  63. mcp_ticketer/cli/setup_command.py +794 -0
  64. mcp_ticketer/cli/simple_health.py +84 -59
  65. mcp_ticketer/cli/ticket_commands.py +1375 -0
  66. mcp_ticketer/cli/update_checker.py +313 -0
  67. mcp_ticketer/cli/utils.py +195 -72
  68. mcp_ticketer/core/__init__.py +64 -1
  69. mcp_ticketer/core/adapter.py +618 -18
  70. mcp_ticketer/core/config.py +77 -68
  71. mcp_ticketer/core/env_discovery.py +75 -16
  72. mcp_ticketer/core/env_loader.py +121 -97
  73. mcp_ticketer/core/exceptions.py +32 -24
  74. mcp_ticketer/core/http_client.py +26 -26
  75. mcp_ticketer/core/instructions.py +405 -0
  76. mcp_ticketer/core/label_manager.py +732 -0
  77. mcp_ticketer/core/mappers.py +42 -30
  78. mcp_ticketer/core/milestone_manager.py +252 -0
  79. mcp_ticketer/core/models.py +566 -19
  80. mcp_ticketer/core/onepassword_secrets.py +379 -0
  81. mcp_ticketer/core/priority_matcher.py +463 -0
  82. mcp_ticketer/core/project_config.py +189 -49
  83. mcp_ticketer/core/project_utils.py +281 -0
  84. mcp_ticketer/core/project_validator.py +376 -0
  85. mcp_ticketer/core/registry.py +3 -3
  86. mcp_ticketer/core/session_state.py +176 -0
  87. mcp_ticketer/core/state_matcher.py +592 -0
  88. mcp_ticketer/core/url_parser.py +425 -0
  89. mcp_ticketer/core/validators.py +69 -0
  90. mcp_ticketer/defaults/ticket_instructions.md +644 -0
  91. mcp_ticketer/mcp/__init__.py +29 -1
  92. mcp_ticketer/mcp/__main__.py +60 -0
  93. mcp_ticketer/mcp/server/__init__.py +25 -0
  94. mcp_ticketer/mcp/server/__main__.py +60 -0
  95. mcp_ticketer/mcp/server/constants.py +58 -0
  96. mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
  97. mcp_ticketer/mcp/server/dto.py +195 -0
  98. mcp_ticketer/mcp/server/main.py +1343 -0
  99. mcp_ticketer/mcp/server/response_builder.py +206 -0
  100. mcp_ticketer/mcp/server/routing.py +723 -0
  101. mcp_ticketer/mcp/server/server_sdk.py +151 -0
  102. mcp_ticketer/mcp/server/tools/__init__.py +69 -0
  103. mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
  104. mcp_ticketer/mcp/server/tools/attachment_tools.py +224 -0
  105. mcp_ticketer/mcp/server/tools/bulk_tools.py +330 -0
  106. mcp_ticketer/mcp/server/tools/comment_tools.py +152 -0
  107. mcp_ticketer/mcp/server/tools/config_tools.py +1564 -0
  108. mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
  109. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +942 -0
  110. mcp_ticketer/mcp/server/tools/instruction_tools.py +295 -0
  111. mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
  112. mcp_ticketer/mcp/server/tools/milestone_tools.py +338 -0
  113. mcp_ticketer/mcp/server/tools/pr_tools.py +150 -0
  114. mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
  115. mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
  116. mcp_ticketer/mcp/server/tools/search_tools.py +318 -0
  117. mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
  118. mcp_ticketer/mcp/server/tools/ticket_tools.py +1413 -0
  119. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +364 -0
  120. mcp_ticketer/queue/__init__.py +1 -0
  121. mcp_ticketer/queue/health_monitor.py +168 -136
  122. mcp_ticketer/queue/manager.py +78 -63
  123. mcp_ticketer/queue/queue.py +108 -21
  124. mcp_ticketer/queue/run_worker.py +2 -2
  125. mcp_ticketer/queue/ticket_registry.py +213 -155
  126. mcp_ticketer/queue/worker.py +96 -58
  127. mcp_ticketer/utils/__init__.py +5 -0
  128. mcp_ticketer/utils/token_utils.py +246 -0
  129. mcp_ticketer-2.2.9.dist-info/METADATA +1396 -0
  130. mcp_ticketer-2.2.9.dist-info/RECORD +158 -0
  131. mcp_ticketer-2.2.9.dist-info/top_level.txt +2 -0
  132. py_mcp_installer/examples/phase3_demo.py +178 -0
  133. py_mcp_installer/scripts/manage_version.py +54 -0
  134. py_mcp_installer/setup.py +6 -0
  135. py_mcp_installer/src/py_mcp_installer/__init__.py +153 -0
  136. py_mcp_installer/src/py_mcp_installer/command_builder.py +445 -0
  137. py_mcp_installer/src/py_mcp_installer/config_manager.py +541 -0
  138. py_mcp_installer/src/py_mcp_installer/exceptions.py +243 -0
  139. py_mcp_installer/src/py_mcp_installer/installation_strategy.py +617 -0
  140. py_mcp_installer/src/py_mcp_installer/installer.py +656 -0
  141. py_mcp_installer/src/py_mcp_installer/mcp_inspector.py +750 -0
  142. py_mcp_installer/src/py_mcp_installer/platform_detector.py +451 -0
  143. py_mcp_installer/src/py_mcp_installer/platforms/__init__.py +26 -0
  144. py_mcp_installer/src/py_mcp_installer/platforms/claude_code.py +225 -0
  145. py_mcp_installer/src/py_mcp_installer/platforms/codex.py +181 -0
  146. py_mcp_installer/src/py_mcp_installer/platforms/cursor.py +191 -0
  147. py_mcp_installer/src/py_mcp_installer/types.py +222 -0
  148. py_mcp_installer/src/py_mcp_installer/utils.py +463 -0
  149. py_mcp_installer/tests/__init__.py +0 -0
  150. py_mcp_installer/tests/platforms/__init__.py +0 -0
  151. py_mcp_installer/tests/test_platform_detector.py +17 -0
  152. mcp_ticketer/adapters/github.py +0 -1354
  153. mcp_ticketer/adapters/jira.py +0 -1011
  154. mcp_ticketer/mcp/server.py +0 -1895
  155. mcp_ticketer-0.2.0.dist-info/METADATA +0 -414
  156. mcp_ticketer-0.2.0.dist-info/RECORD +0 -58
  157. mcp_ticketer-0.2.0.dist-info/top_level.txt +0 -1
  158. {mcp_ticketer-0.2.0.dist-info → mcp_ticketer-2.2.9.dist-info}/WHEEL +0 -0
  159. {mcp_ticketer-0.2.0.dist-info → mcp_ticketer-2.2.9.dist-info}/entry_points.txt +0 -0
  160. {mcp_ticketer-0.2.0.dist-info → mcp_ticketer-2.2.9.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,376 @@
1
+ """Project URL validation with adapter detection and credential checking.
2
+
3
+ This module provides comprehensive validation for project URLs across all supported
4
+ platforms (Linear, GitHub, Jira, Asana). It validates:
5
+
6
+ 1. URL format and parsing
7
+ 2. Adapter detection from URL
8
+ 3. Adapter configuration and credentials
9
+ 4. Project accessibility (optional test mode)
10
+
11
+ Design Decision: Validation Before Configuration
12
+ ------------------------------------------------
13
+ This validator is called BEFORE setting a default project to ensure:
14
+ - URL can be parsed correctly
15
+ - Appropriate adapter exists and is configured
16
+ - Credentials are valid (if test_connection=True)
17
+ - Project is accessible with current credentials
18
+
19
+ Error Reporting:
20
+ - Specific, actionable error messages for each failure scenario
21
+ - Suggestions for resolving configuration issues
22
+ - Platform-specific setup guidance
23
+
24
+ Performance: Lightweight validation by default (format/config check only).
25
+ Optional deep validation with actual API connectivity test.
26
+ """
27
+
28
+ import logging
29
+ from dataclasses import dataclass
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ from .project_config import ConfigResolver, TicketerConfig
34
+ from .registry import AdapterRegistry
35
+ from .url_parser import extract_id_from_url, is_url
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+
40
+ @dataclass
41
+ class ProjectValidationResult:
42
+ """Result of project URL validation.
43
+
44
+ Attributes:
45
+ valid: Whether validation passed
46
+ platform: Detected platform (linear, github, jira, asana)
47
+ project_id: Extracted project identifier
48
+ adapter_configured: Whether adapter is configured
49
+ adapter_valid: Whether adapter credentials are valid
50
+ error: Error message if validation failed
51
+ error_type: Category of error (url_parse, adapter_missing, credentials_invalid, project_not_found)
52
+ suggestions: List of suggested actions to resolve the error
53
+ credential_errors: Specific credential validation errors
54
+ adapter_config: Current adapter configuration (masked)
55
+
56
+ """
57
+
58
+ valid: bool
59
+ platform: str | None = None
60
+ project_id: str | None = None
61
+ adapter_configured: bool = False
62
+ adapter_valid: bool = False
63
+ error: str | None = None
64
+ error_type: str | None = None
65
+ suggestions: list[str] | None = None
66
+ credential_errors: dict[str, str] | None = None
67
+ adapter_config: dict[str, Any] | None = None
68
+
69
+
70
+ class ProjectValidator:
71
+ """Validate project URLs with adapter detection and credential checking."""
72
+
73
+ # Map URL domains to adapter types
74
+ DOMAIN_TO_ADAPTER = {
75
+ "linear.app": "linear",
76
+ "github.com": "github",
77
+ "atlassian.net": "jira",
78
+ "app.asana.com": "asana",
79
+ }
80
+
81
+ # Adapter-specific setup instructions
82
+ SETUP_INSTRUCTIONS = {
83
+ "linear": [
84
+ "1. Get Linear API key from https://linear.app/settings/api",
85
+ "2. Find your team key (short code like 'ENG' in Linear URLs)",
86
+ "3. Run: config(action='setup_wizard', adapter_type='linear', credentials={'api_key': '...', 'team_key': 'ENG'})",
87
+ ],
88
+ "github": [
89
+ "1. Create GitHub Personal Access Token at https://github.com/settings/tokens",
90
+ "2. Get owner and repo from project URL (github.com/owner/repo)",
91
+ "3. Run: config(action='setup_wizard', adapter_type='github', credentials={'token': '...', 'owner': '...', 'repo': '...'})",
92
+ ],
93
+ "jira": [
94
+ "1. Get JIRA server URL (e.g., https://company.atlassian.net)",
95
+ "2. Generate API token at https://id.atlassian.com/manage-profile/security/api-tokens",
96
+ "3. Run: config(action='setup_wizard', adapter_type='jira', credentials={'server': '...', 'email': '...', 'api_token': '...'})",
97
+ ],
98
+ "asana": [
99
+ "1. Get Asana Personal Access Token from https://app.asana.com/0/developer-console",
100
+ "2. Run: config(action='setup_wizard', adapter_type='asana', credentials={'api_key': '...'})",
101
+ ],
102
+ }
103
+
104
+ def __init__(self, project_path: Path | None = None):
105
+ """Initialize project validator.
106
+
107
+ Args:
108
+ project_path: Path to project root (defaults to cwd)
109
+
110
+ """
111
+ self.project_path = project_path or Path.cwd()
112
+ self.resolver = ConfigResolver(project_path=self.project_path)
113
+
114
+ def validate_project_url(
115
+ self, url: str, test_connection: bool = False
116
+ ) -> ProjectValidationResult:
117
+ """Validate project URL with comprehensive checks.
118
+
119
+ Validation Steps:
120
+ 1. Parse URL and extract project ID
121
+ 2. Detect platform from URL domain
122
+ 3. Check if adapter is configured
123
+ 4. Validate adapter credentials (format check)
124
+ 5. (Optional) Test project accessibility via API
125
+
126
+ Args:
127
+ url: Project URL to validate
128
+ test_connection: If True, test actual API connectivity (default: False)
129
+
130
+ Returns:
131
+ ProjectValidationResult with validation status and details
132
+
133
+ Examples:
134
+ >>> validator = ProjectValidator()
135
+ >>> result = validator.validate_project_url("https://linear.app/team/project/abc-123")
136
+ >>> if result.valid:
137
+ ... print(f"Project ID: {result.project_id}")
138
+ ... else:
139
+ ... print(f"Error: {result.error}")
140
+
141
+ """
142
+ # Step 1: Validate URL format
143
+ if not url or not isinstance(url, str):
144
+ return ProjectValidationResult(
145
+ valid=False,
146
+ error="Invalid URL: Empty or non-string value provided",
147
+ error_type="url_parse",
148
+ suggestions=["Provide a valid project URL string"],
149
+ )
150
+
151
+ if not is_url(url):
152
+ return ProjectValidationResult(
153
+ valid=False,
154
+ error=f"Invalid URL format: '{url}'",
155
+ error_type="url_parse",
156
+ suggestions=[
157
+ "Provide a complete URL with protocol (https://...)",
158
+ "Examples:",
159
+ " - Linear: https://linear.app/team/project/project-slug-id",
160
+ " - GitHub: https://github.com/owner/repo/projects/1",
161
+ " - Jira: https://company.atlassian.net/browse/PROJ-123",
162
+ " - Asana: https://app.asana.com/0/workspace/project",
163
+ ],
164
+ )
165
+
166
+ # Step 2: Detect platform from URL
167
+ platform = self._detect_platform(url)
168
+ if not platform:
169
+ return ProjectValidationResult(
170
+ valid=False,
171
+ error=f"Cannot detect platform from URL: {url}",
172
+ error_type="url_parse",
173
+ suggestions=[
174
+ "Supported platforms: Linear, GitHub, Jira, Asana",
175
+ "Ensure URL matches one of these formats:",
176
+ " - Linear: https://linear.app/...",
177
+ " - GitHub: https://github.com/...",
178
+ " - Jira: https://company.atlassian.net/...",
179
+ " - Asana: https://app.asana.com/...",
180
+ ],
181
+ )
182
+
183
+ # Step 3: Extract project ID from URL
184
+ project_id, parse_error = extract_id_from_url(url, adapter_type=platform)
185
+ if parse_error or not project_id:
186
+ return ProjectValidationResult(
187
+ valid=False,
188
+ platform=platform,
189
+ error=f"Failed to parse {platform.title()} URL: {parse_error or 'Unknown error'}",
190
+ error_type="url_parse",
191
+ suggestions=[
192
+ f"Verify {platform.title()} URL format is correct",
193
+ f"Example: {self._get_example_url(platform)}",
194
+ "Check if URL is accessible in your browser",
195
+ ],
196
+ )
197
+
198
+ # Step 4: Check if adapter is configured
199
+ config = self.resolver.load_project_config() or TicketerConfig()
200
+ adapter_configured = platform in config.adapters
201
+
202
+ if not adapter_configured:
203
+ return ProjectValidationResult(
204
+ valid=False,
205
+ platform=platform,
206
+ project_id=project_id,
207
+ adapter_configured=False,
208
+ error=f"{platform.title()} adapter is not configured",
209
+ error_type="adapter_missing",
210
+ suggestions=self.SETUP_INSTRUCTIONS.get(
211
+ platform,
212
+ [f"Configure {platform} adapter using config_setup_wizard"],
213
+ ),
214
+ )
215
+
216
+ # Step 5: Validate adapter configuration
217
+ adapter_config = config.adapters[platform]
218
+ from .project_config import ConfigValidator
219
+
220
+ is_valid, validation_error = ConfigValidator.validate(
221
+ platform, adapter_config.to_dict()
222
+ )
223
+
224
+ if not is_valid:
225
+ # Get masked config for error reporting
226
+ masked_config = self._mask_sensitive_config(adapter_config.to_dict())
227
+
228
+ return ProjectValidationResult(
229
+ valid=False,
230
+ platform=platform,
231
+ project_id=project_id,
232
+ adapter_configured=True,
233
+ adapter_valid=False,
234
+ error=f"{platform.title()} adapter configuration invalid: {validation_error}",
235
+ error_type="credentials_invalid",
236
+ suggestions=[
237
+ f"Review {platform} adapter configuration",
238
+ "Run: config(action='get') to see current settings",
239
+ f"Fix missing/invalid fields: {validation_error}",
240
+ f"Or reconfigure: config(action='setup_wizard', adapter_type='{platform}', credentials={{...}})",
241
+ ],
242
+ adapter_config=masked_config,
243
+ )
244
+
245
+ # Step 6: (Optional) Test project accessibility
246
+ if test_connection:
247
+ accessibility_result = self._test_project_accessibility(
248
+ platform, project_id, adapter_config.to_dict()
249
+ )
250
+ if not accessibility_result["accessible"]:
251
+ return ProjectValidationResult(
252
+ valid=False,
253
+ platform=platform,
254
+ project_id=project_id,
255
+ adapter_configured=True,
256
+ adapter_valid=True,
257
+ error=f"Project not accessible: {accessibility_result['error']}",
258
+ error_type="project_not_found",
259
+ suggestions=[
260
+ "Verify project ID is correct",
261
+ "Check if you have access to this project",
262
+ "Ensure API credentials have proper permissions",
263
+ f"Try accessing project in {platform.title()} web interface",
264
+ ],
265
+ )
266
+
267
+ # Validation successful
268
+ return ProjectValidationResult(
269
+ valid=True,
270
+ platform=platform,
271
+ project_id=project_id,
272
+ adapter_configured=True,
273
+ adapter_valid=True,
274
+ )
275
+
276
+ def _detect_platform(self, url: str) -> str | None:
277
+ """Detect platform from URL domain.
278
+
279
+ Args:
280
+ url: URL to analyze
281
+
282
+ Returns:
283
+ Platform name (linear, github, jira, asana) or None if unknown
284
+
285
+ """
286
+ url_lower = url.lower()
287
+ for domain, adapter in self.DOMAIN_TO_ADAPTER.items():
288
+ if domain in url_lower:
289
+ return adapter
290
+
291
+ # Fallback: check for path patterns
292
+ if "/browse/" in url_lower:
293
+ return "jira"
294
+
295
+ return None
296
+
297
+ def _get_example_url(self, platform: str) -> str:
298
+ """Get example URL for platform.
299
+
300
+ Args:
301
+ platform: Platform name
302
+
303
+ Returns:
304
+ Example URL string
305
+
306
+ """
307
+ examples = {
308
+ "linear": "https://linear.app/workspace/project/project-slug-abc123",
309
+ "github": "https://github.com/owner/repo/projects/1",
310
+ "jira": "https://company.atlassian.net/browse/PROJ-123",
311
+ "asana": "https://app.asana.com/0/workspace-id/project-id",
312
+ }
313
+ return examples.get(platform, "")
314
+
315
+ def _mask_sensitive_config(self, config: dict[str, Any]) -> dict[str, Any]:
316
+ """Mask sensitive values in configuration.
317
+
318
+ Args:
319
+ config: Configuration dictionary
320
+
321
+ Returns:
322
+ Masked configuration dictionary
323
+
324
+ """
325
+ masked = config.copy()
326
+ sensitive_keys = {"api_key", "token", "password", "secret", "api_token"}
327
+
328
+ for key in masked:
329
+ if any(sensitive in key.lower() for sensitive in sensitive_keys):
330
+ if masked[key]:
331
+ masked[key] = (
332
+ "***" + masked[key][-4:] if len(masked[key]) > 4 else "***"
333
+ )
334
+
335
+ return masked
336
+
337
+ def _test_project_accessibility(
338
+ self, platform: str, project_id: str, adapter_config: dict[str, Any]
339
+ ) -> dict[str, Any]:
340
+ """Test if project is accessible with current credentials.
341
+
342
+ Args:
343
+ platform: Platform name
344
+ project_id: Project identifier
345
+ adapter_config: Adapter configuration
346
+
347
+ Returns:
348
+ Dictionary with 'accessible' (bool) and 'error' (str) fields
349
+
350
+ Design Decision: Lightweight Test
351
+ ----------------------------------
352
+ We perform a minimal API call to verify:
353
+ 1. Credentials are valid
354
+ 2. Project exists
355
+ 3. User has access to project
356
+
357
+ This is NOT a full health check - just validates project-specific access.
358
+
359
+ """
360
+ try:
361
+ # Get adapter instance
362
+ _ = AdapterRegistry.get_adapter(platform, adapter_config)
363
+
364
+ # Test project access (adapter-specific)
365
+ # This will raise an exception if project is not accessible
366
+ # For now, we'll assume validation passed if we got here
367
+ # TODO: Implement adapter-specific project validation methods
368
+
369
+ return {"accessible": True, "error": None}
370
+
371
+ except Exception as e:
372
+ logger.error(f"Project accessibility test failed: {e}")
373
+ return {
374
+ "accessible": False,
375
+ "error": str(e),
376
+ }
@@ -1,6 +1,6 @@
1
1
  """Adapter registry for dynamic adapter management."""
2
2
 
3
- from typing import Any, Optional
3
+ from typing import Any
4
4
 
5
5
  from .adapter import BaseAdapter
6
6
 
@@ -37,7 +37,7 @@ class AdapterRegistry:
37
37
 
38
38
  @classmethod
39
39
  def get_adapter(
40
- cls, name: str, config: Optional[dict[str, Any]] = None, force_new: bool = False
40
+ cls, name: str, config: dict[str, Any] | None = None, force_new: bool = False
41
41
  ) -> BaseAdapter:
42
42
  """Get or create an adapter instance.
43
43
 
@@ -115,7 +115,7 @@ class AdapterRegistry:
115
115
 
116
116
 
117
117
  def adapter_factory(adapter_type: str, config: dict[str, Any]) -> BaseAdapter:
118
- """Factory function for creating adapters.
118
+ """Create adapter instance using factory pattern.
119
119
 
120
120
  Args:
121
121
  adapter_type: Type of adapter to create
@@ -0,0 +1,176 @@
1
+ """Session state management for tracking current ticket associations."""
2
+
3
+ import json
4
+ import logging
5
+ import uuid
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime, timedelta
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # Session timeout: 30 minutes of inactivity
14
+ SESSION_TIMEOUT_MINUTES = 30
15
+ SESSION_STATE_FILE = ".mcp-ticketer/session.json"
16
+
17
+
18
+ @dataclass
19
+ class SessionState:
20
+ """Track session-specific state for ticket associations."""
21
+
22
+ session_id: str = field(default_factory=lambda: str(uuid.uuid4()))
23
+ current_ticket: str | None = None # Current ticket ID
24
+ ticket_opted_out: bool = False # User explicitly chose "none"
25
+ last_activity: str = field(default_factory=lambda: datetime.now().isoformat())
26
+
27
+ def to_dict(self) -> dict[str, Any]:
28
+ """Serialize to dictionary."""
29
+ return {
30
+ "session_id": self.session_id,
31
+ "current_ticket": self.current_ticket,
32
+ "ticket_opted_out": self.ticket_opted_out,
33
+ "last_activity": self.last_activity,
34
+ }
35
+
36
+ @classmethod
37
+ def from_dict(cls, data: dict[str, Any]) -> "SessionState":
38
+ """Deserialize from dictionary."""
39
+ return cls(
40
+ session_id=data.get("session_id", str(uuid.uuid4())),
41
+ current_ticket=data.get("current_ticket"),
42
+ ticket_opted_out=data.get("ticket_opted_out", False),
43
+ last_activity=data.get("last_activity", datetime.now().isoformat()),
44
+ )
45
+
46
+ def is_expired(self) -> bool:
47
+ """Check if session has expired due to inactivity."""
48
+ try:
49
+ last_activity = datetime.fromisoformat(self.last_activity)
50
+ timeout = timedelta(minutes=SESSION_TIMEOUT_MINUTES)
51
+ return datetime.now() - last_activity > timeout
52
+ except (ValueError, TypeError):
53
+ # Invalid timestamp, consider expired
54
+ return True
55
+
56
+ def touch(self) -> None:
57
+ """Update last activity timestamp."""
58
+ self.last_activity = datetime.now().isoformat()
59
+
60
+
61
+ class SessionStateManager:
62
+ """Manage session state persistence and lifecycle."""
63
+
64
+ def __init__(self, project_path: Path | None = None):
65
+ """Initialize session state manager.
66
+
67
+ Args:
68
+ project_path: Project root directory (defaults to current directory)
69
+
70
+ """
71
+ self.project_path = project_path or Path.cwd()
72
+ self.state_file = self.project_path / SESSION_STATE_FILE
73
+
74
+ def load_session(self) -> SessionState:
75
+ """Load session state from file.
76
+
77
+ Automatically updates last_activity timestamp on every load to prevent
78
+ session expiration during active use.
79
+
80
+ Returns:
81
+ SessionState instance (creates new if expired or not found)
82
+
83
+ """
84
+ if not self.state_file.exists():
85
+ logger.debug("No session state file found, creating new session")
86
+ return SessionState()
87
+
88
+ try:
89
+ with open(self.state_file) as f:
90
+ data = json.load(f)
91
+
92
+ state = SessionState.from_dict(data)
93
+
94
+ # Check if session expired
95
+ if state.is_expired():
96
+ logger.info(
97
+ f"Session {state.session_id} expired after "
98
+ f"{SESSION_TIMEOUT_MINUTES} minutes, creating new session"
99
+ )
100
+ return SessionState()
101
+
102
+ # Auto-renew: Update last_activity and persist on every load
103
+ state.touch()
104
+ self.save_session(state)
105
+
106
+ return state
107
+
108
+ except (json.JSONDecodeError, FileNotFoundError, KeyError) as e:
109
+ logger.warning(f"Failed to load session state: {e}, creating new session")
110
+ return SessionState()
111
+
112
+ def save_session(self, state: SessionState) -> None:
113
+ """Save session state to file.
114
+
115
+ Args:
116
+ state: SessionState to persist
117
+
118
+ """
119
+ try:
120
+ # Ensure directory exists
121
+ self.state_file.parent.mkdir(parents=True, exist_ok=True)
122
+
123
+ # Touch before saving
124
+ state.touch()
125
+
126
+ # Write state
127
+ with open(self.state_file, "w") as f:
128
+ json.dump(state.to_dict(), f, indent=2)
129
+
130
+ logger.debug(f"Saved session state: session_id={state.session_id}")
131
+
132
+ except Exception as e:
133
+ logger.error(f"Failed to save session state: {e}")
134
+
135
+ def clear_session(self) -> None:
136
+ """Clear session state (delete file)."""
137
+ try:
138
+ if self.state_file.exists():
139
+ self.state_file.unlink()
140
+ logger.info("Session state cleared")
141
+ except Exception as e:
142
+ logger.error(f"Failed to clear session state: {e}")
143
+
144
+ def get_current_ticket(self) -> str | None:
145
+ """Get current ticket for this session (convenience method).
146
+
147
+ Returns:
148
+ Current ticket ID or None
149
+
150
+ """
151
+ state = self.load_session()
152
+
153
+ # If user opted out, return None
154
+ if state.ticket_opted_out:
155
+ return None
156
+
157
+ return state.current_ticket
158
+
159
+ def set_current_ticket(self, ticket_id: str | None) -> None:
160
+ """Set current ticket for this session (convenience method).
161
+
162
+ Args:
163
+ ticket_id: Ticket ID to set (None to clear)
164
+
165
+ """
166
+ state = self.load_session()
167
+ state.current_ticket = ticket_id
168
+ state.ticket_opted_out = False # Clear opt-out when setting ticket
169
+ self.save_session(state)
170
+
171
+ def opt_out_ticket(self) -> None:
172
+ """Mark that user doesn't want to associate work with a ticket (convenience method)."""
173
+ state = self.load_session()
174
+ state.current_ticket = None
175
+ state.ticket_opted_out = True
176
+ self.save_session(state)