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
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
"""GitHub webhook payload models."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import hashlib
|
|
5
|
+
from typing import Optional, Dict, Any, List, Union, Annotated
|
|
6
|
+
from pydantic import BaseModel, Field, Discriminator, Tag
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GitHubInstallation(BaseModel):
|
|
11
|
+
"""GitHub App installation model."""
|
|
12
|
+
id: int
|
|
13
|
+
account: Optional[Dict[str, Any]] = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class GitHubUser(BaseModel):
|
|
17
|
+
"""GitHub user model."""
|
|
18
|
+
login: str
|
|
19
|
+
id: int
|
|
20
|
+
avatar_url: str
|
|
21
|
+
html_url: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class GitHubRepository(BaseModel):
|
|
25
|
+
"""GitHub repository model."""
|
|
26
|
+
id: int
|
|
27
|
+
name: str
|
|
28
|
+
full_name: str
|
|
29
|
+
owner: GitHubUser
|
|
30
|
+
html_url: str
|
|
31
|
+
clone_url: str
|
|
32
|
+
ssh_url: str
|
|
33
|
+
default_branch: str = "main"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class GitHubIssue(BaseModel):
|
|
37
|
+
"""GitHub issue model."""
|
|
38
|
+
id: int
|
|
39
|
+
number: int
|
|
40
|
+
title: str
|
|
41
|
+
body: Optional[str] = None
|
|
42
|
+
state: str
|
|
43
|
+
user: GitHubUser
|
|
44
|
+
assignee: Optional[GitHubUser] = None
|
|
45
|
+
html_url: str
|
|
46
|
+
created_at: datetime
|
|
47
|
+
updated_at: datetime
|
|
48
|
+
comments: int = 0
|
|
49
|
+
|
|
50
|
+
# Additional fields that may be present for PRs treated as issues
|
|
51
|
+
draft: Optional[bool] = None
|
|
52
|
+
pull_request: Optional[Dict[str, Any]] = None
|
|
53
|
+
|
|
54
|
+
class Config:
|
|
55
|
+
extra = "ignore" # Ignore additional fields not in model
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class GitHubPullRequest(BaseModel):
|
|
59
|
+
"""GitHub pull request model."""
|
|
60
|
+
id: int
|
|
61
|
+
number: int
|
|
62
|
+
title: str
|
|
63
|
+
body: Optional[str] = None
|
|
64
|
+
state: str
|
|
65
|
+
user: GitHubUser
|
|
66
|
+
assignee: Optional[GitHubUser] = None
|
|
67
|
+
html_url: str
|
|
68
|
+
head: Dict[str, Any]
|
|
69
|
+
base: Dict[str, Any]
|
|
70
|
+
created_at: datetime
|
|
71
|
+
updated_at: datetime
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class GitHubComment(BaseModel):
|
|
75
|
+
"""GitHub comment model."""
|
|
76
|
+
id: int
|
|
77
|
+
body: str
|
|
78
|
+
user: GitHubUser
|
|
79
|
+
html_url: str
|
|
80
|
+
created_at: datetime
|
|
81
|
+
updated_at: datetime
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class WebhookEvent(BaseModel):
|
|
85
|
+
"""Base webhook event model."""
|
|
86
|
+
action: str
|
|
87
|
+
repository: GitHubRepository
|
|
88
|
+
sender: GitHubUser
|
|
89
|
+
installation: Optional[GitHubInstallation] = None
|
|
90
|
+
is_test: bool = Field(default=False, description="Indicates if this is a test event")
|
|
91
|
+
|
|
92
|
+
def extract_mentions(self, target_user: str) -> List[str]:
|
|
93
|
+
"""Extract @mentions of target user from relevant text."""
|
|
94
|
+
mentions = []
|
|
95
|
+
text_sources = self._get_text_sources()
|
|
96
|
+
|
|
97
|
+
# Create regex pattern with word boundary to match exact username
|
|
98
|
+
# This prevents matching @bot in @botname or @mybotname
|
|
99
|
+
pattern = re.compile(rf"@{re.escape(target_user)}\b", re.IGNORECASE)
|
|
100
|
+
|
|
101
|
+
for text in text_sources:
|
|
102
|
+
if text and pattern.search(text):
|
|
103
|
+
mentions.append(text)
|
|
104
|
+
|
|
105
|
+
return mentions
|
|
106
|
+
|
|
107
|
+
def _get_text_sources(self) -> List[Optional[str]]:
|
|
108
|
+
"""Get text sources to search for mentions. Override in subclasses."""
|
|
109
|
+
return []
|
|
110
|
+
|
|
111
|
+
def get_context_for_claude(self) -> str:
|
|
112
|
+
"""Get formatted context for Claude Code. Override in subclasses."""
|
|
113
|
+
return f"Repository: {self.repository.full_name}\nAction: {self.action}"
|
|
114
|
+
|
|
115
|
+
def get_content_hash(self) -> Optional[str]:
|
|
116
|
+
"""Get hash of content that would be sent to Claude for deduplication."""
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class IssueEvent(WebhookEvent):
|
|
121
|
+
"""GitHub issue webhook event."""
|
|
122
|
+
issue: GitHubIssue
|
|
123
|
+
|
|
124
|
+
def _get_text_sources(self) -> List[Optional[str]]:
|
|
125
|
+
return [self.issue.title, self.issue.body]
|
|
126
|
+
|
|
127
|
+
def get_context_for_claude(self) -> str:
|
|
128
|
+
return f"""GitHub Issue #{self.issue.number} in {self.repository.full_name}
|
|
129
|
+
|
|
130
|
+
Title: {self.issue.title}
|
|
131
|
+
URL: {self.issue.html_url}
|
|
132
|
+
State: {self.issue.state}
|
|
133
|
+
Created by: @{self.issue.user.login}
|
|
134
|
+
|
|
135
|
+
Description:
|
|
136
|
+
{self.issue.body or "No description provided"}
|
|
137
|
+
|
|
138
|
+
Action: {self.action}
|
|
139
|
+
Repository: {self.repository.full_name}
|
|
140
|
+
Clone URL: {self.repository.clone_url}
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
def get_content_hash(self) -> str:
|
|
144
|
+
"""Get hash of issue content for deduplication."""
|
|
145
|
+
# Hash the content that matters to Claude - excludes action field
|
|
146
|
+
content_parts = [
|
|
147
|
+
str(self.repository.full_name),
|
|
148
|
+
str(self.issue.number),
|
|
149
|
+
str(self.issue.title),
|
|
150
|
+
str(self.issue.body or ""),
|
|
151
|
+
str(self.issue.assignee.login if self.issue.assignee else ""),
|
|
152
|
+
str(self.issue.updated_at),
|
|
153
|
+
str(self.issue.comments)
|
|
154
|
+
]
|
|
155
|
+
content_string = "|".join(content_parts)
|
|
156
|
+
return hashlib.sha256(content_string.encode()).hexdigest()[:16]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class PullRequestEvent(WebhookEvent):
|
|
160
|
+
"""GitHub pull request webhook event."""
|
|
161
|
+
pull_request: GitHubPullRequest
|
|
162
|
+
|
|
163
|
+
def _get_text_sources(self) -> List[Optional[str]]:
|
|
164
|
+
return [self.pull_request.title, self.pull_request.body]
|
|
165
|
+
|
|
166
|
+
def get_context_for_claude(self) -> str:
|
|
167
|
+
return f"""GitHub Pull Request #{self.pull_request.number} in {self.repository.full_name}
|
|
168
|
+
|
|
169
|
+
Title: {self.pull_request.title}
|
|
170
|
+
URL: {self.pull_request.html_url}
|
|
171
|
+
State: {self.pull_request.state}
|
|
172
|
+
Created by: @{self.pull_request.user.login}
|
|
173
|
+
|
|
174
|
+
Description:
|
|
175
|
+
{self.pull_request.body or "No description provided"}
|
|
176
|
+
|
|
177
|
+
Source Branch: {self.pull_request.head.get('ref', 'unknown')}
|
|
178
|
+
Target Branch: {self.pull_request.base.get('ref', 'unknown')}
|
|
179
|
+
|
|
180
|
+
Action: {self.action}
|
|
181
|
+
Repository: {self.repository.full_name}
|
|
182
|
+
Clone URL: {self.repository.clone_url}
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
def get_content_hash(self) -> str:
|
|
186
|
+
"""Get hash of PR content for deduplication."""
|
|
187
|
+
content_parts = [
|
|
188
|
+
str(self.repository.full_name),
|
|
189
|
+
str(self.pull_request.number),
|
|
190
|
+
str(self.pull_request.title),
|
|
191
|
+
str(self.pull_request.body or ""),
|
|
192
|
+
str(self.pull_request.assignee.login if self.pull_request.assignee else ""),
|
|
193
|
+
str(self.pull_request.updated_at),
|
|
194
|
+
str(self.pull_request.head.get('ref', '')),
|
|
195
|
+
str(self.pull_request.base.get('ref', ''))
|
|
196
|
+
]
|
|
197
|
+
content_string = "|".join(content_parts)
|
|
198
|
+
return hashlib.sha256(content_string.encode()).hexdigest()[:16]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class CommentEvent(WebhookEvent):
|
|
202
|
+
"""GitHub comment webhook event."""
|
|
203
|
+
comment: GitHubComment
|
|
204
|
+
issue: Optional[GitHubIssue] = None # For issue comments
|
|
205
|
+
pull_request: Optional[GitHubPullRequest] = None # For PR comments
|
|
206
|
+
|
|
207
|
+
def _get_text_sources(self) -> List[Optional[str]]:
|
|
208
|
+
sources = [self.comment.body]
|
|
209
|
+
if self.issue:
|
|
210
|
+
sources.extend([self.issue.title, self.issue.body])
|
|
211
|
+
if self.pull_request:
|
|
212
|
+
sources.extend([self.pull_request.title, self.pull_request.body])
|
|
213
|
+
return sources
|
|
214
|
+
|
|
215
|
+
def get_context_for_claude(self) -> str:
|
|
216
|
+
if self.issue:
|
|
217
|
+
context_type = f"Comment on Issue #{self.issue.number}"
|
|
218
|
+
parent_info = f"""
|
|
219
|
+
Original Issue:
|
|
220
|
+
Title: {self.issue.title}
|
|
221
|
+
Description: {self.issue.body or "No description"}
|
|
222
|
+
URL: {self.issue.html_url}
|
|
223
|
+
"""
|
|
224
|
+
elif self.pull_request:
|
|
225
|
+
context_type = f"Comment on Pull Request #{self.pull_request.number}"
|
|
226
|
+
parent_info = f"""
|
|
227
|
+
Original Pull Request:
|
|
228
|
+
Title: {self.pull_request.title}
|
|
229
|
+
Description: {self.pull_request.body or "No description"}
|
|
230
|
+
URL: {self.pull_request.html_url}
|
|
231
|
+
Source Branch: {self.pull_request.head.get('ref', 'unknown')}
|
|
232
|
+
Target Branch: {self.pull_request.base.get('ref', 'unknown')}
|
|
233
|
+
"""
|
|
234
|
+
else:
|
|
235
|
+
context_type = "Comment"
|
|
236
|
+
parent_info = ""
|
|
237
|
+
|
|
238
|
+
return f"""{context_type} in {self.repository.full_name}
|
|
239
|
+
|
|
240
|
+
Comment by @{self.comment.user.login}:
|
|
241
|
+
{self.comment.body}
|
|
242
|
+
|
|
243
|
+
Comment URL: {self.comment.html_url}
|
|
244
|
+
{parent_info}
|
|
245
|
+
Action: {self.action}
|
|
246
|
+
Repository: {self.repository.full_name}
|
|
247
|
+
Clone URL: {self.repository.clone_url}
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
def get_content_hash(self) -> str:
|
|
251
|
+
"""Get hash of comment content for deduplication."""
|
|
252
|
+
# Comments are always processed since they represent new input
|
|
253
|
+
# But include timestamp to distinguish different comments
|
|
254
|
+
content_parts = [
|
|
255
|
+
str(self.repository.full_name),
|
|
256
|
+
str(self.comment.id),
|
|
257
|
+
str(self.comment.body),
|
|
258
|
+
str(self.comment.created_at),
|
|
259
|
+
str(self.comment.user.login)
|
|
260
|
+
]
|
|
261
|
+
|
|
262
|
+
# Include parent issue/PR info if available
|
|
263
|
+
if self.issue:
|
|
264
|
+
content_parts.extend([
|
|
265
|
+
"issue",
|
|
266
|
+
str(self.issue.number),
|
|
267
|
+
str(self.issue.updated_at)
|
|
268
|
+
])
|
|
269
|
+
elif self.pull_request:
|
|
270
|
+
content_parts.extend([
|
|
271
|
+
"pr",
|
|
272
|
+
str(self.pull_request.number),
|
|
273
|
+
str(self.pull_request.updated_at)
|
|
274
|
+
])
|
|
275
|
+
|
|
276
|
+
content_string = "|".join(content_parts)
|
|
277
|
+
return hashlib.sha256(content_string.encode()).hexdigest()[:16]
|
|
278
|
+
|
|
279
|
+
class TestIssueEvent(IssueEvent):
|
|
280
|
+
"""Test event for issues, used in unit tests."""
|
|
281
|
+
|
|
282
|
+
is_test: bool = True # Mark as test event
|
|
283
|
+
|
|
284
|
+
class Config:
|
|
285
|
+
arbitrary_types_allowed = True
|
|
286
|
+
extra = "allow"
|
|
287
|
+
|
|
288
|
+
def __init__(self, **data):
|
|
289
|
+
super().__init__(**data)
|
|
290
|
+
self.action = "opened" # Default action for test events
|
|
291
|
+
|
|
292
|
+
def get_context_for_claude(self) -> str:
|
|
293
|
+
return f"""Test event. """
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class GitHubCommit(BaseModel):
|
|
297
|
+
"""GitHub commit model."""
|
|
298
|
+
id: str
|
|
299
|
+
message: str
|
|
300
|
+
timestamp: datetime
|
|
301
|
+
url: str
|
|
302
|
+
author: Dict[str, Any]
|
|
303
|
+
committer: Dict[str, Any]
|
|
304
|
+
added: List[str] = []
|
|
305
|
+
removed: List[str] = []
|
|
306
|
+
modified: List[str] = []
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
class GitHubPush(BaseModel):
|
|
310
|
+
"""GitHub push model."""
|
|
311
|
+
ref: str
|
|
312
|
+
before: str
|
|
313
|
+
after: str
|
|
314
|
+
created: bool
|
|
315
|
+
deleted: bool
|
|
316
|
+
forced: bool
|
|
317
|
+
base_ref: Optional[str] = None
|
|
318
|
+
compare: str
|
|
319
|
+
commits: List[GitHubCommit]
|
|
320
|
+
head_commit: Optional[GitHubCommit] = None
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
class PushEvent(WebhookEvent):
|
|
324
|
+
"""GitHub push webhook event."""
|
|
325
|
+
ref: str
|
|
326
|
+
before: str
|
|
327
|
+
after: str
|
|
328
|
+
created: bool = False
|
|
329
|
+
deleted: bool = False
|
|
330
|
+
forced: bool = False
|
|
331
|
+
base_ref: Optional[str] = None
|
|
332
|
+
compare: str = ""
|
|
333
|
+
commits: List[Dict[str, Any]] = []
|
|
334
|
+
head_commit: Optional[Dict[str, Any]] = None
|
|
335
|
+
|
|
336
|
+
def _get_text_sources(self) -> List[Optional[str]]:
|
|
337
|
+
# Push events don't have text to search for mentions
|
|
338
|
+
return []
|
|
339
|
+
|
|
340
|
+
def get_context_for_claude(self) -> str:
|
|
341
|
+
branch = self.ref.replace('refs/heads/', '') if self.ref.startswith('refs/heads/') else self.ref
|
|
342
|
+
|
|
343
|
+
return f"""GitHub Push to {self.repository.full_name}
|
|
344
|
+
|
|
345
|
+
Branch: {branch}
|
|
346
|
+
Pushed by: @{self.sender.login}
|
|
347
|
+
Commits: {len(self.commits)}
|
|
348
|
+
|
|
349
|
+
Latest commit: {self.head_commit['message'] if self.head_commit else 'No commits'}
|
|
350
|
+
Compare: {self.compare}
|
|
351
|
+
|
|
352
|
+
Action: {self.action}
|
|
353
|
+
Repository: {self.repository.full_name}
|
|
354
|
+
Clone URL: {self.repository.clone_url}
|
|
355
|
+
"""
|
|
356
|
+
|
|
357
|
+
def get_content_hash(self) -> str:
|
|
358
|
+
"""Get hash of push content for deduplication."""
|
|
359
|
+
content_parts = [
|
|
360
|
+
str(self.repository.full_name),
|
|
361
|
+
str(self.ref),
|
|
362
|
+
str(self.after), # The commit SHA being pushed
|
|
363
|
+
str(len(self.commits)),
|
|
364
|
+
str(self.head_commit['message'] if self.head_commit else '')
|
|
365
|
+
]
|
|
366
|
+
content_string = "|".join(content_parts)
|
|
367
|
+
return hashlib.sha256(content_string.encode()).hexdigest()[:16]
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class DevsOptions(BaseModel):
|
|
371
|
+
"""DEVS.yml configuration options."""
|
|
372
|
+
default_branch: str = "main"
|
|
373
|
+
prompt_extra: str = ""
|
|
374
|
+
prompt_override: Optional[str] = None
|
|
375
|
+
direct_commit: bool = False
|
|
376
|
+
single_queue: bool = False # Restrict repo to single queue processing
|
|
377
|
+
ci_enabled: bool = False # Enable CI mode for this repository
|
|
378
|
+
ci_test_command: str = "runtests.sh" # Command to run for CI tests
|
|
379
|
+
ci_branches: List[str] = ["main", "master"] # Branches to run CI on for push events
|
|
380
|
+
env_vars: Dict[str, Dict[str, str]] = Field(default_factory=dict) # Environment variables with defaults and per-container overrides
|
|
381
|
+
|
|
382
|
+
def get_env_vars(self, container_name: Optional[str] = None) -> Dict[str, str]:
|
|
383
|
+
"""Get environment variables for a specific container or defaults.
|
|
384
|
+
|
|
385
|
+
Args:
|
|
386
|
+
container_name: Container name to get specific overrides for
|
|
387
|
+
|
|
388
|
+
Returns:
|
|
389
|
+
Dictionary of environment variables with container-specific overrides applied
|
|
390
|
+
"""
|
|
391
|
+
# Start with default env vars if they exist
|
|
392
|
+
env = self.env_vars.get('default', {}).copy()
|
|
393
|
+
|
|
394
|
+
# Apply container-specific overrides if container name provided
|
|
395
|
+
if container_name and container_name in self.env_vars:
|
|
396
|
+
env.update(self.env_vars[container_name])
|
|
397
|
+
|
|
398
|
+
return env
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# Discriminated union for automatic event type detection
|
|
402
|
+
def get_webhook_event_discriminator(v: Any) -> str:
|
|
403
|
+
"""Discriminator function to determine webhook event type."""
|
|
404
|
+
if isinstance(v, dict):
|
|
405
|
+
if 'comment' in v:
|
|
406
|
+
return 'comment'
|
|
407
|
+
elif 'ref' in v and 'commits' in v:
|
|
408
|
+
return 'push'
|
|
409
|
+
elif 'issue' in v and ('pull_request' not in v or v.get('pull_request') is None):
|
|
410
|
+
return 'issue'
|
|
411
|
+
elif 'pull_request' in v and v.get('pull_request') is not None:
|
|
412
|
+
return 'pull_request'
|
|
413
|
+
return 'comment' # Default fallback
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
AnyWebhookEvent = Annotated[
|
|
417
|
+
Union[
|
|
418
|
+
Annotated[CommentEvent, Tag('comment')],
|
|
419
|
+
Annotated[IssueEvent, Tag('issue')],
|
|
420
|
+
Annotated[PullRequestEvent, Tag('pull_request')],
|
|
421
|
+
Annotated[PushEvent, Tag('push')]
|
|
422
|
+
],
|
|
423
|
+
Discriminator(get_webhook_event_discriminator)
|
|
424
|
+
]
|