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.
- mcp_ticketer/__init__.py +10 -10
- mcp_ticketer/__version__.py +1 -1
- mcp_ticketer/_version_scm.py +1 -0
- mcp_ticketer/adapters/aitrackdown.py +507 -6
- mcp_ticketer/adapters/asana/adapter.py +229 -0
- mcp_ticketer/adapters/asana/mappers.py +14 -0
- mcp_ticketer/adapters/github/__init__.py +26 -0
- mcp_ticketer/adapters/github/adapter.py +3229 -0
- mcp_ticketer/adapters/github/client.py +335 -0
- mcp_ticketer/adapters/github/mappers.py +797 -0
- mcp_ticketer/adapters/github/queries.py +692 -0
- mcp_ticketer/adapters/github/types.py +460 -0
- mcp_ticketer/adapters/hybrid.py +47 -5
- mcp_ticketer/adapters/jira/__init__.py +35 -0
- mcp_ticketer/adapters/jira/adapter.py +1351 -0
- mcp_ticketer/adapters/jira/client.py +271 -0
- mcp_ticketer/adapters/jira/mappers.py +246 -0
- mcp_ticketer/adapters/jira/queries.py +216 -0
- mcp_ticketer/adapters/jira/types.py +304 -0
- mcp_ticketer/adapters/linear/adapter.py +2730 -139
- mcp_ticketer/adapters/linear/client.py +175 -3
- mcp_ticketer/adapters/linear/mappers.py +203 -8
- mcp_ticketer/adapters/linear/queries.py +280 -3
- mcp_ticketer/adapters/linear/types.py +120 -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/cli/adapter_diagnostics.py +3 -1
- mcp_ticketer/cli/auggie_configure.py +17 -5
- mcp_ticketer/cli/codex_configure.py +97 -61
- mcp_ticketer/cli/configure.py +1288 -105
- mcp_ticketer/cli/cursor_configure.py +314 -0
- mcp_ticketer/cli/diagnostics.py +13 -12
- mcp_ticketer/cli/discover.py +5 -0
- mcp_ticketer/cli/gemini_configure.py +17 -5
- mcp_ticketer/cli/init_command.py +880 -0
- mcp_ticketer/cli/install_mcp_server.py +418 -0
- mcp_ticketer/cli/instruction_commands.py +6 -0
- mcp_ticketer/cli/main.py +267 -3175
- mcp_ticketer/cli/mcp_configure.py +821 -119
- mcp_ticketer/cli/mcp_server_commands.py +415 -0
- mcp_ticketer/cli/platform_detection.py +77 -12
- mcp_ticketer/cli/platform_installer.py +545 -0
- mcp_ticketer/cli/project_update_commands.py +350 -0
- mcp_ticketer/cli/setup_command.py +795 -0
- mcp_ticketer/cli/simple_health.py +12 -10
- mcp_ticketer/cli/ticket_commands.py +705 -103
- mcp_ticketer/cli/utils.py +113 -0
- mcp_ticketer/core/__init__.py +56 -6
- mcp_ticketer/core/adapter.py +533 -2
- mcp_ticketer/core/config.py +21 -21
- mcp_ticketer/core/exceptions.py +7 -1
- mcp_ticketer/core/label_manager.py +732 -0
- mcp_ticketer/core/mappers.py +31 -19
- mcp_ticketer/core/milestone_manager.py +252 -0
- mcp_ticketer/core/models.py +480 -0
- mcp_ticketer/core/onepassword_secrets.py +1 -1
- mcp_ticketer/core/priority_matcher.py +463 -0
- mcp_ticketer/core/project_config.py +132 -14
- mcp_ticketer/core/project_utils.py +281 -0
- mcp_ticketer/core/project_validator.py +376 -0
- mcp_ticketer/core/session_state.py +176 -0
- mcp_ticketer/core/state_matcher.py +625 -0
- mcp_ticketer/core/url_parser.py +425 -0
- mcp_ticketer/core/validators.py +69 -0
- mcp_ticketer/mcp/server/__main__.py +2 -1
- mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
- mcp_ticketer/mcp/server/main.py +106 -25
- mcp_ticketer/mcp/server/routing.py +723 -0
- mcp_ticketer/mcp/server/server_sdk.py +58 -0
- mcp_ticketer/mcp/server/tools/__init__.py +33 -11
- mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +5 -5
- 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 +1391 -145
- mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +870 -460
- mcp_ticketer/mcp/server/tools/instruction_tools.py +7 -5
- mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
- mcp_ticketer/mcp/server/tools/milestone_tools.py +338 -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 +209 -97
- mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
- mcp_ticketer/mcp/server/tools/ticket_tools.py +1107 -124
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +218 -236
- mcp_ticketer/queue/queue.py +68 -0
- mcp_ticketer/queue/worker.py +1 -1
- mcp_ticketer/utils/__init__.py +5 -0
- mcp_ticketer/utils/token_utils.py +246 -0
- mcp_ticketer-2.2.13.dist-info/METADATA +1396 -0
- mcp_ticketer-2.2.13.dist-info/RECORD +158 -0
- mcp_ticketer-2.2.13.dist-info/top_level.txt +2 -0
- py_mcp_installer/examples/phase3_demo.py +178 -0
- py_mcp_installer/scripts/manage_version.py +54 -0
- py_mcp_installer/setup.py +6 -0
- py_mcp_installer/src/py_mcp_installer/__init__.py +153 -0
- py_mcp_installer/src/py_mcp_installer/command_builder.py +445 -0
- py_mcp_installer/src/py_mcp_installer/config_manager.py +541 -0
- py_mcp_installer/src/py_mcp_installer/exceptions.py +243 -0
- py_mcp_installer/src/py_mcp_installer/installation_strategy.py +617 -0
- py_mcp_installer/src/py_mcp_installer/installer.py +656 -0
- py_mcp_installer/src/py_mcp_installer/mcp_inspector.py +750 -0
- py_mcp_installer/src/py_mcp_installer/platform_detector.py +451 -0
- py_mcp_installer/src/py_mcp_installer/platforms/__init__.py +26 -0
- py_mcp_installer/src/py_mcp_installer/platforms/claude_code.py +225 -0
- py_mcp_installer/src/py_mcp_installer/platforms/codex.py +181 -0
- py_mcp_installer/src/py_mcp_installer/platforms/cursor.py +191 -0
- py_mcp_installer/src/py_mcp_installer/types.py +222 -0
- py_mcp_installer/src/py_mcp_installer/utils.py +463 -0
- py_mcp_installer/tests/__init__.py +0 -0
- py_mcp_installer/tests/platforms/__init__.py +0 -0
- py_mcp_installer/tests/test_platform_detector.py +17 -0
- mcp_ticketer/adapters/github.py +0 -1574
- mcp_ticketer/adapters/jira.py +0 -1258
- mcp_ticketer-0.12.0.dist-info/METADATA +0 -550
- mcp_ticketer-0.12.0.dist-info/RECORD +0 -91
- mcp_ticketer-0.12.0.dist-info/top_level.txt +0 -1
- {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.2.13.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.2.13.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.2.13.dist-info}/licenses/LICENSE +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
|
|
@@ -43,7 +43,8 @@ def run_server() -> None:
|
|
|
43
43
|
os.chdir(project_path)
|
|
44
44
|
sys.stderr.write(f"[MCP Server] Working directory: {project_path}\n")
|
|
45
45
|
except OSError as e:
|
|
46
|
-
sys.stderr.write(f"
|
|
46
|
+
sys.stderr.write(f"Error: Could not change to project directory: {e}\n")
|
|
47
|
+
sys.exit(1)
|
|
47
48
|
|
|
48
49
|
# Run the async main function
|
|
49
50
|
try:
|
|
@@ -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")
|