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,21 @@
1
+ """DevContainer Webhook Handler
2
+
3
+ A GitHub webhook handler for automated devcontainer operations with Claude Code.
4
+ """
5
+
6
+ __version__ = "0.1.0"
7
+ __author__ = "Dan Lester"
8
+
9
+ from .config import WebhookConfig
10
+ from .core.webhook_handler import WebhookHandler
11
+ from .core.container_pool import ContainerPool
12
+ from .core.repository_manager import RepositoryManager
13
+ from .core.claude_dispatcher import ClaudeDispatcher
14
+
15
+ __all__ = [
16
+ "WebhookConfig",
17
+ "WebhookHandler",
18
+ "ContainerPool",
19
+ "RepositoryManager",
20
+ "ClaudeDispatcher",
21
+ ]
devs_webhook/app.py ADDED
@@ -0,0 +1,321 @@
1
+ """FastAPI webhook server.
2
+
3
+ This module provides the FastAPI application for receiving GitHub webhooks.
4
+ It's part of the webhook task source and delegates processing to WebhookHandler,
5
+ which in turn uses TaskProcessor for the core business logic.
6
+
7
+ Architecture:
8
+ FastAPI endpoints -> WebhookHandler -> TaskProcessor -> ContainerPool
9
+ """
10
+
11
+ import secrets
12
+ from fastapi import FastAPI, Request, HTTPException, BackgroundTasks, Depends, status
13
+ from fastapi.security import HTTPBasic, HTTPBasicCredentials
14
+ from fastapi.responses import JSONResponse
15
+ from pydantic import BaseModel
16
+ import structlog
17
+ import uuid
18
+ from datetime import datetime, timezone
19
+
20
+ from .config import get_config, WebhookConfig
21
+ from .core.webhook_handler import WebhookHandler
22
+ from .utils.logging import setup_logging
23
+ from .utils.github import verify_github_signature
24
+
25
+ # Set up logging
26
+ setup_logging()
27
+ logger = structlog.get_logger()
28
+
29
+
30
+ class TestEventRequest(BaseModel):
31
+ """Request model for test event endpoint."""
32
+ prompt: str
33
+ repo: str = "test/repo" # Default test repository
34
+
35
+ # Initialize FastAPI app
36
+ app = FastAPI(
37
+ title="DevContainer Webhook Handler",
38
+ description="GitHub webhook handler for automated devcontainer operations with Claude Code",
39
+ version="0.1.0"
40
+ )
41
+
42
+ # Security setup for admin endpoints
43
+ security = HTTPBasic()
44
+
45
+ # Initialize webhook handler lazily
46
+ webhook_handler = None
47
+
48
+
49
+ def get_webhook_handler():
50
+ """Get or create the webhook handler."""
51
+ global webhook_handler
52
+ if webhook_handler is None:
53
+ webhook_handler = WebhookHandler()
54
+ return webhook_handler
55
+
56
+
57
+ def require_dev_mode(config: WebhookConfig = Depends(get_config)):
58
+ """Dependency that requires development mode."""
59
+ if not config.dev_mode:
60
+ raise HTTPException(
61
+ status_code=404,
62
+ detail="This endpoint is only available in development mode"
63
+ )
64
+
65
+
66
+ def verify_admin_credentials(
67
+ credentials: HTTPBasicCredentials = Depends(security),
68
+ config: WebhookConfig = Depends(get_config)
69
+ ):
70
+ """Verify admin credentials for protected endpoints.
71
+
72
+ Args:
73
+ credentials: HTTP Basic auth credentials
74
+ config: Webhook configuration
75
+
76
+ Returns:
77
+ Username if authentication successful
78
+
79
+ Raises:
80
+ HTTPException: If authentication fails
81
+ """
82
+ # In dev mode with no password set, allow any credentials
83
+ if config.dev_mode and not config.admin_password:
84
+ logger.warning("Admin auth bypassed in dev mode without password")
85
+ return credentials.username
86
+
87
+ # Verify username and password
88
+ correct_username = secrets.compare_digest(
89
+ credentials.username, config.admin_username
90
+ )
91
+ correct_password = secrets.compare_digest(
92
+ credentials.password, config.admin_password
93
+ )
94
+
95
+ if not (correct_username and correct_password):
96
+ logger.warning(
97
+ "Failed admin authentication attempt",
98
+ username=credentials.username
99
+ )
100
+ raise HTTPException(
101
+ status_code=status.HTTP_401_UNAUTHORIZED,
102
+ detail="Invalid authentication credentials"
103
+ )
104
+
105
+ return credentials.username
106
+
107
+
108
+ # verify_github_signature is now imported from .utils module
109
+
110
+
111
+ @app.get("/")
112
+ async def root():
113
+ """Health check endpoint."""
114
+ return {"status": "healthy", "service": "devs-webhook"}
115
+
116
+
117
+ @app.get("/health")
118
+ async def health():
119
+ """Health check endpoint."""
120
+ return {"status": "healthy", "service": "devs-webhook"}
121
+
122
+
123
+ @app.post("/webhook")
124
+ async def handle_webhook(request: Request, background_tasks: BackgroundTasks):
125
+ """Handle GitHub webhook events."""
126
+ config = get_config()
127
+
128
+ # Get headers
129
+ headers = dict(request.headers)
130
+
131
+ # Read payload
132
+ payload = await request.body()
133
+
134
+ # Verify signature
135
+ signature = headers.get("x-hub-signature-256", "")
136
+ if not verify_github_signature(payload, signature, config.github_webhook_secret):
137
+ logger.warning("Invalid webhook signature", signature=signature)
138
+ raise HTTPException(status_code=401, detail="Invalid signature")
139
+
140
+ # Get event type
141
+ event_type = headers.get("x-github-event", "unknown")
142
+ delivery_id = headers.get("x-github-delivery", "unknown")
143
+
144
+ logger.info(
145
+ "Webhook received",
146
+ event_type=event_type,
147
+ delivery_id=delivery_id,
148
+ payload_size=len(payload)
149
+ )
150
+
151
+ # Process webhook in background
152
+ background_tasks.add_task(
153
+ get_webhook_handler().process_webhook,
154
+ headers,
155
+ payload,
156
+ delivery_id
157
+ )
158
+
159
+ return JSONResponse(
160
+ status_code=200,
161
+ content={"status": "accepted", "delivery_id": delivery_id}
162
+ )
163
+
164
+
165
+ @app.get("/status")
166
+ async def get_status(username: str = Depends(verify_admin_credentials)):
167
+ """Get current webhook handler status.
168
+
169
+ Requires admin authentication.
170
+ """
171
+ logger.info("Status endpoint accessed", authenticated_user=username)
172
+ return await get_webhook_handler().get_status()
173
+
174
+
175
+ @app.post("/container/{container_name}/stop")
176
+ async def stop_container(
177
+ container_name: str,
178
+ username: str = Depends(verify_admin_credentials)
179
+ ):
180
+ """Manually stop a container.
181
+
182
+ Requires admin authentication.
183
+ """
184
+ logger.info(
185
+ "Container stop requested",
186
+ container=container_name,
187
+ authenticated_user=username
188
+ )
189
+ success = await get_webhook_handler().stop_container(container_name)
190
+ if success:
191
+ return {"status": "stopped", "container": container_name}
192
+ else:
193
+ raise HTTPException(status_code=404, detail="Container not found or failed to stop")
194
+
195
+
196
+ @app.get("/containers")
197
+ async def list_containers(username: str = Depends(verify_admin_credentials)):
198
+ """List all managed containers.
199
+
200
+ Requires admin authentication.
201
+ """
202
+ logger.info("Containers list accessed", authenticated_user=username)
203
+ return await get_webhook_handler().list_containers()
204
+
205
+
206
+ @app.post("/testevent")
207
+ async def test_event(
208
+ request: TestEventRequest,
209
+ config: WebhookConfig = Depends(require_dev_mode),
210
+ username: str = Depends(verify_admin_credentials)
211
+ ):
212
+ """Test endpoint to simulate GitHub webhook events with custom prompts.
213
+
214
+ Only available in development mode.
215
+
216
+ Example:
217
+ POST /testevent
218
+ {
219
+ "prompt": "Fix the login bug in the authentication module",
220
+ "repo": "myorg/myproject"
221
+ }
222
+ """
223
+ # Generate a unique delivery ID for this test
224
+ delivery_id = f"test-{uuid.uuid4().hex[:8]}"
225
+
226
+ logger.info(
227
+ "Test event received",
228
+ prompt_length=len(request.prompt),
229
+ repo=request.repo,
230
+ delivery_id=delivery_id
231
+ )
232
+
233
+ # Create a minimal mock webhook event
234
+ from .github.models import GitHubRepository, GitHubUser, GitHubIssue, TestIssueEvent
235
+
236
+ # Mock repository
237
+ mock_repo = GitHubRepository(
238
+ id=999999,
239
+ name=request.repo.split("/")[-1],
240
+ full_name=request.repo,
241
+ owner=GitHubUser(
242
+ login=request.repo.split("/")[0],
243
+ id=999999,
244
+ avatar_url="https://github.com/test.png",
245
+ html_url=f"https://github.com/{request.repo.split('/')[0]}"
246
+ ),
247
+ html_url=f"https://github.com/{request.repo}",
248
+ clone_url=f"https://github.com/{request.repo}.git",
249
+ ssh_url=f"git@github.com:{request.repo}.git",
250
+ default_branch="main"
251
+ )
252
+
253
+ # Mock issue with the prompt
254
+ mock_issue = GitHubIssue(
255
+ id=999999,
256
+ number=999,
257
+ title="Test Issue",
258
+ body=f"Test prompt: {request.prompt}",
259
+ state="open",
260
+ user=GitHubUser(
261
+ login="test-user",
262
+ id=999999,
263
+ avatar_url="https://github.com/test.png",
264
+ html_url="https://github.com/test-user"
265
+ ),
266
+ html_url=f"https://github.com/{request.repo}/issues/999",
267
+ created_at=datetime.now(tz=timezone.utc),
268
+ updated_at=datetime.now(tz=timezone.utc)
269
+ )
270
+
271
+ # Mock issue event
272
+ mock_event = TestIssueEvent(
273
+ action="opened",
274
+ issue=mock_issue,
275
+ repository=mock_repo,
276
+ sender=mock_issue.user
277
+ )
278
+
279
+ # Queue the task directly in the container pool
280
+ success = await get_webhook_handler().container_pool.queue_task(
281
+ task_id=delivery_id,
282
+ repo_name=request.repo,
283
+ task_description=request.prompt,
284
+ event=mock_event,
285
+ )
286
+
287
+ if success:
288
+ logger.info("Test task queued successfully",
289
+ delivery_id=delivery_id,
290
+ repo=request.repo)
291
+
292
+ return JSONResponse(
293
+ status_code=202,
294
+ content={
295
+ "status": "test_accepted",
296
+ "delivery_id": delivery_id,
297
+ "repo": request.repo,
298
+ "prompt": request.prompt[:100] + "..." if len(request.prompt) > 100 else request.prompt,
299
+ "message": "Test task queued for processing"
300
+ }
301
+ )
302
+ else:
303
+ logger.error("Failed to queue test task",
304
+ delivery_id=delivery_id,
305
+ repo=request.repo)
306
+
307
+ raise HTTPException(
308
+ status_code=500,
309
+ detail="Failed to queue test task"
310
+ )
311
+
312
+
313
+ # Error handlers
314
+ @app.exception_handler(Exception)
315
+ async def global_exception_handler(request: Request, exc: Exception):
316
+ """Global exception handler."""
317
+ logger.error("Unhandled exception", error=str(exc), path=request.url.path)
318
+ return JSONResponse(
319
+ status_code=500,
320
+ content={"error": "Internal server error", "detail": str(exc)}
321
+ )
@@ -0,0 +1,6 @@
1
+ """CLI commands for devs webhook."""
2
+
3
+ # Import main CLI from parent module
4
+ from ..main_cli import main
5
+
6
+ __all__ = ["main"]
@@ -0,0 +1,296 @@
1
+ """Webhook worker CLI command for processing tasks in isolated subprocess."""
2
+
3
+ import os
4
+ import sys
5
+ import json
6
+ import click
7
+ import structlog
8
+ import asyncio
9
+ from pathlib import Path
10
+ from datetime import datetime, timezone
11
+ from typing import Optional
12
+
13
+ from pydantic import TypeAdapter
14
+ from devs_common.core.project import Project
15
+
16
+ from ..config import get_config
17
+ from ..core.claude_dispatcher import ClaudeDispatcher
18
+ from ..core.test_dispatcher import TestDispatcher
19
+ from ..github.models import AnyWebhookEvent, DevsOptions
20
+
21
+ logger = structlog.get_logger()
22
+
23
+
24
+ @click.command()
25
+ @click.option('--task-id', required=True, help='Unique task identifier')
26
+ @click.option('--dev-name', required=True, help='Development container name')
27
+ @click.option('--repo-name', required=True, help='Repository name (owner/repo)')
28
+ @click.option('--repo-path', required=True, help='Path to repository on host')
29
+ @click.option('--task-type', default='claude', help='Task type: claude or tests (default: claude)')
30
+ @click.option('--timeout', default=3600, help='Task timeout in seconds (default: 3600)')
31
+ def worker(task_id: str, dev_name: str, repo_name: str, repo_path: str, task_type: str, timeout: int):
32
+ """Process a single webhook task in an isolated subprocess.
33
+
34
+ This command runs the complete task processing logic in a separate process to provide
35
+ Docker safety and prevent blocking the main web server.
36
+
37
+ Large payloads (task description, webhook event data, options) are read
38
+ from stdin as JSON to avoid command-line length limitations.
39
+
40
+ Expected stdin JSON format:
41
+ {
42
+ "task_description": "string",
43
+ "event": {...webhook event object...},
44
+ "devs_options": {...devs options object...} (optional)
45
+ }
46
+ """
47
+ # Set environment variable to redirect console output to stderr
48
+ os.environ['DEVS_WEBHOOK_MODE'] = '1'
49
+
50
+ # Configure structured logging for subprocess to output to stderr
51
+ import logging
52
+ logging.basicConfig(
53
+ stream=sys.stderr,
54
+ level=logging.INFO,
55
+ format='%(message)s'
56
+ )
57
+
58
+ structlog.configure(
59
+ processors=[
60
+ structlog.stdlib.filter_by_level,
61
+ structlog.stdlib.add_logger_name,
62
+ structlog.stdlib.add_log_level,
63
+ structlog.stdlib.PositionalArgumentsFormatter(),
64
+ structlog.processors.TimeStamper(fmt="iso"),
65
+ structlog.processors.StackInfoRenderer(),
66
+ structlog.processors.format_exc_info,
67
+ structlog.processors.UnicodeDecoder(),
68
+ structlog.processors.JSONRenderer()
69
+ ],
70
+ wrapper_class=structlog.stdlib.BoundLogger,
71
+ logger_factory=structlog.stdlib.LoggerFactory(),
72
+ cache_logger_on_first_use=True,
73
+ )
74
+
75
+ logger.info("Worker subprocess started",
76
+ task_id=task_id,
77
+ dev_name=dev_name,
78
+ repo_name=repo_name,
79
+ repo_path=repo_path,
80
+ timeout=timeout,
81
+ pid=os.getpid())
82
+
83
+ try:
84
+ # Read payload from stdin
85
+ logger.info("Reading payload from stdin", task_id=task_id)
86
+ stdin_data = sys.stdin.read()
87
+
88
+ if not stdin_data:
89
+ raise ValueError("No data provided on stdin")
90
+
91
+ try:
92
+ payload = json.loads(stdin_data)
93
+ except json.JSONDecodeError as e:
94
+ raise ValueError(f"Invalid JSON on stdin: {e}")
95
+
96
+ # Extract required fields
97
+ task_description = payload.get('task_description')
98
+ event_data = payload.get('event')
99
+ devs_options_data = payload.get('devs_options')
100
+
101
+ # task_description is only required for claude tasks, not for tests
102
+ if task_type == 'claude' and not task_description:
103
+ raise ValueError("task_description required in stdin JSON for claude tasks")
104
+ if not event_data:
105
+ raise ValueError("event required in stdin JSON")
106
+
107
+ # Parse webhook event directly from JSON - let Pydantic figure out the type!
108
+ logger.info("Parsing webhook event from JSON", task_id=task_id)
109
+
110
+ # Use TypeAdapter to handle the union type automatically
111
+ webhook_adapter = TypeAdapter(AnyWebhookEvent)
112
+ event = webhook_adapter.validate_python(event_data)
113
+
114
+ parsed_devs_options = None
115
+ if devs_options_data:
116
+ logger.info("Parsing devs options from JSON", task_id=task_id)
117
+ parsed_devs_options = DevsOptions.model_validate(devs_options_data)
118
+
119
+ # Run the task processing logic (extracted from ContainerPool._process_task)
120
+ result = _process_task_subprocess(
121
+ task_id=task_id,
122
+ dev_name=dev_name,
123
+ repo_name=repo_name,
124
+ repo_path=Path(repo_path),
125
+ task_description=task_description,
126
+ event=event,
127
+ devs_options=parsed_devs_options,
128
+ task_type=task_type
129
+ )
130
+
131
+ # Output result as JSON to stdout
132
+ output = {
133
+ 'success': result['success'],
134
+ 'output': result.get('output', ''),
135
+ 'error': result.get('error'),
136
+ 'task_id': task_id,
137
+ 'timestamp': datetime.now(tz=timezone.utc).isoformat()
138
+ }
139
+
140
+ print(json.dumps(output))
141
+ sys.exit(0 if result['success'] else 1)
142
+
143
+ except Exception as e:
144
+ error_msg = f"Worker subprocess failed: {str(e)}"
145
+ logger.error("Worker subprocess error",
146
+ task_id=task_id,
147
+ error=error_msg,
148
+ exc_info=True)
149
+
150
+ # Output error as JSON to stdout
151
+ error_output = {
152
+ 'success': False,
153
+ 'output': '',
154
+ 'error': error_msg,
155
+ 'task_id': task_id,
156
+ 'timestamp': datetime.now(tz=timezone.utc).isoformat()
157
+ }
158
+
159
+ print(json.dumps(error_output))
160
+ sys.exit(1)
161
+
162
+
163
+ def _process_task_subprocess(
164
+ task_id: str,
165
+ dev_name: str,
166
+ repo_name: str,
167
+ repo_path: Path,
168
+ task_description: Optional[str],
169
+ event,
170
+ devs_options,
171
+ task_type: str = 'claude'
172
+ ) -> dict:
173
+ """Process a single task in subprocess (extracted from ContainerPool._process_task).
174
+
175
+ Args:
176
+ task_id: Unique task identifier
177
+ dev_name: Name of container to execute in
178
+ repo_name: Repository name (owner/repo)
179
+ repo_path: Path to repository on host
180
+ task_description: Task description for Claude (unused for tests, can be None for test tasks)
181
+ event: WebhookEvent instance
182
+ devs_options: DevsOptions instance
183
+ task_type: Task type ('claude' or 'tests')
184
+
185
+ Returns:
186
+ Dict with 'success', 'output', and optionally 'error' keys
187
+ """
188
+ logger.info("Starting task processing in subprocess",
189
+ task_id=task_id,
190
+ dev_name=dev_name,
191
+ repo_name=repo_name,
192
+ repo_path=str(repo_path),
193
+ task_type=task_type)
194
+
195
+ try:
196
+ # Verify repository exists (should already be cloned by container_pool)
197
+ if not repo_path.exists():
198
+ raise Exception(f"Repository path does not exist: {repo_path}")
199
+
200
+ # Create project instance
201
+ logger.info("Creating project instance",
202
+ task_id=task_id,
203
+ dev_name=dev_name,
204
+ repo_path=str(repo_path),
205
+ task_type=task_type)
206
+
207
+ project = Project(repo_path)
208
+ workspace_name = project.get_workspace_name(dev_name)
209
+
210
+ logger.info("Project created successfully",
211
+ task_id=task_id,
212
+ dev_name=dev_name,
213
+ project_name=project.info.name,
214
+ workspace_name=workspace_name,
215
+ task_type=task_type)
216
+
217
+ # Initialize appropriate dispatcher based on task type
218
+ if task_type == 'tests':
219
+ 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
+ else:
233
+ # Default to Claude execution
234
+ 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
+
240
+ # Ensure task_description is provided for Claude tasks
241
+ if not task_description:
242
+ 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
+ ))
252
+
253
+ if result.success:
254
+ logger.info("Task execution completed successfully",
255
+ task_id=task_id,
256
+ dev_name=dev_name,
257
+ output_length=len(result.output) if result.output else 0)
258
+
259
+ return {
260
+ 'success': True,
261
+ 'output': result.output,
262
+ }
263
+ else:
264
+ logger.error("Task execution failed",
265
+ task_id=task_id,
266
+ dev_name=dev_name,
267
+ error=result.error,
268
+ output_preview=result.output[:500] if result.output else "")
269
+
270
+ return {
271
+ 'success': False,
272
+ 'output': result.output or '',
273
+ 'error': result.error
274
+ }
275
+
276
+ except Exception as e:
277
+ error_msg = f"Task processing failed: {str(e)}"
278
+ logger.error("Task processing error in subprocess",
279
+ task_id=task_id,
280
+ dev_name=dev_name,
281
+ repo_name=repo_name,
282
+ repo_path=str(repo_path),
283
+ error=error_msg,
284
+ error_type=type(e).__name__,
285
+ exc_info=True)
286
+
287
+ return {
288
+ 'success': False,
289
+ 'output': '',
290
+ 'error': error_msg
291
+ }
292
+
293
+
294
+ if __name__ == '__main__':
295
+ import os
296
+ worker()