mcp-ticketer 0.12.0__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.

Files changed (87) hide show
  1. mcp_ticketer/__init__.py +10 -10
  2. mcp_ticketer/__version__.py +1 -1
  3. mcp_ticketer/adapters/aitrackdown.py +385 -6
  4. mcp_ticketer/adapters/asana/adapter.py +108 -0
  5. mcp_ticketer/adapters/asana/mappers.py +14 -0
  6. mcp_ticketer/adapters/github.py +525 -11
  7. mcp_ticketer/adapters/hybrid.py +47 -5
  8. mcp_ticketer/adapters/jira.py +521 -0
  9. mcp_ticketer/adapters/linear/adapter.py +1784 -101
  10. mcp_ticketer/adapters/linear/client.py +85 -3
  11. mcp_ticketer/adapters/linear/mappers.py +96 -8
  12. mcp_ticketer/adapters/linear/queries.py +168 -1
  13. mcp_ticketer/adapters/linear/types.py +80 -4
  14. mcp_ticketer/analysis/__init__.py +56 -0
  15. mcp_ticketer/analysis/dependency_graph.py +255 -0
  16. mcp_ticketer/analysis/health_assessment.py +304 -0
  17. mcp_ticketer/analysis/orphaned.py +218 -0
  18. mcp_ticketer/analysis/project_status.py +594 -0
  19. mcp_ticketer/analysis/similarity.py +224 -0
  20. mcp_ticketer/analysis/staleness.py +266 -0
  21. mcp_ticketer/automation/__init__.py +11 -0
  22. mcp_ticketer/automation/project_updates.py +378 -0
  23. mcp_ticketer/cli/adapter_diagnostics.py +3 -1
  24. mcp_ticketer/cli/auggie_configure.py +17 -5
  25. mcp_ticketer/cli/codex_configure.py +97 -61
  26. mcp_ticketer/cli/configure.py +851 -103
  27. mcp_ticketer/cli/cursor_configure.py +314 -0
  28. mcp_ticketer/cli/diagnostics.py +13 -12
  29. mcp_ticketer/cli/discover.py +5 -0
  30. mcp_ticketer/cli/gemini_configure.py +17 -5
  31. mcp_ticketer/cli/init_command.py +880 -0
  32. mcp_ticketer/cli/instruction_commands.py +6 -0
  33. mcp_ticketer/cli/main.py +233 -3151
  34. mcp_ticketer/cli/mcp_configure.py +672 -98
  35. mcp_ticketer/cli/mcp_server_commands.py +415 -0
  36. mcp_ticketer/cli/platform_detection.py +77 -12
  37. mcp_ticketer/cli/platform_installer.py +536 -0
  38. mcp_ticketer/cli/project_update_commands.py +350 -0
  39. mcp_ticketer/cli/setup_command.py +639 -0
  40. mcp_ticketer/cli/simple_health.py +12 -10
  41. mcp_ticketer/cli/ticket_commands.py +264 -24
  42. mcp_ticketer/core/__init__.py +28 -6
  43. mcp_ticketer/core/adapter.py +166 -1
  44. mcp_ticketer/core/config.py +21 -21
  45. mcp_ticketer/core/exceptions.py +7 -1
  46. mcp_ticketer/core/label_manager.py +732 -0
  47. mcp_ticketer/core/mappers.py +31 -19
  48. mcp_ticketer/core/models.py +135 -0
  49. mcp_ticketer/core/onepassword_secrets.py +1 -1
  50. mcp_ticketer/core/priority_matcher.py +463 -0
  51. mcp_ticketer/core/project_config.py +132 -14
  52. mcp_ticketer/core/session_state.py +171 -0
  53. mcp_ticketer/core/state_matcher.py +592 -0
  54. mcp_ticketer/core/url_parser.py +425 -0
  55. mcp_ticketer/core/validators.py +69 -0
  56. mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
  57. mcp_ticketer/mcp/server/main.py +106 -25
  58. mcp_ticketer/mcp/server/routing.py +655 -0
  59. mcp_ticketer/mcp/server/server_sdk.py +58 -0
  60. mcp_ticketer/mcp/server/tools/__init__.py +31 -12
  61. mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
  62. mcp_ticketer/mcp/server/tools/attachment_tools.py +6 -8
  63. mcp_ticketer/mcp/server/tools/bulk_tools.py +259 -202
  64. mcp_ticketer/mcp/server/tools/comment_tools.py +74 -12
  65. mcp_ticketer/mcp/server/tools/config_tools.py +1184 -136
  66. mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
  67. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +870 -460
  68. mcp_ticketer/mcp/server/tools/instruction_tools.py +7 -5
  69. mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
  70. mcp_ticketer/mcp/server/tools/pr_tools.py +3 -7
  71. mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
  72. mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
  73. mcp_ticketer/mcp/server/tools/search_tools.py +180 -97
  74. mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
  75. mcp_ticketer/mcp/server/tools/ticket_tools.py +1070 -123
  76. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +218 -236
  77. mcp_ticketer/queue/worker.py +1 -1
  78. mcp_ticketer/utils/__init__.py +5 -0
  79. mcp_ticketer/utils/token_utils.py +246 -0
  80. mcp_ticketer-2.0.1.dist-info/METADATA +1366 -0
  81. mcp_ticketer-2.0.1.dist-info/RECORD +122 -0
  82. mcp_ticketer-0.12.0.dist-info/METADATA +0 -550
  83. mcp_ticketer-0.12.0.dist-info/RECORD +0 -91
  84. {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.0.1.dist-info}/WHEEL +0 -0
  85. {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.0.1.dist-info}/entry_points.txt +0 -0
  86. {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.0.1.dist-info}/licenses/LICENSE +0 -0
  87. {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.0.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,425 @@
1
+ """URL parsing utilities for extracting project/issue IDs from adapter URLs.
2
+
3
+ This module provides functionality to detect and parse URLs from various ticket
4
+ management platforms (Linear, JIRA, GitHub) and extract the relevant project or
5
+ issue identifiers.
6
+
7
+ Supported URL patterns:
8
+ - Linear: https://linear.app/team-key/project/project-key-123
9
+ - Linear: https://linear.app/team-key/issue/ISS-123
10
+ - Linear: https://linear.app/team-key/view/view-name-uuid (view detection)
11
+ - JIRA: https://company.atlassian.net/browse/PROJ
12
+ - JIRA: https://company.atlassian.net/browse/PROJ-123
13
+ - GitHub: https://github.com/owner/repo/projects/1
14
+ - GitHub: https://github.com/owner/repo/issues/123
15
+ - Asana: https://app.asana.com/0/{workspace_gid}/{task_gid}
16
+ - Asana: https://app.asana.com/0/{workspace_gid}/project/{project_gid}
17
+ """
18
+
19
+ import logging
20
+ import re
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class URLParserError(Exception):
26
+ """Raised when URL parsing fails."""
27
+
28
+ pass
29
+
30
+
31
+ def is_url(value: str) -> bool:
32
+ """Detect if a string is a URL.
33
+
34
+ Args:
35
+ value: String to check
36
+
37
+ Returns:
38
+ True if the string appears to be a URL, False otherwise
39
+
40
+ Examples:
41
+ >>> is_url("https://linear.app/team/project/abc-123")
42
+ True
43
+ >>> is_url("PROJ-123")
44
+ False
45
+ >>> is_url("http://example.com")
46
+ True
47
+
48
+ """
49
+ if not value or not isinstance(value, str):
50
+ return False
51
+
52
+ # Check for URL scheme
53
+ return bool(
54
+ value.startswith(("http://", "https://")) or re.match(r"^[\w.-]+://", value)
55
+ )
56
+
57
+
58
+ def extract_linear_id(url: str) -> tuple[str | None, str | None]:
59
+ """Extract project, issue, view, or team ID from Linear URL.
60
+
61
+ Supported formats:
62
+ - https://linear.app/workspace/project/project-slug-abc123/overview → "project-slug-abc123"
63
+ - https://linear.app/workspace/issue/ISS-123 → "ISS-123"
64
+ - https://linear.app/workspace/view/view-name-uuid → "view-name-uuid"
65
+ - https://linear.app/workspace/team/TEAM → "TEAM" (team key)
66
+
67
+ Args:
68
+ url: Linear URL string
69
+
70
+ Returns:
71
+ Tuple of (extracted_id, error_message). If successful, error_message is None.
72
+
73
+ Examples:
74
+ >>> extract_linear_id("https://linear.app/travel-bta/project/crm-system-f59a41/overview")
75
+ ('crm-system-f59a41', None)
76
+ >>> extract_linear_id("https://linear.app/myteam/issue/BTA-123")
77
+ ('BTA-123', None)
78
+ >>> extract_linear_id("https://linear.app/myteam/view/my-view-abc123")
79
+ ('my-view-abc123', None)
80
+
81
+ """
82
+ if not url:
83
+ return None, "Empty URL provided"
84
+
85
+ # Pattern 1: Project URLs - extract slug-id
86
+ # https://linear.app/workspace/project/project-slug-shortid/...
87
+ project_pattern = r"https?://linear\.app/[\w-]+/project/([\w-]+)"
88
+ match = re.search(project_pattern, url, re.IGNORECASE)
89
+ if match:
90
+ project_id = match.group(1)
91
+ logger.debug(f"Extracted Linear project ID '{project_id}' from URL")
92
+ return project_id, None
93
+
94
+ # Pattern 2: Issue URLs - extract issue key
95
+ # https://linear.app/workspace/issue/ISS-123
96
+ issue_pattern = r"https?://linear\.app/[\w-]+/issue/([\w]+-\d+)"
97
+ match = re.search(issue_pattern, url, re.IGNORECASE)
98
+ if match:
99
+ issue_id = match.group(1)
100
+ logger.debug(f"Extracted Linear issue ID '{issue_id}' from URL")
101
+ return issue_id, None
102
+
103
+ # Pattern 3: View URLs - extract view identifier (slug-uuid format)
104
+ # https://linear.app/workspace/view/view-name-uuid
105
+ view_pattern = r"https?://linear\.app/[\w-]+/view/([\w-]+)"
106
+ match = re.search(view_pattern, url, re.IGNORECASE)
107
+ if match:
108
+ view_id = match.group(1)
109
+ logger.debug(f"Extracted Linear view ID '{view_id}' from URL")
110
+ return view_id, None
111
+
112
+ # Pattern 4: Team URLs - extract team key
113
+ # https://linear.app/workspace/team/TEAM
114
+ team_pattern = r"https?://linear\.app/[\w-]+/team/([\w-]+)"
115
+ match = re.search(team_pattern, url, re.IGNORECASE)
116
+ if match:
117
+ team_key = match.group(1)
118
+ logger.debug(f"Extracted Linear team key '{team_key}' from URL")
119
+ return team_key, None
120
+
121
+ return None, f"Could not extract Linear ID from URL: {url}"
122
+
123
+
124
+ def extract_jira_id(url: str) -> tuple[str | None, str | None]:
125
+ """Extract project or issue key from JIRA URL.
126
+
127
+ Supported formats:
128
+ - https://company.atlassian.net/browse/PROJ → "PROJ"
129
+ - https://company.atlassian.net/browse/PROJ-123 → "PROJ-123"
130
+ - https://jira.company.com/browse/PROJ → "PROJ"
131
+
132
+ Args:
133
+ url: JIRA URL string
134
+
135
+ Returns:
136
+ Tuple of (extracted_id, error_message). If successful, error_message is None.
137
+
138
+ Examples:
139
+ >>> extract_jira_id("https://company.atlassian.net/browse/PROJ")
140
+ ('PROJ', None)
141
+ >>> extract_jira_id("https://company.atlassian.net/browse/PROJ-123")
142
+ ('PROJ-123', None)
143
+
144
+ """
145
+ if not url:
146
+ return None, "Empty URL provided"
147
+
148
+ # Pattern: Extract key from browse URL
149
+ # https://company.atlassian.net/browse/PROJ or PROJ-123
150
+ browse_pattern = r"https?://[\w.-]+/browse/([\w]+-?\d*)"
151
+ match = re.search(browse_pattern, url, re.IGNORECASE)
152
+ if match:
153
+ issue_key = match.group(1)
154
+ logger.debug(f"Extracted JIRA key '{issue_key}' from URL")
155
+ return issue_key, None
156
+
157
+ # Alternative pattern for project URLs
158
+ # https://company.atlassian.net/projects/PROJ
159
+ project_pattern = r"https?://[\w.-]+/projects/([\w]+)"
160
+ match = re.search(project_pattern, url, re.IGNORECASE)
161
+ if match:
162
+ project_key = match.group(1)
163
+ logger.debug(f"Extracted JIRA project key '{project_key}' from URL")
164
+ return project_key, None
165
+
166
+ return None, f"Could not extract JIRA key from URL: {url}"
167
+
168
+
169
+ def extract_github_id(url: str) -> tuple[str | None, str | None]:
170
+ """Extract project, issue, milestone, or PR number from GitHub URL.
171
+
172
+ Supported formats:
173
+ - https://github.com/owner/repo/projects/1 → "1"
174
+ - https://github.com/owner/repo/issues/123 → "123"
175
+ - https://github.com/owner/repo/milestones/5 → "5"
176
+ - https://github.com/owner/repo/pull/456 → "456"
177
+
178
+ Args:
179
+ url: GitHub URL string
180
+
181
+ Returns:
182
+ Tuple of (extracted_id, error_message). If successful, error_message is None.
183
+
184
+ Examples:
185
+ >>> extract_github_id("https://github.com/owner/repo/projects/1")
186
+ ('1', None)
187
+ >>> extract_github_id("https://github.com/owner/repo/issues/123")
188
+ ('123', None)
189
+ >>> extract_github_id("https://github.com/owner/repo/milestones/5")
190
+ ('5', None)
191
+
192
+ """
193
+ if not url:
194
+ return None, "Empty URL provided"
195
+
196
+ # Pattern 1: Project URLs - extract project number
197
+ # https://github.com/owner/repo/projects/1
198
+ project_pattern = r"https?://github\.com/[\w-]+/[\w-]+/projects/(\d+)"
199
+ match = re.search(project_pattern, url, re.IGNORECASE)
200
+ if match:
201
+ project_id = match.group(1)
202
+ logger.debug(f"Extracted GitHub project ID '{project_id}' from URL")
203
+ return project_id, None
204
+
205
+ # Pattern 2: Issue URLs - extract issue number
206
+ # https://github.com/owner/repo/issues/123
207
+ issue_pattern = r"https?://github\.com/[\w-]+/[\w-]+/issues/(\d+)"
208
+ match = re.search(issue_pattern, url, re.IGNORECASE)
209
+ if match:
210
+ issue_id = match.group(1)
211
+ logger.debug(f"Extracted GitHub issue ID '{issue_id}' from URL")
212
+ return issue_id, None
213
+
214
+ # Pattern 3: Milestone URLs - extract milestone number
215
+ # https://github.com/owner/repo/milestones/5
216
+ milestone_pattern = r"https?://github\.com/[\w-]+/[\w-]+/milestones/(\d+)"
217
+ match = re.search(milestone_pattern, url, re.IGNORECASE)
218
+ if match:
219
+ milestone_id = match.group(1)
220
+ logger.debug(f"Extracted GitHub milestone ID '{milestone_id}' from URL")
221
+ return milestone_id, None
222
+
223
+ # Pattern 4: Pull request URLs - extract PR number
224
+ # https://github.com/owner/repo/pull/456
225
+ pr_pattern = r"https?://github\.com/[\w-]+/[\w-]+/pull/(\d+)"
226
+ match = re.search(pr_pattern, url, re.IGNORECASE)
227
+ if match:
228
+ pr_id = match.group(1)
229
+ logger.debug(f"Extracted GitHub PR ID '{pr_id}' from URL")
230
+ return pr_id, None
231
+
232
+ return None, f"Could not extract GitHub ID from URL: {url}"
233
+
234
+
235
+ def parse_github_repo_url(url: str) -> tuple[str | None, str | None, str | None]:
236
+ """Parse GitHub repository URL to extract owner and repo name.
237
+
238
+ Supported formats:
239
+ - https://github.com/owner/repo → ("owner", "repo")
240
+ - https://github.com/owner/repo/ → ("owner", "repo")
241
+ - https://github.com/owner/repo/issues → ("owner", "repo")
242
+ - https://github.com/owner/repo/projects/1 → ("owner", "repo")
243
+ - http://github.com/owner/repo → ("owner", "repo")
244
+
245
+ Args:
246
+ url: GitHub repository URL string
247
+
248
+ Returns:
249
+ Tuple of (owner, repo, error_message). If successful, error_message is None.
250
+
251
+ Examples:
252
+ >>> parse_github_repo_url("https://github.com/owner/repo")
253
+ ('owner', 'repo', None)
254
+ >>> parse_github_repo_url("https://github.com/owner/repo/")
255
+ ('owner', 'repo', None)
256
+ >>> parse_github_repo_url("https://github.com/owner/repo/issues/123")
257
+ ('owner', 'repo', None)
258
+
259
+ """
260
+ if not url:
261
+ return None, None, "Empty URL provided"
262
+
263
+ # Pattern: Extract owner and repo from any GitHub URL
264
+ # https://github.com/{owner}/{repo}[/anything/else]
265
+ github_pattern = r"https?://github\.com/([\w-]+)/([\w.-]+)(?:/|$)"
266
+ match = re.search(github_pattern, url, re.IGNORECASE)
267
+
268
+ if match:
269
+ owner = match.group(1)
270
+ repo = match.group(2)
271
+ logger.debug(f"Extracted GitHub owner '{owner}' and repo '{repo}' from URL")
272
+ return owner, repo, None
273
+
274
+ return None, None, f"Could not parse GitHub repository URL: {url}"
275
+
276
+
277
+ def extract_asana_id(url: str) -> tuple[str | None, str | None]:
278
+ """Extract task or project GID from Asana URL.
279
+
280
+ Supported formats:
281
+ - https://app.asana.com/0/{workspace_gid}/{task_gid} → "{task_gid}"
282
+ - https://app.asana.com/0/{workspace_gid}/{task_gid}/f → "{task_gid}"
283
+ - https://app.asana.com/0/{workspace_gid}/list/{project_gid} → "{project_gid}"
284
+
285
+ Args:
286
+ url: Asana URL string
287
+
288
+ Returns:
289
+ Tuple of (extracted_id, error_message). If successful, error_message is None.
290
+
291
+ Examples:
292
+ >>> extract_asana_id("https://app.asana.com/0/1234567890/9876543210")
293
+ ('9876543210', None)
294
+ >>> extract_asana_id("https://app.asana.com/0/1234567890/list/5555555555")
295
+ ('5555555555', None)
296
+
297
+ """
298
+ if not url:
299
+ return None, "Empty URL provided"
300
+
301
+ # Pattern 1: Task URLs - extract task GID
302
+ # https://app.asana.com/0/{workspace_gid}/{task_gid}
303
+ # https://app.asana.com/0/{workspace_gid}/{task_gid}/f (with focus mode)
304
+ task_pattern = r"https?://app\.asana\.com/0/\d+/(\d+)"
305
+ match = re.search(task_pattern, url, re.IGNORECASE)
306
+ if match:
307
+ task_gid = match.group(1)
308
+ logger.debug(f"Extracted Asana task GID '{task_gid}' from URL")
309
+ return task_gid, None
310
+
311
+ # Pattern 2: Project/List URLs - extract project GID
312
+ # https://app.asana.com/0/{workspace_gid}/list/{project_gid}
313
+ project_pattern = r"https?://app\.asana\.com/0/\d+/list/(\d+)"
314
+ match = re.search(project_pattern, url, re.IGNORECASE)
315
+ if match:
316
+ project_gid = match.group(1)
317
+ logger.debug(f"Extracted Asana project GID '{project_gid}' from URL")
318
+ return project_gid, None
319
+
320
+ return None, f"Could not extract Asana ID from URL: {url}"
321
+
322
+
323
+ def extract_id_from_url(
324
+ url: str, adapter_type: str | None = None
325
+ ) -> tuple[str | None, str | None]:
326
+ """Extract project/issue ID from URL for any supported adapter.
327
+
328
+ This is the main entry point for URL parsing. It auto-detects the adapter type
329
+ from the URL if not explicitly provided.
330
+
331
+ Args:
332
+ url: URL string to parse
333
+ adapter_type: Optional adapter type hint ("linear", "jira", "github").
334
+ If not provided, adapter is auto-detected from URL domain.
335
+
336
+ Returns:
337
+ Tuple of (extracted_id, error_message). If successful, error_message is None.
338
+
339
+ Raises:
340
+ URLParserError: If URL parsing fails
341
+
342
+ Examples:
343
+ >>> extract_id_from_url("https://linear.app/team/project/abc-123")
344
+ ('abc-123', None)
345
+ >>> extract_id_from_url("https://company.atlassian.net/browse/PROJ-123")
346
+ ('PROJ-123', None)
347
+ >>> extract_id_from_url("https://github.com/owner/repo/issues/123")
348
+ ('123', None)
349
+
350
+ """
351
+ if not url:
352
+ return None, "Empty URL provided"
353
+
354
+ if not is_url(url):
355
+ # Not a URL - return as-is (could be a plain ID)
356
+ return url, None
357
+
358
+ # Auto-detect adapter type from URL if not provided
359
+ if not adapter_type:
360
+ # Check for specific domains first (more reliable than path patterns)
361
+ if "linear.app" in url.lower():
362
+ adapter_type = "linear"
363
+ elif "github.com" in url.lower():
364
+ adapter_type = "github"
365
+ elif "atlassian.net" in url.lower():
366
+ adapter_type = "jira"
367
+ elif "app.asana.com" in url.lower():
368
+ adapter_type = "asana"
369
+ # Fallback to path-based detection for self-hosted instances
370
+ elif "/browse/" in url:
371
+ adapter_type = "jira"
372
+ else:
373
+ return None, f"Unknown URL format - cannot auto-detect adapter: {url}"
374
+
375
+ # Route to appropriate parser
376
+ if adapter_type.lower() == "linear":
377
+ return extract_linear_id(url)
378
+ elif adapter_type.lower() == "jira":
379
+ return extract_jira_id(url)
380
+ elif adapter_type.lower() == "github":
381
+ return extract_github_id(url)
382
+ elif adapter_type.lower() == "asana":
383
+ return extract_asana_id(url)
384
+ else:
385
+ return None, f"Unsupported adapter type: {adapter_type}"
386
+
387
+
388
+ def normalize_project_id(value: str, adapter_type: str | None = None) -> str:
389
+ """Normalize a project ID by extracting from URL if necessary.
390
+
391
+ This is a convenience function that handles both URLs and plain IDs.
392
+ If the value is a URL, it extracts the ID. If it's already a plain ID,
393
+ it returns it unchanged.
394
+
395
+ Args:
396
+ value: Project ID or URL
397
+ adapter_type: Optional adapter type hint
398
+
399
+ Returns:
400
+ Normalized project ID (extracted from URL if applicable)
401
+
402
+ Raises:
403
+ URLParserError: If URL parsing fails
404
+
405
+ Examples:
406
+ >>> normalize_project_id("PROJ-123")
407
+ 'PROJ-123'
408
+ >>> normalize_project_id("https://linear.app/team/project/abc-123")
409
+ 'abc-123'
410
+
411
+ """
412
+ if not value:
413
+ return value
414
+
415
+ # If not a URL, return as-is
416
+ if not is_url(value):
417
+ return value
418
+
419
+ # Extract ID from URL
420
+ extracted_id, error = extract_id_from_url(value, adapter_type)
421
+
422
+ if error:
423
+ raise URLParserError(error)
424
+
425
+ return extracted_id or value
@@ -0,0 +1,69 @@
1
+ """Field validation utilities for adapter data."""
2
+
3
+
4
+ class ValidationError(Exception):
5
+ """Raised when field validation fails."""
6
+
7
+ pass
8
+
9
+
10
+ class FieldValidator:
11
+ """Validates field lengths and formats across adapters."""
12
+
13
+ # Field length limits per adapter
14
+ LIMITS = {
15
+ "linear": {
16
+ "epic_description": 255,
17
+ "epic_name": 255,
18
+ "issue_description": 100000, # Issues have much higher limit
19
+ "issue_title": 255,
20
+ },
21
+ "jira": {
22
+ "summary": 255,
23
+ "description": 32767,
24
+ },
25
+ "github": {
26
+ "title": 256,
27
+ "body": 65536,
28
+ },
29
+ }
30
+
31
+ @classmethod
32
+ def validate_field(
33
+ cls,
34
+ adapter_name: str,
35
+ field_name: str,
36
+ value: str | None,
37
+ truncate: bool = False,
38
+ ) -> str:
39
+ """Validate and optionally truncate a field value.
40
+
41
+ Args:
42
+ adapter_name: Name of adapter (linear, jira, github)
43
+ field_name: Name of field being validated
44
+ value: Field value to validate
45
+ truncate: If True, truncate instead of raising error
46
+
47
+ Returns:
48
+ Validated (possibly truncated) value
49
+
50
+ Raises:
51
+ ValidationError: If value exceeds limit and truncate=False
52
+
53
+ """
54
+ if value is None:
55
+ return ""
56
+
57
+ adapter_limits = cls.LIMITS.get(adapter_name.lower(), {})
58
+ limit = adapter_limits.get(field_name)
59
+
60
+ if limit and len(value) > limit:
61
+ if truncate:
62
+ return value[:limit]
63
+ else:
64
+ raise ValidationError(
65
+ f"{field_name} exceeds {adapter_name} limit of {limit} characters "
66
+ f"(got {len(value)}). Use truncate=True to auto-truncate."
67
+ )
68
+
69
+ return value
@@ -0,0 +1,175 @@
1
+ """Diagnostic helper for MCP error handling.
2
+
3
+ Provides quick diagnostic checks and error classification to help users
4
+ troubleshoot system configuration issues when MCP tools encounter errors.
5
+ """
6
+
7
+ import logging
8
+ from enum import Enum
9
+ from typing import Any
10
+
11
+ from ...core.exceptions import (
12
+ AuthenticationError,
13
+ ConfigurationError,
14
+ NetworkError,
15
+ NotFoundError,
16
+ PermissionError,
17
+ RateLimitError,
18
+ StateTransitionError,
19
+ TimeoutError,
20
+ ValidationError,
21
+ )
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class ErrorSeverity(Enum):
27
+ """Classification of error severity for diagnostic suggestions."""
28
+
29
+ CRITICAL = "critical" # Always suggest diagnostics
30
+ MEDIUM = "medium" # Suggest if pattern detected
31
+ LOW = "low" # Never suggest diagnostics
32
+
33
+
34
+ # Map exception types to severity levels
35
+ ERROR_SEVERITY_MAP = {
36
+ AuthenticationError: ErrorSeverity.CRITICAL,
37
+ ConfigurationError: ErrorSeverity.CRITICAL,
38
+ NetworkError: ErrorSeverity.CRITICAL,
39
+ TimeoutError: ErrorSeverity.CRITICAL,
40
+ NotFoundError: ErrorSeverity.MEDIUM,
41
+ PermissionError: ErrorSeverity.MEDIUM,
42
+ RateLimitError: ErrorSeverity.MEDIUM,
43
+ ValidationError: ErrorSeverity.LOW,
44
+ StateTransitionError: ErrorSeverity.LOW,
45
+ }
46
+
47
+
48
+ def should_suggest_diagnostics(exception: Exception) -> bool:
49
+ """Determine if error response should include diagnostic suggestion.
50
+
51
+ Args:
52
+ exception: The exception that was raised
53
+
54
+ Returns:
55
+ True if diagnostics should be suggested
56
+
57
+ """
58
+ severity = get_error_severity(exception)
59
+ return severity in (ErrorSeverity.CRITICAL, ErrorSeverity.MEDIUM)
60
+
61
+
62
+ def get_error_severity(exception: Exception) -> ErrorSeverity:
63
+ """Get severity level for an exception.
64
+
65
+ Args:
66
+ exception: The exception to classify
67
+
68
+ Returns:
69
+ Error severity level
70
+
71
+ """
72
+ exception_type = type(exception)
73
+ return ERROR_SEVERITY_MAP.get(exception_type, ErrorSeverity.MEDIUM)
74
+
75
+
76
+ async def get_quick_diagnostic_info() -> dict[str, Any]:
77
+ """Get lightweight diagnostic info without running full test suite.
78
+
79
+ Performs fast checks (< 100ms) to provide immediate troubleshooting hints:
80
+ - Adapter configuration status
81
+ - Credential presence
82
+ - Queue system status
83
+
84
+ Returns:
85
+ Dictionary with quick diagnostic results
86
+
87
+ """
88
+ info: dict[str, Any] = {}
89
+
90
+ try:
91
+ # Check adapter configuration (fast file read)
92
+ from ...cli.utils import CommonPatterns
93
+
94
+ config = CommonPatterns.load_config()
95
+ adapters = config.get("adapters", {})
96
+
97
+ info["adapter_configured"] = len(adapters) > 0
98
+ info["configured_adapters"] = list(adapters.keys())
99
+ info["default_adapter"] = config.get("default_adapter")
100
+
101
+ except Exception as e:
102
+ logger.debug(f"Quick diagnostic config check failed: {e}")
103
+ info["adapter_configured"] = False
104
+ info["config_error"] = str(e)
105
+
106
+ try:
107
+ # Check queue system status (fast status check, no operations)
108
+ from ...queue.worker import Worker
109
+
110
+ worker = Worker()
111
+ info["queue_running"] = worker.running
112
+
113
+ except Exception as e:
114
+ logger.debug(f"Quick diagnostic queue check failed: {e}")
115
+ info["queue_running"] = False
116
+
117
+ try:
118
+ # Get version information
119
+ from ...__version__ import __version__
120
+
121
+ info["mcp_ticketer_version"] = __version__
122
+
123
+ except Exception as e:
124
+ logger.debug(f"Quick diagnostic version check failed: {e}")
125
+ info["mcp_ticketer_version"] = "unknown"
126
+
127
+ return info
128
+
129
+
130
+ def build_diagnostic_suggestion(
131
+ exception: Exception, quick_info: dict[str, Any] | None = None
132
+ ) -> dict[str, Any]:
133
+ """Build diagnostic suggestion dict for error response.
134
+
135
+ Args:
136
+ exception: The exception that occurred
137
+ quick_info: Optional quick diagnostic info (from get_quick_diagnostic_info())
138
+
139
+ Returns:
140
+ Diagnostic suggestion dictionary for inclusion in error response
141
+
142
+ """
143
+ severity = get_error_severity(exception)
144
+
145
+ suggestion: dict[str, Any] = {
146
+ "severity": severity.value,
147
+ "message": _get_severity_message(severity),
148
+ "recommendation": _get_recommendation(severity),
149
+ "command": "Use the 'system_diagnostics' MCP tool or CLI: mcp-ticketer doctor",
150
+ }
151
+
152
+ if quick_info:
153
+ suggestion["quick_checks"] = quick_info
154
+
155
+ return suggestion
156
+
157
+
158
+ def _get_severity_message(severity: ErrorSeverity) -> str:
159
+ """Get human-readable message for severity level."""
160
+ messages = {
161
+ ErrorSeverity.CRITICAL: "This appears to be a system configuration issue",
162
+ ErrorSeverity.MEDIUM: "This may indicate a configuration or permission issue",
163
+ ErrorSeverity.LOW: "This is a validation or input error",
164
+ }
165
+ return messages.get(severity, "An error occurred")
166
+
167
+
168
+ def _get_recommendation(severity: ErrorSeverity) -> str:
169
+ """Get recommendation text for severity level."""
170
+ recommendations = {
171
+ ErrorSeverity.CRITICAL: "Run system diagnostics to identify the problem",
172
+ ErrorSeverity.MEDIUM: "Consider running diagnostics if the issue persists",
173
+ ErrorSeverity.LOW: "Check your input and try again",
174
+ }
175
+ return recommendations.get(severity, "Review the error message")