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,281 @@
|
|
|
1
|
+
"""Utilities for project conversion and backwards compatibility.
|
|
2
|
+
|
|
3
|
+
This module provides conversion functions between the legacy Epic model and
|
|
4
|
+
the new Project model, ensuring backward compatibility during the migration
|
|
5
|
+
to unified project support.
|
|
6
|
+
|
|
7
|
+
The conversions maintain semantic equivalence while mapping between the
|
|
8
|
+
simpler Epic structure and the richer Project model with additional fields
|
|
9
|
+
for visibility, scope, ownership, and statistics.
|
|
10
|
+
|
|
11
|
+
Example:
|
|
12
|
+
>>> from mcp_ticketer.core.models import Epic, Priority
|
|
13
|
+
>>> from mcp_ticketer.core.project_utils import epic_to_project
|
|
14
|
+
>>>
|
|
15
|
+
>>> epic = Epic(
|
|
16
|
+
... epic_id="epic-123",
|
|
17
|
+
... title="User Authentication",
|
|
18
|
+
... priority=Priority.HIGH
|
|
19
|
+
... )
|
|
20
|
+
>>> project = epic_to_project(epic)
|
|
21
|
+
>>> print(project.scope) # ProjectScope.TEAM (default)
|
|
22
|
+
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from typing import TYPE_CHECKING
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from .models import Epic, Project
|
|
31
|
+
|
|
32
|
+
from .models import ProjectScope, ProjectState, TicketState
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def epic_to_project(epic: Epic) -> Project:
|
|
36
|
+
"""Convert Epic model to Project model for backwards compatibility.
|
|
37
|
+
|
|
38
|
+
Maps legacy Epic fields to the new Project structure with sensible defaults
|
|
39
|
+
for new fields not present in Epic.
|
|
40
|
+
|
|
41
|
+
Field Mappings:
|
|
42
|
+
- epic.id -> project.id
|
|
43
|
+
- epic.id -> project.platform_id
|
|
44
|
+
- epic.title -> project.name
|
|
45
|
+
- epic.description -> project.description
|
|
46
|
+
- epic.state -> project.state (via state mapping)
|
|
47
|
+
- epic.url -> project.url
|
|
48
|
+
- epic.created_at -> project.created_at
|
|
49
|
+
- epic.updated_at -> project.updated_at
|
|
50
|
+
- epic.target_date -> project.target_date
|
|
51
|
+
- epic.child_issues -> project.child_issues
|
|
52
|
+
|
|
53
|
+
New fields receive defaults:
|
|
54
|
+
- scope: ProjectScope.TEAM (epics are team-level by convention)
|
|
55
|
+
- visibility: ProjectVisibility.TEAM
|
|
56
|
+
- platform: Extracted from epic.metadata or "unknown"
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
epic: Epic instance to convert
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Project instance with equivalent data
|
|
63
|
+
|
|
64
|
+
Example:
|
|
65
|
+
>>> epic = Epic(
|
|
66
|
+
... epic_id="linear-epic-123",
|
|
67
|
+
... title="Q4 Features",
|
|
68
|
+
... state="in_progress",
|
|
69
|
+
... child_issues=["issue-1", "issue-2"]
|
|
70
|
+
... )
|
|
71
|
+
>>> project = epic_to_project(epic)
|
|
72
|
+
>>> project.name == epic.title
|
|
73
|
+
True
|
|
74
|
+
>>> project.scope == ProjectScope.TEAM
|
|
75
|
+
True
|
|
76
|
+
|
|
77
|
+
"""
|
|
78
|
+
from .models import Project, ProjectVisibility
|
|
79
|
+
|
|
80
|
+
# Extract platform from metadata if available
|
|
81
|
+
platform = epic.metadata.get("platform", "unknown") if epic.metadata else "unknown"
|
|
82
|
+
|
|
83
|
+
# Map epic state to project state
|
|
84
|
+
state = _map_epic_state_to_project(epic.state)
|
|
85
|
+
|
|
86
|
+
return Project(
|
|
87
|
+
id=epic.id or "",
|
|
88
|
+
platform=platform,
|
|
89
|
+
platform_id=epic.id or "",
|
|
90
|
+
scope=ProjectScope.TEAM, # Default for epics
|
|
91
|
+
name=epic.title,
|
|
92
|
+
description=epic.description,
|
|
93
|
+
state=state,
|
|
94
|
+
visibility=ProjectVisibility.TEAM, # Default visibility
|
|
95
|
+
url=getattr(epic.metadata, "url", None) if epic.metadata else None,
|
|
96
|
+
created_at=epic.created_at,
|
|
97
|
+
updated_at=epic.updated_at,
|
|
98
|
+
target_date=(
|
|
99
|
+
getattr(epic.metadata, "target_date", None) if epic.metadata else None
|
|
100
|
+
),
|
|
101
|
+
completed_at=(
|
|
102
|
+
getattr(epic.metadata, "completed_at", None) if epic.metadata else None
|
|
103
|
+
),
|
|
104
|
+
child_issues=epic.child_issues or [],
|
|
105
|
+
extra_data={"original_type": "epic", **epic.metadata} if epic.metadata else {},
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def project_to_epic(project: Project) -> Epic:
|
|
110
|
+
"""Convert Project model back to Epic for backwards compatibility.
|
|
111
|
+
|
|
112
|
+
Maps Project fields back to the simpler Epic structure, preserving data
|
|
113
|
+
in metadata where Epic doesn't have direct field equivalents.
|
|
114
|
+
|
|
115
|
+
Field Mappings:
|
|
116
|
+
- project.id -> epic.id
|
|
117
|
+
- project.name -> epic.title
|
|
118
|
+
- project.description -> epic.description
|
|
119
|
+
- project.state -> epic.state (via state mapping)
|
|
120
|
+
- project.child_issues -> epic.child_issues
|
|
121
|
+
|
|
122
|
+
Additional project data stored in metadata:
|
|
123
|
+
- platform, scope, visibility, ownership fields
|
|
124
|
+
- Stored under metadata["project_data"]
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
project: Project instance to convert
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Epic instance with equivalent core data
|
|
131
|
+
|
|
132
|
+
Example:
|
|
133
|
+
>>> from .models import ProjectScope, ProjectState
|
|
134
|
+
>>> project = Project(
|
|
135
|
+
... id="proj-123",
|
|
136
|
+
... platform="linear",
|
|
137
|
+
... platform_id="abc123",
|
|
138
|
+
... scope=ProjectScope.TEAM,
|
|
139
|
+
... name="Q4 Features",
|
|
140
|
+
... state=ProjectState.ACTIVE
|
|
141
|
+
... )
|
|
142
|
+
>>> epic = project_to_epic(project)
|
|
143
|
+
>>> epic.title == project.name
|
|
144
|
+
True
|
|
145
|
+
>>> epic.metadata["project_data"]["platform"] == "linear"
|
|
146
|
+
True
|
|
147
|
+
|
|
148
|
+
"""
|
|
149
|
+
from .models import Epic
|
|
150
|
+
|
|
151
|
+
# Map project state back to epic state string
|
|
152
|
+
state = _map_project_state_to_epic(project.state)
|
|
153
|
+
|
|
154
|
+
# Build metadata with project-specific data
|
|
155
|
+
metadata = {
|
|
156
|
+
"platform": project.platform,
|
|
157
|
+
"url": project.url,
|
|
158
|
+
"target_date": project.target_date,
|
|
159
|
+
"completed_at": project.completed_at,
|
|
160
|
+
"project_data": {
|
|
161
|
+
"scope": project.scope,
|
|
162
|
+
"visibility": project.visibility,
|
|
163
|
+
"owner_id": project.owner_id,
|
|
164
|
+
"owner_name": project.owner_name,
|
|
165
|
+
"team_id": project.team_id,
|
|
166
|
+
"team_name": project.team_name,
|
|
167
|
+
"platform_id": project.platform_id,
|
|
168
|
+
},
|
|
169
|
+
**project.extra_data,
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return Epic(
|
|
173
|
+
id=project.id,
|
|
174
|
+
title=project.name,
|
|
175
|
+
description=project.description,
|
|
176
|
+
state=state,
|
|
177
|
+
created_at=project.created_at,
|
|
178
|
+
updated_at=project.updated_at,
|
|
179
|
+
child_issues=project.child_issues,
|
|
180
|
+
metadata=metadata,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _map_epic_state_to_project(epic_state: str | None) -> ProjectState:
|
|
185
|
+
"""Map epic state string to ProjectState enum.
|
|
186
|
+
|
|
187
|
+
Provides flexible mapping from various platform-specific epic states
|
|
188
|
+
to the standardized ProjectState values.
|
|
189
|
+
|
|
190
|
+
State Mappings:
|
|
191
|
+
- "planned", "backlog" -> PLANNED
|
|
192
|
+
- "in_progress", "active", "started" -> ACTIVE
|
|
193
|
+
- "completed", "done" -> COMPLETED
|
|
194
|
+
- "archived" -> ARCHIVED
|
|
195
|
+
- "cancelled", "canceled" -> CANCELLED
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
epic_state: Epic state string (case-insensitive)
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
Corresponding ProjectState, defaults to PLANNED if unknown
|
|
202
|
+
|
|
203
|
+
Example:
|
|
204
|
+
>>> _map_epic_state_to_project("in_progress")
|
|
205
|
+
<ProjectState.ACTIVE: 'active'>
|
|
206
|
+
>>> _map_epic_state_to_project("Done")
|
|
207
|
+
<ProjectState.COMPLETED: 'completed'>
|
|
208
|
+
>>> _map_epic_state_to_project(None)
|
|
209
|
+
<ProjectState.PLANNED: 'planned'>
|
|
210
|
+
|
|
211
|
+
"""
|
|
212
|
+
if not epic_state:
|
|
213
|
+
return ProjectState.PLANNED
|
|
214
|
+
|
|
215
|
+
# Normalize to lowercase for case-insensitive matching
|
|
216
|
+
normalized = epic_state.lower().strip()
|
|
217
|
+
|
|
218
|
+
# State mapping dictionary
|
|
219
|
+
mapping = {
|
|
220
|
+
# Planned states
|
|
221
|
+
"planned": ProjectState.PLANNED,
|
|
222
|
+
"backlog": ProjectState.PLANNED,
|
|
223
|
+
"todo": ProjectState.PLANNED,
|
|
224
|
+
# Active states
|
|
225
|
+
"in_progress": ProjectState.ACTIVE,
|
|
226
|
+
"active": ProjectState.ACTIVE,
|
|
227
|
+
"started": ProjectState.ACTIVE,
|
|
228
|
+
"in progress": ProjectState.ACTIVE,
|
|
229
|
+
# Completed states
|
|
230
|
+
"completed": ProjectState.COMPLETED,
|
|
231
|
+
"done": ProjectState.COMPLETED,
|
|
232
|
+
"finished": ProjectState.COMPLETED,
|
|
233
|
+
# Archived states
|
|
234
|
+
"archived": ProjectState.ARCHIVED,
|
|
235
|
+
"archive": ProjectState.ARCHIVED,
|
|
236
|
+
# Cancelled states
|
|
237
|
+
"cancelled": ProjectState.CANCELLED,
|
|
238
|
+
"canceled": ProjectState.CANCELLED,
|
|
239
|
+
"dropped": ProjectState.CANCELLED,
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return mapping.get(normalized, ProjectState.PLANNED)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _map_project_state_to_epic(project_state: ProjectState | str) -> TicketState:
|
|
246
|
+
"""Map ProjectState back to TicketState enum for Epic.
|
|
247
|
+
|
|
248
|
+
Converts ProjectState enum values to TicketState enum values
|
|
249
|
+
suitable for Epic model which uses TicketState.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
project_state: ProjectState enum or string value
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
TicketState enum value compatible with Epic model
|
|
256
|
+
|
|
257
|
+
Example:
|
|
258
|
+
>>> _map_project_state_to_epic(ProjectState.ACTIVE)
|
|
259
|
+
<TicketState.IN_PROGRESS: 'in_progress'>
|
|
260
|
+
>>> _map_project_state_to_epic(ProjectState.COMPLETED)
|
|
261
|
+
<TicketState.DONE: 'done'>
|
|
262
|
+
|
|
263
|
+
"""
|
|
264
|
+
# Handle both enum and string inputs
|
|
265
|
+
if isinstance(project_state, str):
|
|
266
|
+
try:
|
|
267
|
+
project_state = ProjectState(project_state)
|
|
268
|
+
except ValueError:
|
|
269
|
+
# If invalid string, return default
|
|
270
|
+
return TicketState.OPEN
|
|
271
|
+
|
|
272
|
+
# Map ProjectState to TicketState
|
|
273
|
+
mapping = {
|
|
274
|
+
ProjectState.PLANNED: TicketState.OPEN,
|
|
275
|
+
ProjectState.ACTIVE: TicketState.IN_PROGRESS,
|
|
276
|
+
ProjectState.COMPLETED: TicketState.DONE,
|
|
277
|
+
ProjectState.ARCHIVED: TicketState.CLOSED,
|
|
278
|
+
ProjectState.CANCELLED: TicketState.CLOSED,
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return mapping.get(project_state, TicketState.OPEN)
|
|
@@ -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
|
+
}
|