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/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."""