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.

Files changed (129) hide show
  1. mcp_ticketer/__init__.py +10 -10
  2. mcp_ticketer/__version__.py +1 -1
  3. mcp_ticketer/_version_scm.py +1 -0
  4. mcp_ticketer/adapters/aitrackdown.py +507 -6
  5. mcp_ticketer/adapters/asana/adapter.py +229 -0
  6. mcp_ticketer/adapters/asana/mappers.py +14 -0
  7. mcp_ticketer/adapters/github/__init__.py +26 -0
  8. mcp_ticketer/adapters/github/adapter.py +3229 -0
  9. mcp_ticketer/adapters/github/client.py +335 -0
  10. mcp_ticketer/adapters/github/mappers.py +797 -0
  11. mcp_ticketer/adapters/github/queries.py +692 -0
  12. mcp_ticketer/adapters/github/types.py +460 -0
  13. mcp_ticketer/adapters/hybrid.py +47 -5
  14. mcp_ticketer/adapters/jira/__init__.py +35 -0
  15. mcp_ticketer/adapters/jira/adapter.py +1351 -0
  16. mcp_ticketer/adapters/jira/client.py +271 -0
  17. mcp_ticketer/adapters/jira/mappers.py +246 -0
  18. mcp_ticketer/adapters/jira/queries.py +216 -0
  19. mcp_ticketer/adapters/jira/types.py +304 -0
  20. mcp_ticketer/adapters/linear/adapter.py +2730 -139
  21. mcp_ticketer/adapters/linear/client.py +175 -3
  22. mcp_ticketer/adapters/linear/mappers.py +203 -8
  23. mcp_ticketer/adapters/linear/queries.py +280 -3
  24. mcp_ticketer/adapters/linear/types.py +120 -4
  25. mcp_ticketer/analysis/__init__.py +56 -0
  26. mcp_ticketer/analysis/dependency_graph.py +255 -0
  27. mcp_ticketer/analysis/health_assessment.py +304 -0
  28. mcp_ticketer/analysis/orphaned.py +218 -0
  29. mcp_ticketer/analysis/project_status.py +594 -0
  30. mcp_ticketer/analysis/similarity.py +224 -0
  31. mcp_ticketer/analysis/staleness.py +266 -0
  32. mcp_ticketer/automation/__init__.py +11 -0
  33. mcp_ticketer/automation/project_updates.py +378 -0
  34. mcp_ticketer/cli/adapter_diagnostics.py +3 -1
  35. mcp_ticketer/cli/auggie_configure.py +17 -5
  36. mcp_ticketer/cli/codex_configure.py +97 -61
  37. mcp_ticketer/cli/configure.py +1288 -105
  38. mcp_ticketer/cli/cursor_configure.py +314 -0
  39. mcp_ticketer/cli/diagnostics.py +13 -12
  40. mcp_ticketer/cli/discover.py +5 -0
  41. mcp_ticketer/cli/gemini_configure.py +17 -5
  42. mcp_ticketer/cli/init_command.py +880 -0
  43. mcp_ticketer/cli/install_mcp_server.py +418 -0
  44. mcp_ticketer/cli/instruction_commands.py +6 -0
  45. mcp_ticketer/cli/main.py +267 -3175
  46. mcp_ticketer/cli/mcp_configure.py +821 -119
  47. mcp_ticketer/cli/mcp_server_commands.py +415 -0
  48. mcp_ticketer/cli/platform_detection.py +77 -12
  49. mcp_ticketer/cli/platform_installer.py +545 -0
  50. mcp_ticketer/cli/project_update_commands.py +350 -0
  51. mcp_ticketer/cli/setup_command.py +795 -0
  52. mcp_ticketer/cli/simple_health.py +12 -10
  53. mcp_ticketer/cli/ticket_commands.py +705 -103
  54. mcp_ticketer/cli/utils.py +113 -0
  55. mcp_ticketer/core/__init__.py +56 -6
  56. mcp_ticketer/core/adapter.py +533 -2
  57. mcp_ticketer/core/config.py +21 -21
  58. mcp_ticketer/core/exceptions.py +7 -1
  59. mcp_ticketer/core/label_manager.py +732 -0
  60. mcp_ticketer/core/mappers.py +31 -19
  61. mcp_ticketer/core/milestone_manager.py +252 -0
  62. mcp_ticketer/core/models.py +480 -0
  63. mcp_ticketer/core/onepassword_secrets.py +1 -1
  64. mcp_ticketer/core/priority_matcher.py +463 -0
  65. mcp_ticketer/core/project_config.py +132 -14
  66. mcp_ticketer/core/project_utils.py +281 -0
  67. mcp_ticketer/core/project_validator.py +376 -0
  68. mcp_ticketer/core/session_state.py +176 -0
  69. mcp_ticketer/core/state_matcher.py +625 -0
  70. mcp_ticketer/core/url_parser.py +425 -0
  71. mcp_ticketer/core/validators.py +69 -0
  72. mcp_ticketer/mcp/server/__main__.py +2 -1
  73. mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
  74. mcp_ticketer/mcp/server/main.py +106 -25
  75. mcp_ticketer/mcp/server/routing.py +723 -0
  76. mcp_ticketer/mcp/server/server_sdk.py +58 -0
  77. mcp_ticketer/mcp/server/tools/__init__.py +33 -11
  78. mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
  79. mcp_ticketer/mcp/server/tools/attachment_tools.py +5 -5
  80. mcp_ticketer/mcp/server/tools/bulk_tools.py +259 -202
  81. mcp_ticketer/mcp/server/tools/comment_tools.py +74 -12
  82. mcp_ticketer/mcp/server/tools/config_tools.py +1391 -145
  83. mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
  84. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +870 -460
  85. mcp_ticketer/mcp/server/tools/instruction_tools.py +7 -5
  86. mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
  87. mcp_ticketer/mcp/server/tools/milestone_tools.py +338 -0
  88. mcp_ticketer/mcp/server/tools/pr_tools.py +3 -7
  89. mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
  90. mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
  91. mcp_ticketer/mcp/server/tools/search_tools.py +209 -97
  92. mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
  93. mcp_ticketer/mcp/server/tools/ticket_tools.py +1107 -124
  94. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +218 -236
  95. mcp_ticketer/queue/queue.py +68 -0
  96. mcp_ticketer/queue/worker.py +1 -1
  97. mcp_ticketer/utils/__init__.py +5 -0
  98. mcp_ticketer/utils/token_utils.py +246 -0
  99. mcp_ticketer-2.2.13.dist-info/METADATA +1396 -0
  100. mcp_ticketer-2.2.13.dist-info/RECORD +158 -0
  101. mcp_ticketer-2.2.13.dist-info/top_level.txt +2 -0
  102. py_mcp_installer/examples/phase3_demo.py +178 -0
  103. py_mcp_installer/scripts/manage_version.py +54 -0
  104. py_mcp_installer/setup.py +6 -0
  105. py_mcp_installer/src/py_mcp_installer/__init__.py +153 -0
  106. py_mcp_installer/src/py_mcp_installer/command_builder.py +445 -0
  107. py_mcp_installer/src/py_mcp_installer/config_manager.py +541 -0
  108. py_mcp_installer/src/py_mcp_installer/exceptions.py +243 -0
  109. py_mcp_installer/src/py_mcp_installer/installation_strategy.py +617 -0
  110. py_mcp_installer/src/py_mcp_installer/installer.py +656 -0
  111. py_mcp_installer/src/py_mcp_installer/mcp_inspector.py +750 -0
  112. py_mcp_installer/src/py_mcp_installer/platform_detector.py +451 -0
  113. py_mcp_installer/src/py_mcp_installer/platforms/__init__.py +26 -0
  114. py_mcp_installer/src/py_mcp_installer/platforms/claude_code.py +225 -0
  115. py_mcp_installer/src/py_mcp_installer/platforms/codex.py +181 -0
  116. py_mcp_installer/src/py_mcp_installer/platforms/cursor.py +191 -0
  117. py_mcp_installer/src/py_mcp_installer/types.py +222 -0
  118. py_mcp_installer/src/py_mcp_installer/utils.py +463 -0
  119. py_mcp_installer/tests/__init__.py +0 -0
  120. py_mcp_installer/tests/platforms/__init__.py +0 -0
  121. py_mcp_installer/tests/test_platform_detector.py +17 -0
  122. mcp_ticketer/adapters/github.py +0 -1574
  123. mcp_ticketer/adapters/jira.py +0 -1258
  124. mcp_ticketer-0.12.0.dist-info/METADATA +0 -550
  125. mcp_ticketer-0.12.0.dist-info/RECORD +0 -91
  126. mcp_ticketer-0.12.0.dist-info/top_level.txt +0 -1
  127. {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.2.13.dist-info}/WHEEL +0 -0
  128. {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.2.13.dist-info}/entry_points.txt +0 -0
  129. {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
+ }