mcp-ticketer 0.4.11__py3-none-any.whl → 2.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of mcp-ticketer might be problematic. Click here for more details.
- mcp_ticketer/__init__.py +10 -10
- mcp_ticketer/__version__.py +3 -3
- mcp_ticketer/adapters/__init__.py +2 -0
- mcp_ticketer/adapters/aitrackdown.py +394 -9
- mcp_ticketer/adapters/asana/__init__.py +15 -0
- mcp_ticketer/adapters/asana/adapter.py +1416 -0
- mcp_ticketer/adapters/asana/client.py +292 -0
- mcp_ticketer/adapters/asana/mappers.py +348 -0
- mcp_ticketer/adapters/asana/types.py +146 -0
- mcp_ticketer/adapters/github.py +836 -105
- mcp_ticketer/adapters/hybrid.py +47 -5
- mcp_ticketer/adapters/jira.py +772 -1
- mcp_ticketer/adapters/linear/adapter.py +2293 -108
- mcp_ticketer/adapters/linear/client.py +146 -12
- mcp_ticketer/adapters/linear/mappers.py +105 -11
- mcp_ticketer/adapters/linear/queries.py +168 -1
- mcp_ticketer/adapters/linear/types.py +80 -4
- mcp_ticketer/analysis/__init__.py +56 -0
- mcp_ticketer/analysis/dependency_graph.py +255 -0
- mcp_ticketer/analysis/health_assessment.py +304 -0
- mcp_ticketer/analysis/orphaned.py +218 -0
- mcp_ticketer/analysis/project_status.py +594 -0
- mcp_ticketer/analysis/similarity.py +224 -0
- mcp_ticketer/analysis/staleness.py +266 -0
- mcp_ticketer/automation/__init__.py +11 -0
- mcp_ticketer/automation/project_updates.py +378 -0
- mcp_ticketer/cache/memory.py +3 -3
- mcp_ticketer/cli/adapter_diagnostics.py +4 -2
- mcp_ticketer/cli/auggie_configure.py +18 -6
- mcp_ticketer/cli/codex_configure.py +175 -60
- mcp_ticketer/cli/configure.py +884 -146
- mcp_ticketer/cli/cursor_configure.py +314 -0
- mcp_ticketer/cli/diagnostics.py +31 -28
- mcp_ticketer/cli/discover.py +293 -21
- mcp_ticketer/cli/gemini_configure.py +18 -6
- mcp_ticketer/cli/init_command.py +880 -0
- mcp_ticketer/cli/instruction_commands.py +435 -0
- mcp_ticketer/cli/linear_commands.py +99 -15
- mcp_ticketer/cli/main.py +109 -2055
- mcp_ticketer/cli/mcp_configure.py +673 -99
- mcp_ticketer/cli/mcp_server_commands.py +415 -0
- mcp_ticketer/cli/migrate_config.py +12 -8
- mcp_ticketer/cli/platform_commands.py +6 -6
- mcp_ticketer/cli/platform_detection.py +477 -0
- mcp_ticketer/cli/platform_installer.py +536 -0
- mcp_ticketer/cli/project_update_commands.py +350 -0
- mcp_ticketer/cli/queue_commands.py +15 -15
- mcp_ticketer/cli/setup_command.py +639 -0
- mcp_ticketer/cli/simple_health.py +13 -11
- mcp_ticketer/cli/ticket_commands.py +277 -36
- mcp_ticketer/cli/update_checker.py +313 -0
- mcp_ticketer/cli/utils.py +45 -41
- mcp_ticketer/core/__init__.py +35 -1
- mcp_ticketer/core/adapter.py +170 -5
- mcp_ticketer/core/config.py +38 -31
- mcp_ticketer/core/env_discovery.py +33 -3
- mcp_ticketer/core/env_loader.py +7 -6
- mcp_ticketer/core/exceptions.py +10 -4
- mcp_ticketer/core/http_client.py +10 -10
- mcp_ticketer/core/instructions.py +405 -0
- mcp_ticketer/core/label_manager.py +732 -0
- mcp_ticketer/core/mappers.py +32 -20
- mcp_ticketer/core/models.py +136 -1
- mcp_ticketer/core/onepassword_secrets.py +379 -0
- mcp_ticketer/core/priority_matcher.py +463 -0
- mcp_ticketer/core/project_config.py +148 -14
- mcp_ticketer/core/registry.py +1 -1
- mcp_ticketer/core/session_state.py +171 -0
- mcp_ticketer/core/state_matcher.py +592 -0
- mcp_ticketer/core/url_parser.py +425 -0
- mcp_ticketer/core/validators.py +69 -0
- mcp_ticketer/defaults/ticket_instructions.md +644 -0
- mcp_ticketer/mcp/__init__.py +2 -2
- mcp_ticketer/mcp/server/__init__.py +2 -2
- mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
- mcp_ticketer/mcp/server/main.py +187 -93
- mcp_ticketer/mcp/server/routing.py +655 -0
- mcp_ticketer/mcp/server/server_sdk.py +58 -0
- mcp_ticketer/mcp/server/tools/__init__.py +37 -9
- mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +65 -20
- mcp_ticketer/mcp/server/tools/bulk_tools.py +259 -202
- mcp_ticketer/mcp/server/tools/comment_tools.py +74 -12
- mcp_ticketer/mcp/server/tools/config_tools.py +1429 -0
- mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +878 -319
- mcp_ticketer/mcp/server/tools/instruction_tools.py +295 -0
- mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
- mcp_ticketer/mcp/server/tools/pr_tools.py +3 -7
- mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
- mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
- mcp_ticketer/mcp/server/tools/search_tools.py +180 -97
- mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
- mcp_ticketer/mcp/server/tools/ticket_tools.py +1182 -82
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +364 -0
- mcp_ticketer/queue/health_monitor.py +1 -0
- mcp_ticketer/queue/manager.py +4 -4
- mcp_ticketer/queue/queue.py +3 -3
- mcp_ticketer/queue/run_worker.py +1 -1
- mcp_ticketer/queue/ticket_registry.py +2 -2
- mcp_ticketer/queue/worker.py +15 -13
- mcp_ticketer/utils/__init__.py +5 -0
- mcp_ticketer/utils/token_utils.py +246 -0
- mcp_ticketer-2.0.1.dist-info/METADATA +1366 -0
- mcp_ticketer-2.0.1.dist-info/RECORD +122 -0
- mcp_ticketer-0.4.11.dist-info/METADATA +0 -496
- mcp_ticketer-0.4.11.dist-info/RECORD +0 -77
- {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/licenses/LICENSE +0 -0
- {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,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
|