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,386 @@
1
+ """CLI for webhook management."""
2
+
3
+ import os
4
+ import subprocess
5
+ import click
6
+ import httpx
7
+ import uvicorn
8
+ from httpx import BasicAuth
9
+ from pathlib import Path
10
+
11
+ from .config import get_config
12
+ from .utils.logging import setup_logging
13
+ from .cli.worker import worker
14
+
15
+
16
+ @click.group()
17
+ def cli():
18
+ """DevContainer Webhook Handler CLI."""
19
+ pass
20
+
21
+ # Add worker command to the CLI group
22
+ cli.add_command(worker)
23
+
24
+
25
+ @cli.command()
26
+ @click.option('--host', default=None, help='Host to bind to (webhook mode only)')
27
+ @click.option('--port', default=None, type=int, help='Port to bind to (webhook mode only)')
28
+ @click.option('--reload', is_flag=True, help='Enable auto-reload for development (webhook mode only)')
29
+ @click.option('--env-file', type=click.Path(exists=True, path_type=Path), help='Path to .env file to load')
30
+ @click.option('--dev', is_flag=True, help='Development mode (auto-loads .env, enables reload, console logs)')
31
+ @click.option('--source', type=click.Choice(['webhook', 'sqs'], case_sensitive=False), help='Task source override')
32
+ def serve(host: str, port: int, reload: bool, env_file: Path, dev: bool, source: str):
33
+ """Start the webhook handler server.
34
+
35
+ The server can run in two modes:
36
+ - webhook: Receives GitHub webhooks via FastAPI HTTP endpoint (default)
37
+ - sqs: Polls AWS SQS queue for webhook events
38
+
39
+ Examples:
40
+ devs-webhook serve --dev # Development mode with .env loading
41
+ devs-webhook serve --env-file /path/.env # Load specific .env file
42
+ devs-webhook serve --host 127.0.0.1 # Override host from config
43
+ devs-webhook serve --source sqs # Use SQS polling mode
44
+ """
45
+ setup_logging()
46
+
47
+ # Handle development mode
48
+ if dev:
49
+ reload = True
50
+ if env_file is None:
51
+ # Look for .env in current directory
52
+ env_file = Path.cwd() / ".env"
53
+ if not env_file.exists():
54
+ click.echo("โš ๏ธ Development mode enabled but no .env file found")
55
+ env_file = None
56
+
57
+ click.echo("๐Ÿš€ Development mode enabled")
58
+ if env_file:
59
+ click.echo(f"๐Ÿ“„ Loading environment variables from {env_file}")
60
+
61
+ # Load config with optional .env file
62
+ elif env_file:
63
+ click.echo(f"๐Ÿ“„ Loading environment variables from {env_file}")
64
+
65
+ # Load .env file first (before creating config)
66
+ if env_file:
67
+ # Load the env file explicitly
68
+ try:
69
+ from dotenv import load_dotenv
70
+ load_dotenv(env_file)
71
+ except ImportError:
72
+ click.echo("โš ๏ธ python-dotenv not available, skipping .env file loading")
73
+
74
+ # Set environment variables for dev mode
75
+ if dev:
76
+ os.environ["DEV_MODE"] = "true"
77
+ os.environ["LOG_FORMAT"] = "console"
78
+ if not source or source == "webhook":
79
+ os.environ["WEBHOOK_HOST"] = "127.0.0.1"
80
+
81
+ # Override task source if specified via CLI
82
+ if source:
83
+ os.environ["TASK_SOURCE"] = source
84
+
85
+ # Get config for display purposes (after loading env file)
86
+ config = get_config()
87
+
88
+ # Display configuration
89
+ click.echo(f"Task source: {config.task_source}")
90
+ click.echo(f"Watching for @{config.github_mentioned_user} mentions")
91
+ click.echo(f"Container pool: {', '.join(config.get_container_pool_list())}")
92
+
93
+ # Start the appropriate task source
94
+ if config.task_source == "webhook":
95
+ # Override config with CLI options
96
+ actual_host = host or config.webhook_host
97
+ actual_port = port or config.webhook_port
98
+
99
+ click.echo(f"Starting webhook server on {actual_host}:{actual_port}")
100
+ if dev:
101
+ click.echo("๐Ÿ”ง Development mode enabled - /testevent endpoint available")
102
+
103
+ uvicorn.run(
104
+ "devs_webhook.app:app",
105
+ host=actual_host,
106
+ port=actual_port,
107
+ reload=reload,
108
+ log_config=None, # Use our structlog config
109
+ )
110
+
111
+ elif config.task_source == "sqs":
112
+ click.echo(f"Starting SQS polling from: {config.aws_sqs_queue_url}")
113
+ click.echo(f"AWS region: {config.aws_region}")
114
+ if config.aws_sqs_dlq_url:
115
+ click.echo(f"DLQ configured: {config.aws_sqs_dlq_url}")
116
+
117
+ # Import and run SQS source
118
+ import asyncio
119
+ from .sources.sqs_source import SQSTaskSource
120
+
121
+ async def run_sqs():
122
+ sqs_source = SQSTaskSource()
123
+ try:
124
+ await sqs_source.start()
125
+ except KeyboardInterrupt:
126
+ click.echo("\n๐Ÿ›‘ Shutting down SQS polling...")
127
+ await sqs_source.stop()
128
+
129
+ try:
130
+ asyncio.run(run_sqs())
131
+ except KeyboardInterrupt:
132
+ click.echo("๐Ÿ›‘ Server stopped")
133
+
134
+ else:
135
+ click.echo(f"โŒ Unknown task source: {config.task_source}")
136
+ click.echo(" Valid options: webhook, sqs")
137
+ exit(1)
138
+
139
+
140
+ @cli.command()
141
+ def status():
142
+ """Show webhook handler status."""
143
+ config = get_config()
144
+ base_url = f"http://{config.webhook_host}:{config.webhook_port}"
145
+
146
+ # Try authenticated /status endpoint first if credentials are available
147
+ if config.admin_username and config.admin_password:
148
+ try:
149
+ auth = BasicAuth(config.admin_username, config.admin_password)
150
+ response = httpx.get(f"{base_url}/status", auth=auth, timeout=5.0)
151
+
152
+ if response.status_code == 200:
153
+ data = response.json()
154
+
155
+ click.echo("๐ŸŸข Webhook Handler Status")
156
+ click.echo(f"Queued tasks: {data['queued_tasks']}")
157
+ click.echo(f"Container pool size: {data['container_pool_size']}")
158
+ click.echo(f"Mentioned user: @{data['mentioned_user']}")
159
+
160
+ containers = data['containers']
161
+ click.echo(f"\nContainers:")
162
+ click.echo(f" Available: {len(containers['available'])}")
163
+ click.echo(f" Busy: {len(containers['busy'])}")
164
+
165
+ for name, info in containers['busy'].items():
166
+ click.echo(f" {name}: {info['repo']} (expires: {info['expires_at']})")
167
+ return
168
+
169
+ except Exception as e:
170
+ # Fall through to health endpoint
171
+ pass
172
+
173
+ # Fall back to unauthenticated /health endpoint
174
+ try:
175
+ response = httpx.get(f"{base_url}/health", timeout=5.0)
176
+ if response.status_code == 200:
177
+ data = response.json()
178
+
179
+ click.echo("๐ŸŸข Webhook Handler Health")
180
+ click.echo(f"Service: {data['service']} v{data['version']}")
181
+ click.echo(f"Status: {data['status']}")
182
+ click.echo(f"Mentioned user: @{data['config']['mentioned_user']}")
183
+ click.echo(f"Container pool: {data['config']['container_pool']}")
184
+ click.echo(f"Dev mode: {data['dev_mode']}")
185
+
186
+ click.echo("\n๐Ÿ’ก For detailed status, configure admin credentials")
187
+ else:
188
+ click.echo(f"โŒ Server returned {response.status_code}")
189
+
190
+ except Exception as e:
191
+ click.echo(f"โŒ Failed to connect to webhook handler: {e}")
192
+
193
+
194
+ @cli.command()
195
+ def config():
196
+ """Show current configuration."""
197
+ try:
198
+ config = get_config()
199
+
200
+ click.echo("๐Ÿ“‹ Webhook Handler Configuration")
201
+ click.echo(f"Mentioned user: @{config.github_mentioned_user}")
202
+ click.echo(f"Container pool: {', '.join(config.get_container_pool_list())}")
203
+ click.echo(f"Container timeout: {config.container_timeout_minutes} minutes")
204
+ click.echo(f"Repository cache: {config.repo_cache_dir}")
205
+ click.echo(f"Workspace directory: {config.workspaces_dir}")
206
+ click.echo(f"Server: {config.webhook_host}:{config.webhook_port}")
207
+ click.echo(f"Webhook path: {config.webhook_path}")
208
+ click.echo(f"Log level: {config.log_level}")
209
+
210
+ # Check for missing required settings
211
+ missing = []
212
+ if not config.github_webhook_secret:
213
+ missing.append("GITHUB_WEBHOOK_SECRET")
214
+ if not config.github_token:
215
+ missing.append("GITHUB_TOKEN")
216
+
217
+ if missing:
218
+ click.echo(f"\nโš ๏ธ Missing required environment variables:")
219
+ for var in missing:
220
+ click.echo(f" {var}")
221
+ else:
222
+ click.echo(f"\nโœ… All required configuration present")
223
+
224
+ except Exception as e:
225
+ click.echo(f"โŒ Configuration error: {e}")
226
+
227
+
228
+ @cli.command()
229
+ @click.argument('container_name')
230
+ def stop_container(container_name: str):
231
+ """Stop a specific container."""
232
+ config = get_config()
233
+ url = f"http://{config.webhook_host}:{config.webhook_port}/container/{container_name}/stop"
234
+
235
+ try:
236
+ # Include authentication if available
237
+ auth = None
238
+ if config.admin_username and config.admin_password:
239
+ auth = BasicAuth(config.admin_username, config.admin_password)
240
+
241
+ response = httpx.post(url, auth=auth, timeout=10.0)
242
+ if response.status_code == 200:
243
+ click.echo(f"โœ… Container {container_name} stopped")
244
+ elif response.status_code == 404:
245
+ click.echo(f"โŒ Container {container_name} not found")
246
+ elif response.status_code == 401:
247
+ click.echo(f"โŒ Authentication required. Configure admin credentials.")
248
+ else:
249
+ click.echo(f"โŒ Failed to stop container: {response.status_code}")
250
+
251
+ except Exception as e:
252
+ click.echo(f"โŒ Failed to connect to webhook handler: {e}")
253
+
254
+
255
+ @cli.command()
256
+ def test_setup():
257
+ """Test webhook handler setup and dependencies."""
258
+ click.echo("๐Ÿงช Testing webhook handler setup...")
259
+
260
+ # Test configuration
261
+ try:
262
+ config = get_config()
263
+ click.echo("โœ… Configuration loaded")
264
+ except Exception as e:
265
+ click.echo(f"โŒ Configuration error: {e}")
266
+ return
267
+
268
+ # Test directories
269
+ try:
270
+ config.ensure_directories()
271
+ click.echo("โœ… Directories created")
272
+ except Exception as e:
273
+ click.echo(f"โŒ Directory creation failed: {e}")
274
+ return
275
+
276
+ # Test GitHub CLI
277
+ try:
278
+ result = subprocess.run(['gh', '--version'], capture_output=True, text=True)
279
+ if result.returncode == 0:
280
+ click.echo("โœ… GitHub CLI available")
281
+ else:
282
+ click.echo("โŒ GitHub CLI not working")
283
+ except FileNotFoundError:
284
+ click.echo("โŒ GitHub CLI not installed")
285
+
286
+ # Test Docker
287
+ try:
288
+ result = subprocess.run(['docker', '--version'], capture_output=True, text=True)
289
+ if result.returncode == 0:
290
+ click.echo("โœ… Docker available")
291
+ else:
292
+ click.echo("โŒ Docker not working")
293
+ except FileNotFoundError:
294
+ click.echo("โŒ Docker not installed")
295
+
296
+ # Test DevContainer CLI
297
+ try:
298
+ result = subprocess.run(['devcontainer', '--version'], capture_output=True, text=True)
299
+ if result.returncode == 0:
300
+ click.echo("โœ… DevContainer CLI available")
301
+ else:
302
+ click.echo("โŒ DevContainer CLI not working")
303
+ except FileNotFoundError:
304
+ click.echo("โŒ DevContainer CLI not installed")
305
+
306
+ click.echo("\n๐ŸŽ‰ Setup test complete!")
307
+
308
+
309
+ @cli.command()
310
+ @click.argument('prompt')
311
+ @click.option('--repo', default='test/repo', help='Repository name (default: test/repo)')
312
+ @click.option('--host', default=None, help='Webhook server host')
313
+ @click.option('--port', default=None, type=int, help='Webhook server port')
314
+ def test(prompt: str, repo: str, host: str, port: int):
315
+ """Send a test prompt to the webhook handler.
316
+
317
+ This sends a test event to the /testevent endpoint, which is only available
318
+ in development mode.
319
+
320
+ Examples:
321
+ devs-webhook test "Fix the login bug"
322
+ devs-webhook test "Add dark mode toggle" --repo myorg/myproject
323
+ """
324
+ config = get_config()
325
+
326
+ # Use CLI options or config defaults
327
+ actual_host = host or config.webhook_host
328
+ actual_port = port or config.webhook_port
329
+ url = f"http://{actual_host}:{actual_port}/testevent"
330
+
331
+ payload = {
332
+ "prompt": prompt,
333
+ "repo": repo
334
+ }
335
+
336
+ try:
337
+ click.echo(f"๐Ÿงช Sending test event to {url}")
338
+ click.echo(f"๐Ÿ“ Prompt: {prompt}")
339
+ click.echo(f"๐Ÿ“ฆ Repository: {repo}")
340
+
341
+ # Include authentication if available
342
+ auth = None
343
+ if config.admin_username and config.admin_password:
344
+ auth = BasicAuth(config.admin_username, config.admin_password)
345
+
346
+ response = httpx.post(
347
+ url,
348
+ json=payload,
349
+ auth=auth,
350
+ timeout=10.0
351
+ )
352
+
353
+ if response.status_code == 202:
354
+ data = response.json()
355
+ click.echo(f"\nโœ… Test event accepted!")
356
+ click.echo(f"๐Ÿ†” Delivery ID: {data['delivery_id']}")
357
+ click.echo(f"๐Ÿ“‹ Status: {data['status']}")
358
+ click.echo(f"\n๐Ÿ’ก Check logs or /status endpoint for processing updates")
359
+
360
+ elif response.status_code == 404:
361
+ click.echo(f"โŒ Test endpoint not available (server not in development mode)")
362
+ click.echo(f"๐Ÿ’ก Start server with: devs-webhook serve --dev")
363
+
364
+ else:
365
+ click.echo(f"โŒ Request failed with status {response.status_code}")
366
+ try:
367
+ error_data = response.json()
368
+ click.echo(f"Error: {error_data.get('detail', 'Unknown error')}")
369
+ except:
370
+ click.echo(f"Response: {response.text}")
371
+
372
+ except httpx.ConnectError:
373
+ click.echo(f"โŒ Failed to connect to webhook server at {actual_host}:{actual_port}")
374
+ click.echo(f"๐Ÿ’ก Make sure the server is running with: devs-webhook serve --dev")
375
+
376
+ except Exception as e:
377
+ click.echo(f"โŒ Unexpected error: {e}")
378
+
379
+
380
+ def main():
381
+ """Main CLI entry point."""
382
+ cli()
383
+
384
+
385
+ if __name__ == '__main__':
386
+ main()
@@ -0,0 +1,7 @@
1
+ """Task source implementations for webhook handler."""
2
+
3
+ from .base import TaskSource
4
+ from .webhook_source import WebhookTaskSource
5
+ from .sqs_source import SQSTaskSource
6
+
7
+ __all__ = ["TaskSource", "WebhookTaskSource", "SQSTaskSource"]
@@ -0,0 +1,24 @@
1
+ """Base interface for task sources."""
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+
6
+ class TaskSource(ABC):
7
+ """Abstract interface for task sources that feed tasks into the webhook handler.
8
+
9
+ A task source is responsible for receiving task requests from external systems
10
+ (webhooks, queues, etc.) and forwarding them to the task processor.
11
+ """
12
+
13
+ @abstractmethod
14
+ async def start(self) -> None:
15
+ """Start receiving and processing tasks.
16
+
17
+ This method should block until the task source is stopped.
18
+ """
19
+ pass
20
+
21
+ @abstractmethod
22
+ async def stop(self) -> None:
23
+ """Stop receiving tasks and clean up resources."""
24
+ pass