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.
Files changed (160) hide show
  1. mcp_ticketer/__init__.py +10 -10
  2. mcp_ticketer/__version__.py +3 -3
  3. mcp_ticketer/_version_scm.py +1 -0
  4. mcp_ticketer/adapters/__init__.py +2 -0
  5. mcp_ticketer/adapters/aitrackdown.py +930 -52
  6. mcp_ticketer/adapters/asana/__init__.py +15 -0
  7. mcp_ticketer/adapters/asana/adapter.py +1537 -0
  8. mcp_ticketer/adapters/asana/client.py +292 -0
  9. mcp_ticketer/adapters/asana/mappers.py +348 -0
  10. mcp_ticketer/adapters/asana/types.py +146 -0
  11. mcp_ticketer/adapters/github/__init__.py +26 -0
  12. mcp_ticketer/adapters/github/adapter.py +3229 -0
  13. mcp_ticketer/adapters/github/client.py +335 -0
  14. mcp_ticketer/adapters/github/mappers.py +797 -0
  15. mcp_ticketer/adapters/github/queries.py +692 -0
  16. mcp_ticketer/adapters/github/types.py +460 -0
  17. mcp_ticketer/adapters/hybrid.py +58 -16
  18. mcp_ticketer/adapters/jira/__init__.py +35 -0
  19. mcp_ticketer/adapters/jira/adapter.py +1351 -0
  20. mcp_ticketer/adapters/jira/client.py +271 -0
  21. mcp_ticketer/adapters/jira/mappers.py +246 -0
  22. mcp_ticketer/adapters/jira/queries.py +216 -0
  23. mcp_ticketer/adapters/jira/types.py +304 -0
  24. mcp_ticketer/adapters/linear/__init__.py +1 -1
  25. mcp_ticketer/adapters/linear/adapter.py +3810 -462
  26. mcp_ticketer/adapters/linear/client.py +312 -69
  27. mcp_ticketer/adapters/linear/mappers.py +305 -85
  28. mcp_ticketer/adapters/linear/queries.py +317 -17
  29. mcp_ticketer/adapters/linear/types.py +187 -64
  30. mcp_ticketer/adapters/linear.py +2 -2
  31. mcp_ticketer/analysis/__init__.py +56 -0
  32. mcp_ticketer/analysis/dependency_graph.py +255 -0
  33. mcp_ticketer/analysis/health_assessment.py +304 -0
  34. mcp_ticketer/analysis/orphaned.py +218 -0
  35. mcp_ticketer/analysis/project_status.py +594 -0
  36. mcp_ticketer/analysis/similarity.py +224 -0
  37. mcp_ticketer/analysis/staleness.py +266 -0
  38. mcp_ticketer/automation/__init__.py +11 -0
  39. mcp_ticketer/automation/project_updates.py +378 -0
  40. mcp_ticketer/cache/memory.py +9 -8
  41. mcp_ticketer/cli/adapter_diagnostics.py +421 -0
  42. mcp_ticketer/cli/auggie_configure.py +116 -15
  43. mcp_ticketer/cli/codex_configure.py +274 -82
  44. mcp_ticketer/cli/configure.py +1323 -151
  45. mcp_ticketer/cli/cursor_configure.py +314 -0
  46. mcp_ticketer/cli/diagnostics.py +209 -114
  47. mcp_ticketer/cli/discover.py +297 -26
  48. mcp_ticketer/cli/gemini_configure.py +119 -26
  49. mcp_ticketer/cli/init_command.py +880 -0
  50. mcp_ticketer/cli/install_mcp_server.py +418 -0
  51. mcp_ticketer/cli/instruction_commands.py +435 -0
  52. mcp_ticketer/cli/linear_commands.py +256 -130
  53. mcp_ticketer/cli/main.py +140 -1284
  54. mcp_ticketer/cli/mcp_configure.py +1013 -100
  55. mcp_ticketer/cli/mcp_server_commands.py +415 -0
  56. mcp_ticketer/cli/migrate_config.py +12 -8
  57. mcp_ticketer/cli/platform_commands.py +123 -0
  58. mcp_ticketer/cli/platform_detection.py +477 -0
  59. mcp_ticketer/cli/platform_installer.py +545 -0
  60. mcp_ticketer/cli/project_update_commands.py +350 -0
  61. mcp_ticketer/cli/python_detection.py +126 -0
  62. mcp_ticketer/cli/queue_commands.py +15 -15
  63. mcp_ticketer/cli/setup_command.py +794 -0
  64. mcp_ticketer/cli/simple_health.py +84 -59
  65. mcp_ticketer/cli/ticket_commands.py +1375 -0
  66. mcp_ticketer/cli/update_checker.py +313 -0
  67. mcp_ticketer/cli/utils.py +195 -72
  68. mcp_ticketer/core/__init__.py +64 -1
  69. mcp_ticketer/core/adapter.py +618 -18
  70. mcp_ticketer/core/config.py +77 -68
  71. mcp_ticketer/core/env_discovery.py +75 -16
  72. mcp_ticketer/core/env_loader.py +121 -97
  73. mcp_ticketer/core/exceptions.py +32 -24
  74. mcp_ticketer/core/http_client.py +26 -26
  75. mcp_ticketer/core/instructions.py +405 -0
  76. mcp_ticketer/core/label_manager.py +732 -0
  77. mcp_ticketer/core/mappers.py +42 -30
  78. mcp_ticketer/core/milestone_manager.py +252 -0
  79. mcp_ticketer/core/models.py +566 -19
  80. mcp_ticketer/core/onepassword_secrets.py +379 -0
  81. mcp_ticketer/core/priority_matcher.py +463 -0
  82. mcp_ticketer/core/project_config.py +189 -49
  83. mcp_ticketer/core/project_utils.py +281 -0
  84. mcp_ticketer/core/project_validator.py +376 -0
  85. mcp_ticketer/core/registry.py +3 -3
  86. mcp_ticketer/core/session_state.py +176 -0
  87. mcp_ticketer/core/state_matcher.py +592 -0
  88. mcp_ticketer/core/url_parser.py +425 -0
  89. mcp_ticketer/core/validators.py +69 -0
  90. mcp_ticketer/defaults/ticket_instructions.md +644 -0
  91. mcp_ticketer/mcp/__init__.py +29 -1
  92. mcp_ticketer/mcp/__main__.py +60 -0
  93. mcp_ticketer/mcp/server/__init__.py +25 -0
  94. mcp_ticketer/mcp/server/__main__.py +60 -0
  95. mcp_ticketer/mcp/server/constants.py +58 -0
  96. mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
  97. mcp_ticketer/mcp/server/dto.py +195 -0
  98. mcp_ticketer/mcp/server/main.py +1343 -0
  99. mcp_ticketer/mcp/server/response_builder.py +206 -0
  100. mcp_ticketer/mcp/server/routing.py +723 -0
  101. mcp_ticketer/mcp/server/server_sdk.py +151 -0
  102. mcp_ticketer/mcp/server/tools/__init__.py +69 -0
  103. mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
  104. mcp_ticketer/mcp/server/tools/attachment_tools.py +224 -0
  105. mcp_ticketer/mcp/server/tools/bulk_tools.py +330 -0
  106. mcp_ticketer/mcp/server/tools/comment_tools.py +152 -0
  107. mcp_ticketer/mcp/server/tools/config_tools.py +1564 -0
  108. mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
  109. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +942 -0
  110. mcp_ticketer/mcp/server/tools/instruction_tools.py +295 -0
  111. mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
  112. mcp_ticketer/mcp/server/tools/milestone_tools.py +338 -0
  113. mcp_ticketer/mcp/server/tools/pr_tools.py +150 -0
  114. mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
  115. mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
  116. mcp_ticketer/mcp/server/tools/search_tools.py +318 -0
  117. mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
  118. mcp_ticketer/mcp/server/tools/ticket_tools.py +1413 -0
  119. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +364 -0
  120. mcp_ticketer/queue/__init__.py +1 -0
  121. mcp_ticketer/queue/health_monitor.py +168 -136
  122. mcp_ticketer/queue/manager.py +78 -63
  123. mcp_ticketer/queue/queue.py +108 -21
  124. mcp_ticketer/queue/run_worker.py +2 -2
  125. mcp_ticketer/queue/ticket_registry.py +213 -155
  126. mcp_ticketer/queue/worker.py +96 -58
  127. mcp_ticketer/utils/__init__.py +5 -0
  128. mcp_ticketer/utils/token_utils.py +246 -0
  129. mcp_ticketer-2.2.9.dist-info/METADATA +1396 -0
  130. mcp_ticketer-2.2.9.dist-info/RECORD +158 -0
  131. mcp_ticketer-2.2.9.dist-info/top_level.txt +2 -0
  132. py_mcp_installer/examples/phase3_demo.py +178 -0
  133. py_mcp_installer/scripts/manage_version.py +54 -0
  134. py_mcp_installer/setup.py +6 -0
  135. py_mcp_installer/src/py_mcp_installer/__init__.py +153 -0
  136. py_mcp_installer/src/py_mcp_installer/command_builder.py +445 -0
  137. py_mcp_installer/src/py_mcp_installer/config_manager.py +541 -0
  138. py_mcp_installer/src/py_mcp_installer/exceptions.py +243 -0
  139. py_mcp_installer/src/py_mcp_installer/installation_strategy.py +617 -0
  140. py_mcp_installer/src/py_mcp_installer/installer.py +656 -0
  141. py_mcp_installer/src/py_mcp_installer/mcp_inspector.py +750 -0
  142. py_mcp_installer/src/py_mcp_installer/platform_detector.py +451 -0
  143. py_mcp_installer/src/py_mcp_installer/platforms/__init__.py +26 -0
  144. py_mcp_installer/src/py_mcp_installer/platforms/claude_code.py +225 -0
  145. py_mcp_installer/src/py_mcp_installer/platforms/codex.py +181 -0
  146. py_mcp_installer/src/py_mcp_installer/platforms/cursor.py +191 -0
  147. py_mcp_installer/src/py_mcp_installer/types.py +222 -0
  148. py_mcp_installer/src/py_mcp_installer/utils.py +463 -0
  149. py_mcp_installer/tests/__init__.py +0 -0
  150. py_mcp_installer/tests/platforms/__init__.py +0 -0
  151. py_mcp_installer/tests/test_platform_detector.py +17 -0
  152. mcp_ticketer/adapters/github.py +0 -1354
  153. mcp_ticketer/adapters/jira.py +0 -1011
  154. mcp_ticketer/mcp/server.py +0 -1895
  155. mcp_ticketer-0.2.0.dist-info/METADATA +0 -414
  156. mcp_ticketer-0.2.0.dist-info/RECORD +0 -58
  157. mcp_ticketer-0.2.0.dist-info/top_level.txt +0 -1
  158. {mcp_ticketer-0.2.0.dist-info → mcp_ticketer-2.2.9.dist-info}/WHEEL +0 -0
  159. {mcp_ticketer-0.2.0.dist-info → mcp_ticketer-2.2.9.dist-info}/entry_points.txt +0 -0
  160. {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, TYPE_CHECKING
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: Optional[str] = None
51
- token: Optional[str] = None
50
+ api_key: str | None = None
51
+ token: str | None = None
52
52
 
53
53
  # Linear-specific
54
- team_id: Optional[str] = None
55
- team_key: Optional[str] = None
56
- workspace: Optional[str] = None
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: Optional[str] = None
60
- email: Optional[str] = None
61
- api_token: Optional[str] = None
62
- project_key: Optional[str] = None
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: Optional[str] = None
66
- repo: Optional[str] = None
65
+ owner: str | None = None
66
+ repo: str | None = None
67
67
 
68
68
  # AITrackdown-specific
69
- base_path: Optional[str] = None
69
+ base_path: str | None = None
70
70
 
71
71
  # Project ID (can be used by any adapter for scoping)
72
- project_id: Optional[str] = None
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: Optional[str] = None
130
- project_id: Optional[str] = None
131
- team_id: Optional[str] = None
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: Optional[str] = None
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: Optional[HybridConfig] = None
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
- return {
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, Optional[str]]:
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
- for field in required:
231
- if field not in config or not config[field]:
232
- return False, f"Linear config missing required field: {field}"
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
- # Require either team_key or team_id (team_id is preferred)
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 requires either team_key (short key like 'BTA') or team_id (UUID)",
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, Optional[str]]:
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 field in required:
267
- if field not in config or not config[field]:
268
- return False, f"GitHub config missing required field: {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, Optional[str]]:
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 field in required:
282
- if field not in config or not config[field]:
283
- return False, f"JIRA config missing required field: {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, Optional[str]]:
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, Optional[str]]:
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: Optional[Path] = None, enable_env_discovery: bool = True
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: Optional[TicketerConfig] = None
365
- self._discovered_config: Optional[DiscoveryResult] = None
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: Optional[Path] = None
386
- ) -> Optional[TicketerConfig]:
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: Optional[Path] = None
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: Optional[str] = None,
465
- cli_overrides: Optional[dict[str, Any]] = None,
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) -> Optional[HybridConfig]:
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: Optional[ConfigResolver] = None
798
+ _default_resolver: ConfigResolver | None = None
659
799
 
660
800
 
661
- def get_config_resolver(project_path: Optional[Path] = None) -> ConfigResolver:
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)