devs-webhook 0.1.0__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.
@@ -0,0 +1,448 @@
1
+ """Test runner dispatcher for executing CI tests in containers."""
2
+
3
+ from typing import NamedTuple, Optional
4
+ import structlog
5
+ from pathlib import Path
6
+
7
+ from devs_common.core.project import Project
8
+ from devs_common.core.container import ContainerManager
9
+ from devs_common.core.workspace import WorkspaceManager
10
+ from ..config import get_config
11
+ from ..github.models import WebhookEvent, PushEvent, PullRequestEvent, DevsOptions
12
+ from ..github.client import GitHubClient
13
+
14
+ logger = structlog.get_logger()
15
+
16
+
17
+ class TestResult(NamedTuple):
18
+ """Result of a test execution."""
19
+ success: bool
20
+ output: str
21
+ error: Optional[str] = None
22
+ exit_code: Optional[int] = None
23
+
24
+
25
+ class TestDispatcher:
26
+ """Dispatches test commands to containers and reports results via GitHub Checks API."""
27
+
28
+ def __init__(self):
29
+ """Initialize test dispatcher."""
30
+ self.config = get_config()
31
+
32
+ self.github_client = GitHubClient(self.config)
33
+
34
+ logger.info("Test dispatcher initialized")
35
+
36
+ async def execute_tests(
37
+ self,
38
+ dev_name: str,
39
+ repo_path: Path,
40
+ event: WebhookEvent,
41
+ devs_options: Optional[DevsOptions] = None
42
+ ) -> TestResult:
43
+ """Execute tests using container and report results via GitHub Checks API.
44
+
45
+ Args:
46
+ dev_name: Name of dev container (e.g., eamonn)
47
+ repo_path: Path to repository on host (already calculated by container_pool)
48
+ event: Original webhook event
49
+ devs_options: Options from DEVS.yml file
50
+
51
+ Returns:
52
+ Test execution result
53
+ """
54
+ check_run_id = None
55
+
56
+ try:
57
+ logger.info("Starting test execution",
58
+ container=dev_name,
59
+ repo=event.repository.full_name,
60
+ repo_path=str(repo_path))
61
+
62
+ # Determine the commit SHA to test
63
+ commit_sha = self._get_commit_sha(event)
64
+ logger.info("Commit SHA determination result", commit_sha=commit_sha)
65
+
66
+ if not commit_sha:
67
+ logger.error("Could not determine commit SHA, using fallback for testing")
68
+ # Use a fallback SHA for testing - in real scenarios this shouldn't happen
69
+ commit_sha = "HEAD" # Fallback to HEAD for now
70
+
71
+ # Create GitHub check run
72
+ # Safely extract installation ID, handling potential encoding issues
73
+ installation_id = None
74
+ try:
75
+ if event.installation and hasattr(event.installation, 'id') and event.installation.id is not None:
76
+ installation_id = str(event.installation.id)
77
+ logger.info("Extracted installation ID from event", installation_id=installation_id)
78
+ else:
79
+ logger.warning("No installation ID found in event")
80
+ except (AttributeError, TypeError, ValueError) as e:
81
+ logger.warning("Could not extract installation ID", error=str(e))
82
+ installation_id = None
83
+
84
+ # Skip GitHub API calls for test events or in dev mode
85
+ if hasattr(event, 'is_test') and event.is_test:
86
+ logger.info("Skipping GitHub check run creation for test event")
87
+ check_run_id = None
88
+ else:
89
+ logger.info("About to create GitHub check run",
90
+ repo=event.repository.full_name,
91
+ commit_sha=commit_sha,
92
+ installation_id=installation_id)
93
+
94
+ check_run_id = await self.github_client.create_check_run(
95
+ repo=event.repository.full_name,
96
+ name="devs-ci",
97
+ head_sha=commit_sha,
98
+ status="in_progress",
99
+ installation_id=installation_id
100
+ )
101
+
102
+ logger.info("GitHub check run creation attempt completed",
103
+ check_run_id=check_run_id,
104
+ success=check_run_id is not None)
105
+
106
+ if check_run_id:
107
+ logger.info("Created GitHub check run",
108
+ repo=event.repository.full_name,
109
+ check_run_id=check_run_id,
110
+ commit_sha=commit_sha)
111
+
112
+ # Execute tests
113
+ success, output, error, exit_code = self._execute_tests_sync(
114
+ repo_path,
115
+ dev_name,
116
+ event,
117
+ devs_options
118
+ )
119
+
120
+ # Build result
121
+ result = TestResult(
122
+ success=success,
123
+ output=output,
124
+ error=error if not success else None,
125
+ exit_code=exit_code
126
+ )
127
+
128
+ # Report results to GitHub
129
+ if check_run_id:
130
+ # Safely extract installation ID, handling potential encoding issues
131
+ installation_id = None
132
+ try:
133
+ if event.installation and hasattr(event.installation, 'id') and event.installation.id is not None:
134
+ installation_id = str(event.installation.id)
135
+ except (AttributeError, TypeError, ValueError) as e:
136
+ logger.warning("Could not extract installation ID", error=str(e))
137
+ installation_id = None
138
+
139
+ await self._report_test_results(
140
+ event.repository.full_name,
141
+ check_run_id,
142
+ result,
143
+ installation_id
144
+ )
145
+ elif hasattr(event, 'is_test') and event.is_test:
146
+ logger.info("Skipping GitHub check run result reporting for test event")
147
+
148
+ if result.success:
149
+ logger.info("Test execution completed successfully",
150
+ container=dev_name,
151
+ repo=event.repository.full_name,
152
+ exit_code=exit_code)
153
+ else:
154
+ logger.error("Test execution failed",
155
+ container=dev_name,
156
+ repo=event.repository.full_name,
157
+ exit_code=exit_code,
158
+ error=result.error)
159
+
160
+ return result
161
+
162
+ except Exception as e:
163
+ error_msg = f"Test execution failed: {str(e)}"
164
+ logger.error("Test execution error",
165
+ container=dev_name,
166
+ error=error_msg,
167
+ exc_info=True)
168
+
169
+ # Report failure to GitHub if we created a check run
170
+ if check_run_id:
171
+ # Safely extract installation ID, handling potential encoding issues
172
+ installation_id = None
173
+ try:
174
+ if event.installation and hasattr(event.installation, 'id') and event.installation.id is not None:
175
+ installation_id = str(event.installation.id)
176
+ except (AttributeError, TypeError, ValueError) as e:
177
+ logger.warning("Could not extract installation ID", error=str(e))
178
+ installation_id = None
179
+
180
+ await self.github_client.complete_check_run_failure(
181
+ repo=event.repository.full_name,
182
+ check_run_id=check_run_id,
183
+ title="Test execution failed",
184
+ summary=f"An error occurred during test execution: {error_msg}",
185
+ installation_id=installation_id
186
+ )
187
+ elif hasattr(event, 'is_test') and event.is_test:
188
+ logger.info("Skipping GitHub check run failure reporting for test event")
189
+
190
+ return TestResult(success=False, output="", error=error_msg)
191
+
192
+ def _execute_tests_sync(
193
+ self,
194
+ repo_path: Path,
195
+ dev_name: str,
196
+ event: WebhookEvent,
197
+ devs_options: Optional[DevsOptions] = None
198
+ ) -> tuple[bool, str, str, int]:
199
+ """Execute tests synchronously in container.
200
+
201
+ Args:
202
+ repo_path: Path to repository
203
+ dev_name: Development environment name
204
+ event: Webhook event
205
+ devs_options: Options from DEVS.yml
206
+
207
+ Returns:
208
+ Tuple of (success, stdout, stderr, exit_code)
209
+ """
210
+ try:
211
+ # 1. Create project, workspace manager, and container manager
212
+ project = Project(repo_path)
213
+ workspace_manager = WorkspaceManager(project, self.config)
214
+ container_manager = ContainerManager(project, self.config)
215
+
216
+ logger.info("Created project and managers for tests",
217
+ container=dev_name,
218
+ project_name=project.info.name)
219
+
220
+ # 2. Ensure workspace exists
221
+ workspace_dir = workspace_manager.create_workspace(dev_name, reset_contents=True)
222
+
223
+ logger.info("Workspace created/reset for tests",
224
+ container=dev_name,
225
+ workspace_dir=str(workspace_dir))
226
+
227
+ # 3. Ensure container is running with environment variables from DEVS.yml
228
+ extra_env = None
229
+ if devs_options:
230
+ extra_env = devs_options.get_env_vars(dev_name)
231
+
232
+ if not container_manager.ensure_container_running(
233
+ dev_name,
234
+ workspace_dir,
235
+ debug=self.config.dev_mode,
236
+ extra_env=extra_env
237
+ ):
238
+ return False, "", f"Failed to start container for {dev_name}", 1
239
+
240
+ # 4. Checkout appropriate commit if this is a PR or push
241
+ commit_sha = self._get_commit_sha(event)
242
+ if commit_sha:
243
+ logger.info("Checking out commit for tests",
244
+ container=dev_name,
245
+ commit_sha=commit_sha)
246
+
247
+ checkout_success, checkout_stdout, checkout_stderr, checkout_code = self._exec_command_in_container(
248
+ project=project,
249
+ dev_name=dev_name,
250
+ workspace_dir=workspace_dir,
251
+ command=f"git checkout {commit_sha}",
252
+ debug=self.config.dev_mode
253
+ )
254
+
255
+ if not checkout_success:
256
+ logger.error("Failed to checkout commit",
257
+ container=dev_name,
258
+ commit_sha=commit_sha,
259
+ stderr=checkout_stderr)
260
+ return False, checkout_stdout, f"Failed to checkout commit {commit_sha}: {checkout_stderr}", checkout_code
261
+
262
+ # 5. Determine test command
263
+ test_command = "runtests.sh" # Default
264
+ if devs_options and devs_options.ci_test_command:
265
+ test_command = devs_options.ci_test_command
266
+
267
+ logger.info("Executing test command",
268
+ container=dev_name,
269
+ test_command=test_command)
270
+
271
+ # 6. Execute tests
272
+ success, stdout, stderr, exit_code = self._exec_command_in_container(
273
+ project=project,
274
+ dev_name=dev_name,
275
+ workspace_dir=workspace_dir,
276
+ command=test_command,
277
+ debug=self.config.dev_mode
278
+ )
279
+
280
+ logger.info("Test command completed",
281
+ container=dev_name,
282
+ success=success,
283
+ exit_code=exit_code,
284
+ output_length=len(stdout) if stdout else 0,
285
+ error_length=len(stderr) if stderr else 0)
286
+
287
+ return success, stdout, stderr, exit_code
288
+
289
+ except Exception as e:
290
+ error_msg = f"Test execution failed: {str(e)}"
291
+ logger.error("Test execution error",
292
+ container=dev_name,
293
+ error=error_msg,
294
+ exc_info=True)
295
+ return False, "", error_msg, 1
296
+
297
+ def _get_commit_sha(self, event: WebhookEvent) -> Optional[str]:
298
+ """Get the commit SHA to test from the webhook event.
299
+
300
+ Args:
301
+ event: Webhook event
302
+
303
+ Returns:
304
+ Commit SHA or None if not available
305
+ """
306
+ logger.info("Extracting commit SHA from event",
307
+ event_type=type(event).__name__)
308
+
309
+ if isinstance(event, PushEvent):
310
+ sha = event.after
311
+ logger.info("Got commit SHA from PushEvent", sha=sha)
312
+ return sha
313
+ elif isinstance(event, PullRequestEvent):
314
+ sha = event.pull_request.head.get("sha")
315
+ logger.info("Got commit SHA from PullRequestEvent",
316
+ sha=sha, head_keys=list(event.pull_request.head.keys()))
317
+ return sha
318
+ else:
319
+ logger.warning("Event type not supported for commit SHA extraction",
320
+ event_type=type(event).__name__)
321
+ return None
322
+
323
+ async def _report_test_results(
324
+ self,
325
+ repo_name: str,
326
+ check_run_id: int,
327
+ result: TestResult,
328
+ installation_id: Optional[str] = None
329
+ ) -> None:
330
+ """Report test results to GitHub via Checks API.
331
+
332
+ Args:
333
+ repo_name: Repository name (owner/repo)
334
+ check_run_id: GitHub check run ID
335
+ result: Test execution result
336
+ installation_id: GitHub App installation ID if known from webhook event
337
+ """
338
+ try:
339
+ if result.success:
340
+ await self.github_client.complete_check_run_success(
341
+ repo=repo_name,
342
+ check_run_id=check_run_id,
343
+ title="Tests passed",
344
+ summary=f"All tests completed successfully (exit code: {result.exit_code})",
345
+ installation_id=installation_id
346
+ )
347
+ logger.info("Reported test success to GitHub",
348
+ repo=repo_name,
349
+ check_run_id=check_run_id)
350
+ else:
351
+ # Truncate output for GitHub (limit to ~65k chars to stay under API limits)
352
+ error_text = result.error or result.output or "Test execution failed"
353
+ if len(error_text) > 65000:
354
+ error_text = error_text[:65000] + "\n\n[Output truncated]"
355
+
356
+ await self.github_client.complete_check_run_failure(
357
+ repo=repo_name,
358
+ check_run_id=check_run_id,
359
+ title="Tests failed",
360
+ summary=f"Tests failed with exit code: {result.exit_code}",
361
+ text=error_text,
362
+ installation_id=installation_id
363
+ )
364
+ logger.info("Reported test failure to GitHub",
365
+ repo=repo_name,
366
+ check_run_id=check_run_id,
367
+ exit_code=result.exit_code)
368
+
369
+ except Exception as e:
370
+ logger.error("Failed to report test results to GitHub",
371
+ repo=repo_name,
372
+ check_run_id=check_run_id,
373
+ error=str(e))
374
+
375
+ def _exec_command_in_container(
376
+ self,
377
+ project: Project,
378
+ dev_name: str,
379
+ workspace_dir: Path,
380
+ command: str,
381
+ debug: bool = False
382
+ ) -> tuple[bool, str, str, int]:
383
+ """Execute a command in the container.
384
+
385
+ Args:
386
+ project: Project instance
387
+ dev_name: Development environment name
388
+ workspace_dir: Workspace directory path
389
+ command: Command to execute
390
+ debug: Show debug output
391
+
392
+ Returns:
393
+ Tuple of (success, stdout, stderr, exit_code)
394
+ """
395
+ import subprocess
396
+
397
+ project_prefix = self.config.project_prefix if self.config else "dev"
398
+ container_name = project.get_container_name(dev_name, project_prefix)
399
+ workspace_name = project.get_workspace_name(dev_name)
400
+ container_workspace_dir = f"/workspaces/{workspace_name}"
401
+
402
+ try:
403
+ # Execute command in the container
404
+ # Use same pattern as exec_claude: cd to workspace directory then run command
405
+ full_cmd = f"source ~/.zshrc >/dev/stderr 2>&1 && cd {container_workspace_dir} && {command}"
406
+ cmd = [
407
+ 'docker', 'exec', '-i', # -i for stdin, no TTY
408
+ container_name,
409
+ '/bin/zsh', '-c', full_cmd # Use zsh with explicit sourcing
410
+ ]
411
+
412
+ if debug:
413
+ logger.info("Executing command in container",
414
+ container=container_name,
415
+ command=command,
416
+ full_command=' '.join(cmd))
417
+
418
+ # Execute command without streaming (for CI we want to collect all output)
419
+ process = subprocess.run(
420
+ cmd,
421
+ capture_output=True,
422
+ text=True
423
+ )
424
+
425
+ stdout = process.stdout if process.stdout else ""
426
+ stderr = process.stderr if process.stderr else ""
427
+ success = process.returncode == 0
428
+ exit_code = process.returncode
429
+
430
+ if debug:
431
+ logger.info("Command execution completed",
432
+ container=container_name,
433
+ command=command,
434
+ exit_code=exit_code,
435
+ success=success,
436
+ stdout_length=len(stdout),
437
+ stderr_length=len(stderr))
438
+
439
+ return success, stdout, stderr, exit_code
440
+
441
+ except Exception as e:
442
+ error_msg = f"Command execution failed: {str(e)}"
443
+ logger.error("Error executing command in container",
444
+ container=container_name,
445
+ command=command,
446
+ error=error_msg,
447
+ exc_info=True)
448
+ return False, "", error_msg, 1
@@ -0,0 +1,16 @@
1
+ """Webhook-specific configuration extending the base config."""
2
+
3
+ from pathlib import Path
4
+ from devs_common.config import BaseConfig
5
+
6
+
7
+ class WebhookConfig(BaseConfig):
8
+ """Configuration for webhook handler extending base config."""
9
+
10
+ def get_default_workspaces_dir(self) -> Path:
11
+ """Get default workspaces directory - shared with CLI for interoperability."""
12
+ return Path.home() / ".devs" / "workspaces"
13
+
14
+ def get_default_project_prefix(self) -> str:
15
+ """Get default project prefix - same as CLI for interoperability."""
16
+ return "dev"
@@ -0,0 +1,57 @@
1
+ """Main webhook event handler (compatibility layer).
2
+
3
+ This module provides backward compatibility for the existing WebhookHandler API
4
+ by delegating to the new TaskProcessor.
5
+ """
6
+
7
+ from typing import Dict, Any
8
+ import structlog
9
+
10
+ from .task_processor import TaskProcessor
11
+
12
+ logger = structlog.get_logger()
13
+
14
+
15
+ class WebhookHandler:
16
+ """Main webhook event handler that coordinates all components.
17
+
18
+ This is now a thin compatibility wrapper around TaskProcessor.
19
+ """
20
+
21
+ def __init__(self):
22
+ """Initialize webhook handler."""
23
+ self.task_processor = TaskProcessor()
24
+ self.container_pool = self.task_processor.container_pool
25
+ self.config = self.task_processor.config
26
+
27
+ logger.info("Webhook handler initialized (compatibility mode)",
28
+ mentioned_user=self.config.github_mentioned_user,
29
+ container_pool=self.config.get_container_pool_list())
30
+
31
+ async def process_webhook(
32
+ self,
33
+ headers: Dict[str, str],
34
+ payload: bytes,
35
+ delivery_id: str
36
+ ) -> None:
37
+ """Process a GitHub webhook event.
38
+
39
+ Args:
40
+ headers: HTTP headers from webhook
41
+ payload: Raw webhook payload
42
+ delivery_id: GitHub delivery ID for tracking
43
+ """
44
+ # Delegate to TaskProcessor
45
+ await self.task_processor.process_webhook(headers, payload, delivery_id)
46
+
47
+ async def get_status(self) -> Dict[str, Any]:
48
+ """Get current handler status."""
49
+ return await self.task_processor.get_status()
50
+
51
+ async def stop_container(self, container_name: str) -> bool:
52
+ """Manually stop a container."""
53
+ return await self.task_processor.stop_container(container_name)
54
+
55
+ async def list_containers(self) -> Dict[str, Any]:
56
+ """List all managed containers."""
57
+ return await self.task_processor.list_containers()
@@ -0,0 +1,15 @@
1
+ """GitHub integration modules."""
2
+
3
+ from .models import WebhookEvent, IssueEvent, PullRequestEvent, CommentEvent
4
+ from .parser import WebhookParser
5
+ from .client import GitHubClient
6
+
7
+ __all__ = [
8
+ "WebhookEvent",
9
+ "IssueEvent",
10
+ "PullRequestEvent",
11
+ "CommentEvent",
12
+ "WebhookParser",
13
+ "GitHubClient",
14
+ "TestIssueEvent",
15
+ ]