mcp-ticketer 0.2.0__py3-none-any.whl → 2.2.9__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.
- mcp_ticketer/__init__.py +10 -10
- mcp_ticketer/__version__.py +3 -3
- mcp_ticketer/_version_scm.py +1 -0
- mcp_ticketer/adapters/__init__.py +2 -0
- mcp_ticketer/adapters/aitrackdown.py +930 -52
- mcp_ticketer/adapters/asana/__init__.py +15 -0
- mcp_ticketer/adapters/asana/adapter.py +1537 -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/__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 +58 -16
- 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/__init__.py +1 -1
- mcp_ticketer/adapters/linear/adapter.py +3810 -462
- mcp_ticketer/adapters/linear/client.py +312 -69
- mcp_ticketer/adapters/linear/mappers.py +305 -85
- mcp_ticketer/adapters/linear/queries.py +317 -17
- mcp_ticketer/adapters/linear/types.py +187 -64
- mcp_ticketer/adapters/linear.py +2 -2
- 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 +9 -8
- mcp_ticketer/cli/adapter_diagnostics.py +421 -0
- mcp_ticketer/cli/auggie_configure.py +116 -15
- mcp_ticketer/cli/codex_configure.py +274 -82
- mcp_ticketer/cli/configure.py +1323 -151
- mcp_ticketer/cli/cursor_configure.py +314 -0
- mcp_ticketer/cli/diagnostics.py +209 -114
- mcp_ticketer/cli/discover.py +297 -26
- mcp_ticketer/cli/gemini_configure.py +119 -26
- mcp_ticketer/cli/init_command.py +880 -0
- mcp_ticketer/cli/install_mcp_server.py +418 -0
- mcp_ticketer/cli/instruction_commands.py +435 -0
- mcp_ticketer/cli/linear_commands.py +256 -130
- mcp_ticketer/cli/main.py +140 -1284
- mcp_ticketer/cli/mcp_configure.py +1013 -100
- mcp_ticketer/cli/mcp_server_commands.py +415 -0
- mcp_ticketer/cli/migrate_config.py +12 -8
- mcp_ticketer/cli/platform_commands.py +123 -0
- mcp_ticketer/cli/platform_detection.py +477 -0
- mcp_ticketer/cli/platform_installer.py +545 -0
- mcp_ticketer/cli/project_update_commands.py +350 -0
- mcp_ticketer/cli/python_detection.py +126 -0
- mcp_ticketer/cli/queue_commands.py +15 -15
- mcp_ticketer/cli/setup_command.py +794 -0
- mcp_ticketer/cli/simple_health.py +84 -59
- mcp_ticketer/cli/ticket_commands.py +1375 -0
- mcp_ticketer/cli/update_checker.py +313 -0
- mcp_ticketer/cli/utils.py +195 -72
- mcp_ticketer/core/__init__.py +64 -1
- mcp_ticketer/core/adapter.py +618 -18
- mcp_ticketer/core/config.py +77 -68
- mcp_ticketer/core/env_discovery.py +75 -16
- mcp_ticketer/core/env_loader.py +121 -97
- mcp_ticketer/core/exceptions.py +32 -24
- mcp_ticketer/core/http_client.py +26 -26
- mcp_ticketer/core/instructions.py +405 -0
- mcp_ticketer/core/label_manager.py +732 -0
- mcp_ticketer/core/mappers.py +42 -30
- mcp_ticketer/core/milestone_manager.py +252 -0
- mcp_ticketer/core/models.py +566 -19
- mcp_ticketer/core/onepassword_secrets.py +379 -0
- mcp_ticketer/core/priority_matcher.py +463 -0
- mcp_ticketer/core/project_config.py +189 -49
- mcp_ticketer/core/project_utils.py +281 -0
- mcp_ticketer/core/project_validator.py +376 -0
- mcp_ticketer/core/registry.py +3 -3
- mcp_ticketer/core/session_state.py +176 -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 +29 -1
- mcp_ticketer/mcp/__main__.py +60 -0
- mcp_ticketer/mcp/server/__init__.py +25 -0
- mcp_ticketer/mcp/server/__main__.py +60 -0
- mcp_ticketer/mcp/server/constants.py +58 -0
- mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
- mcp_ticketer/mcp/server/dto.py +195 -0
- mcp_ticketer/mcp/server/main.py +1343 -0
- mcp_ticketer/mcp/server/response_builder.py +206 -0
- mcp_ticketer/mcp/server/routing.py +723 -0
- mcp_ticketer/mcp/server/server_sdk.py +151 -0
- mcp_ticketer/mcp/server/tools/__init__.py +69 -0
- mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +224 -0
- mcp_ticketer/mcp/server/tools/bulk_tools.py +330 -0
- mcp_ticketer/mcp/server/tools/comment_tools.py +152 -0
- mcp_ticketer/mcp/server/tools/config_tools.py +1564 -0
- mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +942 -0
- 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/milestone_tools.py +338 -0
- mcp_ticketer/mcp/server/tools/pr_tools.py +150 -0
- 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 +318 -0
- mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
- mcp_ticketer/mcp/server/tools/ticket_tools.py +1413 -0
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +364 -0
- mcp_ticketer/queue/__init__.py +1 -0
- mcp_ticketer/queue/health_monitor.py +168 -136
- mcp_ticketer/queue/manager.py +78 -63
- mcp_ticketer/queue/queue.py +108 -21
- mcp_ticketer/queue/run_worker.py +2 -2
- mcp_ticketer/queue/ticket_registry.py +213 -155
- mcp_ticketer/queue/worker.py +96 -58
- mcp_ticketer/utils/__init__.py +5 -0
- mcp_ticketer/utils/token_utils.py +246 -0
- mcp_ticketer-2.2.9.dist-info/METADATA +1396 -0
- mcp_ticketer-2.2.9.dist-info/RECORD +158 -0
- mcp_ticketer-2.2.9.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 -1354
- mcp_ticketer/adapters/jira.py +0 -1011
- mcp_ticketer/mcp/server.py +0 -1895
- mcp_ticketer-0.2.0.dist-info/METADATA +0 -414
- mcp_ticketer-0.2.0.dist-info/RECORD +0 -58
- mcp_ticketer-0.2.0.dist-info/top_level.txt +0 -1
- {mcp_ticketer-0.2.0.dist-info → mcp_ticketer-2.2.9.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.2.0.dist-info → mcp_ticketer-2.2.9.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.2.0.dist-info → mcp_ticketer-2.2.9.dist-info}/licenses/LICENSE +0 -0
|
@@ -14,7 +14,7 @@ import os
|
|
|
14
14
|
from dataclasses import asdict, dataclass, field
|
|
15
15
|
from enum import Enum
|
|
16
16
|
from pathlib import Path
|
|
17
|
-
from typing import Any, Optional
|
|
17
|
+
from typing import TYPE_CHECKING, Any, Optional
|
|
18
18
|
|
|
19
19
|
if TYPE_CHECKING:
|
|
20
20
|
from .env_discovery import DiscoveryResult
|
|
@@ -47,29 +47,29 @@ class AdapterConfig:
|
|
|
47
47
|
enabled: bool = True
|
|
48
48
|
|
|
49
49
|
# Common fields (not all adapters use all fields)
|
|
50
|
-
api_key:
|
|
51
|
-
token:
|
|
50
|
+
api_key: str | None = None
|
|
51
|
+
token: str | None = None
|
|
52
52
|
|
|
53
53
|
# Linear-specific
|
|
54
|
-
team_id:
|
|
55
|
-
team_key:
|
|
56
|
-
workspace:
|
|
54
|
+
team_id: str | None = None
|
|
55
|
+
team_key: str | None = None
|
|
56
|
+
workspace: str | None = None
|
|
57
57
|
|
|
58
58
|
# JIRA-specific
|
|
59
|
-
server:
|
|
60
|
-
email:
|
|
61
|
-
api_token:
|
|
62
|
-
project_key:
|
|
59
|
+
server: str | None = None
|
|
60
|
+
email: str | None = None
|
|
61
|
+
api_token: str | None = None
|
|
62
|
+
project_key: str | None = None
|
|
63
63
|
|
|
64
64
|
# GitHub-specific
|
|
65
|
-
owner:
|
|
66
|
-
repo:
|
|
65
|
+
owner: str | None = None
|
|
66
|
+
repo: str | None = None
|
|
67
67
|
|
|
68
68
|
# AITrackdown-specific
|
|
69
|
-
base_path:
|
|
69
|
+
base_path: str | None = None
|
|
70
70
|
|
|
71
71
|
# Project ID (can be used by any adapter for scoping)
|
|
72
|
-
project_id:
|
|
72
|
+
project_id: str | None = None
|
|
73
73
|
|
|
74
74
|
# Additional adapter-specific configuration
|
|
75
75
|
additional_config: dict[str, Any] = field(default_factory=dict)
|
|
@@ -126,9 +126,9 @@ class ProjectConfig:
|
|
|
126
126
|
"""Configuration for a specific project."""
|
|
127
127
|
|
|
128
128
|
adapter: str
|
|
129
|
-
api_key:
|
|
130
|
-
project_id:
|
|
131
|
-
team_id:
|
|
129
|
+
api_key: str | None = None
|
|
130
|
+
project_id: str | None = None
|
|
131
|
+
team_id: str | None = None
|
|
132
132
|
additional_config: dict[str, Any] = field(default_factory=dict)
|
|
133
133
|
|
|
134
134
|
def to_dict(self) -> dict[str, Any]:
|
|
@@ -147,7 +147,7 @@ class HybridConfig:
|
|
|
147
147
|
|
|
148
148
|
enabled: bool = False
|
|
149
149
|
adapters: list[str] = field(default_factory=list)
|
|
150
|
-
primary_adapter:
|
|
150
|
+
primary_adapter: str | None = None
|
|
151
151
|
sync_strategy: SyncStrategy = SyncStrategy.PRIMARY_SOURCE
|
|
152
152
|
|
|
153
153
|
def to_dict(self) -> dict[str, Any]:
|
|
@@ -167,16 +167,76 @@ class HybridConfig:
|
|
|
167
167
|
|
|
168
168
|
@dataclass
|
|
169
169
|
class TicketerConfig:
|
|
170
|
-
"""Complete ticketer configuration with hierarchical resolution.
|
|
170
|
+
"""Complete ticketer configuration with hierarchical resolution.
|
|
171
|
+
|
|
172
|
+
Supports URL parsing for default_project field:
|
|
173
|
+
- Linear URLs: https://linear.app/workspace/project/project-slug-abc123
|
|
174
|
+
- JIRA URLs: https://company.atlassian.net/browse/PROJ-123
|
|
175
|
+
- GitHub URLs: https://github.com/owner/repo/projects/1
|
|
176
|
+
- Plain IDs: PROJ-123, abc-123, 1 (backward compatible)
|
|
177
|
+
"""
|
|
171
178
|
|
|
172
179
|
default_adapter: str = "aitrackdown"
|
|
173
180
|
project_configs: dict[str, ProjectConfig] = field(default_factory=dict)
|
|
174
181
|
adapters: dict[str, AdapterConfig] = field(default_factory=dict)
|
|
175
|
-
hybrid_mode:
|
|
182
|
+
hybrid_mode: HybridConfig | None = None
|
|
183
|
+
|
|
184
|
+
# Default values for ticket operations
|
|
185
|
+
default_user: str | None = None # Default assignee (user_id or email)
|
|
186
|
+
default_project: str | None = None # Default project/epic ID (supports URLs)
|
|
187
|
+
default_epic: str | None = None # Alias for default_project (backward compat)
|
|
188
|
+
default_tags: list[str] | None = None # Default tags for new tickets
|
|
189
|
+
default_team: str | None = None # Default team ID/key for multi-team platforms
|
|
190
|
+
default_cycle: str | None = None # Default sprint/cycle ID for timeline scoping
|
|
191
|
+
assignment_labels: list[str] | None = None # Labels indicating ticket assignment
|
|
192
|
+
|
|
193
|
+
# Automatic project updates configuration (1M-315)
|
|
194
|
+
auto_project_updates: dict[str, Any] | None = None # Auto update settings
|
|
195
|
+
|
|
196
|
+
def __post_init__(self):
|
|
197
|
+
"""Normalize default_project if it's a URL."""
|
|
198
|
+
if self.default_project:
|
|
199
|
+
self.default_project = self._normalize_project_id(self.default_project)
|
|
200
|
+
if self.default_epic:
|
|
201
|
+
self.default_epic = self._normalize_project_id(self.default_epic)
|
|
202
|
+
|
|
203
|
+
def _normalize_project_id(self, value: str) -> str:
|
|
204
|
+
"""Normalize project ID by extracting from URL if needed.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
value: Project ID or URL
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
Normalized project ID (plain ID, not URL)
|
|
211
|
+
|
|
212
|
+
Examples:
|
|
213
|
+
>>> config._normalize_project_id("PROJ-123")
|
|
214
|
+
'PROJ-123'
|
|
215
|
+
>>> config._normalize_project_id("https://linear.app/team/project/abc-123")
|
|
216
|
+
'abc-123'
|
|
217
|
+
|
|
218
|
+
"""
|
|
219
|
+
from .url_parser import is_url, normalize_project_id
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
# If it's a URL, use auto-detection (don't rely on default_adapter)
|
|
223
|
+
# This allows users to paste URLs from any platform
|
|
224
|
+
if is_url(value):
|
|
225
|
+
normalized = normalize_project_id(value, adapter_type=None)
|
|
226
|
+
else:
|
|
227
|
+
# For plain IDs, just return as-is
|
|
228
|
+
normalized = normalize_project_id(value, self.default_adapter)
|
|
229
|
+
|
|
230
|
+
logger.debug(f"Normalized '{value}' to '{normalized}'")
|
|
231
|
+
return normalized
|
|
232
|
+
except Exception as e:
|
|
233
|
+
# If normalization fails, log warning but keep original value
|
|
234
|
+
logger.warning(f"Failed to normalize project ID '{value}': {e}")
|
|
235
|
+
return value
|
|
176
236
|
|
|
177
237
|
def to_dict(self) -> dict[str, Any]:
|
|
178
238
|
"""Convert to dictionary for JSON serialization."""
|
|
179
|
-
|
|
239
|
+
result = {
|
|
180
240
|
"default_adapter": self.default_adapter,
|
|
181
241
|
"project_configs": {
|
|
182
242
|
path: config.to_dict() for path, config in self.project_configs.items()
|
|
@@ -186,6 +246,24 @@ class TicketerConfig:
|
|
|
186
246
|
},
|
|
187
247
|
"hybrid_mode": self.hybrid_mode.to_dict() if self.hybrid_mode else None,
|
|
188
248
|
}
|
|
249
|
+
# Add optional fields if set
|
|
250
|
+
if self.default_user is not None:
|
|
251
|
+
result["default_user"] = self.default_user
|
|
252
|
+
if self.default_project is not None:
|
|
253
|
+
result["default_project"] = self.default_project
|
|
254
|
+
if self.default_epic is not None:
|
|
255
|
+
result["default_epic"] = self.default_epic
|
|
256
|
+
if self.default_tags is not None:
|
|
257
|
+
result["default_tags"] = self.default_tags
|
|
258
|
+
if self.default_team is not None:
|
|
259
|
+
result["default_team"] = self.default_team
|
|
260
|
+
if self.default_cycle is not None:
|
|
261
|
+
result["default_cycle"] = self.default_cycle
|
|
262
|
+
if self.assignment_labels is not None:
|
|
263
|
+
result["assignment_labels"] = self.assignment_labels
|
|
264
|
+
if self.auto_project_updates is not None:
|
|
265
|
+
result["auto_project_updates"] = self.auto_project_updates
|
|
266
|
+
return result
|
|
189
267
|
|
|
190
268
|
@classmethod
|
|
191
269
|
def from_dict(cls, data: dict[str, Any]) -> "TicketerConfig":
|
|
@@ -212,6 +290,14 @@ class TicketerConfig:
|
|
|
212
290
|
project_configs=project_configs,
|
|
213
291
|
adapters=adapters,
|
|
214
292
|
hybrid_mode=hybrid_mode,
|
|
293
|
+
default_user=data.get("default_user"),
|
|
294
|
+
default_project=data.get("default_project"),
|
|
295
|
+
default_epic=data.get("default_epic"),
|
|
296
|
+
default_tags=data.get("default_tags"),
|
|
297
|
+
default_team=data.get("default_team"),
|
|
298
|
+
default_cycle=data.get("default_cycle"),
|
|
299
|
+
assignment_labels=data.get("assignment_labels"),
|
|
300
|
+
auto_project_updates=data.get("auto_project_updates"),
|
|
215
301
|
)
|
|
216
302
|
|
|
217
303
|
|
|
@@ -219,29 +305,77 @@ class ConfigValidator:
|
|
|
219
305
|
"""Validate adapter configurations."""
|
|
220
306
|
|
|
221
307
|
@staticmethod
|
|
222
|
-
def validate_linear_config(config: dict[str, Any]) -> tuple[bool,
|
|
308
|
+
def validate_linear_config(config: dict[str, Any]) -> tuple[bool, str | None]:
|
|
223
309
|
"""Validate Linear adapter configuration.
|
|
224
310
|
|
|
311
|
+
Args:
|
|
312
|
+
config: Linear configuration dictionary
|
|
313
|
+
|
|
225
314
|
Returns:
|
|
226
315
|
Tuple of (is_valid, error_message)
|
|
227
316
|
|
|
228
317
|
"""
|
|
318
|
+
import logging
|
|
319
|
+
import re
|
|
320
|
+
|
|
321
|
+
logger = logging.getLogger(__name__)
|
|
322
|
+
|
|
229
323
|
required = ["api_key"]
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
324
|
+
missing_fields = []
|
|
325
|
+
|
|
326
|
+
for field_name in required:
|
|
327
|
+
if field_name not in config or not config[field_name]:
|
|
328
|
+
missing_fields.append(field_name)
|
|
233
329
|
|
|
234
|
-
|
|
235
|
-
if not config.get("team_key") and not config.get("team_id"):
|
|
330
|
+
if missing_fields:
|
|
236
331
|
return (
|
|
237
332
|
False,
|
|
238
|
-
"Linear config
|
|
333
|
+
f"Linear config missing required fields: {', '.join(missing_fields)}",
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
# Require either team_key or team_id (team_key is preferred)
|
|
337
|
+
has_team_key = config.get("team_key") and config["team_key"].strip()
|
|
338
|
+
has_team_id = config.get("team_id") and config["team_id"].strip()
|
|
339
|
+
|
|
340
|
+
if not has_team_key and not has_team_id:
|
|
341
|
+
return (
|
|
342
|
+
False,
|
|
343
|
+
"Linear config requires either team_key (short key like 'ENG') or team_id (UUID)",
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
# Validate team_id format if provided (should be UUID)
|
|
347
|
+
if has_team_id:
|
|
348
|
+
team_id = config["team_id"]
|
|
349
|
+
uuid_pattern = re.compile(
|
|
350
|
+
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
|
|
351
|
+
re.IGNORECASE,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
if not uuid_pattern.match(team_id):
|
|
355
|
+
# Not a UUID - could be a team_key mistakenly stored as team_id
|
|
356
|
+
logger.warning(
|
|
357
|
+
f"team_id '{team_id}' is not a UUID format. "
|
|
358
|
+
f"It will be treated as team_key and resolved at runtime."
|
|
359
|
+
)
|
|
360
|
+
# Move it to team_key if team_key is empty
|
|
361
|
+
if not has_team_key:
|
|
362
|
+
config["team_key"] = team_id
|
|
363
|
+
del config["team_id"]
|
|
364
|
+
logger.info(f"Moved non-UUID team_id to team_key: {team_id}")
|
|
365
|
+
|
|
366
|
+
# Validate user_email format if provided
|
|
367
|
+
if config.get("user_email"):
|
|
368
|
+
email = config["user_email"]
|
|
369
|
+
email_pattern = re.compile(
|
|
370
|
+
r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
|
|
239
371
|
)
|
|
372
|
+
if not email_pattern.match(email):
|
|
373
|
+
return False, f"Invalid email format for user_email: {email}"
|
|
240
374
|
|
|
241
375
|
return True, None
|
|
242
376
|
|
|
243
377
|
@staticmethod
|
|
244
|
-
def validate_github_config(config: dict[str, Any]) -> tuple[bool,
|
|
378
|
+
def validate_github_config(config: dict[str, Any]) -> tuple[bool, str | None]:
|
|
245
379
|
"""Validate GitHub adapter configuration.
|
|
246
380
|
|
|
247
381
|
Returns:
|
|
@@ -263,14 +397,14 @@ class ConfigValidator:
|
|
|
263
397
|
|
|
264
398
|
# Otherwise need explicit owner and repo
|
|
265
399
|
required = ["owner", "repo"]
|
|
266
|
-
for
|
|
267
|
-
if
|
|
268
|
-
return False, f"GitHub config missing required field: {
|
|
400
|
+
for field_name in required:
|
|
401
|
+
if field_name not in config or not config[field_name]:
|
|
402
|
+
return False, f"GitHub config missing required field: {field_name}"
|
|
269
403
|
|
|
270
404
|
return True, None
|
|
271
405
|
|
|
272
406
|
@staticmethod
|
|
273
|
-
def validate_jira_config(config: dict[str, Any]) -> tuple[bool,
|
|
407
|
+
def validate_jira_config(config: dict[str, Any]) -> tuple[bool, str | None]:
|
|
274
408
|
"""Validate JIRA adapter configuration.
|
|
275
409
|
|
|
276
410
|
Returns:
|
|
@@ -278,9 +412,9 @@ class ConfigValidator:
|
|
|
278
412
|
|
|
279
413
|
"""
|
|
280
414
|
required = ["server", "email", "api_token"]
|
|
281
|
-
for
|
|
282
|
-
if
|
|
283
|
-
return False, f"JIRA config missing required field: {
|
|
415
|
+
for field_name in required:
|
|
416
|
+
if field_name not in config or not config[field_name]:
|
|
417
|
+
return False, f"JIRA config missing required field: {field_name}"
|
|
284
418
|
|
|
285
419
|
# Validate server URL format
|
|
286
420
|
server = config["server"]
|
|
@@ -292,7 +426,7 @@ class ConfigValidator:
|
|
|
292
426
|
@staticmethod
|
|
293
427
|
def validate_aitrackdown_config(
|
|
294
428
|
config: dict[str, Any],
|
|
295
|
-
) -> tuple[bool,
|
|
429
|
+
) -> tuple[bool, str | None]:
|
|
296
430
|
"""Validate AITrackdown adapter configuration.
|
|
297
431
|
|
|
298
432
|
Returns:
|
|
@@ -306,7 +440,7 @@ class ConfigValidator:
|
|
|
306
440
|
@classmethod
|
|
307
441
|
def validate(
|
|
308
442
|
cls, adapter_type: str, config: dict[str, Any]
|
|
309
|
-
) -> tuple[bool,
|
|
443
|
+
) -> tuple[bool, str | None]:
|
|
310
444
|
"""Validate configuration for any adapter type.
|
|
311
445
|
|
|
312
446
|
Args:
|
|
@@ -350,7 +484,7 @@ class ConfigResolver:
|
|
|
350
484
|
PROJECT_CONFIG_SUBPATH = ".mcp-ticketer" / Path("config.json")
|
|
351
485
|
|
|
352
486
|
def __init__(
|
|
353
|
-
self, project_path:
|
|
487
|
+
self, project_path: Path | None = None, enable_env_discovery: bool = True
|
|
354
488
|
):
|
|
355
489
|
"""Initialize config resolver.
|
|
356
490
|
|
|
@@ -361,8 +495,8 @@ class ConfigResolver:
|
|
|
361
495
|
"""
|
|
362
496
|
self.project_path = project_path or Path.cwd()
|
|
363
497
|
self.enable_env_discovery = enable_env_discovery
|
|
364
|
-
self._project_config:
|
|
365
|
-
self._discovered_config:
|
|
498
|
+
self._project_config: TicketerConfig | None = None
|
|
499
|
+
self._discovered_config: DiscoveryResult | None = None
|
|
366
500
|
|
|
367
501
|
def load_global_config(self) -> TicketerConfig:
|
|
368
502
|
"""Load default configuration (global config loading removed for security).
|
|
@@ -382,8 +516,8 @@ class ConfigResolver:
|
|
|
382
516
|
return default_config
|
|
383
517
|
|
|
384
518
|
def load_project_config(
|
|
385
|
-
self, project_path:
|
|
386
|
-
) ->
|
|
519
|
+
self, project_path: Path | None = None
|
|
520
|
+
) -> TicketerConfig | None:
|
|
387
521
|
"""Load project-specific configuration.
|
|
388
522
|
|
|
389
523
|
Args:
|
|
@@ -424,7 +558,7 @@ class ConfigResolver:
|
|
|
424
558
|
self.save_project_config(config)
|
|
425
559
|
|
|
426
560
|
def save_project_config(
|
|
427
|
-
self, config: TicketerConfig, project_path:
|
|
561
|
+
self, config: TicketerConfig, project_path: Path | None = None
|
|
428
562
|
) -> None:
|
|
429
563
|
"""Save project-specific configuration.
|
|
430
564
|
|
|
@@ -461,8 +595,8 @@ class ConfigResolver:
|
|
|
461
595
|
|
|
462
596
|
def resolve_adapter_config(
|
|
463
597
|
self,
|
|
464
|
-
adapter_name:
|
|
465
|
-
cli_overrides:
|
|
598
|
+
adapter_name: str | None = None,
|
|
599
|
+
cli_overrides: dict[str, Any] | None = None,
|
|
466
600
|
) -> dict[str, Any]:
|
|
467
601
|
"""Resolve adapter configuration with hierarchical precedence.
|
|
468
602
|
|
|
@@ -582,6 +716,12 @@ class ConfigResolver:
|
|
|
582
716
|
overrides["team_id"] = os.getenv("MCP_TICKETER_LINEAR_TEAM_ID")
|
|
583
717
|
if os.getenv("LINEAR_API_KEY"):
|
|
584
718
|
overrides["api_key"] = os.getenv("LINEAR_API_KEY")
|
|
719
|
+
if os.getenv("LINEAR_TEAM_ID"):
|
|
720
|
+
overrides["team_id"] = os.getenv("LINEAR_TEAM_ID")
|
|
721
|
+
if os.getenv("LINEAR_TEAM_KEY"):
|
|
722
|
+
overrides["team_key"] = os.getenv("LINEAR_TEAM_KEY")
|
|
723
|
+
if os.getenv("MCP_TICKETER_LINEAR_TEAM_KEY"):
|
|
724
|
+
overrides["team_key"] = os.getenv("MCP_TICKETER_LINEAR_TEAM_KEY")
|
|
585
725
|
|
|
586
726
|
elif adapter_type == AdapterType.GITHUB.value:
|
|
587
727
|
if os.getenv("MCP_TICKETER_GITHUB_TOKEN"):
|
|
@@ -623,7 +763,7 @@ class ConfigResolver:
|
|
|
623
763
|
|
|
624
764
|
return overrides
|
|
625
765
|
|
|
626
|
-
def get_hybrid_config(self) ->
|
|
766
|
+
def get_hybrid_config(self) -> HybridConfig | None:
|
|
627
767
|
"""Get hybrid mode configuration if enabled.
|
|
628
768
|
|
|
629
769
|
Returns:
|
|
@@ -655,10 +795,10 @@ class ConfigResolver:
|
|
|
655
795
|
|
|
656
796
|
|
|
657
797
|
# Singleton instance for global access
|
|
658
|
-
_default_resolver:
|
|
798
|
+
_default_resolver: ConfigResolver | None = None
|
|
659
799
|
|
|
660
800
|
|
|
661
|
-
def get_config_resolver(project_path:
|
|
801
|
+
def get_config_resolver(project_path: Path | None = None) -> ConfigResolver:
|
|
662
802
|
"""Get the global config resolver instance.
|
|
663
803
|
|
|
664
804
|
Args:
|
|
@@ -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)
|