devs-webhook 0.1.0__tar.gz → 0.1.2__tar.gz

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 (47) hide show
  1. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/PKG-INFO +21 -2
  2. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/README.md +20 -1
  3. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/devs_webhook/__init__.py +4 -0
  4. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/devs_webhook/cli/worker.py +15 -26
  5. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/devs_webhook/config.py +5 -1
  6. devs_webhook-0.1.2/devs_webhook/core/base_dispatcher.py +57 -0
  7. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/devs_webhook/core/claude_dispatcher.py +17 -22
  8. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/devs_webhook/core/container_pool.py +35 -152
  9. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/devs_webhook/core/test_dispatcher.py +25 -31
  10. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/devs_webhook/github/models.py +0 -29
  11. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/devs_webhook/main_cli.py +30 -6
  12. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/devs_webhook/sources/__init__.py +2 -2
  13. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/devs_webhook/sources/sqs_source.py +101 -5
  14. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/devs_webhook/utils/serialization.py +2 -1
  15. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/devs_webhook.egg-info/PKG-INFO +21 -2
  16. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/devs_webhook.egg-info/SOURCES.txt +2 -0
  17. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/pyproject.toml +2 -2
  18. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/tests/test_single_queue.py +68 -58
  19. devs_webhook-0.1.2/tests/test_sqs_burst.py +282 -0
  20. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/LICENSE +0 -0
  21. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/devs_webhook/app.py +0 -0
  22. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/devs_webhook/cli/__init__.py +0 -0
  23. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/devs_webhook/core/__init__.py +0 -0
  24. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/devs_webhook/core/deduplication.py +0 -0
  25. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/devs_webhook/core/repository_manager.py +0 -0
  26. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/devs_webhook/core/task_processor.py +0 -0
  27. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/devs_webhook/core/webhook_config.py +0 -0
  28. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/devs_webhook/core/webhook_handler.py +0 -0
  29. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/devs_webhook/github/__init__.py +0 -0
  30. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/devs_webhook/github/app_auth.py +0 -0
  31. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/devs_webhook/github/client.py +0 -0
  32. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/devs_webhook/github/parser.py +0 -0
  33. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/devs_webhook/sources/base.py +0 -0
  34. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/devs_webhook/sources/webhook_source.py +0 -0
  35. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/devs_webhook/utils/__init__.py +0 -0
  36. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/devs_webhook/utils/async_utils.py +0 -0
  37. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/devs_webhook/utils/github.py +0 -0
  38. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/devs_webhook/utils/logging.py +0 -0
  39. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/devs_webhook.egg-info/dependency_links.txt +0 -0
  40. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/devs_webhook.egg-info/entry_points.txt +0 -0
  41. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/devs_webhook.egg-info/requires.txt +0 -0
  42. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/devs_webhook.egg-info/top_level.txt +0 -0
  43. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/setup.cfg +0 -0
  44. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/tests/test_allowlist.py +0 -0
  45. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/tests/test_authentication.py +0 -0
  46. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/tests/test_authorized_users.py +0 -0
  47. {devs_webhook-0.1.0 → devs_webhook-0.1.2}/tests/test_webhook_parser.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devs-webhook
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: GitHub webhook handler for automated devcontainer operations with Claude Code
5
5
  Author: Dan Lester
6
6
  License-Expression: MIT
@@ -156,6 +156,25 @@ export AWS_SQS_QUEUE_URL="https://sqs.us-east-1.amazonaws.com/..."
156
156
  devs-webhook serve --source sqs
157
157
  ```
158
158
 
159
+ #### Burst Mode
160
+
161
+ For batch processing or scheduled jobs, use `--burst` to process all available messages and exit:
162
+
163
+ ```bash
164
+ # Process all messages in queue then exit
165
+ devs-webhook serve --source sqs --burst
166
+ ```
167
+
168
+ Exit codes:
169
+ - **0**: Successfully processed one or more messages
170
+ - **42**: Queue was empty (no messages to process)
171
+ - **Other**: Error occurred
172
+
173
+ This is useful for:
174
+ - Scheduled cron jobs that drain the queue periodically
175
+ - Lambda-triggered batch processing
176
+ - Testing and debugging SQS integration
177
+
159
178
  **Security**: Both modes validate GitHub webhook signatures for defense-in-depth security.
160
179
 
161
180
  For detailed configuration and deployment guides, see:
@@ -365,7 +384,7 @@ Available options:
365
384
  - **`ci_test_command`**: Command to run for CI tests
366
385
  - Shell command executed in container for CI test runs
367
386
  - Should exit with code 0 for success, non-zero for failure
368
- - Default: `runtests.sh`
387
+ - Default: `./runtests.sh`
369
388
 
370
389
  - **`ci_branches`**: Branches to run CI on for push events
371
390
  - List of branch names to trigger CI for push events
@@ -99,6 +99,25 @@ export AWS_SQS_QUEUE_URL="https://sqs.us-east-1.amazonaws.com/..."
99
99
  devs-webhook serve --source sqs
100
100
  ```
101
101
 
102
+ #### Burst Mode
103
+
104
+ For batch processing or scheduled jobs, use `--burst` to process all available messages and exit:
105
+
106
+ ```bash
107
+ # Process all messages in queue then exit
108
+ devs-webhook serve --source sqs --burst
109
+ ```
110
+
111
+ Exit codes:
112
+ - **0**: Successfully processed one or more messages
113
+ - **42**: Queue was empty (no messages to process)
114
+ - **Other**: Error occurred
115
+
116
+ This is useful for:
117
+ - Scheduled cron jobs that drain the queue periodically
118
+ - Lambda-triggered batch processing
119
+ - Testing and debugging SQS integration
120
+
102
121
  **Security**: Both modes validate GitHub webhook signatures for defense-in-depth security.
103
122
 
104
123
  For detailed configuration and deployment guides, see:
@@ -308,7 +327,7 @@ Available options:
308
327
  - **`ci_test_command`**: Command to run for CI tests
309
328
  - Shell command executed in container for CI test runs
310
329
  - Should exit with code 0 for success, non-zero for failure
311
- - Default: `runtests.sh`
330
+ - Default: `./runtests.sh`
312
331
 
313
332
  - **`ci_branches`**: Branches to run CI on for push events
314
333
  - List of branch names to trigger CI for push events
@@ -11,6 +11,8 @@ from .core.webhook_handler import WebhookHandler
11
11
  from .core.container_pool import ContainerPool
12
12
  from .core.repository_manager import RepositoryManager
13
13
  from .core.claude_dispatcher import ClaudeDispatcher
14
+ from .core.test_dispatcher import TestDispatcher
15
+ from .core.base_dispatcher import TaskResult
14
16
 
15
17
  __all__ = [
16
18
  "WebhookConfig",
@@ -18,4 +20,6 @@ __all__ = [
18
20
  "ContainerPool",
19
21
  "RepositoryManager",
20
22
  "ClaudeDispatcher",
23
+ "TestDispatcher",
24
+ "TaskResult",
21
25
  ]
@@ -16,7 +16,8 @@ from devs_common.core.project import Project
16
16
  from ..config import get_config
17
17
  from ..core.claude_dispatcher import ClaudeDispatcher
18
18
  from ..core.test_dispatcher import TestDispatcher
19
- from ..github.models import AnyWebhookEvent, DevsOptions
19
+ from ..github.models import AnyWebhookEvent
20
+ from devs_common.devs_config import DevsOptions
20
21
 
21
22
  logger = structlog.get_logger()
22
23
 
@@ -217,38 +218,26 @@ def _process_task_subprocess(
217
218
  # Initialize appropriate dispatcher based on task type
218
219
  if task_type == 'tests':
219
220
  dispatcher = TestDispatcher()
220
- logger.info("Executing task with Test dispatcher",
221
- task_id=task_id,
222
- dev_name=dev_name,
223
- workspace_name=workspace_name)
224
-
225
- # Execute tests
226
- result = asyncio.run(dispatcher.execute_tests(
227
- dev_name=dev_name,
228
- repo_path=repo_path,
229
- event=event,
230
- devs_options=devs_options
231
- ))
232
221
  else:
233
222
  # Default to Claude execution
234
223
  dispatcher = ClaudeDispatcher()
235
- logger.info("Executing task with Claude dispatcher",
236
- task_id=task_id,
237
- dev_name=dev_name,
238
- workspace_name=workspace_name)
239
224
 
240
225
  # Ensure task_description is provided for Claude tasks
241
226
  if not task_description:
242
227
  raise ValueError("task_description is required for Claude tasks")
243
-
244
- # Execute Claude task
245
- result = asyncio.run(dispatcher.execute_task(
246
- dev_name=dev_name,
247
- repo_path=repo_path,
248
- task_description=task_description,
249
- event=event,
250
- devs_options=devs_options
251
- ))
228
+
229
+ logger.info(f"Executing task with {dispatcher.dispatcher_name} dispatcher",
230
+ task_id=task_id,
231
+ dev_name=dev_name,
232
+ workspace_name=workspace_name)
233
+
234
+ result = asyncio.run(dispatcher.execute_task(
235
+ dev_name=dev_name,
236
+ repo_path=repo_path,
237
+ event=event,
238
+ devs_options=devs_options,
239
+ task_description=task_description
240
+ ))
252
241
 
253
242
  if result.success:
254
243
  logger.info("Task execution completed successfully",
@@ -236,7 +236,11 @@ class WebhookConfig(BaseSettings, BaseConfig):
236
236
  def get_default_workspaces_dir(self) -> Path:
237
237
  """Get default workspaces directory for webhook package."""
238
238
  return Path.home() / ".devs" / "workspaces"
239
-
239
+
240
+ def get_default_bridge_dir(self) -> Path:
241
+ """Get default bridge directory for webhook package."""
242
+ return Path.home() / ".devs" / "bridge"
243
+
240
244
  def get_default_project_prefix(self) -> str:
241
245
  """Get default project prefix for webhook package."""
242
246
  return "dev"
@@ -0,0 +1,57 @@
1
+ """Base dispatcher class with shared functionality."""
2
+
3
+ from typing import NamedTuple, Optional
4
+ import structlog
5
+ from pathlib import Path
6
+
7
+ from ..config import get_config
8
+ from ..github.models import WebhookEvent
9
+ from ..github.client import GitHubClient
10
+ from devs_common.devs_config import DevsOptions
11
+
12
+ logger = structlog.get_logger()
13
+
14
+
15
+ class TaskResult(NamedTuple):
16
+ """Result of a task execution (consolidates TestResult and TaskResult)."""
17
+ success: bool
18
+ output: str
19
+ error: Optional[str] = None
20
+ exit_code: Optional[int] = None
21
+
22
+
23
+ class BaseDispatcher:
24
+ """Base class for dispatchers with common functionality."""
25
+
26
+ def __init__(self, dispatcher_type: str = "base"):
27
+ """Initialize dispatcher with common setup.
28
+
29
+ Args:
30
+ dispatcher_type: Type of dispatcher for logging
31
+ """
32
+ self.config = get_config()
33
+ self.github_client = GitHubClient(self.config)
34
+
35
+ logger.info(f"{dispatcher_type.title()} dispatcher initialized")
36
+
37
+ async def execute_task(
38
+ self,
39
+ dev_name: str,
40
+ repo_path: Path,
41
+ event: WebhookEvent,
42
+ devs_options: Optional[DevsOptions] = None,
43
+ task_description: Optional[str] = None
44
+ ) -> TaskResult:
45
+ """Execute operation in container - to be implemented by subclasses.
46
+
47
+ Args:
48
+ dev_name: Name of dev container (e.g., eamonn)
49
+ repo_path: Path to repository on host
50
+ event: Original webhook event
51
+ devs_options: Options from DEVS.yml file
52
+ task_description: Task description
53
+
54
+ Returns:
55
+ Task execution result
56
+ """
57
+ raise NotImplementedError("Subclasses must implement execute_task")
@@ -1,44 +1,35 @@
1
1
  """Claude Code CLI integration for executing tasks in containers."""
2
2
 
3
- from typing import NamedTuple, Optional
3
+ from typing import Optional
4
4
  import structlog
5
5
  from pathlib import Path
6
6
 
7
7
  from devs_common.core.project import Project
8
8
  from devs_common.core.container import ContainerManager
9
9
  from devs_common.core.workspace import WorkspaceManager
10
- from ..config import get_config
11
- from ..github.models import WebhookEvent, IssueEvent, PullRequestEvent, CommentEvent, DevsOptions
12
- from ..github.client import GitHubClient
10
+ from ..github.models import WebhookEvent, IssueEvent, PullRequestEvent, CommentEvent
11
+ from devs_common.devs_config import DevsOptions
12
+ from .base_dispatcher import BaseDispatcher, TaskResult
13
13
 
14
14
  logger = structlog.get_logger()
15
15
 
16
16
 
17
- class TaskResult(NamedTuple):
18
- """Result of a Claude Code task execution."""
19
- success: bool
20
- output: str
21
- error: Optional[str] = None
22
-
23
-
24
- class ClaudeDispatcher:
17
+ class ClaudeDispatcher(BaseDispatcher):
25
18
  """Dispatches tasks to Claude Code CLI running in containers."""
26
19
 
20
+ dispatcher_name = "Claude"
21
+
27
22
  def __init__(self):
28
23
  """Initialize Claude dispatcher."""
29
- self.config = get_config()
30
-
31
- self.github_client = GitHubClient(self.config)
32
-
33
- logger.info("Claude dispatcher initialized")
24
+ super().__init__("Claude")
34
25
 
35
26
  async def execute_task(
36
27
  self,
37
28
  dev_name: str,
38
29
  repo_path: Path,
39
- task_description: str,
40
30
  event: WebhookEvent,
41
- devs_options: Optional[DevsOptions] = None
31
+ devs_options: Optional[DevsOptions] = None,
32
+ task_description: Optional[str] = None
42
33
  ) -> TaskResult:
43
34
  """Execute a task using Claude Code CLI in a container.
44
35
 
@@ -59,10 +50,13 @@ class ClaudeDispatcher:
59
50
  repo_path=str(repo_path))
60
51
 
61
52
  # Execute Claude directly - prompt building, workspace setup, container startup, Claude execution
53
+ # Use task_description if provided, otherwise extract from event
54
+ task_desc = task_description or "Process webhook event"
55
+
62
56
  success, output, error = self._execute_claude_sync(
63
57
  repo_path,
64
58
  dev_name,
65
- task_description,
59
+ task_desc,
66
60
  event,
67
61
  devs_options
68
62
  )
@@ -78,7 +72,8 @@ class ClaudeDispatcher:
78
72
  result = TaskResult(
79
73
  success=success,
80
74
  output=output,
81
- error=error if not success else None
75
+ error=error if not success else None,
76
+ exit_code=None # Claude doesn't provide exit codes
82
77
  )
83
78
 
84
79
  if result.success:
@@ -104,7 +99,7 @@ class ClaudeDispatcher:
104
99
  exc_info=True)
105
100
 
106
101
  await self._handle_task_failure(event, error_msg)
107
- return TaskResult(success=False, output="", error=error_msg)
102
+ return TaskResult(success=False, output="", error=error_msg, exit_code=None)
108
103
 
109
104
  def _execute_claude_sync(
110
105
  self,
@@ -9,15 +9,15 @@ from datetime import datetime, timedelta, timezone
9
9
  from typing import Dict, Optional, Any, NamedTuple
10
10
  from pathlib import Path
11
11
  import structlog
12
- import yaml
13
12
 
14
13
  from devs_common.core.project import Project
15
14
  from devs_common.core.container import ContainerManager
16
15
  from devs_common.core.workspace import WorkspaceManager
16
+ from devs_common.devs_config import DevsConfigLoader, DevsOptions
17
17
 
18
18
  from ..config import get_config
19
- from ..github.models import WebhookEvent, DevsOptions, IssueEvent, PullRequestEvent, CommentEvent
20
- from .claude_dispatcher import ClaudeDispatcher, TaskResult
19
+ from ..github.models import WebhookEvent, IssueEvent, PullRequestEvent, CommentEvent
20
+ from .base_dispatcher import TaskResult
21
21
  from ..github.client import GitHubClient
22
22
 
23
23
  logger = structlog.get_logger()
@@ -39,7 +39,6 @@ class ContainerPool:
39
39
  def __init__(self):
40
40
  """Initialize container pool."""
41
41
  self.config = get_config()
42
- self.claude_dispatcher = ClaudeDispatcher()
43
42
 
44
43
  # Track running containers for idle cleanup
45
44
  self.running_containers: Dict[str, Dict[str, Any]] = {}
@@ -140,21 +139,6 @@ class ContainerPool:
140
139
  Returns:
141
140
  DevsOptions if user-specific config exists, None otherwise
142
141
  """
143
- def _load_devs_yml(file_path: Path) -> dict:
144
- """Load and parse a DEVS.yml file, returning empty dict if not found."""
145
- if not file_path.exists():
146
- return {}
147
-
148
- try:
149
- with open(file_path, 'r') as f:
150
- data = yaml.safe_load(f)
151
- return data if data else {}
152
- except Exception as e:
153
- logger.warning("Failed to parse user DEVS.yml",
154
- file_path=str(file_path),
155
- error=str(e))
156
- return {}
157
-
158
142
  # Check for user-specific configuration files
159
143
  user_envs_dir = Path.home() / ".devs" / "envs"
160
144
  default_devs_yml = user_envs_dir / "default" / "DEVS.yml"
@@ -165,69 +149,26 @@ class ContainerPool:
165
149
  if not default_devs_yml.exists() and not project_devs_yml.exists():
166
150
  return None
167
151
 
168
- # Load user configuration
169
- devs_options = DevsOptions() # Start with defaults
152
+ # Load configuration without repository files (user configs only)
153
+ # Create a fake path that doesn't exist to skip repository DEVS.yml
154
+ fake_repo_path = Path("/dev/null/fake_repo_path")
155
+ devs_options = DevsConfigLoader.load(project_name=project_name, repo_path=fake_repo_path)
170
156
 
171
- # 1. Load user default DEVS.yml
172
- default_data = _load_devs_yml(default_devs_yml)
173
-
174
- # 2. Load user project-specific DEVS.yml (higher priority)
175
- project_data = _load_devs_yml(project_devs_yml)
176
-
177
- # Merge data in priority order
178
- all_data = {}
179
- all_data.update(default_data)
180
- all_data.update(project_data)
181
-
182
- # Update devs_options with merged values
183
- if all_data:
184
- if 'default_branch' in all_data:
185
- devs_options.default_branch = all_data['default_branch']
186
- if 'prompt_extra' in all_data:
187
- devs_options.prompt_extra = all_data['prompt_extra']
188
- if 'prompt_override' in all_data:
189
- devs_options.prompt_override = all_data['prompt_override']
190
- if 'direct_commit' in all_data:
191
- devs_options.direct_commit = all_data['direct_commit']
192
- if 'single_queue' in all_data:
193
- devs_options.single_queue = all_data['single_queue']
194
- if 'ci_enabled' in all_data:
195
- devs_options.ci_enabled = all_data['ci_enabled']
196
- if 'ci_test_command' in all_data:
197
- devs_options.ci_test_command = all_data['ci_test_command']
198
- if 'ci_branches' in all_data:
199
- devs_options.ci_branches = all_data['ci_branches']
200
-
201
- # Merge env_vars from both sources
202
- merged_env_vars = {}
203
- for source_data in [default_data, project_data]:
204
- if 'env_vars' in source_data and source_data['env_vars']:
205
- for container_name, env_dict in source_data['env_vars'].items():
206
- if container_name not in merged_env_vars:
207
- merged_env_vars[container_name] = {}
208
- merged_env_vars[container_name].update(env_dict)
209
-
210
- if merged_env_vars:
211
- devs_options.env_vars = merged_env_vars
212
-
213
- logger.info("Loaded user-specific DEVS.yml configuration",
214
- repo=repo_name,
215
- default_file_exists=default_devs_yml.exists(),
216
- project_file_exists=project_devs_yml.exists(),
217
- default_branch=devs_options.default_branch,
218
- single_queue=devs_options.single_queue,
219
- ci_enabled=devs_options.ci_enabled,
220
- env_vars_containers=list(devs_options.env_vars.keys()) if devs_options.env_vars else [])
221
-
222
- return devs_options
157
+ logger.info("Loaded user-specific DEVS.yml configuration",
158
+ repo=repo_name,
159
+ default_file_exists=default_devs_yml.exists(),
160
+ project_file_exists=project_devs_yml.exists(),
161
+ default_branch=devs_options.default_branch,
162
+ single_queue=devs_options.single_queue,
163
+ ci_enabled=devs_options.ci_enabled,
164
+ env_vars_containers=list(devs_options.env_vars.keys()) if devs_options.env_vars else [])
223
165
 
224
- # No meaningful configuration found
225
- return None
166
+ return devs_options
226
167
 
227
168
  def _read_devs_options(self, repo_path: Path, repo_name: str) -> DevsOptions:
228
169
  """Read and parse DEVS.yml options from multiple sources.
229
170
 
230
- Loads from multiple sources in priority order:
171
+ Uses the shared DevsConfigLoader to load from:
231
172
  1. ~/.devs/envs/{org-repo}/DEVS.yml (user-specific overrides)
232
173
  2. ~/.devs/envs/default/DEVS.yml (user defaults)
233
174
  3. {repo_path}/DEVS.yml (repository configuration)
@@ -239,87 +180,29 @@ class ContainerPool:
239
180
  Returns:
240
181
  DevsOptions with values from DEVS.yml files or defaults
241
182
  """
242
- devs_options = DevsOptions() # Start with defaults
243
-
244
- def _load_devs_yml(file_path: Path) -> dict:
245
- """Load and parse a DEVS.yml file, returning empty dict if not found."""
246
- if not file_path.exists():
247
- return {}
248
-
249
- try:
250
- with open(file_path, 'r') as f:
251
- data = yaml.safe_load(f)
252
- return data if data else {}
253
- except Exception as e:
254
- logger.warning("Failed to parse DEVS.yml",
255
- file_path=str(file_path),
256
- error=str(e))
257
- return {}
258
-
259
- # 1. Load repository DEVS.yml (lowest priority)
260
- repo_devs_yml = repo_path / "DEVS.yml"
261
- repo_data = _load_devs_yml(repo_devs_yml)
183
+ project_name = repo_name.replace('/', '-') # Convert org/repo to org-repo
184
+ devs_options = DevsConfigLoader.load(project_name=project_name, repo_path=repo_path)
262
185
 
263
- # 2. Load user default DEVS.yml
186
+ # Check which files exist for logging
264
187
  user_envs_dir = Path.home() / ".devs" / "envs"
188
+ repo_devs_yml = repo_path / "DEVS.yml"
265
189
  default_devs_yml = user_envs_dir / "default" / "DEVS.yml"
266
- default_data = _load_devs_yml(default_devs_yml)
267
-
268
- # 3. Load user project-specific DEVS.yml (highest priority)
269
- project_name = repo_name.replace('/', '-') # Convert org/repo to org-repo
270
190
  project_devs_yml = user_envs_dir / project_name / "DEVS.yml"
271
- project_data = _load_devs_yml(project_devs_yml)
272
-
273
- # Merge data in priority order (later updates override earlier ones)
274
- all_data = {}
275
- for source_data in [repo_data, default_data, project_data]:
276
- all_data.update(source_data)
277
191
 
278
- # Update devs_options with merged values
279
- if all_data:
280
- if 'default_branch' in all_data:
281
- devs_options.default_branch = all_data['default_branch']
282
- if 'prompt_extra' in all_data:
283
- devs_options.prompt_extra = all_data['prompt_extra']
284
- if 'prompt_override' in all_data:
285
- devs_options.prompt_override = all_data['prompt_override']
286
- if 'direct_commit' in all_data:
287
- devs_options.direct_commit = all_data['direct_commit']
288
- if 'single_queue' in all_data:
289
- devs_options.single_queue = all_data['single_queue']
290
- if 'ci_enabled' in all_data:
291
- devs_options.ci_enabled = all_data['ci_enabled']
292
- if 'ci_test_command' in all_data:
293
- devs_options.ci_test_command = all_data['ci_test_command']
294
- if 'ci_branches' in all_data:
295
- devs_options.ci_branches = all_data['ci_branches']
296
-
297
- # Merge env_vars from all sources (repository < default < project)
298
- merged_env_vars = {}
299
- for source_data in [repo_data, default_data, project_data]:
300
- if 'env_vars' in source_data and source_data['env_vars']:
301
- for container_name, env_dict in source_data['env_vars'].items():
302
- if container_name not in merged_env_vars:
303
- merged_env_vars[container_name] = {}
304
- merged_env_vars[container_name].update(env_dict)
305
-
306
- if merged_env_vars:
307
- devs_options.env_vars = merged_env_vars
308
-
309
- logger.info("Loaded DEVS.yml configuration from multiple sources",
310
- repo=repo_name,
311
- repo_file_exists=repo_devs_yml.exists(),
312
- default_file_exists=default_devs_yml.exists(),
313
- project_file_exists=project_devs_yml.exists(),
314
- default_branch=devs_options.default_branch,
315
- has_prompt_extra=bool(devs_options.prompt_extra),
316
- has_prompt_override=bool(devs_options.prompt_override),
317
- direct_commit=devs_options.direct_commit,
318
- single_queue=devs_options.single_queue,
319
- ci_enabled=devs_options.ci_enabled,
320
- ci_test_command=devs_options.ci_test_command,
321
- ci_branches=devs_options.ci_branches,
322
- env_vars_containers=list(devs_options.env_vars.keys()) if devs_options.env_vars else [])
192
+ logger.info("Loaded DEVS.yml configuration from multiple sources",
193
+ repo=repo_name,
194
+ repo_file_exists=repo_devs_yml.exists(),
195
+ default_file_exists=default_devs_yml.exists(),
196
+ project_file_exists=project_devs_yml.exists(),
197
+ default_branch=devs_options.default_branch,
198
+ has_prompt_extra=bool(devs_options.prompt_extra),
199
+ has_prompt_override=bool(devs_options.prompt_override),
200
+ direct_commit=devs_options.direct_commit,
201
+ single_queue=devs_options.single_queue,
202
+ ci_enabled=devs_options.ci_enabled,
203
+ ci_test_command=devs_options.ci_test_command,
204
+ ci_branches=devs_options.ci_branches,
205
+ env_vars_containers=list(devs_options.env_vars.keys()) if devs_options.env_vars else [])
323
206
 
324
207
  return devs_options
325
208