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
@@ -6,7 +6,7 @@ import os
6
6
  from enum import Enum
7
7
  from functools import lru_cache
8
8
  from pathlib import Path
9
- from typing import Any, Optional, Union
9
+ from typing import Any, Optional
10
10
 
11
11
  import yaml
12
12
  from pydantic import BaseModel, Field, field_validator, model_validator
@@ -27,27 +27,28 @@ class BaseAdapterConfig(BaseModel):
27
27
  """Base configuration for all adapters."""
28
28
 
29
29
  type: AdapterType
30
- name: Optional[str] = None
30
+ name: str | None = None
31
31
  enabled: bool = True
32
32
  timeout: float = 30.0
33
33
  max_retries: int = 3
34
- rate_limit: Optional[dict[str, Any]] = None
34
+ rate_limit: dict[str, Any] | None = None
35
35
 
36
36
 
37
37
  class GitHubConfig(BaseAdapterConfig):
38
38
  """GitHub adapter configuration."""
39
39
 
40
40
  type: AdapterType = AdapterType.GITHUB
41
- token: Optional[str] = Field(None, env="GITHUB_TOKEN")
42
- owner: Optional[str] = Field(None, env="GITHUB_OWNER")
43
- repo: Optional[str] = Field(None, env="GITHUB_REPO")
41
+ token: str | None = Field(default=None)
42
+ owner: str | None = Field(default=None)
43
+ repo: str | None = Field(default=None)
44
44
  api_url: str = "https://api.github.com"
45
45
  use_projects_v2: bool = False
46
- custom_priority_scheme: Optional[dict[str, list[str]]] = None
46
+ custom_priority_scheme: dict[str, list[str]] | None = None
47
47
 
48
48
  @field_validator("token", mode="before")
49
49
  @classmethod
50
- def validate_token(cls, v):
50
+ def validate_token(cls, v: Any) -> str:
51
+ """Validate GitHub token from config or environment."""
51
52
  if not v:
52
53
  v = os.getenv("GITHUB_TOKEN")
53
54
  if not v:
@@ -56,7 +57,8 @@ class GitHubConfig(BaseAdapterConfig):
56
57
 
57
58
  @field_validator("owner", mode="before")
58
59
  @classmethod
59
- def validate_owner(cls, v):
60
+ def validate_owner(cls, v: Any) -> str:
61
+ """Validate GitHub repository owner from config or environment."""
60
62
  if not v:
61
63
  v = os.getenv("GITHUB_OWNER")
62
64
  if not v:
@@ -65,7 +67,8 @@ class GitHubConfig(BaseAdapterConfig):
65
67
 
66
68
  @field_validator("repo", mode="before")
67
69
  @classmethod
68
- def validate_repo(cls, v):
70
+ def validate_repo(cls, v: Any) -> str:
71
+ """Validate GitHub repository name from config or environment."""
69
72
  if not v:
70
73
  v = os.getenv("GITHUB_REPO")
71
74
  if not v:
@@ -77,16 +80,17 @@ class JiraConfig(BaseAdapterConfig):
77
80
  """JIRA adapter configuration."""
78
81
 
79
82
  type: AdapterType = AdapterType.JIRA
80
- server: Optional[str] = Field(None, env="JIRA_SERVER")
81
- email: Optional[str] = Field(None, env="JIRA_EMAIL")
82
- api_token: Optional[str] = Field(None, env="JIRA_API_TOKEN")
83
- project_key: Optional[str] = Field(None, env="JIRA_PROJECT_KEY")
83
+ server: str | None = Field(default=None)
84
+ email: str | None = Field(default=None)
85
+ api_token: str | None = Field(default=None)
86
+ project_key: str | None = Field(default=None)
84
87
  cloud: bool = True
85
88
  verify_ssl: bool = True
86
89
 
87
90
  @field_validator("server", mode="before")
88
91
  @classmethod
89
- def validate_server(cls, v):
92
+ def validate_server(cls, v: Any) -> str:
93
+ """Validate JIRA server URL from config or environment."""
90
94
  if not v:
91
95
  v = os.getenv("JIRA_SERVER")
92
96
  if not v:
@@ -95,7 +99,8 @@ class JiraConfig(BaseAdapterConfig):
95
99
 
96
100
  @field_validator("email", mode="before")
97
101
  @classmethod
98
- def validate_email(cls, v):
102
+ def validate_email(cls, v: Any) -> str:
103
+ """Validate JIRA user email from config or environment."""
99
104
  if not v:
100
105
  v = os.getenv("JIRA_EMAIL")
101
106
  if not v:
@@ -104,7 +109,8 @@ class JiraConfig(BaseAdapterConfig):
104
109
 
105
110
  @field_validator("api_token", mode="before")
106
111
  @classmethod
107
- def validate_api_token(cls, v):
112
+ def validate_api_token(cls, v: Any) -> str:
113
+ """Validate JIRA API token from config or environment."""
108
114
  if not v:
109
115
  v = os.getenv("JIRA_API_TOKEN")
110
116
  if not v:
@@ -116,14 +122,23 @@ class LinearConfig(BaseAdapterConfig):
116
122
  """Linear adapter configuration."""
117
123
 
118
124
  type: AdapterType = AdapterType.LINEAR
119
- api_key: Optional[str] = Field(None, env="LINEAR_API_KEY")
120
- workspace: Optional[str] = None
121
- team_key: str
125
+ api_key: str | None = Field(default=None)
126
+ workspace: str | None = None
127
+ team_key: str | None = None # Short team key like "BTA"
128
+ team_id: str | None = None # UUID team identifier
122
129
  api_url: str = "https://api.linear.app/graphql"
123
130
 
131
+ @model_validator(mode="after")
132
+ def validate_team_identifier(self) -> "LinearConfig":
133
+ """Ensure either team_key or team_id is provided."""
134
+ if not self.team_key and not self.team_id:
135
+ raise ValueError("Either team_key or team_id is required")
136
+ return self
137
+
124
138
  @field_validator("api_key", mode="before")
125
139
  @classmethod
126
- def validate_api_key(cls, v):
140
+ def validate_api_key(cls, v: Any) -> str:
141
+ """Validate Linear API key from config or environment."""
127
142
  if not v:
128
143
  v = os.getenv("LINEAR_API_KEY")
129
144
  if not v:
@@ -142,7 +157,7 @@ class QueueConfig(BaseModel):
142
157
  """Queue configuration."""
143
158
 
144
159
  provider: str = "sqlite"
145
- connection_string: Optional[str] = None
160
+ connection_string: str | None = None
146
161
  batch_size: int = 10
147
162
  max_concurrent: int = 5
148
163
  retry_attempts: int = 3
@@ -154,7 +169,7 @@ class LoggingConfig(BaseModel):
154
169
 
155
170
  level: str = "INFO"
156
171
  format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
157
- file: Optional[str] = None
172
+ file: str | None = None
158
173
  max_size: str = "10MB"
159
174
  backup_count: int = 5
160
175
 
@@ -163,15 +178,15 @@ class AppConfig(BaseModel):
163
178
  """Main application configuration."""
164
179
 
165
180
  adapters: dict[
166
- str, Union[GitHubConfig, JiraConfig, LinearConfig, AITrackdownConfig]
181
+ str, GitHubConfig | JiraConfig | LinearConfig | AITrackdownConfig
167
182
  ] = {}
168
183
  queue: QueueConfig = QueueConfig()
169
184
  logging: LoggingConfig = LoggingConfig()
170
185
  cache_ttl: int = 300 # Cache TTL in seconds
171
- default_adapter: Optional[str] = None
186
+ default_adapter: str | None = None
172
187
 
173
188
  @model_validator(mode="after")
174
- def validate_adapters(self):
189
+ def validate_adapters(self) -> "AppConfig":
175
190
  """Validate adapter configurations."""
176
191
  adapters = self.adapters
177
192
 
@@ -188,7 +203,7 @@ class AppConfig(BaseModel):
188
203
 
189
204
  return self
190
205
 
191
- def get_adapter_config(self, adapter_name: str) -> Optional[BaseAdapterConfig]:
206
+ def get_adapter_config(self, adapter_name: str) -> BaseAdapterConfig | None:
192
207
  """Get configuration for a specific adapter."""
193
208
  return self.adapters.get(adapter_name)
194
209
 
@@ -203,7 +218,7 @@ class ConfigurationManager:
203
218
  """Centralized configuration management with caching and validation."""
204
219
 
205
220
  _instance: Optional["ConfigurationManager"] = None
206
- _config: Optional[AppConfig] = None
221
+ _config: AppConfig | None = None
207
222
  _config_file_paths: list[Path] = []
208
223
 
209
224
  def __new__(cls) -> "ConfigurationManager":
@@ -212,7 +227,7 @@ class ConfigurationManager:
212
227
  cls._instance = super().__new__(cls)
213
228
  return cls._instance
214
229
 
215
- def __init__(self):
230
+ def __init__(self) -> None:
216
231
  """Initialize configuration manager."""
217
232
  if not hasattr(self, "_initialized"):
218
233
  self._initialized = True
@@ -258,7 +273,7 @@ class ConfigurationManager:
258
273
  logger.debug("No project-local config files found, will use defaults")
259
274
 
260
275
  @lru_cache(maxsize=1)
261
- def load_config(self, config_file: Optional[Union[str, Path]] = None) -> AppConfig:
276
+ def load_config(self, config_file: str | Path | None = None) -> AppConfig:
262
277
  """Load and validate configuration from file and environment.
263
278
 
264
279
  Args:
@@ -283,6 +298,12 @@ class ConfigurationManager:
283
298
  # Load from first available config file
284
299
  config_data = self._load_config_file(self._config_file_paths[0])
285
300
  logger.info(f"Loaded configuration from: {self._config_file_paths[0]}")
301
+ else:
302
+ # No config file found - use empty config
303
+ config_data = {"adapters": {}, "default_adapter": None}
304
+
305
+ # Use saved configuration only - no automatic discovery
306
+ # Discovery should be done explicitly via 'mcp-ticketer init' or 'mcp-ticketer discover'
286
307
 
287
308
  # Parse adapter configurations
288
309
  if "adapters" in config_data:
@@ -290,13 +311,21 @@ class ConfigurationManager:
290
311
  for name, adapter_config in config_data["adapters"].items():
291
312
  adapter_type = adapter_config.get("type", "").lower()
292
313
 
314
+ # If no type specified, try to infer from adapter name
315
+ if not adapter_type:
316
+ adapter_type = name.lower()
317
+
293
318
  if adapter_type == "github":
319
+ adapter_config["type"] = "github"
294
320
  parsed_adapters[name] = GitHubConfig(**adapter_config)
295
321
  elif adapter_type == "jira":
322
+ adapter_config["type"] = "jira"
296
323
  parsed_adapters[name] = JiraConfig(**adapter_config)
297
324
  elif adapter_type == "linear":
325
+ adapter_config["type"] = "linear"
298
326
  parsed_adapters[name] = LinearConfig(**adapter_config)
299
327
  elif adapter_type == "aitrackdown":
328
+ adapter_config["type"] = "aitrackdown"
300
329
  parsed_adapters[name] = AITrackdownConfig(**adapter_config)
301
330
  else:
302
331
  logger.warning(
@@ -328,13 +357,94 @@ class ConfigurationManager:
328
357
  logger.error(f"Error loading config file {config_path}: {e}")
329
358
  return {}
330
359
 
360
+ def _discover_from_environment(self) -> dict[str, Any]:
361
+ """Discover configuration from environment variables."""
362
+ try:
363
+ from .env_discovery import EnvDiscovery
364
+
365
+ discovery = EnvDiscovery()
366
+ discovered = discovery.discover()
367
+
368
+ if not discovered.adapters:
369
+ logger.info("No adapters discovered from environment variables")
370
+ # Return minimal config with aitrackdown as fallback
371
+ return {
372
+ "adapters": {
373
+ "aitrackdown": {
374
+ "type": "aitrackdown",
375
+ "enabled": True,
376
+ "base_path": str(
377
+ Path.home() / ".mcp-ticketer" / ".aitrackdown"
378
+ ),
379
+ }
380
+ },
381
+ "default_adapter": "aitrackdown",
382
+ }
383
+
384
+ # Convert discovered adapters to config format
385
+ config_data: dict[str, Any] = {"adapters": {}, "default_adapter": None}
386
+
387
+ for adapter in discovered.adapters:
388
+ adapter_config = {"type": adapter.adapter_type, "enabled": True}
389
+
390
+ # Add adapter-specific configuration from discovered config
391
+ adapter_config.update(adapter.config)
392
+
393
+ # Ensure type is set correctly (remove 'adapter' key if present)
394
+ if "adapter" in adapter_config:
395
+ del adapter_config["adapter"]
396
+
397
+ # Use adapter type as the key name
398
+ adapter_name = adapter.adapter_type
399
+ config_data["adapters"][adapter_name] = adapter_config
400
+
401
+ # Set first discovered adapter as default
402
+ if config_data["default_adapter"] is None:
403
+ config_data["default_adapter"] = adapter.adapter_type
404
+
405
+ logger.info(
406
+ f"Discovered {len(config_data['adapters'])} adapter(s) from environment"
407
+ )
408
+ return config_data
409
+
410
+ except ImportError:
411
+ logger.warning(
412
+ "Environment discovery not available, using aitrackdown fallback"
413
+ )
414
+ return {
415
+ "adapters": {
416
+ "aitrackdown": {
417
+ "type": "aitrackdown",
418
+ "enabled": True,
419
+ "base_path": str(
420
+ Path.home() / ".mcp-ticketer" / ".aitrackdown"
421
+ ),
422
+ }
423
+ },
424
+ "default_adapter": "aitrackdown",
425
+ }
426
+ except Exception as e:
427
+ logger.error(f"Environment discovery failed: {e}")
428
+ return {
429
+ "adapters": {
430
+ "aitrackdown": {
431
+ "type": "aitrackdown",
432
+ "enabled": True,
433
+ "base_path": str(
434
+ Path.home() / ".mcp-ticketer" / ".aitrackdown"
435
+ ),
436
+ }
437
+ },
438
+ "default_adapter": "aitrackdown",
439
+ }
440
+
331
441
  def get_config(self) -> AppConfig:
332
442
  """Get the current configuration."""
333
443
  if self._config is None:
334
444
  return self.load_config()
335
445
  return self._config
336
446
 
337
- def get_adapter_config(self, adapter_name: str) -> Optional[BaseAdapterConfig]:
447
+ def get_adapter_config(self, adapter_name: str) -> BaseAdapterConfig | None:
338
448
  """Get configuration for a specific adapter."""
339
449
  config = self.get_config()
340
450
  return config.get_adapter_config(adapter_name)
@@ -354,9 +464,7 @@ class ConfigurationManager:
354
464
  config = self.get_config()
355
465
  return config.logging
356
466
 
357
- def reload_config(
358
- self, config_file: Optional[Union[str, Path]] = None
359
- ) -> AppConfig:
467
+ def reload_config(self, config_file: str | Path | None = None) -> AppConfig:
360
468
  """Reload configuration from file."""
361
469
  # Clear cache
362
470
  self.load_config.cache_clear()
@@ -389,7 +497,7 @@ class ConfigurationManager:
389
497
  self._config_cache[key] = value
390
498
  return value
391
499
 
392
- def create_sample_config(self, output_path: Union[str, Path]) -> None:
500
+ def create_sample_config(self, output_path: str | Path) -> None:
393
501
  """Create a sample configuration file."""
394
502
  sample_config = {
395
503
  "adapters": {
@@ -436,11 +544,11 @@ def get_config() -> AppConfig:
436
544
  return config_manager.get_config()
437
545
 
438
546
 
439
- def get_adapter_config(adapter_name: str) -> Optional[BaseAdapterConfig]:
547
+ def get_adapter_config(adapter_name: str) -> BaseAdapterConfig | None:
440
548
  """Get configuration for a specific adapter."""
441
549
  return config_manager.get_adapter_config(adapter_name)
442
550
 
443
551
 
444
- def reload_config(config_file: Optional[Union[str, Path]] = None) -> AppConfig:
552
+ def reload_config(config_file: str | Path | None = None) -> AppConfig:
445
553
  """Reload the global configuration."""
446
554
  return config_manager.reload_config(config_file)
@@ -6,15 +6,17 @@ environment files, including:
6
6
  - Support for multiple naming conventions
7
7
  - Project information extraction
8
8
  - Security validation
9
+ - 1Password CLI integration for secret references
9
10
  """
10
11
 
11
12
  import logging
12
13
  from dataclasses import dataclass, field
13
14
  from pathlib import Path
14
- from typing import Any, Optional
15
+ from typing import Any
15
16
 
16
17
  from dotenv import dotenv_values
17
18
 
19
+ from .onepassword_secrets import OnePasswordConfig, OnePasswordSecretsLoader
18
20
  from .project_config import AdapterType
19
21
 
20
22
  logger = logging.getLogger(__name__)
@@ -30,8 +32,10 @@ LINEAR_KEY_PATTERNS = [
30
32
 
31
33
  LINEAR_TEAM_PATTERNS = [
32
34
  "LINEAR_TEAM_ID",
35
+ "LINEAR_TEAM_KEY", # Added support for team key (e.g., "BTA")
33
36
  "LINEAR_TEAM",
34
37
  "MCP_TICKETER_LINEAR_TEAM_ID",
38
+ "MCP_TICKETER_LINEAR_TEAM_KEY",
35
39
  ]
36
40
 
37
41
  LINEAR_PROJECT_PATTERNS = [
@@ -123,7 +127,7 @@ class DiscoveryResult:
123
127
  warnings: list[str] = field(default_factory=list)
124
128
  env_files_found: list[str] = field(default_factory=list)
125
129
 
126
- def get_primary_adapter(self) -> Optional[DiscoveredAdapter]:
130
+ def get_primary_adapter(self) -> DiscoveredAdapter | None:
127
131
  """Get the adapter with highest confidence and completeness."""
128
132
  if not self.adapters:
129
133
  return None
@@ -134,7 +138,7 @@ class DiscoveryResult:
134
138
  )
135
139
  return sorted_adapters[0]
136
140
 
137
- def get_adapter_by_type(self, adapter_type: str) -> Optional[DiscoveredAdapter]:
141
+ def get_adapter_by_type(self, adapter_type: str) -> DiscoveredAdapter | None:
138
142
  """Get discovered adapter by type."""
139
143
  for adapter in self.adapters:
140
144
  if adapter.adapter_type == adapter_type:
@@ -153,14 +157,27 @@ class EnvDiscovery:
153
157
  ".env.development",
154
158
  ]
155
159
 
156
- def __init__(self, project_path: Optional[Path] = None):
160
+ def __init__(
161
+ self,
162
+ project_path: Path | None = None,
163
+ enable_1password: bool = True,
164
+ onepassword_config: OnePasswordConfig | None = None,
165
+ ):
157
166
  """Initialize discovery.
158
167
 
159
168
  Args:
160
169
  project_path: Path to project root (defaults to cwd)
170
+ enable_1password: Enable 1Password CLI integration for secret resolution
171
+ onepassword_config: Configuration for 1Password integration
161
172
 
162
173
  """
163
174
  self.project_path = project_path or Path.cwd()
175
+ self.enable_1password = enable_1password
176
+ self.op_loader = (
177
+ OnePasswordSecretsLoader(onepassword_config or OnePasswordConfig())
178
+ if enable_1password
179
+ else None
180
+ )
164
181
 
165
182
  def discover(self) -> DiscoveryResult:
166
183
  """Discover adapter configurations from environment files.
@@ -210,7 +227,11 @@ class EnvDiscovery:
210
227
  return result
211
228
 
212
229
  def _load_env_files(self, result: DiscoveryResult) -> dict[str, str]:
213
- """Load environment variables from files.
230
+ """Load environment variables from files and actual environment.
231
+
232
+ Priority order (highest to lowest):
233
+ 1. .env files (highest priority)
234
+ 2. Environment variables (lowest priority)
214
235
 
215
236
  Args:
216
237
  result: DiscoveryResult to update with found files
@@ -221,12 +242,36 @@ class EnvDiscovery:
221
242
  """
222
243
  merged_env: dict[str, str] = {}
223
244
 
224
- # Load files in reverse order (lowest priority first)
245
+ # First, load from actual environment variables (lowest priority)
246
+ import os
247
+
248
+ actual_env = {k: v for k, v in os.environ.items() if v}
249
+ merged_env.update(actual_env)
250
+ if actual_env:
251
+ result.env_files_found.append("environment")
252
+ logger.debug(f"Loaded {len(actual_env)} variables from environment")
253
+
254
+ # Load files in reverse order (higher priority than environment)
225
255
  for env_file in reversed(self.ENV_FILE_ORDER):
226
256
  file_path = self.project_path / env_file
227
257
  if file_path.exists():
228
258
  try:
229
- env_vars = dotenv_values(file_path)
259
+ # Check if file contains 1Password references and use op loader if available
260
+ if self.op_loader and self.enable_1password:
261
+ content = file_path.read_text(encoding="utf-8")
262
+ if "op://" in content:
263
+ logger.info(
264
+ f"Detected 1Password references in {env_file}, "
265
+ "attempting to resolve..."
266
+ )
267
+ env_vars = self.op_loader.load_secrets_from_env_file(
268
+ file_path
269
+ )
270
+ else:
271
+ env_vars = dotenv_values(file_path)
272
+ else:
273
+ env_vars = dotenv_values(file_path)
274
+
230
275
  # Filter out None values
231
276
  env_vars = {k: v for k, v in env_vars.items() if v is not None}
232
277
  merged_env.update(env_vars)
@@ -240,7 +285,7 @@ class EnvDiscovery:
240
285
 
241
286
  def _find_key_value(
242
287
  self, env_vars: dict[str, str], patterns: list[str]
243
- ) -> Optional[str]:
288
+ ) -> str | None:
244
289
  """Find first matching key value from patterns.
245
290
 
246
291
  Args:
@@ -258,7 +303,7 @@ class EnvDiscovery:
258
303
 
259
304
  def _detect_linear(
260
305
  self, env_vars: dict[str, str], found_in: str
261
- ) -> Optional[DiscoveredAdapter]:
306
+ ) -> DiscoveredAdapter | None:
262
307
  """Detect Linear adapter configuration.
263
308
 
264
309
  Args:
@@ -282,13 +327,19 @@ class EnvDiscovery:
282
327
  missing_fields: list[str] = []
283
328
  confidence = 0.6 # Has API key
284
329
 
285
- # Extract team ID (recommended but not required)
286
- team_id = self._find_key_value(env_vars, LINEAR_TEAM_PATTERNS)
287
- if team_id:
288
- config["team_id"] = team_id
330
+ # Extract team identifier (either team_id or team_key is required)
331
+ team_identifier = self._find_key_value(env_vars, LINEAR_TEAM_PATTERNS)
332
+ if team_identifier:
333
+ # Determine if it's a team_id (UUID format) or team_key (short string)
334
+ if len(team_identifier) > 20 and "-" in team_identifier:
335
+ # Looks like a UUID (team_id)
336
+ config["team_id"] = team_identifier
337
+ else:
338
+ # Looks like a short key (team_key)
339
+ config["team_key"] = team_identifier
289
340
  confidence += 0.3
290
341
  else:
291
- missing_fields.append("team_id (recommended)")
342
+ missing_fields.append("team_id or team_key (required)")
292
343
 
293
344
  # Extract project ID (optional)
294
345
  project_id = self._find_key_value(env_vars, LINEAR_PROJECT_PATTERNS)
@@ -306,7 +357,7 @@ class EnvDiscovery:
306
357
 
307
358
  def _detect_github(
308
359
  self, env_vars: dict[str, str], found_in: str
309
- ) -> Optional[DiscoveredAdapter]:
360
+ ) -> DiscoveredAdapter | None:
310
361
  """Detect GitHub adapter configuration.
311
362
 
312
363
  Args:
@@ -365,7 +416,7 @@ class EnvDiscovery:
365
416
 
366
417
  def _detect_jira(
367
418
  self, env_vars: dict[str, str], found_in: str
368
- ) -> Optional[DiscoveredAdapter]:
419
+ ) -> DiscoveredAdapter | None:
369
420
  """Detect JIRA adapter configuration.
370
421
 
371
422
  Args:
@@ -421,7 +472,7 @@ class EnvDiscovery:
421
472
 
422
473
  def _detect_aitrackdown(
423
474
  self, env_vars: dict[str, str], found_in: str
424
- ) -> Optional[DiscoveredAdapter]:
475
+ ) -> DiscoveredAdapter | None:
425
476
  """Detect AITrackdown adapter configuration.
426
477
 
427
478
  Args:
@@ -434,11 +485,32 @@ class EnvDiscovery:
434
485
  """
435
486
  base_path = self._find_key_value(env_vars, AITRACKDOWN_PATH_PATTERNS)
436
487
 
437
- # Also check if .aitrackdown directory exists
488
+ # Check for explicit MCP_TICKETER_ADAPTER setting
489
+ explicit_adapter = env_vars.get("MCP_TICKETER_ADAPTER")
490
+ if explicit_adapter and explicit_adapter != "aitrackdown":
491
+ # If another adapter is explicitly set, don't detect aitrackdown
492
+ return None
493
+
494
+ # Check if .aitrackdown directory exists
438
495
  aitrackdown_dir = self.project_path / ".aitrackdown"
496
+
497
+ # Only detect aitrackdown if:
498
+ # 1. There's an explicit base_path setting, OR
499
+ # 2. There's a .aitrackdown directory AND no other adapter variables are present
500
+ has_other_adapter_vars = (
501
+ any(key.startswith("LINEAR_") for key in env_vars)
502
+ or any(key.startswith("GITHUB_") for key in env_vars)
503
+ or any(key.startswith("JIRA_") for key in env_vars)
504
+ )
505
+
439
506
  if not base_path and not aitrackdown_dir.exists():
440
507
  return None
441
508
 
509
+ if not base_path and has_other_adapter_vars:
510
+ # Don't detect aitrackdown if other adapter variables are present
511
+ # unless explicitly configured
512
+ return None
513
+
442
514
  config: dict[str, Any] = {
443
515
  "adapter": AdapterType.AITRACKDOWN.value,
444
516
  }
@@ -448,8 +520,15 @@ class EnvDiscovery:
448
520
  else:
449
521
  config["base_path"] = ".aitrackdown"
450
522
 
451
- # AITrackdown has no required external credentials
452
- confidence = 1.0 if aitrackdown_dir.exists() else 0.7
523
+ # Lower confidence when other adapter variables are present
524
+ if has_other_adapter_vars:
525
+ confidence = 0.3 # Low confidence when other adapters are configured
526
+ elif base_path:
527
+ confidence = 1.0 # High confidence when explicitly configured
528
+ elif aitrackdown_dir.exists():
529
+ confidence = 0.8 # Medium confidence when directory exists
530
+ else:
531
+ confidence = 0.5 # Low confidence as fallback
453
532
 
454
533
  return DiscoveredAdapter(
455
534
  adapter_type=AdapterType.AITRACKDOWN.value,
@@ -573,8 +652,8 @@ class EnvDiscovery:
573
652
  return warnings
574
653
 
575
654
 
576
- def discover_config(project_path: Optional[Path] = None) -> DiscoveryResult:
577
- """Convenience function to discover configuration.
655
+ def discover_config(project_path: Path | None = None) -> DiscoveryResult:
656
+ """Discover configuration from environment files.
578
657
 
579
658
  Args:
580
659
  project_path: Path to project root (defaults to cwd)