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.
- devs_webhook/__init__.py +21 -0
- devs_webhook/app.py +321 -0
- devs_webhook/cli/__init__.py +6 -0
- devs_webhook/cli/worker.py +296 -0
- devs_webhook/config.py +319 -0
- devs_webhook/core/__init__.py +1 -0
- devs_webhook/core/claude_dispatcher.py +420 -0
- devs_webhook/core/container_pool.py +1109 -0
- devs_webhook/core/deduplication.py +113 -0
- devs_webhook/core/repository_manager.py +197 -0
- devs_webhook/core/task_processor.py +286 -0
- devs_webhook/core/test_dispatcher.py +448 -0
- devs_webhook/core/webhook_config.py +16 -0
- devs_webhook/core/webhook_handler.py +57 -0
- devs_webhook/github/__init__.py +15 -0
- devs_webhook/github/app_auth.py +226 -0
- devs_webhook/github/client.py +472 -0
- devs_webhook/github/models.py +424 -0
- devs_webhook/github/parser.py +325 -0
- devs_webhook/main_cli.py +386 -0
- devs_webhook/sources/__init__.py +7 -0
- devs_webhook/sources/base.py +24 -0
- devs_webhook/sources/sqs_source.py +306 -0
- devs_webhook/sources/webhook_source.py +82 -0
- devs_webhook/utils/__init__.py +1 -0
- devs_webhook/utils/async_utils.py +86 -0
- devs_webhook/utils/github.py +43 -0
- devs_webhook/utils/logging.py +34 -0
- devs_webhook/utils/serialization.py +102 -0
- devs_webhook-0.1.0.dist-info/METADATA +664 -0
- devs_webhook-0.1.0.dist-info/RECORD +35 -0
- devs_webhook-0.1.0.dist-info/WHEEL +5 -0
- devs_webhook-0.1.0.dist-info/entry_points.txt +3 -0
- devs_webhook-0.1.0.dist-info/licenses/LICENSE +21 -0
- devs_webhook-0.1.0.dist-info/top_level.txt +1 -0
devs_webhook/main_cli.py
ADDED
|
@@ -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,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
|