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/config.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""Configuration management for webhook handler."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
try:
|
|
8
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
9
|
+
except ImportError:
|
|
10
|
+
# Fallback for older pydantic versions
|
|
11
|
+
from pydantic import BaseSettings
|
|
12
|
+
SettingsConfigDict = None
|
|
13
|
+
from pydantic import Field, model_validator
|
|
14
|
+
from devs_common.config import BaseConfig
|
|
15
|
+
import structlog
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class WebhookConfig(BaseSettings, BaseConfig):
|
|
19
|
+
"""Configuration for the webhook handler."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, **kwargs):
|
|
22
|
+
"""Initialize webhook configuration with both BaseSettings and BaseConfig."""
|
|
23
|
+
BaseSettings.__init__(self, **kwargs)
|
|
24
|
+
BaseConfig.__init__(self)
|
|
25
|
+
|
|
26
|
+
# GitHub settings
|
|
27
|
+
github_webhook_secret: str = Field(default="", description="GitHub webhook secret")
|
|
28
|
+
github_token: str = Field(default="", description="GitHub personal access token")
|
|
29
|
+
github_mentioned_user: str = Field(default="", description="GitHub username to watch for @mentions")
|
|
30
|
+
|
|
31
|
+
# GitHub App settings (optional, for enhanced Checks API support)
|
|
32
|
+
github_app_id: str = Field(default="", description="GitHub App ID for app authentication")
|
|
33
|
+
github_app_private_key: str = Field(default="", description="GitHub App private key (PEM format) or path to private key file")
|
|
34
|
+
github_app_installation_id: str = Field(default="", description="GitHub App installation ID (optional, can be auto-discovered)")
|
|
35
|
+
|
|
36
|
+
# Access control settings
|
|
37
|
+
allowed_orgs: str = Field(
|
|
38
|
+
default="",
|
|
39
|
+
description="Comma-separated list of allowed GitHub organizations"
|
|
40
|
+
)
|
|
41
|
+
allowed_users: str = Field(
|
|
42
|
+
default="",
|
|
43
|
+
description="Comma-separated list of allowed GitHub usernames"
|
|
44
|
+
)
|
|
45
|
+
authorized_trigger_users: str = Field(
|
|
46
|
+
default="",
|
|
47
|
+
description="Comma-separated list of GitHub usernames authorized to trigger webhook processing"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Basic auth settings for admin endpoints
|
|
51
|
+
admin_username: str = Field(
|
|
52
|
+
default="admin",
|
|
53
|
+
description="Username for admin endpoint authentication"
|
|
54
|
+
)
|
|
55
|
+
admin_password: str = Field(
|
|
56
|
+
default="",
|
|
57
|
+
description="Password for admin endpoint authentication (required for production)"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Runtime settings
|
|
61
|
+
dev_mode: bool = Field(default=False, description="Development mode enabled")
|
|
62
|
+
|
|
63
|
+
# Container pool settings
|
|
64
|
+
container_pool: str = Field(
|
|
65
|
+
default="eamonn,harry,darren",
|
|
66
|
+
description="Comma-separated list of named containers in the pool"
|
|
67
|
+
)
|
|
68
|
+
container_timeout_minutes: int = Field(default=60, description="Container timeout in minutes")
|
|
69
|
+
max_concurrent_tasks: int = Field(default=3, description="Maximum concurrent tasks")
|
|
70
|
+
|
|
71
|
+
# Repository settings
|
|
72
|
+
repo_cache_dir: Path = Field(
|
|
73
|
+
default_factory=lambda: Path.home() / ".devs" / "repocache",
|
|
74
|
+
description="Directory to cache cloned repositories (shared with CLI)"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Claude Code settings (shared with CLI for interoperability)
|
|
78
|
+
claude_config_dir: Path = Field(
|
|
79
|
+
default_factory=lambda: Path.home() / ".devs" / "claudeconfig",
|
|
80
|
+
description="Directory for Claude Code configuration (shared with CLI)"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Server settings
|
|
84
|
+
webhook_host: str = Field(default="0.0.0.0", description="Host to bind webhook server")
|
|
85
|
+
webhook_port: int = Field(default=8000, description="Port to bind webhook server")
|
|
86
|
+
webhook_path: str = Field(default="/webhook", description="Webhook endpoint path")
|
|
87
|
+
|
|
88
|
+
# Logging
|
|
89
|
+
log_level: str = Field(default="INFO", description="Logging level")
|
|
90
|
+
log_format: str = Field(default="json", description="Logging format (json|console)")
|
|
91
|
+
|
|
92
|
+
# Task source configuration
|
|
93
|
+
task_source: str = Field(
|
|
94
|
+
default="webhook",
|
|
95
|
+
description="Task source type: 'webhook' (FastAPI) or 'sqs' (AWS SQS polling)"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# AWS SQS configuration (only used when task_source='sqs')
|
|
99
|
+
aws_sqs_queue_url: str = Field(
|
|
100
|
+
default="",
|
|
101
|
+
description="AWS SQS queue URL for receiving webhook events"
|
|
102
|
+
)
|
|
103
|
+
aws_sqs_dlq_url: str = Field(
|
|
104
|
+
default="",
|
|
105
|
+
description="AWS SQS dead-letter queue URL for failed messages"
|
|
106
|
+
)
|
|
107
|
+
aws_region: str = Field(
|
|
108
|
+
default="us-east-1",
|
|
109
|
+
description="AWS region for SQS"
|
|
110
|
+
)
|
|
111
|
+
sqs_wait_time_seconds: int = Field(
|
|
112
|
+
default=20,
|
|
113
|
+
description="SQS long polling wait time in seconds (1-20)"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
@model_validator(mode='after')
|
|
117
|
+
def adjust_dev_mode_defaults(self):
|
|
118
|
+
"""Adjust defaults based on dev_mode."""
|
|
119
|
+
if self.dev_mode:
|
|
120
|
+
if self.webhook_host == "0.0.0.0":
|
|
121
|
+
self.webhook_host = "127.0.0.1"
|
|
122
|
+
if self.log_format == "json":
|
|
123
|
+
self.log_format = "console"
|
|
124
|
+
return self
|
|
125
|
+
|
|
126
|
+
# Configuration for Pydantic Settings
|
|
127
|
+
# Note: .env files are optional - environment variables are the primary source
|
|
128
|
+
if SettingsConfigDict:
|
|
129
|
+
model_config = SettingsConfigDict(
|
|
130
|
+
env_file=".env",
|
|
131
|
+
env_file_encoding="utf-8",
|
|
132
|
+
case_sensitive=False,
|
|
133
|
+
env_ignore_empty=True # Ignore empty .env values, prefer environment
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def get_allowed_orgs_list(self) -> List[str]:
|
|
137
|
+
"""Get allowed orgs as a list."""
|
|
138
|
+
if not self.allowed_orgs:
|
|
139
|
+
return []
|
|
140
|
+
return [org.strip().lower() for org in self.allowed_orgs.split(',') if org.strip()]
|
|
141
|
+
|
|
142
|
+
def get_allowed_users_list(self) -> List[str]:
|
|
143
|
+
"""Get allowed users as a list."""
|
|
144
|
+
if not self.allowed_users:
|
|
145
|
+
return []
|
|
146
|
+
return [user.strip().lower() for user in self.allowed_users.split(',') if user.strip()]
|
|
147
|
+
|
|
148
|
+
def get_authorized_trigger_users_list(self) -> List[str]:
|
|
149
|
+
"""Get authorized trigger users as a list."""
|
|
150
|
+
if not self.authorized_trigger_users:
|
|
151
|
+
return []
|
|
152
|
+
return [user.strip().lower() for user in self.authorized_trigger_users.split(',') if user.strip()]
|
|
153
|
+
|
|
154
|
+
def get_container_pool_list(self) -> List[str]:
|
|
155
|
+
"""Get container pool as a list."""
|
|
156
|
+
if not self.container_pool:
|
|
157
|
+
return ["eamonn", "harry", "darren"] # Default fallback
|
|
158
|
+
return [container.strip() for container in self.container_pool.split(',') if container.strip()]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def ensure_directories(self) -> None:
|
|
162
|
+
"""Ensure required directories exist."""
|
|
163
|
+
# Call parent's ensure_directories (creates workspaces_dir)
|
|
164
|
+
super().ensure_directories()
|
|
165
|
+
# Create webhook-specific directories
|
|
166
|
+
self.repo_cache_dir.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
# Claude config directory for container mounts
|
|
168
|
+
self.claude_config_dir.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
|
|
170
|
+
def validate_required_settings(self) -> None:
|
|
171
|
+
"""Validate that required settings are present."""
|
|
172
|
+
missing = []
|
|
173
|
+
|
|
174
|
+
# GitHub token and webhook secret are always required
|
|
175
|
+
if not self.github_token:
|
|
176
|
+
missing.append("github_token (GITHUB_TOKEN)")
|
|
177
|
+
if not self.github_mentioned_user:
|
|
178
|
+
missing.append("github_mentioned_user (GITHUB_MENTIONED_USER)")
|
|
179
|
+
if not self.github_webhook_secret:
|
|
180
|
+
missing.append("github_webhook_secret (GITHUB_WEBHOOK_SECRET) - required for signature verification")
|
|
181
|
+
|
|
182
|
+
# Task source specific validations
|
|
183
|
+
if self.task_source == "webhook":
|
|
184
|
+
# Require admin password in production mode
|
|
185
|
+
if not self.dev_mode and not self.admin_password:
|
|
186
|
+
missing.append("admin_password (ADMIN_PASSWORD) - required in production mode")
|
|
187
|
+
|
|
188
|
+
elif self.task_source == "sqs":
|
|
189
|
+
# SQS source requires queue URL
|
|
190
|
+
if not self.aws_sqs_queue_url:
|
|
191
|
+
missing.append("aws_sqs_queue_url (AWS_SQS_QUEUE_URL) - required for SQS source")
|
|
192
|
+
|
|
193
|
+
else:
|
|
194
|
+
missing.append(f"task_source must be 'webhook' or 'sqs', got '{self.task_source}'")
|
|
195
|
+
|
|
196
|
+
# Raise error if any required settings are missing
|
|
197
|
+
if missing:
|
|
198
|
+
raise ValueError(
|
|
199
|
+
f"Missing required configuration:\n " + "\n ".join(missing)
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def is_repository_allowed(self, repo_full_name: str, repo_owner: str) -> bool:
|
|
203
|
+
"""Check if a repository is allowed based on allowlist configuration.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
repo_full_name: Full repository name (e.g., "owner/repo")
|
|
207
|
+
repo_owner: Repository owner username or organization
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
True if repository is allowed, False otherwise
|
|
211
|
+
"""
|
|
212
|
+
allowed_orgs = self.get_allowed_orgs_list()
|
|
213
|
+
allowed_users = self.get_allowed_users_list()
|
|
214
|
+
|
|
215
|
+
# Check if owner is in allowed orgs or users
|
|
216
|
+
return repo_owner.lower() in allowed_orgs or repo_owner.lower() in allowed_users
|
|
217
|
+
|
|
218
|
+
def is_user_authorized_to_trigger(self, username: str) -> bool:
|
|
219
|
+
"""Check if a user is authorized to trigger webhook processing.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
username: GitHub username that triggered the event
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
True if user is authorized, False otherwise
|
|
226
|
+
"""
|
|
227
|
+
authorized_users = self.get_authorized_trigger_users_list()
|
|
228
|
+
|
|
229
|
+
# If no authorized users are configured, allow all (backward compatibility)
|
|
230
|
+
if not authorized_users:
|
|
231
|
+
return True
|
|
232
|
+
|
|
233
|
+
# Check if the user is in the authorized list
|
|
234
|
+
return username.lower() in authorized_users
|
|
235
|
+
|
|
236
|
+
def get_default_workspaces_dir(self) -> Path:
|
|
237
|
+
"""Get default workspaces directory for webhook package."""
|
|
238
|
+
return Path.home() / ".devs" / "workspaces"
|
|
239
|
+
|
|
240
|
+
def get_default_project_prefix(self) -> str:
|
|
241
|
+
"""Get default project prefix for webhook package."""
|
|
242
|
+
return "dev"
|
|
243
|
+
|
|
244
|
+
def has_github_app_auth(self) -> bool:
|
|
245
|
+
"""Check if GitHub App authentication is configured.
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
True if both app_id and private_key are provided
|
|
249
|
+
"""
|
|
250
|
+
return bool(self.github_app_id and self.github_app_private_key)
|
|
251
|
+
|
|
252
|
+
def get_github_app_private_key(self) -> str:
|
|
253
|
+
"""Get GitHub App private key content.
|
|
254
|
+
|
|
255
|
+
If github_app_private_key is a file path, read the file.
|
|
256
|
+
Otherwise, return the value directly as PEM content.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
Private key content in PEM format
|
|
260
|
+
|
|
261
|
+
Raises:
|
|
262
|
+
FileNotFoundError: If private key file doesn't exist
|
|
263
|
+
ValueError: If private key is not configured
|
|
264
|
+
"""
|
|
265
|
+
if not self.github_app_private_key:
|
|
266
|
+
raise ValueError("GitHub App private key not configured")
|
|
267
|
+
|
|
268
|
+
# If it looks like a file path, read the file
|
|
269
|
+
if (self.github_app_private_key.startswith('/') or
|
|
270
|
+
self.github_app_private_key.startswith('~/') or
|
|
271
|
+
'-----BEGIN' not in self.github_app_private_key):
|
|
272
|
+
key_path = Path(self.github_app_private_key).expanduser()
|
|
273
|
+
if not key_path.exists():
|
|
274
|
+
raise FileNotFoundError(f"GitHub App private key file not found: {key_path}")
|
|
275
|
+
return key_path.read_text()
|
|
276
|
+
|
|
277
|
+
# Otherwise, treat as PEM content directly
|
|
278
|
+
return self.github_app_private_key
|
|
279
|
+
|
|
280
|
+
def create_github_app_auth(self, context: str = "") -> Optional["GitHubAppAuth"]:
|
|
281
|
+
"""Create a GitHubAppAuth instance if configuration is available.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
context: Context string for logging (e.g., "webhook handler", "claude dispatcher")
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
GitHubAppAuth instance if configured, None otherwise
|
|
288
|
+
"""
|
|
289
|
+
if not self.has_github_app_auth():
|
|
290
|
+
return None
|
|
291
|
+
|
|
292
|
+
try:
|
|
293
|
+
# Import here to avoid circular imports
|
|
294
|
+
from .github.app_auth import GitHubAppAuth
|
|
295
|
+
|
|
296
|
+
app_auth = GitHubAppAuth(
|
|
297
|
+
app_id=self.github_app_id,
|
|
298
|
+
private_key=self.get_github_app_private_key(),
|
|
299
|
+
installation_id=self.github_app_installation_id if self.github_app_installation_id else None
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
logger = structlog.get_logger()
|
|
303
|
+
logger.info("GitHub App authentication configured", context=context)
|
|
304
|
+
return app_auth
|
|
305
|
+
|
|
306
|
+
except Exception as e:
|
|
307
|
+
logger = structlog.get_logger()
|
|
308
|
+
logger.error("Failed to initialize GitHub App authentication",
|
|
309
|
+
context=context, error=str(e))
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@lru_cache()
|
|
314
|
+
def get_config() -> WebhookConfig:
|
|
315
|
+
"""Get the webhook configuration using FastAPI's recommended pattern."""
|
|
316
|
+
config = WebhookConfig()
|
|
317
|
+
config.ensure_directories()
|
|
318
|
+
config.validate_required_settings()
|
|
319
|
+
return config
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core webhook handling modules."""
|