mcp-ticketer 0.1.30__py3-none-any.whl → 1.2.11__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 (109) hide show
  1. mcp_ticketer/__init__.py +10 -10
  2. mcp_ticketer/__version__.py +3 -3
  3. mcp_ticketer/adapters/__init__.py +2 -0
  4. mcp_ticketer/adapters/aitrackdown.py +796 -46
  5. mcp_ticketer/adapters/asana/__init__.py +15 -0
  6. mcp_ticketer/adapters/asana/adapter.py +1416 -0
  7. mcp_ticketer/adapters/asana/client.py +292 -0
  8. mcp_ticketer/adapters/asana/mappers.py +348 -0
  9. mcp_ticketer/adapters/asana/types.py +146 -0
  10. mcp_ticketer/adapters/github.py +879 -129
  11. mcp_ticketer/adapters/hybrid.py +11 -11
  12. mcp_ticketer/adapters/jira.py +973 -73
  13. mcp_ticketer/adapters/linear/__init__.py +24 -0
  14. mcp_ticketer/adapters/linear/adapter.py +2732 -0
  15. mcp_ticketer/adapters/linear/client.py +344 -0
  16. mcp_ticketer/adapters/linear/mappers.py +420 -0
  17. mcp_ticketer/adapters/linear/queries.py +479 -0
  18. mcp_ticketer/adapters/linear/types.py +360 -0
  19. mcp_ticketer/adapters/linear.py +10 -2315
  20. mcp_ticketer/analysis/__init__.py +23 -0
  21. mcp_ticketer/analysis/orphaned.py +218 -0
  22. mcp_ticketer/analysis/similarity.py +224 -0
  23. mcp_ticketer/analysis/staleness.py +266 -0
  24. mcp_ticketer/cache/memory.py +9 -8
  25. mcp_ticketer/cli/adapter_diagnostics.py +421 -0
  26. mcp_ticketer/cli/auggie_configure.py +116 -15
  27. mcp_ticketer/cli/codex_configure.py +274 -82
  28. mcp_ticketer/cli/configure.py +888 -151
  29. mcp_ticketer/cli/diagnostics.py +400 -157
  30. mcp_ticketer/cli/discover.py +297 -26
  31. mcp_ticketer/cli/gemini_configure.py +119 -26
  32. mcp_ticketer/cli/init_command.py +880 -0
  33. mcp_ticketer/cli/instruction_commands.py +435 -0
  34. mcp_ticketer/cli/linear_commands.py +616 -0
  35. mcp_ticketer/cli/main.py +203 -1165
  36. mcp_ticketer/cli/mcp_configure.py +474 -90
  37. mcp_ticketer/cli/mcp_server_commands.py +415 -0
  38. mcp_ticketer/cli/migrate_config.py +12 -8
  39. mcp_ticketer/cli/platform_commands.py +123 -0
  40. mcp_ticketer/cli/platform_detection.py +418 -0
  41. mcp_ticketer/cli/platform_installer.py +513 -0
  42. mcp_ticketer/cli/python_detection.py +126 -0
  43. mcp_ticketer/cli/queue_commands.py +15 -15
  44. mcp_ticketer/cli/setup_command.py +639 -0
  45. mcp_ticketer/cli/simple_health.py +90 -65
  46. mcp_ticketer/cli/ticket_commands.py +1013 -0
  47. mcp_ticketer/cli/update_checker.py +313 -0
  48. mcp_ticketer/cli/utils.py +114 -66
  49. mcp_ticketer/core/__init__.py +24 -1
  50. mcp_ticketer/core/adapter.py +250 -16
  51. mcp_ticketer/core/config.py +145 -37
  52. mcp_ticketer/core/env_discovery.py +101 -22
  53. mcp_ticketer/core/env_loader.py +349 -0
  54. mcp_ticketer/core/exceptions.py +160 -0
  55. mcp_ticketer/core/http_client.py +26 -26
  56. mcp_ticketer/core/instructions.py +405 -0
  57. mcp_ticketer/core/label_manager.py +732 -0
  58. mcp_ticketer/core/mappers.py +42 -30
  59. mcp_ticketer/core/models.py +280 -28
  60. mcp_ticketer/core/onepassword_secrets.py +379 -0
  61. mcp_ticketer/core/project_config.py +183 -49
  62. mcp_ticketer/core/registry.py +3 -3
  63. mcp_ticketer/core/session_state.py +171 -0
  64. mcp_ticketer/core/state_matcher.py +592 -0
  65. mcp_ticketer/core/url_parser.py +425 -0
  66. mcp_ticketer/core/validators.py +69 -0
  67. mcp_ticketer/defaults/ticket_instructions.md +644 -0
  68. mcp_ticketer/mcp/__init__.py +29 -1
  69. mcp_ticketer/mcp/__main__.py +60 -0
  70. mcp_ticketer/mcp/server/__init__.py +25 -0
  71. mcp_ticketer/mcp/server/__main__.py +60 -0
  72. mcp_ticketer/mcp/server/constants.py +58 -0
  73. mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
  74. mcp_ticketer/mcp/server/dto.py +195 -0
  75. mcp_ticketer/mcp/server/main.py +1343 -0
  76. mcp_ticketer/mcp/server/response_builder.py +206 -0
  77. mcp_ticketer/mcp/server/routing.py +655 -0
  78. mcp_ticketer/mcp/server/server_sdk.py +151 -0
  79. mcp_ticketer/mcp/server/tools/__init__.py +56 -0
  80. mcp_ticketer/mcp/server/tools/analysis_tools.py +495 -0
  81. mcp_ticketer/mcp/server/tools/attachment_tools.py +226 -0
  82. mcp_ticketer/mcp/server/tools/bulk_tools.py +273 -0
  83. mcp_ticketer/mcp/server/tools/comment_tools.py +152 -0
  84. mcp_ticketer/mcp/server/tools/config_tools.py +1439 -0
  85. mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
  86. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +921 -0
  87. mcp_ticketer/mcp/server/tools/instruction_tools.py +300 -0
  88. mcp_ticketer/mcp/server/tools/label_tools.py +948 -0
  89. mcp_ticketer/mcp/server/tools/pr_tools.py +152 -0
  90. mcp_ticketer/mcp/server/tools/search_tools.py +215 -0
  91. mcp_ticketer/mcp/server/tools/session_tools.py +170 -0
  92. mcp_ticketer/mcp/server/tools/ticket_tools.py +1268 -0
  93. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +547 -0
  94. mcp_ticketer/queue/__init__.py +1 -0
  95. mcp_ticketer/queue/health_monitor.py +168 -136
  96. mcp_ticketer/queue/manager.py +95 -25
  97. mcp_ticketer/queue/queue.py +40 -21
  98. mcp_ticketer/queue/run_worker.py +6 -1
  99. mcp_ticketer/queue/ticket_registry.py +213 -155
  100. mcp_ticketer/queue/worker.py +109 -49
  101. mcp_ticketer-1.2.11.dist-info/METADATA +792 -0
  102. mcp_ticketer-1.2.11.dist-info/RECORD +110 -0
  103. mcp_ticketer/mcp/server.py +0 -1895
  104. mcp_ticketer-0.1.30.dist-info/METADATA +0 -413
  105. mcp_ticketer-0.1.30.dist-info/RECORD +0 -49
  106. {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/WHEEL +0 -0
  107. {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/entry_points.txt +0 -0
  108. {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/licenses/LICENSE +0 -0
  109. {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/top_level.txt +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,73 @@ 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
+ def __post_init__(self):
194
+ """Normalize default_project if it's a URL."""
195
+ if self.default_project:
196
+ self.default_project = self._normalize_project_id(self.default_project)
197
+ if self.default_epic:
198
+ self.default_epic = self._normalize_project_id(self.default_epic)
199
+
200
+ def _normalize_project_id(self, value: str) -> str:
201
+ """Normalize project ID by extracting from URL if needed.
202
+
203
+ Args:
204
+ value: Project ID or URL
205
+
206
+ Returns:
207
+ Normalized project ID (plain ID, not URL)
208
+
209
+ Examples:
210
+ >>> config._normalize_project_id("PROJ-123")
211
+ 'PROJ-123'
212
+ >>> config._normalize_project_id("https://linear.app/team/project/abc-123")
213
+ 'abc-123'
214
+
215
+ """
216
+ from .url_parser import is_url, normalize_project_id
217
+
218
+ try:
219
+ # If it's a URL, use auto-detection (don't rely on default_adapter)
220
+ # This allows users to paste URLs from any platform
221
+ if is_url(value):
222
+ normalized = normalize_project_id(value, adapter_type=None)
223
+ else:
224
+ # For plain IDs, just return as-is
225
+ normalized = normalize_project_id(value, self.default_adapter)
226
+
227
+ logger.debug(f"Normalized '{value}' to '{normalized}'")
228
+ return normalized
229
+ except Exception as e:
230
+ # If normalization fails, log warning but keep original value
231
+ logger.warning(f"Failed to normalize project ID '{value}': {e}")
232
+ return value
176
233
 
177
234
  def to_dict(self) -> dict[str, Any]:
178
235
  """Convert to dictionary for JSON serialization."""
179
- return {
236
+ result = {
180
237
  "default_adapter": self.default_adapter,
181
238
  "project_configs": {
182
239
  path: config.to_dict() for path, config in self.project_configs.items()
@@ -186,6 +243,22 @@ class TicketerConfig:
186
243
  },
187
244
  "hybrid_mode": self.hybrid_mode.to_dict() if self.hybrid_mode else None,
188
245
  }
246
+ # Add optional fields if set
247
+ if self.default_user is not None:
248
+ result["default_user"] = self.default_user
249
+ if self.default_project is not None:
250
+ result["default_project"] = self.default_project
251
+ if self.default_epic is not None:
252
+ result["default_epic"] = self.default_epic
253
+ if self.default_tags is not None:
254
+ result["default_tags"] = self.default_tags
255
+ if self.default_team is not None:
256
+ result["default_team"] = self.default_team
257
+ if self.default_cycle is not None:
258
+ result["default_cycle"] = self.default_cycle
259
+ if self.assignment_labels is not None:
260
+ result["assignment_labels"] = self.assignment_labels
261
+ return result
189
262
 
190
263
  @classmethod
191
264
  def from_dict(cls, data: dict[str, Any]) -> "TicketerConfig":
@@ -212,6 +285,13 @@ class TicketerConfig:
212
285
  project_configs=project_configs,
213
286
  adapters=adapters,
214
287
  hybrid_mode=hybrid_mode,
288
+ default_user=data.get("default_user"),
289
+ default_project=data.get("default_project"),
290
+ default_epic=data.get("default_epic"),
291
+ default_tags=data.get("default_tags"),
292
+ default_team=data.get("default_team"),
293
+ default_cycle=data.get("default_cycle"),
294
+ assignment_labels=data.get("assignment_labels"),
215
295
  )
216
296
 
217
297
 
@@ -219,29 +299,77 @@ class ConfigValidator:
219
299
  """Validate adapter configurations."""
220
300
 
221
301
  @staticmethod
222
- def validate_linear_config(config: dict[str, Any]) -> tuple[bool, Optional[str]]:
302
+ def validate_linear_config(config: dict[str, Any]) -> tuple[bool, str | None]:
223
303
  """Validate Linear adapter configuration.
224
304
 
305
+ Args:
306
+ config: Linear configuration dictionary
307
+
225
308
  Returns:
226
309
  Tuple of (is_valid, error_message)
227
310
 
228
311
  """
312
+ import logging
313
+ import re
314
+
315
+ logger = logging.getLogger(__name__)
316
+
229
317
  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}"
318
+ missing_fields = []
319
+
320
+ for field_name in required:
321
+ if field_name not in config or not config[field_name]:
322
+ missing_fields.append(field_name)
233
323
 
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"):
324
+ if missing_fields:
236
325
  return (
237
326
  False,
238
- "Linear config requires either team_key (short key like 'BTA') or team_id (UUID)",
327
+ f"Linear config missing required fields: {', '.join(missing_fields)}",
328
+ )
329
+
330
+ # Require either team_key or team_id (team_key is preferred)
331
+ has_team_key = config.get("team_key") and config["team_key"].strip()
332
+ has_team_id = config.get("team_id") and config["team_id"].strip()
333
+
334
+ if not has_team_key and not has_team_id:
335
+ return (
336
+ False,
337
+ "Linear config requires either team_key (short key like 'ENG') or team_id (UUID)",
338
+ )
339
+
340
+ # Validate team_id format if provided (should be UUID)
341
+ if has_team_id:
342
+ team_id = config["team_id"]
343
+ uuid_pattern = re.compile(
344
+ r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
345
+ re.IGNORECASE,
346
+ )
347
+
348
+ if not uuid_pattern.match(team_id):
349
+ # Not a UUID - could be a team_key mistakenly stored as team_id
350
+ logger.warning(
351
+ f"team_id '{team_id}' is not a UUID format. "
352
+ f"It will be treated as team_key and resolved at runtime."
353
+ )
354
+ # Move it to team_key if team_key is empty
355
+ if not has_team_key:
356
+ config["team_key"] = team_id
357
+ del config["team_id"]
358
+ logger.info(f"Moved non-UUID team_id to team_key: {team_id}")
359
+
360
+ # Validate user_email format if provided
361
+ if config.get("user_email"):
362
+ email = config["user_email"]
363
+ email_pattern = re.compile(
364
+ r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
239
365
  )
366
+ if not email_pattern.match(email):
367
+ return False, f"Invalid email format for user_email: {email}"
240
368
 
241
369
  return True, None
242
370
 
243
371
  @staticmethod
244
- def validate_github_config(config: dict[str, Any]) -> tuple[bool, Optional[str]]:
372
+ def validate_github_config(config: dict[str, Any]) -> tuple[bool, str | None]:
245
373
  """Validate GitHub adapter configuration.
246
374
 
247
375
  Returns:
@@ -263,14 +391,14 @@ class ConfigValidator:
263
391
 
264
392
  # Otherwise need explicit owner and repo
265
393
  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}"
394
+ for field_name in required:
395
+ if field_name not in config or not config[field_name]:
396
+ return False, f"GitHub config missing required field: {field_name}"
269
397
 
270
398
  return True, None
271
399
 
272
400
  @staticmethod
273
- def validate_jira_config(config: dict[str, Any]) -> tuple[bool, Optional[str]]:
401
+ def validate_jira_config(config: dict[str, Any]) -> tuple[bool, str | None]:
274
402
  """Validate JIRA adapter configuration.
275
403
 
276
404
  Returns:
@@ -278,9 +406,9 @@ class ConfigValidator:
278
406
 
279
407
  """
280
408
  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}"
409
+ for field_name in required:
410
+ if field_name not in config or not config[field_name]:
411
+ return False, f"JIRA config missing required field: {field_name}"
284
412
 
285
413
  # Validate server URL format
286
414
  server = config["server"]
@@ -292,7 +420,7 @@ class ConfigValidator:
292
420
  @staticmethod
293
421
  def validate_aitrackdown_config(
294
422
  config: dict[str, Any],
295
- ) -> tuple[bool, Optional[str]]:
423
+ ) -> tuple[bool, str | None]:
296
424
  """Validate AITrackdown adapter configuration.
297
425
 
298
426
  Returns:
@@ -306,7 +434,7 @@ class ConfigValidator:
306
434
  @classmethod
307
435
  def validate(
308
436
  cls, adapter_type: str, config: dict[str, Any]
309
- ) -> tuple[bool, Optional[str]]:
437
+ ) -> tuple[bool, str | None]:
310
438
  """Validate configuration for any adapter type.
311
439
 
312
440
  Args:
@@ -350,7 +478,7 @@ class ConfigResolver:
350
478
  PROJECT_CONFIG_SUBPATH = ".mcp-ticketer" / Path("config.json")
351
479
 
352
480
  def __init__(
353
- self, project_path: Optional[Path] = None, enable_env_discovery: bool = True
481
+ self, project_path: Path | None = None, enable_env_discovery: bool = True
354
482
  ):
355
483
  """Initialize config resolver.
356
484
 
@@ -361,8 +489,8 @@ class ConfigResolver:
361
489
  """
362
490
  self.project_path = project_path or Path.cwd()
363
491
  self.enable_env_discovery = enable_env_discovery
364
- self._project_config: Optional[TicketerConfig] = None
365
- self._discovered_config: Optional[DiscoveryResult] = None
492
+ self._project_config: TicketerConfig | None = None
493
+ self._discovered_config: DiscoveryResult | None = None
366
494
 
367
495
  def load_global_config(self) -> TicketerConfig:
368
496
  """Load default configuration (global config loading removed for security).
@@ -382,8 +510,8 @@ class ConfigResolver:
382
510
  return default_config
383
511
 
384
512
  def load_project_config(
385
- self, project_path: Optional[Path] = None
386
- ) -> Optional[TicketerConfig]:
513
+ self, project_path: Path | None = None
514
+ ) -> TicketerConfig | None:
387
515
  """Load project-specific configuration.
388
516
 
389
517
  Args:
@@ -424,7 +552,7 @@ class ConfigResolver:
424
552
  self.save_project_config(config)
425
553
 
426
554
  def save_project_config(
427
- self, config: TicketerConfig, project_path: Optional[Path] = None
555
+ self, config: TicketerConfig, project_path: Path | None = None
428
556
  ) -> None:
429
557
  """Save project-specific configuration.
430
558
 
@@ -461,8 +589,8 @@ class ConfigResolver:
461
589
 
462
590
  def resolve_adapter_config(
463
591
  self,
464
- adapter_name: Optional[str] = None,
465
- cli_overrides: Optional[dict[str, Any]] = None,
592
+ adapter_name: str | None = None,
593
+ cli_overrides: dict[str, Any] | None = None,
466
594
  ) -> dict[str, Any]:
467
595
  """Resolve adapter configuration with hierarchical precedence.
468
596
 
@@ -582,6 +710,12 @@ class ConfigResolver:
582
710
  overrides["team_id"] = os.getenv("MCP_TICKETER_LINEAR_TEAM_ID")
583
711
  if os.getenv("LINEAR_API_KEY"):
584
712
  overrides["api_key"] = os.getenv("LINEAR_API_KEY")
713
+ if os.getenv("LINEAR_TEAM_ID"):
714
+ overrides["team_id"] = os.getenv("LINEAR_TEAM_ID")
715
+ if os.getenv("LINEAR_TEAM_KEY"):
716
+ overrides["team_key"] = os.getenv("LINEAR_TEAM_KEY")
717
+ if os.getenv("MCP_TICKETER_LINEAR_TEAM_KEY"):
718
+ overrides["team_key"] = os.getenv("MCP_TICKETER_LINEAR_TEAM_KEY")
585
719
 
586
720
  elif adapter_type == AdapterType.GITHUB.value:
587
721
  if os.getenv("MCP_TICKETER_GITHUB_TOKEN"):
@@ -623,7 +757,7 @@ class ConfigResolver:
623
757
 
624
758
  return overrides
625
759
 
626
- def get_hybrid_config(self) -> Optional[HybridConfig]:
760
+ def get_hybrid_config(self) -> HybridConfig | None:
627
761
  """Get hybrid mode configuration if enabled.
628
762
 
629
763
  Returns:
@@ -655,10 +789,10 @@ class ConfigResolver:
655
789
 
656
790
 
657
791
  # Singleton instance for global access
658
- _default_resolver: Optional[ConfigResolver] = None
792
+ _default_resolver: ConfigResolver | None = None
659
793
 
660
794
 
661
- def get_config_resolver(project_path: Optional[Path] = None) -> ConfigResolver:
795
+ def get_config_resolver(project_path: Path | None = None) -> ConfigResolver:
662
796
  """Get the global config resolver instance.
663
797
 
664
798
  Args:
@@ -1,6 +1,6 @@
1
1
  """Adapter registry for dynamic adapter management."""
2
2
 
3
- from typing import Any, Optional
3
+ from typing import Any
4
4
 
5
5
  from .adapter import BaseAdapter
6
6
 
@@ -37,7 +37,7 @@ class AdapterRegistry:
37
37
 
38
38
  @classmethod
39
39
  def get_adapter(
40
- cls, name: str, config: Optional[dict[str, Any]] = None, force_new: bool = False
40
+ cls, name: str, config: dict[str, Any] | None = None, force_new: bool = False
41
41
  ) -> BaseAdapter:
42
42
  """Get or create an adapter instance.
43
43
 
@@ -115,7 +115,7 @@ class AdapterRegistry:
115
115
 
116
116
 
117
117
  def adapter_factory(adapter_type: str, config: dict[str, Any]) -> BaseAdapter:
118
- """Factory function for creating adapters.
118
+ """Create adapter instance using factory pattern.
119
119
 
120
120
  Args:
121
121
  adapter_type: Type of adapter to create
@@ -0,0 +1,171 @@
1
+ """Session state management for tracking current ticket associations."""
2
+
3
+ import json
4
+ import logging
5
+ import uuid
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime, timedelta
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # Session timeout: 30 minutes of inactivity
14
+ SESSION_TIMEOUT_MINUTES = 30
15
+ SESSION_STATE_FILE = ".mcp-ticketer/session.json"
16
+
17
+
18
+ @dataclass
19
+ class SessionState:
20
+ """Track session-specific state for ticket associations."""
21
+
22
+ session_id: str = field(default_factory=lambda: str(uuid.uuid4()))
23
+ current_ticket: str | None = None # Current ticket ID
24
+ ticket_opted_out: bool = False # User explicitly chose "none"
25
+ last_activity: str = field(default_factory=lambda: datetime.now().isoformat())
26
+
27
+ def to_dict(self) -> dict[str, Any]:
28
+ """Serialize to dictionary."""
29
+ return {
30
+ "session_id": self.session_id,
31
+ "current_ticket": self.current_ticket,
32
+ "ticket_opted_out": self.ticket_opted_out,
33
+ "last_activity": self.last_activity,
34
+ }
35
+
36
+ @classmethod
37
+ def from_dict(cls, data: dict[str, Any]) -> "SessionState":
38
+ """Deserialize from dictionary."""
39
+ return cls(
40
+ session_id=data.get("session_id", str(uuid.uuid4())),
41
+ current_ticket=data.get("current_ticket"),
42
+ ticket_opted_out=data.get("ticket_opted_out", False),
43
+ last_activity=data.get("last_activity", datetime.now().isoformat()),
44
+ )
45
+
46
+ def is_expired(self) -> bool:
47
+ """Check if session has expired due to inactivity."""
48
+ try:
49
+ last_activity = datetime.fromisoformat(self.last_activity)
50
+ timeout = timedelta(minutes=SESSION_TIMEOUT_MINUTES)
51
+ return datetime.now() - last_activity > timeout
52
+ except (ValueError, TypeError):
53
+ # Invalid timestamp, consider expired
54
+ return True
55
+
56
+ def touch(self) -> None:
57
+ """Update last activity timestamp."""
58
+ self.last_activity = datetime.now().isoformat()
59
+
60
+
61
+ class SessionStateManager:
62
+ """Manage session state persistence and lifecycle."""
63
+
64
+ def __init__(self, project_path: Path | None = None):
65
+ """Initialize session state manager.
66
+
67
+ Args:
68
+ project_path: Project root directory (defaults to current directory)
69
+
70
+ """
71
+ self.project_path = project_path or Path.cwd()
72
+ self.state_file = self.project_path / SESSION_STATE_FILE
73
+
74
+ def load_session(self) -> SessionState:
75
+ """Load session state from file.
76
+
77
+ Returns:
78
+ SessionState instance (creates new if expired or not found)
79
+
80
+ """
81
+ if not self.state_file.exists():
82
+ logger.debug("No session state file found, creating new session")
83
+ return SessionState()
84
+
85
+ try:
86
+ with open(self.state_file) as f:
87
+ data = json.load(f)
88
+
89
+ state = SessionState.from_dict(data)
90
+
91
+ # Check if session expired
92
+ if state.is_expired():
93
+ logger.info(
94
+ f"Session {state.session_id} expired after "
95
+ f"{SESSION_TIMEOUT_MINUTES} minutes, creating new session"
96
+ )
97
+ return SessionState()
98
+
99
+ # Touch to update activity
100
+ state.touch()
101
+ return state
102
+
103
+ except (json.JSONDecodeError, FileNotFoundError, KeyError) as e:
104
+ logger.warning(f"Failed to load session state: {e}, creating new session")
105
+ return SessionState()
106
+
107
+ def save_session(self, state: SessionState) -> None:
108
+ """Save session state to file.
109
+
110
+ Args:
111
+ state: SessionState to persist
112
+
113
+ """
114
+ try:
115
+ # Ensure directory exists
116
+ self.state_file.parent.mkdir(parents=True, exist_ok=True)
117
+
118
+ # Touch before saving
119
+ state.touch()
120
+
121
+ # Write state
122
+ with open(self.state_file, "w") as f:
123
+ json.dump(state.to_dict(), f, indent=2)
124
+
125
+ logger.debug(f"Saved session state: session_id={state.session_id}")
126
+
127
+ except Exception as e:
128
+ logger.error(f"Failed to save session state: {e}")
129
+
130
+ def clear_session(self) -> None:
131
+ """Clear session state (delete file)."""
132
+ try:
133
+ if self.state_file.exists():
134
+ self.state_file.unlink()
135
+ logger.info("Session state cleared")
136
+ except Exception as e:
137
+ logger.error(f"Failed to clear session state: {e}")
138
+
139
+ def get_current_ticket(self) -> str | None:
140
+ """Get current ticket for this session (convenience method).
141
+
142
+ Returns:
143
+ Current ticket ID or None
144
+
145
+ """
146
+ state = self.load_session()
147
+
148
+ # If user opted out, return None
149
+ if state.ticket_opted_out:
150
+ return None
151
+
152
+ return state.current_ticket
153
+
154
+ def set_current_ticket(self, ticket_id: str | None) -> None:
155
+ """Set current ticket for this session (convenience method).
156
+
157
+ Args:
158
+ ticket_id: Ticket ID to set (None to clear)
159
+
160
+ """
161
+ state = self.load_session()
162
+ state.current_ticket = ticket_id
163
+ state.ticket_opted_out = False # Clear opt-out when setting ticket
164
+ self.save_session(state)
165
+
166
+ def opt_out_ticket(self) -> None:
167
+ """Mark that user doesn't want to associate work with a ticket (convenience method)."""
168
+ state = self.load_session()
169
+ state.current_ticket = None
170
+ state.ticket_opted_out = True
171
+ self.save_session(state)