mcp-ticketer 0.1.20__py3-none-any.whl → 0.1.22__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 (42) hide show
  1. mcp_ticketer/__init__.py +7 -7
  2. mcp_ticketer/__version__.py +4 -2
  3. mcp_ticketer/adapters/__init__.py +4 -4
  4. mcp_ticketer/adapters/aitrackdown.py +54 -38
  5. mcp_ticketer/adapters/github.py +175 -109
  6. mcp_ticketer/adapters/hybrid.py +90 -45
  7. mcp_ticketer/adapters/jira.py +139 -130
  8. mcp_ticketer/adapters/linear.py +374 -225
  9. mcp_ticketer/cache/__init__.py +1 -1
  10. mcp_ticketer/cache/memory.py +14 -15
  11. mcp_ticketer/cli/__init__.py +1 -1
  12. mcp_ticketer/cli/configure.py +69 -93
  13. mcp_ticketer/cli/discover.py +43 -35
  14. mcp_ticketer/cli/main.py +250 -293
  15. mcp_ticketer/cli/mcp_configure.py +39 -15
  16. mcp_ticketer/cli/migrate_config.py +10 -12
  17. mcp_ticketer/cli/queue_commands.py +21 -58
  18. mcp_ticketer/cli/utils.py +115 -60
  19. mcp_ticketer/core/__init__.py +2 -2
  20. mcp_ticketer/core/adapter.py +36 -30
  21. mcp_ticketer/core/config.py +113 -77
  22. mcp_ticketer/core/env_discovery.py +51 -19
  23. mcp_ticketer/core/http_client.py +46 -29
  24. mcp_ticketer/core/mappers.py +79 -35
  25. mcp_ticketer/core/models.py +29 -15
  26. mcp_ticketer/core/project_config.py +131 -66
  27. mcp_ticketer/core/registry.py +12 -12
  28. mcp_ticketer/mcp/__init__.py +1 -1
  29. mcp_ticketer/mcp/server.py +183 -129
  30. mcp_ticketer/queue/__init__.py +2 -2
  31. mcp_ticketer/queue/__main__.py +1 -1
  32. mcp_ticketer/queue/manager.py +29 -25
  33. mcp_ticketer/queue/queue.py +144 -82
  34. mcp_ticketer/queue/run_worker.py +2 -3
  35. mcp_ticketer/queue/worker.py +48 -33
  36. {mcp_ticketer-0.1.20.dist-info → mcp_ticketer-0.1.22.dist-info}/METADATA +1 -1
  37. mcp_ticketer-0.1.22.dist-info/RECORD +42 -0
  38. mcp_ticketer-0.1.20.dist-info/RECORD +0 -42
  39. {mcp_ticketer-0.1.20.dist-info → mcp_ticketer-0.1.22.dist-info}/WHEEL +0 -0
  40. {mcp_ticketer-0.1.20.dist-info → mcp_ticketer-0.1.22.dist-info}/entry_points.txt +0 -0
  41. {mcp_ticketer-0.1.20.dist-info → mcp_ticketer-0.1.22.dist-info}/licenses/LICENSE +0 -0
  42. {mcp_ticketer-0.1.20.dist-info → mcp_ticketer-0.1.22.dist-info}/top_level.txt +0 -0
@@ -1,20 +1,22 @@
1
1
  """Centralized configuration management with caching and validation."""
2
2
 
3
- import os
4
3
  import json
5
4
  import logging
6
- from typing import Dict, Any, Optional, List, Union
7
- from pathlib import Path
5
+ import os
6
+ from enum import Enum
8
7
  from functools import lru_cache
8
+ from pathlib import Path
9
+ from typing import Any, Dict, List, Optional, Union
10
+
9
11
  import yaml
10
- from pydantic import BaseModel, Field, validator, root_validator
11
- from enum import Enum
12
+ from pydantic import BaseModel, Field, root_validator, validator
12
13
 
13
14
  logger = logging.getLogger(__name__)
14
15
 
15
16
 
16
17
  class AdapterType(str, Enum):
17
18
  """Supported adapter types."""
19
+
18
20
  GITHUB = "github"
19
21
  JIRA = "jira"
20
22
  LINEAR = "linear"
@@ -23,6 +25,7 @@ class AdapterType(str, Enum):
23
25
 
24
26
  class BaseAdapterConfig(BaseModel):
25
27
  """Base configuration for all adapters."""
28
+
26
29
  type: AdapterType
27
30
  name: Optional[str] = None
28
31
  enabled: bool = True
@@ -33,99 +36,104 @@ class BaseAdapterConfig(BaseModel):
33
36
 
34
37
  class GitHubConfig(BaseAdapterConfig):
35
38
  """GitHub adapter configuration."""
39
+
36
40
  type: AdapterType = AdapterType.GITHUB
37
- token: Optional[str] = Field(None, env='GITHUB_TOKEN')
38
- owner: Optional[str] = Field(None, env='GITHUB_OWNER')
39
- repo: Optional[str] = Field(None, env='GITHUB_REPO')
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")
40
44
  api_url: str = "https://api.github.com"
41
45
  use_projects_v2: bool = False
42
46
  custom_priority_scheme: Optional[Dict[str, List[str]]] = None
43
47
 
44
- @validator('token', pre=True, always=True)
48
+ @validator("token", pre=True, always=True)
45
49
  def validate_token(cls, v):
46
50
  if not v:
47
- v = os.getenv('GITHUB_TOKEN')
51
+ v = os.getenv("GITHUB_TOKEN")
48
52
  if not v:
49
- raise ValueError('GitHub token is required')
53
+ raise ValueError("GitHub token is required")
50
54
  return v
51
55
 
52
- @validator('owner', pre=True, always=True)
56
+ @validator("owner", pre=True, always=True)
53
57
  def validate_owner(cls, v):
54
58
  if not v:
55
- v = os.getenv('GITHUB_OWNER')
59
+ v = os.getenv("GITHUB_OWNER")
56
60
  if not v:
57
- raise ValueError('GitHub owner is required')
61
+ raise ValueError("GitHub owner is required")
58
62
  return v
59
63
 
60
- @validator('repo', pre=True, always=True)
64
+ @validator("repo", pre=True, always=True)
61
65
  def validate_repo(cls, v):
62
66
  if not v:
63
- v = os.getenv('GITHUB_REPO')
67
+ v = os.getenv("GITHUB_REPO")
64
68
  if not v:
65
- raise ValueError('GitHub repo is required')
69
+ raise ValueError("GitHub repo is required")
66
70
  return v
67
71
 
68
72
 
69
73
  class JiraConfig(BaseAdapterConfig):
70
74
  """JIRA adapter configuration."""
75
+
71
76
  type: AdapterType = AdapterType.JIRA
72
- server: Optional[str] = Field(None, env='JIRA_SERVER')
73
- email: Optional[str] = Field(None, env='JIRA_EMAIL')
74
- api_token: Optional[str] = Field(None, env='JIRA_API_TOKEN')
75
- project_key: Optional[str] = Field(None, env='JIRA_PROJECT_KEY')
77
+ server: Optional[str] = Field(None, env="JIRA_SERVER")
78
+ email: Optional[str] = Field(None, env="JIRA_EMAIL")
79
+ api_token: Optional[str] = Field(None, env="JIRA_API_TOKEN")
80
+ project_key: Optional[str] = Field(None, env="JIRA_PROJECT_KEY")
76
81
  cloud: bool = True
77
82
  verify_ssl: bool = True
78
83
 
79
- @validator('server', pre=True, always=True)
84
+ @validator("server", pre=True, always=True)
80
85
  def validate_server(cls, v):
81
86
  if not v:
82
- v = os.getenv('JIRA_SERVER')
87
+ v = os.getenv("JIRA_SERVER")
83
88
  if not v:
84
- raise ValueError('JIRA server URL is required')
85
- return v.rstrip('/')
89
+ raise ValueError("JIRA server URL is required")
90
+ return v.rstrip("/")
86
91
 
87
- @validator('email', pre=True, always=True)
92
+ @validator("email", pre=True, always=True)
88
93
  def validate_email(cls, v):
89
94
  if not v:
90
- v = os.getenv('JIRA_EMAIL')
95
+ v = os.getenv("JIRA_EMAIL")
91
96
  if not v:
92
- raise ValueError('JIRA email is required')
97
+ raise ValueError("JIRA email is required")
93
98
  return v
94
99
 
95
- @validator('api_token', pre=True, always=True)
100
+ @validator("api_token", pre=True, always=True)
96
101
  def validate_api_token(cls, v):
97
102
  if not v:
98
- v = os.getenv('JIRA_API_TOKEN')
103
+ v = os.getenv("JIRA_API_TOKEN")
99
104
  if not v:
100
- raise ValueError('JIRA API token is required')
105
+ raise ValueError("JIRA API token is required")
101
106
  return v
102
107
 
103
108
 
104
109
  class LinearConfig(BaseAdapterConfig):
105
110
  """Linear adapter configuration."""
111
+
106
112
  type: AdapterType = AdapterType.LINEAR
107
- api_key: Optional[str] = Field(None, env='LINEAR_API_KEY')
113
+ api_key: Optional[str] = Field(None, env="LINEAR_API_KEY")
108
114
  workspace: Optional[str] = None
109
115
  team_key: str
110
116
  api_url: str = "https://api.linear.app/graphql"
111
117
 
112
- @validator('api_key', pre=True, always=True)
118
+ @validator("api_key", pre=True, always=True)
113
119
  def validate_api_key(cls, v):
114
120
  if not v:
115
- v = os.getenv('LINEAR_API_KEY')
121
+ v = os.getenv("LINEAR_API_KEY")
116
122
  if not v:
117
- raise ValueError('Linear API key is required')
123
+ raise ValueError("Linear API key is required")
118
124
  return v
119
125
 
120
126
 
121
127
  class AITrackdownConfig(BaseAdapterConfig):
122
128
  """AITrackdown adapter configuration."""
129
+
123
130
  type: AdapterType = AdapterType.AITRACKDOWN
124
131
  # AITrackdown uses local storage, minimal config needed
125
132
 
126
133
 
127
134
  class QueueConfig(BaseModel):
128
135
  """Queue configuration."""
136
+
129
137
  provider: str = "sqlite"
130
138
  connection_string: Optional[str] = None
131
139
  batch_size: int = 10
@@ -136,6 +144,7 @@ class QueueConfig(BaseModel):
136
144
 
137
145
  class LoggingConfig(BaseModel):
138
146
  """Logging configuration."""
147
+
139
148
  level: str = "INFO"
140
149
  format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
141
150
  file: Optional[str] = None
@@ -145,25 +154,30 @@ class LoggingConfig(BaseModel):
145
154
 
146
155
  class AppConfig(BaseModel):
147
156
  """Main application configuration."""
148
- adapters: Dict[str, Union[GitHubConfig, JiraConfig, LinearConfig, AITrackdownConfig]] = {}
157
+
158
+ adapters: Dict[
159
+ str, Union[GitHubConfig, JiraConfig, LinearConfig, AITrackdownConfig]
160
+ ] = {}
149
161
  queue: QueueConfig = QueueConfig()
150
162
  logging: LoggingConfig = LoggingConfig()
151
163
  cache_ttl: int = 300 # Cache TTL in seconds
152
164
  default_adapter: Optional[str] = None
153
165
 
154
- @root_validator
166
+ @root_validator(skip_on_failure=True)
155
167
  def validate_adapters(cls, values):
156
168
  """Validate adapter configurations."""
157
- adapters = values.get('adapters', {})
169
+ adapters = values.get("adapters", {})
158
170
 
159
171
  if not adapters:
160
172
  logger.warning("No adapters configured")
161
173
  return values
162
174
 
163
175
  # Validate default adapter
164
- default_adapter = values.get('default_adapter')
176
+ default_adapter = values.get("default_adapter")
165
177
  if default_adapter and default_adapter not in adapters:
166
- raise ValueError(f"Default adapter '{default_adapter}' not found in adapters")
178
+ raise ValueError(
179
+ f"Default adapter '{default_adapter}' not found in adapters"
180
+ )
167
181
 
168
182
  return values
169
183
 
@@ -173,17 +187,19 @@ class AppConfig(BaseModel):
173
187
 
174
188
  def get_enabled_adapters(self) -> Dict[str, BaseAdapterConfig]:
175
189
  """Get all enabled adapters."""
176
- return {name: config for name, config in self.adapters.items() if config.enabled}
190
+ return {
191
+ name: config for name, config in self.adapters.items() if config.enabled
192
+ }
177
193
 
178
194
 
179
195
  class ConfigurationManager:
180
196
  """Centralized configuration management with caching and validation."""
181
197
 
182
- _instance: Optional['ConfigurationManager'] = None
198
+ _instance: Optional["ConfigurationManager"] = None
183
199
  _config: Optional[AppConfig] = None
184
200
  _config_file_paths: List[Path] = []
185
201
 
186
- def __new__(cls) -> 'ConfigurationManager':
202
+ def __new__(cls) -> "ConfigurationManager":
187
203
  """Singleton pattern for global config access."""
188
204
  if cls._instance is None:
189
205
  cls._instance = super().__new__(cls)
@@ -191,26 +207,48 @@ class ConfigurationManager:
191
207
 
192
208
  def __init__(self):
193
209
  """Initialize configuration manager."""
194
- if not hasattr(self, '_initialized'):
210
+ if not hasattr(self, "_initialized"):
195
211
  self._initialized = True
196
212
  self._config_cache: Dict[str, Any] = {}
197
213
  self._find_config_files()
198
214
 
199
215
  def _find_config_files(self) -> None:
200
- """Find configuration files in standard locations."""
216
+ """Find configuration files in project-local directory ONLY.
217
+
218
+ SECURITY: This method ONLY searches in the current project directory
219
+ to prevent configuration leakage across projects. It will NEVER read
220
+ from user home directory or system-wide locations.
221
+ """
222
+ # ONLY search in current project directory, never external locations
201
223
  possible_paths = [
202
- Path.cwd() / "mcp-ticketer.yaml",
203
- Path.cwd() / "mcp-ticketer.yml",
204
- Path.cwd() / "config.yaml",
205
- Path.cwd() / "config.yml",
206
- Path.home() / ".mcp-ticketer.yaml",
207
- Path.home() / ".mcp-ticketer.yml",
208
- Path("/etc/mcp-ticketer/config.yaml"),
209
- Path("/etc/mcp-ticketer/config.yml"),
224
+ Path.cwd() / ".mcp-ticketer" / "config.json", # Primary JSON config
225
+ Path.cwd() / "mcp-ticketer.yaml", # Alternative YAML
226
+ Path.cwd() / "mcp-ticketer.yml", # Alternative YML
227
+ Path.cwd() / "config.yaml", # Generic YAML
228
+ Path.cwd() / "config.yml", # Generic YML
210
229
  ]
211
230
 
212
- self._config_file_paths = [path for path in possible_paths if path.exists()]
213
- logger.debug(f"Found config files: {self._config_file_paths}")
231
+ # Validate all paths are within project (security check)
232
+ validated_paths = []
233
+ for path in possible_paths:
234
+ if path.exists():
235
+ try:
236
+ if path.resolve().is_relative_to(Path.cwd().resolve()):
237
+ validated_paths.append(path)
238
+ else:
239
+ logger.warning(
240
+ f"Security: Ignoring config file outside project: {path}"
241
+ )
242
+ except (ValueError, RuntimeError):
243
+ # is_relative_to may raise ValueError in some cases
244
+ # Skip this file if we can't validate it
245
+ logger.warning(f"Could not validate config file path: {path}")
246
+
247
+ self._config_file_paths = validated_paths
248
+ if self._config_file_paths:
249
+ logger.debug(f"Found project-local config files: {self._config_file_paths}")
250
+ else:
251
+ logger.debug("No project-local config files found, will use defaults")
214
252
 
215
253
  @lru_cache(maxsize=1)
216
254
  def load_config(self, config_file: Optional[Union[str, Path]] = None) -> AppConfig:
@@ -221,6 +259,7 @@ class ConfigurationManager:
221
259
 
222
260
  Returns:
223
261
  Validated application configuration
262
+
224
263
  """
225
264
  if self._config is not None and config_file is None:
226
265
  return self._config
@@ -253,7 +292,9 @@ class ConfigurationManager:
253
292
  elif adapter_type == "aitrackdown":
254
293
  parsed_adapters[name] = AITrackdownConfig(**adapter_config)
255
294
  else:
256
- logger.warning(f"Unknown adapter type: {adapter_type} for adapter: {name}")
295
+ logger.warning(
296
+ f"Unknown adapter type: {adapter_type} for adapter: {name}"
297
+ )
257
298
 
258
299
  config_data["adapters"] = parsed_adapters
259
300
 
@@ -264,10 +305,10 @@ class ConfigurationManager:
264
305
  def _load_config_file(self, config_path: Path) -> Dict[str, Any]:
265
306
  """Load configuration from YAML or JSON file."""
266
307
  try:
267
- with open(config_path, 'r', encoding='utf-8') as file:
268
- if config_path.suffix.lower() in ['.yaml', '.yml']:
308
+ with open(config_path, encoding="utf-8") as file:
309
+ if config_path.suffix.lower() in [".yaml", ".yml"]:
269
310
  return yaml.safe_load(file) or {}
270
- elif config_path.suffix.lower() == '.json':
311
+ elif config_path.suffix.lower() == ".json":
271
312
  return json.load(file)
272
313
  else:
273
314
  # Try YAML first, then JSON
@@ -306,7 +347,9 @@ class ConfigurationManager:
306
347
  config = self.get_config()
307
348
  return config.logging
308
349
 
309
- def reload_config(self, config_file: Optional[Union[str, Path]] = None) -> AppConfig:
350
+ def reload_config(
351
+ self, config_file: Optional[Union[str, Path]] = None
352
+ ) -> AppConfig:
310
353
  """Reload configuration from file."""
311
354
  # Clear cache
312
355
  self.load_config.cache_clear()
@@ -327,7 +370,7 @@ class ConfigurationManager:
327
370
 
328
371
  # Parse nested keys like "queue.batch_size"
329
372
  config = self.get_config()
330
- parts = key.split('.')
373
+ parts = key.split(".")
331
374
  value = config.dict()
332
375
 
333
376
  for part in parts:
@@ -348,13 +391,13 @@ class ConfigurationManager:
348
391
  "token": "${GITHUB_TOKEN}",
349
392
  "owner": "your-org",
350
393
  "repo": "your-repo",
351
- "enabled": True
394
+ "enabled": True,
352
395
  },
353
396
  "linear-dev": {
354
397
  "type": "linear",
355
398
  "api_key": "${LINEAR_API_KEY}",
356
399
  "team_key": "DEV",
357
- "enabled": True
400
+ "enabled": True,
358
401
  },
359
402
  "jira-support": {
360
403
  "type": "jira",
@@ -362,23 +405,16 @@ class ConfigurationManager:
362
405
  "email": "${JIRA_EMAIL}",
363
406
  "api_token": "${JIRA_API_TOKEN}",
364
407
  "project_key": "SUPPORT",
365
- "enabled": False
366
- }
367
- },
368
- "queue": {
369
- "provider": "sqlite",
370
- "batch_size": 10,
371
- "max_concurrent": 5
372
- },
373
- "logging": {
374
- "level": "INFO",
375
- "file": "mcp-ticketer.log"
408
+ "enabled": False,
409
+ },
376
410
  },
377
- "default_adapter": "github-main"
411
+ "queue": {"provider": "sqlite", "batch_size": 10, "max_concurrent": 5},
412
+ "logging": {"level": "INFO", "file": "mcp-ticketer.log"},
413
+ "default_adapter": "github-main",
378
414
  }
379
415
 
380
416
  output_path = Path(output_path)
381
- with open(output_path, 'w', encoding='utf-8') as file:
417
+ with open(output_path, "w", encoding="utf-8") as file:
382
418
  yaml.dump(sample_config, file, default_flow_style=False, indent=2)
383
419
 
384
420
  logger.info(f"Sample configuration created at: {output_path}")
@@ -400,4 +436,4 @@ def get_adapter_config(adapter_name: str) -> Optional[BaseAdapterConfig]:
400
436
 
401
437
  def reload_config(config_file: Optional[Union[str, Path]] = None) -> AppConfig:
402
438
  """Reload the global configuration."""
403
- return config_manager.reload_config(config_file)
439
+ return config_manager.reload_config(config_file)
@@ -8,14 +8,14 @@ environment files, including:
8
8
  - Security validation
9
9
  """
10
10
 
11
- import os
12
11
  import logging
13
- from pathlib import Path
14
- from typing import Dict, Any, Optional, List, Tuple
15
12
  from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Any, Dict, List, Optional
15
+
16
16
  from dotenv import dotenv_values
17
17
 
18
- from .project_config import AdapterType, AdapterConfig
18
+ from .project_config import AdapterType
19
19
 
20
20
  logger = logging.getLogger(__name__)
21
21
 
@@ -130,9 +130,7 @@ class DiscoveryResult:
130
130
 
131
131
  # Sort by: complete configs first, then by confidence
132
132
  sorted_adapters = sorted(
133
- self.adapters,
134
- key=lambda a: (a.is_complete(), a.confidence),
135
- reverse=True
133
+ self.adapters, key=lambda a: (a.is_complete(), a.confidence), reverse=True
136
134
  )
137
135
  return sorted_adapters[0]
138
136
 
@@ -160,6 +158,7 @@ class EnvDiscovery:
160
158
 
161
159
  Args:
162
160
  project_path: Path to project root (defaults to cwd)
161
+
163
162
  """
164
163
  self.project_path = project_path or Path.cwd()
165
164
 
@@ -168,6 +167,7 @@ class EnvDiscovery:
168
167
 
169
168
  Returns:
170
169
  DiscoveryResult with found adapters and warnings
170
+
171
171
  """
172
172
  result = DiscoveryResult()
173
173
 
@@ -179,19 +179,27 @@ class EnvDiscovery:
179
179
  return result
180
180
 
181
181
  # Detect adapters
182
- linear_adapter = self._detect_linear(env_vars, result.env_files_found[0] if result.env_files_found else ".env")
182
+ linear_adapter = self._detect_linear(
183
+ env_vars, result.env_files_found[0] if result.env_files_found else ".env"
184
+ )
183
185
  if linear_adapter:
184
186
  result.adapters.append(linear_adapter)
185
187
 
186
- github_adapter = self._detect_github(env_vars, result.env_files_found[0] if result.env_files_found else ".env")
188
+ github_adapter = self._detect_github(
189
+ env_vars, result.env_files_found[0] if result.env_files_found else ".env"
190
+ )
187
191
  if github_adapter:
188
192
  result.adapters.append(github_adapter)
189
193
 
190
- jira_adapter = self._detect_jira(env_vars, result.env_files_found[0] if result.env_files_found else ".env")
194
+ jira_adapter = self._detect_jira(
195
+ env_vars, result.env_files_found[0] if result.env_files_found else ".env"
196
+ )
191
197
  if jira_adapter:
192
198
  result.adapters.append(jira_adapter)
193
199
 
194
- aitrackdown_adapter = self._detect_aitrackdown(env_vars, result.env_files_found[0] if result.env_files_found else ".env")
200
+ aitrackdown_adapter = self._detect_aitrackdown(
201
+ env_vars, result.env_files_found[0] if result.env_files_found else ".env"
202
+ )
195
203
  if aitrackdown_adapter:
196
204
  result.adapters.append(aitrackdown_adapter)
197
205
 
@@ -209,6 +217,7 @@ class EnvDiscovery:
209
217
 
210
218
  Returns:
211
219
  Merged dictionary of environment variables
220
+
212
221
  """
213
222
  merged_env: Dict[str, str] = {}
214
223
 
@@ -229,7 +238,9 @@ class EnvDiscovery:
229
238
 
230
239
  return merged_env
231
240
 
232
- def _find_key_value(self, env_vars: Dict[str, str], patterns: List[str]) -> Optional[str]:
241
+ def _find_key_value(
242
+ self, env_vars: Dict[str, str], patterns: List[str]
243
+ ) -> Optional[str]:
233
244
  """Find first matching key value from patterns.
234
245
 
235
246
  Args:
@@ -238,13 +249,16 @@ class EnvDiscovery:
238
249
 
239
250
  Returns:
240
251
  Value if found, None otherwise
252
+
241
253
  """
242
254
  for pattern in patterns:
243
255
  if pattern in env_vars and env_vars[pattern]:
244
256
  return env_vars[pattern]
245
257
  return None
246
258
 
247
- def _detect_linear(self, env_vars: Dict[str, str], found_in: str) -> Optional[DiscoveredAdapter]:
259
+ def _detect_linear(
260
+ self, env_vars: Dict[str, str], found_in: str
261
+ ) -> Optional[DiscoveredAdapter]:
248
262
  """Detect Linear adapter configuration.
249
263
 
250
264
  Args:
@@ -253,6 +267,7 @@ class EnvDiscovery:
253
267
 
254
268
  Returns:
255
269
  DiscoveredAdapter if Linear config detected, None otherwise
270
+
256
271
  """
257
272
  api_key = self._find_key_value(env_vars, LINEAR_KEY_PATTERNS)
258
273
 
@@ -289,7 +304,9 @@ class EnvDiscovery:
289
304
  found_in=found_in,
290
305
  )
291
306
 
292
- def _detect_github(self, env_vars: Dict[str, str], found_in: str) -> Optional[DiscoveredAdapter]:
307
+ def _detect_github(
308
+ self, env_vars: Dict[str, str], found_in: str
309
+ ) -> Optional[DiscoveredAdapter]:
293
310
  """Detect GitHub adapter configuration.
294
311
 
295
312
  Args:
@@ -298,6 +315,7 @@ class EnvDiscovery:
298
315
 
299
316
  Returns:
300
317
  DiscoveredAdapter if GitHub config detected, None otherwise
318
+
301
319
  """
302
320
  token = self._find_key_value(env_vars, GITHUB_TOKEN_PATTERNS)
303
321
 
@@ -345,7 +363,9 @@ class EnvDiscovery:
345
363
  found_in=found_in,
346
364
  )
347
365
 
348
- def _detect_jira(self, env_vars: Dict[str, str], found_in: str) -> Optional[DiscoveredAdapter]:
366
+ def _detect_jira(
367
+ self, env_vars: Dict[str, str], found_in: str
368
+ ) -> Optional[DiscoveredAdapter]:
349
369
  """Detect JIRA adapter configuration.
350
370
 
351
371
  Args:
@@ -354,6 +374,7 @@ class EnvDiscovery:
354
374
 
355
375
  Returns:
356
376
  DiscoveredAdapter if JIRA config detected, None otherwise
377
+
357
378
  """
358
379
  api_token = self._find_key_value(env_vars, JIRA_TOKEN_PATTERNS)
359
380
 
@@ -398,7 +419,9 @@ class EnvDiscovery:
398
419
  found_in=found_in,
399
420
  )
400
421
 
401
- def _detect_aitrackdown(self, env_vars: Dict[str, str], found_in: str) -> Optional[DiscoveredAdapter]:
422
+ def _detect_aitrackdown(
423
+ self, env_vars: Dict[str, str], found_in: str
424
+ ) -> Optional[DiscoveredAdapter]:
402
425
  """Detect AITrackdown adapter configuration.
403
426
 
404
427
  Args:
@@ -407,6 +430,7 @@ class EnvDiscovery:
407
430
 
408
431
  Returns:
409
432
  DiscoveredAdapter if AITrackdown config detected, None otherwise
433
+
410
434
  """
411
435
  base_path = self._find_key_value(env_vars, AITRACKDOWN_PATH_PATTERNS)
412
436
 
@@ -440,6 +464,7 @@ class EnvDiscovery:
440
464
 
441
465
  Returns:
442
466
  List of security warnings
467
+
443
468
  """
444
469
  warnings: List[str] = []
445
470
 
@@ -460,7 +485,7 @@ class EnvDiscovery:
460
485
  # Check if .gitignore exists and has .env patterns
461
486
  if gitignore_path.exists():
462
487
  try:
463
- with open(gitignore_path, 'r') as f:
488
+ with open(gitignore_path) as f:
464
489
  gitignore_content = f.read()
465
490
  if ".env" not in gitignore_content:
466
491
  warnings.append(
@@ -479,6 +504,7 @@ class EnvDiscovery:
479
504
 
480
505
  Returns:
481
506
  True if file is tracked in git, False otherwise
507
+
482
508
  """
483
509
  import subprocess
484
510
 
@@ -506,6 +532,7 @@ class EnvDiscovery:
506
532
 
507
533
  Returns:
508
534
  List of validation warnings
535
+
509
536
  """
510
537
  warnings: List[str] = []
511
538
 
@@ -522,12 +549,16 @@ class EnvDiscovery:
522
549
 
523
550
  # Validate token prefix
524
551
  if token and not token.startswith(("ghp_", "gho_", "ghu_", "ghs_", "ghr_")):
525
- warnings.append("⚠️ GitHub token doesn't match expected format (should start with ghp_, gho_, etc.)")
552
+ warnings.append(
553
+ "⚠️ GitHub token doesn't match expected format (should start with ghp_, gho_, etc.)"
554
+ )
526
555
 
527
556
  elif adapter.adapter_type == AdapterType.JIRA.value:
528
557
  server = adapter.config.get("server", "")
529
558
  if server and not server.startswith(("http://", "https://")):
530
- warnings.append("⚠️ JIRA server URL should start with http:// or https://")
559
+ warnings.append(
560
+ "⚠️ JIRA server URL should start with http:// or https://"
561
+ )
531
562
 
532
563
  email = adapter.config.get("email", "")
533
564
  if email and "@" not in email:
@@ -550,6 +581,7 @@ def discover_config(project_path: Optional[Path] = None) -> DiscoveryResult:
550
581
 
551
582
  Returns:
552
583
  DiscoveryResult with found adapters and warnings
584
+
553
585
  """
554
586
  discovery = EnvDiscovery(project_path)
555
587
  return discovery.discover()