mcp-ticketer 2.0.1__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 (73) hide show
  1. mcp_ticketer/__version__.py +1 -1
  2. mcp_ticketer/_version_scm.py +1 -0
  3. mcp_ticketer/adapters/aitrackdown.py +122 -0
  4. mcp_ticketer/adapters/asana/adapter.py +121 -0
  5. mcp_ticketer/adapters/github/__init__.py +26 -0
  6. mcp_ticketer/adapters/{github.py → github/adapter.py} +1506 -365
  7. mcp_ticketer/adapters/github/client.py +335 -0
  8. mcp_ticketer/adapters/github/mappers.py +797 -0
  9. mcp_ticketer/adapters/github/queries.py +692 -0
  10. mcp_ticketer/adapters/github/types.py +460 -0
  11. mcp_ticketer/adapters/jira/__init__.py +35 -0
  12. mcp_ticketer/adapters/{jira.py → jira/adapter.py} +250 -678
  13. mcp_ticketer/adapters/jira/client.py +271 -0
  14. mcp_ticketer/adapters/jira/mappers.py +246 -0
  15. mcp_ticketer/adapters/jira/queries.py +216 -0
  16. mcp_ticketer/adapters/jira/types.py +304 -0
  17. mcp_ticketer/adapters/linear/adapter.py +1000 -92
  18. mcp_ticketer/adapters/linear/client.py +91 -1
  19. mcp_ticketer/adapters/linear/mappers.py +107 -0
  20. mcp_ticketer/adapters/linear/queries.py +112 -2
  21. mcp_ticketer/adapters/linear/types.py +50 -10
  22. mcp_ticketer/cli/configure.py +524 -89
  23. mcp_ticketer/cli/install_mcp_server.py +418 -0
  24. mcp_ticketer/cli/main.py +10 -0
  25. mcp_ticketer/cli/mcp_configure.py +177 -49
  26. mcp_ticketer/cli/platform_installer.py +9 -0
  27. mcp_ticketer/cli/setup_command.py +157 -1
  28. mcp_ticketer/cli/ticket_commands.py +443 -81
  29. mcp_ticketer/cli/utils.py +113 -0
  30. mcp_ticketer/core/__init__.py +28 -0
  31. mcp_ticketer/core/adapter.py +367 -1
  32. mcp_ticketer/core/milestone_manager.py +252 -0
  33. mcp_ticketer/core/models.py +345 -0
  34. mcp_ticketer/core/project_utils.py +281 -0
  35. mcp_ticketer/core/project_validator.py +376 -0
  36. mcp_ticketer/core/session_state.py +6 -1
  37. mcp_ticketer/core/state_matcher.py +36 -3
  38. mcp_ticketer/mcp/server/__main__.py +2 -1
  39. mcp_ticketer/mcp/server/routing.py +68 -0
  40. mcp_ticketer/mcp/server/tools/__init__.py +7 -4
  41. mcp_ticketer/mcp/server/tools/attachment_tools.py +3 -1
  42. mcp_ticketer/mcp/server/tools/config_tools.py +233 -35
  43. mcp_ticketer/mcp/server/tools/milestone_tools.py +338 -0
  44. mcp_ticketer/mcp/server/tools/search_tools.py +30 -1
  45. mcp_ticketer/mcp/server/tools/ticket_tools.py +37 -1
  46. mcp_ticketer/queue/queue.py +68 -0
  47. {mcp_ticketer-2.0.1.dist-info → mcp_ticketer-2.2.13.dist-info}/METADATA +33 -3
  48. {mcp_ticketer-2.0.1.dist-info → mcp_ticketer-2.2.13.dist-info}/RECORD +72 -36
  49. mcp_ticketer-2.2.13.dist-info/top_level.txt +2 -0
  50. py_mcp_installer/examples/phase3_demo.py +178 -0
  51. py_mcp_installer/scripts/manage_version.py +54 -0
  52. py_mcp_installer/setup.py +6 -0
  53. py_mcp_installer/src/py_mcp_installer/__init__.py +153 -0
  54. py_mcp_installer/src/py_mcp_installer/command_builder.py +445 -0
  55. py_mcp_installer/src/py_mcp_installer/config_manager.py +541 -0
  56. py_mcp_installer/src/py_mcp_installer/exceptions.py +243 -0
  57. py_mcp_installer/src/py_mcp_installer/installation_strategy.py +617 -0
  58. py_mcp_installer/src/py_mcp_installer/installer.py +656 -0
  59. py_mcp_installer/src/py_mcp_installer/mcp_inspector.py +750 -0
  60. py_mcp_installer/src/py_mcp_installer/platform_detector.py +451 -0
  61. py_mcp_installer/src/py_mcp_installer/platforms/__init__.py +26 -0
  62. py_mcp_installer/src/py_mcp_installer/platforms/claude_code.py +225 -0
  63. py_mcp_installer/src/py_mcp_installer/platforms/codex.py +181 -0
  64. py_mcp_installer/src/py_mcp_installer/platforms/cursor.py +191 -0
  65. py_mcp_installer/src/py_mcp_installer/types.py +222 -0
  66. py_mcp_installer/src/py_mcp_installer/utils.py +463 -0
  67. py_mcp_installer/tests/__init__.py +0 -0
  68. py_mcp_installer/tests/platforms/__init__.py +0 -0
  69. py_mcp_installer/tests/test_platform_detector.py +17 -0
  70. mcp_ticketer-2.0.1.dist-info/top_level.txt +0 -1
  71. {mcp_ticketer-2.0.1.dist-info → mcp_ticketer-2.2.13.dist-info}/WHEEL +0 -0
  72. {mcp_ticketer-2.0.1.dist-info → mcp_ticketer-2.2.13.dist-info}/entry_points.txt +0 -0
  73. {mcp_ticketer-2.0.1.dist-info → mcp_ticketer-2.2.13.dist-info}/licenses/LICENSE +0 -0
@@ -199,7 +199,6 @@ class SemanticStateMatcher:
199
199
  "complete",
200
200
  "finished",
201
201
  "resolved",
202
- "closed",
203
202
  "done done",
204
203
  "done-done",
205
204
  "delivered",
@@ -271,17 +270,51 @@ class SemanticStateMatcher:
271
270
  """Initialize the semantic state matcher.
272
271
 
273
272
  Creates reverse lookup dictionary for O(1) synonym matching.
273
+ Detects and logs duplicate synonyms across states.
274
274
  """
275
+ import logging
276
+
277
+ logger = logging.getLogger(__name__)
278
+
275
279
  # Build reverse lookup: synonym -> (state, is_exact)
276
280
  self._synonym_to_state: dict[str, tuple[TicketState, bool]] = {}
277
281
 
282
+ # Track duplicates for validation
283
+ duplicate_check: dict[str, list[TicketState]] = {}
284
+
278
285
  for state in TicketState:
279
286
  # Add exact state value
280
- self._synonym_to_state[state.value.lower()] = (state, True)
287
+ normalized_value = state.value.lower()
288
+ self._synonym_to_state[normalized_value] = (state, True)
289
+
290
+ if normalized_value not in duplicate_check:
291
+ duplicate_check[normalized_value] = []
292
+ duplicate_check[normalized_value].append(state)
281
293
 
282
294
  # Add all synonyms
283
295
  for synonym in self.STATE_SYNONYMS.get(state, []):
284
- self._synonym_to_state[synonym.lower()] = (state, False)
296
+ normalized_synonym = synonym.lower()
297
+
298
+ # Check for duplicates
299
+ if normalized_synonym in duplicate_check:
300
+ duplicate_check[normalized_synonym].append(state)
301
+ else:
302
+ duplicate_check[normalized_synonym] = [state]
303
+
304
+ self._synonym_to_state[normalized_synonym] = (state, False)
305
+
306
+ # Log warnings for any duplicates found (excluding expected state value duplicates)
307
+ for synonym, states in duplicate_check.items():
308
+ if len(states) > 1:
309
+ # Filter out duplicate state values (they're expected - exact match + synonym)
310
+ unique_states = list(set(states))
311
+ if len(unique_states) > 1:
312
+ logger.warning(
313
+ "Duplicate synonym '%s' found in multiple states: %s. "
314
+ "This may cause non-deterministic behavior.",
315
+ synonym,
316
+ ", ".join(s.value for s in unique_states),
317
+ )
285
318
 
286
319
  def match_state(
287
320
  self,
@@ -43,7 +43,8 @@ def run_server() -> None:
43
43
  os.chdir(project_path)
44
44
  sys.stderr.write(f"[MCP Server] Working directory: {project_path}\n")
45
45
  except OSError as e:
46
- sys.stderr.write(f"Warning: Could not change to project directory: {e}\n")
46
+ sys.stderr.write(f"Error: Could not change to project directory: {e}\n")
47
+ sys.exit(1)
47
48
 
48
49
  # Run the async main function
49
50
  try:
@@ -637,6 +637,74 @@ class TicketRouter:
637
637
  f"Failed to route list_tasks_by_issue operation: {str(e)}"
638
638
  ) from e
639
639
 
640
+ async def validate_project_access(
641
+ self, project_url: str, test_connection: bool = True
642
+ ) -> dict[str, Any]:
643
+ """Validate project URL and test accessibility.
644
+
645
+ This method provides comprehensive validation for project URLs:
646
+ 1. Parses URL to extract platform and project ID
647
+ 2. Validates adapter configuration exists
648
+ 3. Validates adapter credentials
649
+ 4. Optionally tests project accessibility via API
650
+
651
+ Args:
652
+ project_url: Project URL to validate
653
+ test_connection: If True, test actual API connectivity (default: True)
654
+
655
+ Returns:
656
+ Validation result dictionary with:
657
+ - valid (bool): Whether validation passed
658
+ - platform (str): Detected platform
659
+ - project_id (str): Extracted project ID
660
+ - adapter_configured (bool): Whether adapter is configured
661
+ - error (str): Error message if validation failed
662
+ - suggestions (list): Suggested actions to resolve error
663
+
664
+ Examples:
665
+ >>> router = TicketRouter(...)
666
+ >>> result = await router.validate_project_access("https://linear.app/team/project/abc-123")
667
+ >>> if result["valid"]:
668
+ ... print(f"Project {result['project_id']} is accessible")
669
+ ... else:
670
+ ... print(f"Error: {result['error']}")
671
+
672
+ """
673
+ try:
674
+ # Import project validator
675
+ # Create validator (use router's config for consistency)
676
+ from pathlib import Path
677
+
678
+ from ...core.project_validator import ProjectValidator
679
+
680
+ validator = ProjectValidator(project_path=Path.cwd())
681
+
682
+ # Validate project URL
683
+ validation_result = validator.validate_project_url(
684
+ url=project_url, test_connection=test_connection
685
+ )
686
+
687
+ # Convert dataclass to dictionary
688
+ return {
689
+ "valid": validation_result.valid,
690
+ "platform": validation_result.platform,
691
+ "project_id": validation_result.project_id,
692
+ "adapter_configured": validation_result.adapter_configured,
693
+ "adapter_valid": validation_result.adapter_valid,
694
+ "error": validation_result.error,
695
+ "error_type": validation_result.error_type,
696
+ "suggestions": validation_result.suggestions,
697
+ "credential_errors": validation_result.credential_errors,
698
+ }
699
+
700
+ except Exception as e:
701
+ logger.error(f"Project validation failed: {e}")
702
+ return {
703
+ "valid": False,
704
+ "error": f"Validation failed with exception: {str(e)}",
705
+ "error_type": "validation_error",
706
+ }
707
+
640
708
  async def close(self) -> None:
641
709
  """Close all cached adapter connections.
642
710
 
@@ -17,13 +17,14 @@ Modules:
17
17
  label_tools: Label management, normalization, deduplication, and cleanup
18
18
  project_update_tools: Project status update management (1M-238)
19
19
  project_status_tools: Project status analysis and work planning (1M-316)
20
+ milestone_tools: Milestone management and progress tracking (1M-607)
21
+ attachment_tools: File attachment management (ticket_attach, ticket_attachments)
20
22
 
21
23
  Note:
22
24
  instruction_tools: Removed from MCP server (CLI-only as of Phase 2 Sprint 2.3)
23
25
  pr_tools: Removed from MCP server (CLI-only as of Phase 2 Sprint 1.3)
24
- attachment_tools: Removed from MCP server (CLI-only as of Phase 2 Sprint 1.3)
25
26
  These tools are available via CLI commands but not exposed through MCP interface.
26
- Use filesystem MCP for file operations and GitHub MCP for PR management.
27
+ Use GitHub MCP for PR management.
27
28
 
28
29
  """
29
30
 
@@ -31,13 +32,14 @@ Note:
31
32
  # Order matters - import core functionality first
32
33
  from . import (
33
34
  analysis_tools, # noqa: F401
34
- # attachment_tools removed - CLI-only (Phase 2 Sprint 1.3 - use filesystem MCP)
35
+ attachment_tools, # noqa: F401
35
36
  bulk_tools, # noqa: F401
36
37
  comment_tools, # noqa: F401
37
38
  config_tools, # noqa: F401
38
39
  hierarchy_tools, # noqa: F401
39
40
  # instruction_tools removed - CLI-only (Phase 2 Sprint 2.3)
40
41
  label_tools, # noqa: F401
42
+ milestone_tools, # noqa: F401
41
43
  # pr_tools removed - CLI-only (Phase 2 Sprint 1.3 - use GitHub MCP)
42
44
  project_status_tools, # noqa: F401
43
45
  project_update_tools, # noqa: F401
@@ -49,13 +51,14 @@ from . import (
49
51
 
50
52
  __all__ = [
51
53
  "analysis_tools",
52
- # "attachment_tools" removed - CLI-only (Phase 2 Sprint 1.3)
54
+ "attachment_tools",
53
55
  "bulk_tools",
54
56
  "comment_tools",
55
57
  "config_tools",
56
58
  "hierarchy_tools",
57
59
  # "instruction_tools" removed - CLI-only (Phase 2 Sprint 2.3)
58
60
  "label_tools",
61
+ "milestone_tools",
59
62
  # "pr_tools" removed - CLI-only (Phase 2 Sprint 1.3)
60
63
  "project_status_tools",
61
64
  "project_update_tools",
@@ -10,9 +10,10 @@ from pathlib import Path
10
10
  from typing import Any
11
11
 
12
12
  from ....core.models import Comment, TicketType
13
- from ..server_sdk import get_adapter
13
+ from ..server_sdk import get_adapter, mcp
14
14
 
15
15
 
16
+ @mcp.tool()
16
17
  async def ticket_attach(
17
18
  ticket_id: str,
18
19
  file_path: str,
@@ -144,6 +145,7 @@ async def ticket_attach(
144
145
  }
145
146
 
146
147
 
148
+ @mcp.tool()
147
149
  async def ticket_attachments(
148
150
  ticket_id: str,
149
151
  ) -> dict[str, Any]: # Keep as dict for MCP compatibility
@@ -26,6 +26,7 @@ Performance: Configuration is cached in memory by ConfigResolver,
26
26
  so repeated reads are fast (O(1) after first load).
27
27
  """
28
28
 
29
+ import logging
29
30
  import warnings
30
31
  from pathlib import Path
31
32
  from typing import Any
@@ -39,6 +40,8 @@ from ....core.project_config import (
39
40
  from ....core.registry import AdapterRegistry
40
41
  from ..server_sdk import mcp
41
42
 
43
+ logger = logging.getLogger(__name__)
44
+
42
45
 
43
46
  def get_resolver() -> ConfigResolver:
44
47
  """Get or create the configuration resolver.
@@ -57,6 +60,55 @@ def get_resolver() -> ConfigResolver:
57
60
  return ConfigResolver(project_path=Path.cwd())
58
61
 
59
62
 
63
+ def _safe_load_config() -> TicketerConfig:
64
+ """Safely load project configuration, preserving existing adapters.
65
+
66
+ This function prevents data loss when updating config fields by:
67
+ 1. Attempting to load existing configuration
68
+ 2. If file doesn't exist: create new empty config (first-time setup OK)
69
+ 3. If file exists but fails to load: raise error to prevent data wipe
70
+
71
+ Returns:
72
+ Loaded or new TicketerConfig instance
73
+
74
+ Raises:
75
+ RuntimeError: If config file exists but cannot be loaded
76
+
77
+ Design Rationale:
78
+ The pattern `config = resolver.load_project_config() or TicketerConfig()`
79
+ is DANGEROUS because load_project_config() returns None on ANY failure
80
+ (file read error, JSON parse error, etc), which creates an empty config
81
+ and wipes all adapter configurations when saved.
82
+
83
+ This function prevents data loss by explicitly checking if the file
84
+ exists before deciding whether to create a new config.
85
+ """
86
+ resolver = get_resolver()
87
+ config_path = resolver.project_path / resolver.PROJECT_CONFIG_SUBPATH
88
+
89
+ # Try to load existing config
90
+ config = resolver.load_project_config()
91
+
92
+ # If config loaded successfully, return it
93
+ if config is not None:
94
+ return config
95
+
96
+ # Config is None - need to determine if this is first-time setup or an error
97
+ if config_path.exists():
98
+ # File exists but failed to load - this is an error condition
99
+ # DO NOT create empty config and wipe existing data
100
+ raise RuntimeError(
101
+ f"Configuration file exists at {config_path} but failed to load. "
102
+ f"This may indicate a corrupted or invalid JSON file. "
103
+ f"Please check the file manually before retrying. "
104
+ f"To prevent data loss, this operation was aborted."
105
+ )
106
+
107
+ # File doesn't exist - first-time setup, safe to create new config
108
+ logger.info(f"No configuration file found at {config_path}, creating new config")
109
+ return TicketerConfig()
110
+
111
+
60
112
  @mcp.tool()
61
113
  async def config(
62
114
  action: str,
@@ -68,7 +120,9 @@ async def config(
68
120
  credentials: dict[str, Any] | None = None,
69
121
  set_as_default: bool = True,
70
122
  test_connection: bool = True,
71
- **kwargs: Any,
123
+ # Explicitly define optional parameters (previously in **kwargs)
124
+ project_key: str | None = None,
125
+ user_email: str | None = None,
72
126
  ) -> dict[str, Any]:
73
127
  """Unified configuration management tool with action-based routing (v2.0.0).
74
128
 
@@ -79,6 +133,7 @@ async def config(
79
133
  action: Operation to perform. Valid values:
80
134
  - "get": Get current configuration
81
135
  - "set": Set a configuration value (requires key and value)
136
+ - "set_project_from_url": Set default project from URL with validation (requires value=URL)
82
137
  - "validate": Validate all adapter configurations
83
138
  - "test": Test adapter connectivity (requires adapter_name)
84
139
  - "list_adapters": List all available adapters
@@ -93,7 +148,8 @@ async def config(
93
148
  credentials: Adapter credentials dict (for action="setup_wizard")
94
149
  set_as_default: Set adapter as default (for action="setup_wizard", default: True)
95
150
  test_connection: Test connection during setup (for action="setup_wizard", default: True)
96
- **kwargs: Additional parameters passed to underlying functions
151
+ project_key: Project key for JIRA adapter (for action="set" with key="project")
152
+ user_email: User email for adapter-specific user identification (for action="set" with key="user")
97
153
 
98
154
  Returns:
99
155
  Response dict with status and action-specific data
@@ -150,6 +206,16 @@ async def config(
150
206
  # Route based on action
151
207
  if action_lower == "get":
152
208
  return await config_get()
209
+ elif action_lower == "set_project_from_url":
210
+ if value is None:
211
+ return {
212
+ "status": "error",
213
+ "error": "Parameter 'value' (project URL) is required for action='set_project_from_url'",
214
+ "hint": "Use config(action='set_project_from_url', value='https://linear.app/...')",
215
+ }
216
+ return await config_set_project_from_url(
217
+ project_url=str(value), test_connection=test_connection
218
+ )
153
219
  elif action_lower == "set":
154
220
  if key is None:
155
221
  return {
@@ -163,7 +229,15 @@ async def config(
163
229
  "error": "Parameter 'value' is required for action='set'",
164
230
  "hint": "Use config(action='set', key='adapter', value='linear')",
165
231
  }
166
- return await config_set(key=key, value=value, **kwargs)
232
+
233
+ # Build extra params dict from non-None values
234
+ extra_params = {}
235
+ if project_key is not None:
236
+ extra_params["project_key"] = project_key
237
+ if user_email is not None:
238
+ extra_params["user_email"] = user_email
239
+
240
+ return await config_set(key=key, value=value, **extra_params)
167
241
  elif action_lower == "validate":
168
242
  return await config_validate()
169
243
  elif action_lower == "test":
@@ -207,6 +281,7 @@ async def config(
207
281
  valid_actions = [
208
282
  "get",
209
283
  "set",
284
+ "set_project_from_url",
210
285
  "validate",
211
286
  "test",
212
287
  "list_adapters",
@@ -359,9 +434,8 @@ async def config_set_primary_adapter(adapter: str) -> dict[str, Any]:
359
434
  "valid_adapters": valid_adapters,
360
435
  }
361
436
 
362
- # Load current configuration
363
- resolver = get_resolver()
364
- config = resolver.load_project_config() or TicketerConfig()
437
+ # Load current configuration safely (preserves adapters)
438
+ config = _safe_load_config()
365
439
 
366
440
  # Store previous adapter for response
367
441
  previous_adapter = config.default_adapter
@@ -370,6 +444,7 @@ async def config_set_primary_adapter(adapter: str) -> dict[str, Any]:
370
444
  config.default_adapter = adapter.lower()
371
445
 
372
446
  # Save configuration
447
+ resolver = get_resolver()
373
448
  resolver.save_project_config(config)
374
449
 
375
450
  return {
@@ -407,9 +482,8 @@ async def config_set_default_project(
407
482
  stacklevel=2,
408
483
  )
409
484
  try:
410
- # Load current configuration
411
- resolver = get_resolver()
412
- config = resolver.load_project_config() or TicketerConfig()
485
+ # Load current configuration safely (preserves adapters)
486
+ config = _safe_load_config()
413
487
 
414
488
  # Store previous project for response
415
489
  previous_project = config.default_project or config.default_epic
@@ -419,6 +493,7 @@ async def config_set_default_project(
419
493
  config.default_epic = project_id if project_id else None
420
494
 
421
495
  # Save configuration
496
+ resolver = get_resolver()
422
497
  resolver.save_project_config(config)
423
498
 
424
499
  return {
@@ -459,9 +534,8 @@ async def config_set_default_user(
459
534
  stacklevel=2,
460
535
  )
461
536
  try:
462
- # Load current configuration
463
- resolver = get_resolver()
464
- config = resolver.load_project_config() or TicketerConfig()
537
+ # Load current configuration safely (preserves adapters)
538
+ config = _safe_load_config()
465
539
 
466
540
  # Store previous user for response
467
541
  previous_user = config.default_user
@@ -470,6 +544,7 @@ async def config_set_default_user(
470
544
  config.default_user = user_id if user_id else None
471
545
 
472
546
  # Save configuration
547
+ resolver = get_resolver()
473
548
  resolver.save_project_config(config)
474
549
 
475
550
  return {
@@ -576,12 +651,14 @@ async def config_set_default_tags(
576
651
  "error": f"Tag '{tag}' is too long (max 50 characters)",
577
652
  }
578
653
 
579
- # Load current configuration
580
- resolver = get_resolver()
581
- config = resolver.load_project_config() or TicketerConfig()
654
+ # Load current configuration safely (preserves adapters)
655
+ config = _safe_load_config()
582
656
 
583
657
  # Update config
584
658
  config.default_tags = [tag.strip() for tag in tags]
659
+
660
+ # Save configuration
661
+ resolver = get_resolver()
585
662
  resolver.save_project_config(config)
586
663
 
587
664
  return {
@@ -624,15 +701,17 @@ async def config_set_default_team(
624
701
  "error": "Team ID must be at least 1 character",
625
702
  }
626
703
 
627
- # Load current configuration
628
- resolver = get_resolver()
629
- config = resolver.load_project_config() or TicketerConfig()
704
+ # Load current configuration safely (preserves adapters)
705
+ config = _safe_load_config()
630
706
 
631
707
  # Store previous team for response
632
708
  previous_team = config.default_team
633
709
 
634
710
  # Update default team
635
711
  config.default_team = team_id.strip()
712
+
713
+ # Save configuration
714
+ resolver = get_resolver()
636
715
  resolver.save_project_config(config)
637
716
 
638
717
  return {
@@ -676,15 +755,17 @@ async def config_set_default_cycle(
676
755
  "error": "Cycle ID must be at least 1 character",
677
756
  }
678
757
 
679
- # Load current configuration
680
- resolver = get_resolver()
681
- config = resolver.load_project_config() or TicketerConfig()
758
+ # Load current configuration safely (preserves adapters)
759
+ config = _safe_load_config()
682
760
 
683
761
  # Store previous cycle for response
684
762
  previous_cycle = config.default_cycle
685
763
 
686
764
  # Update default cycle
687
765
  config.default_cycle = cycle_id.strip()
766
+
767
+ # Save configuration
768
+ resolver = get_resolver()
688
769
  resolver.save_project_config(config)
689
770
 
690
771
  return {
@@ -727,13 +808,15 @@ async def config_set_default_epic(
727
808
  "error": "Epic/project ID must be at least 2 characters",
728
809
  }
729
810
 
730
- # Load current configuration
731
- resolver = get_resolver()
732
- config = resolver.load_project_config() or TicketerConfig()
811
+ # Load current configuration safely (preserves adapters)
812
+ config = _safe_load_config()
733
813
 
734
814
  # Update config (set both for compatibility)
735
815
  config.default_epic = epic_id.strip()
736
816
  config.default_project = epic_id.strip()
817
+
818
+ # Save configuration
819
+ resolver = get_resolver()
737
820
  resolver.save_project_config(config)
738
821
 
739
822
  return {
@@ -776,10 +859,13 @@ async def config_set_assignment_labels(labels: list[str]) -> dict[str, Any]:
776
859
  "error": f"Invalid label '{label}': must be 2-50 characters",
777
860
  }
778
861
 
779
- resolver = get_resolver()
780
- config = resolver.load_project_config() or TicketerConfig()
862
+ # Load current configuration safely (preserves adapters)
863
+ config = _safe_load_config()
781
864
 
782
865
  config.assignment_labels = labels if labels else None
866
+
867
+ # Save configuration
868
+ resolver = get_resolver()
783
869
  resolver.save_project_config(config)
784
870
 
785
871
  config_path = Path.cwd() / ".mcp-ticketer" / "config.json"
@@ -1292,10 +1378,11 @@ async def config_setup_wizard(
1292
1378
  test_error = None
1293
1379
 
1294
1380
  if test_connection:
1295
- # Save config temporarily for testing
1296
- resolver = get_resolver()
1297
- config = resolver.load_project_config() or TicketerConfig()
1381
+ # Save config temporarily for testing (preserves adapters)
1382
+ config = _safe_load_config()
1298
1383
  config.adapters[adapter_lower] = adapter_config
1384
+
1385
+ resolver = get_resolver()
1299
1386
  resolver.save_project_config(config)
1300
1387
 
1301
1388
  # Test the adapter with enhanced error handling (1M-431)
@@ -1361,18 +1448,20 @@ async def config_setup_wizard(
1361
1448
  ],
1362
1449
  }
1363
1450
  else:
1364
- # Save config without testing
1365
- resolver = get_resolver()
1366
- config = resolver.load_project_config() or TicketerConfig()
1451
+ # Save config without testing (preserves adapters)
1452
+ config = _safe_load_config()
1367
1453
  config.adapters[adapter_lower] = adapter_config
1454
+
1455
+ resolver = get_resolver()
1368
1456
  resolver.save_project_config(config)
1369
1457
 
1370
1458
  # Step 8: Set as default if enabled
1371
1459
  if set_as_default:
1372
- # Update default adapter
1373
- resolver = get_resolver()
1374
- config = resolver.load_project_config() or TicketerConfig()
1460
+ # Update default adapter (preserves adapters)
1461
+ config = _safe_load_config()
1375
1462
  config.default_adapter = adapter_lower
1463
+
1464
+ resolver = get_resolver()
1376
1465
  resolver.save_project_config(config)
1377
1466
 
1378
1467
  # Step 9: Return success
@@ -1398,6 +1487,115 @@ async def config_setup_wizard(
1398
1487
  }
1399
1488
 
1400
1489
 
1490
+ async def config_set_project_from_url(
1491
+ project_url: str,
1492
+ test_connection: bool = True,
1493
+ ) -> dict[str, Any]:
1494
+ """Set default project from URL with comprehensive validation.
1495
+
1496
+ This function provides enhanced project URL handling:
1497
+ 1. Parses project URL to detect platform
1498
+ 2. Validates adapter configuration and credentials
1499
+ 3. Optionally tests project accessibility
1500
+ 4. Sets as default project if all validations pass
1501
+
1502
+ Args:
1503
+ project_url: Project URL from any supported platform
1504
+ test_connection: Test project accessibility (default: True)
1505
+
1506
+ Returns:
1507
+ ConfigResponse with status, platform, project_id, validation details
1508
+
1509
+ Examples:
1510
+ # Set Linear project with validation
1511
+ config_set_project_from_url("https://linear.app/team/project/abc-123")
1512
+
1513
+ # Set GitHub project without connectivity test
1514
+ config_set_project_from_url("https://github.com/owner/repo/projects/1", test_connection=False)
1515
+
1516
+ Error Scenarios:
1517
+ - Invalid URL format: Returns parsing error with format examples
1518
+ - Adapter not configured: Returns setup instructions for platform
1519
+ - Invalid credentials: Returns credential validation errors
1520
+ - Project not accessible: Returns accessibility error with troubleshooting
1521
+
1522
+ """
1523
+ try:
1524
+ # Import project validator
1525
+ from ....core.project_validator import ProjectValidator
1526
+
1527
+ # Create validator
1528
+ validator = ProjectValidator(project_path=Path.cwd())
1529
+
1530
+ # Validate project URL
1531
+ result = validator.validate_project_url(
1532
+ url=project_url, test_connection=test_connection
1533
+ )
1534
+
1535
+ # Check validation result
1536
+ if not result.valid:
1537
+ return {
1538
+ "status": "error",
1539
+ "error": result.error,
1540
+ "error_type": result.error_type,
1541
+ "platform": result.platform,
1542
+ "project_id": result.project_id,
1543
+ "adapter_configured": result.adapter_configured,
1544
+ "adapter_valid": result.adapter_valid,
1545
+ "suggestions": result.suggestions,
1546
+ "credential_errors": result.credential_errors,
1547
+ "adapter_config": result.adapter_config,
1548
+ }
1549
+
1550
+ # Validation passed - set as default project
1551
+ project_id = result.project_id
1552
+ platform = result.platform
1553
+
1554
+ # Load current configuration safely (preserves adapters)
1555
+ config = _safe_load_config()
1556
+
1557
+ # Store previous project for response
1558
+ previous_project = config.default_project or config.default_epic
1559
+
1560
+ # Update default project (and epic for backward compat)
1561
+ config.default_project = project_id
1562
+ config.default_epic = project_id
1563
+
1564
+ # Also update default adapter to match the project's platform
1565
+ previous_adapter = config.default_adapter
1566
+ config.default_adapter = platform
1567
+
1568
+ # Save configuration
1569
+ resolver = get_resolver()
1570
+ resolver.save_project_config(config)
1571
+
1572
+ return {
1573
+ "status": "completed",
1574
+ "message": f"Default project set to '{project_id}' from {platform.title()}",
1575
+ "platform": platform,
1576
+ "project_id": project_id,
1577
+ "project_url": project_url,
1578
+ "previous_project": previous_project,
1579
+ "new_project": project_id,
1580
+ "adapter_changed": previous_adapter != platform,
1581
+ "previous_adapter": previous_adapter,
1582
+ "new_adapter": platform,
1583
+ "validated": True,
1584
+ "connection_tested": test_connection,
1585
+ "config_path": str(resolver.project_path / resolver.PROJECT_CONFIG_SUBPATH),
1586
+ }
1587
+
1588
+ except Exception as e:
1589
+ import traceback
1590
+
1591
+ logger.error(f"Failed to set project from URL: {e}", exc_info=True)
1592
+ return {
1593
+ "status": "error",
1594
+ "error": f"Failed to set project from URL: {str(e)}",
1595
+ "traceback": traceback.format_exc(),
1596
+ }
1597
+
1598
+
1401
1599
  def _mask_sensitive_values(config: dict[str, Any]) -> dict[str, Any]:
1402
1600
  """Mask sensitive values in configuration dictionary.
1403
1601