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,325 @@
|
|
|
1
|
+
"""GitHub webhook payload parsing."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Optional, Dict, Any
|
|
5
|
+
from .models import WebhookEvent, IssueEvent, PullRequestEvent, CommentEvent, PushEvent
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class WebhookParser:
|
|
9
|
+
"""Parses GitHub webhook payloads into structured events."""
|
|
10
|
+
|
|
11
|
+
@staticmethod
|
|
12
|
+
def parse_webhook(headers: Dict[str, str], payload: bytes) -> Optional[WebhookEvent]:
|
|
13
|
+
"""Parse a GitHub webhook payload into a structured event.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
headers: HTTP headers from the webhook request
|
|
17
|
+
payload: Raw webhook payload bytes
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
Parsed webhook event or None if not supported/parseable
|
|
21
|
+
"""
|
|
22
|
+
try:
|
|
23
|
+
event_type = headers.get("x-github-event", "").lower()
|
|
24
|
+
data = json.loads(payload.decode("utf-8"))
|
|
25
|
+
|
|
26
|
+
if event_type == "issues":
|
|
27
|
+
return WebhookParser._parse_issue_event(data)
|
|
28
|
+
elif event_type == "pull_request":
|
|
29
|
+
return WebhookParser._parse_pull_request_event(data)
|
|
30
|
+
elif event_type == "issue_comment":
|
|
31
|
+
return WebhookParser._parse_issue_comment_event(data)
|
|
32
|
+
elif event_type == "pull_request_review_comment":
|
|
33
|
+
return WebhookParser._parse_pr_comment_event(data)
|
|
34
|
+
elif event_type == "push":
|
|
35
|
+
return WebhookParser._parse_push_event(data)
|
|
36
|
+
else:
|
|
37
|
+
# Unsupported event type
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
except (json.JSONDecodeError, KeyError, ValueError) as e:
|
|
41
|
+
# Invalid payload format
|
|
42
|
+
import structlog
|
|
43
|
+
logger = structlog.get_logger()
|
|
44
|
+
logger.error("Failed to parse webhook payload",
|
|
45
|
+
event_type=event_type,
|
|
46
|
+
error=str(e),
|
|
47
|
+
exc_info=True)
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def _parse_issue_event(data: Dict[str, Any]) -> IssueEvent:
|
|
52
|
+
"""Parse an issue webhook event."""
|
|
53
|
+
# Ensure installation data is included if present
|
|
54
|
+
return IssueEvent(**data)
|
|
55
|
+
|
|
56
|
+
@staticmethod
|
|
57
|
+
def _parse_pull_request_event(data: Dict[str, Any]) -> PullRequestEvent:
|
|
58
|
+
"""Parse a pull request webhook event."""
|
|
59
|
+
# Ensure installation data is included if present
|
|
60
|
+
return PullRequestEvent(**data)
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def _parse_issue_comment_event(data: Dict[str, Any]) -> CommentEvent:
|
|
64
|
+
"""Parse an issue comment webhook event."""
|
|
65
|
+
issue_data = data.get("issue")
|
|
66
|
+
|
|
67
|
+
# Always treat as issue - we'll detect PR nature in the processing logic
|
|
68
|
+
return CommentEvent(
|
|
69
|
+
action=data["action"],
|
|
70
|
+
repository=data["repository"],
|
|
71
|
+
sender=data["sender"],
|
|
72
|
+
comment=data["comment"],
|
|
73
|
+
issue=issue_data, # Keep as issue, detect PR in logic
|
|
74
|
+
installation=data.get("installation") # Include installation if present
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def _parse_pr_comment_event(data: Dict[str, Any]) -> CommentEvent:
|
|
79
|
+
"""Parse a pull request comment webhook event."""
|
|
80
|
+
return CommentEvent(
|
|
81
|
+
action=data["action"],
|
|
82
|
+
repository=data["repository"],
|
|
83
|
+
sender=data["sender"],
|
|
84
|
+
comment=data["comment"],
|
|
85
|
+
pull_request=data.get("pull_request"), # Present for PR comments
|
|
86
|
+
installation=data.get("installation") # Include installation if present
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
def _parse_push_event(data: Dict[str, Any]) -> PushEvent:
|
|
91
|
+
"""Parse a push webhook event."""
|
|
92
|
+
return PushEvent(
|
|
93
|
+
action="pushed", # Push events don't have an action field, so we set a default
|
|
94
|
+
repository=data["repository"],
|
|
95
|
+
sender=data["sender"],
|
|
96
|
+
installation=data.get("installation"), # Include installation if present
|
|
97
|
+
ref=data["ref"],
|
|
98
|
+
before=data["before"],
|
|
99
|
+
after=data["after"],
|
|
100
|
+
created=data.get("created", False),
|
|
101
|
+
deleted=data.get("deleted", False),
|
|
102
|
+
forced=data.get("forced", False),
|
|
103
|
+
base_ref=data.get("base_ref"),
|
|
104
|
+
compare=data.get("compare", ""),
|
|
105
|
+
commits=data.get("commits", []),
|
|
106
|
+
head_commit=data.get("head_commit")
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
@staticmethod
|
|
110
|
+
def should_process_event(event: WebhookEvent, mentioned_user: str) -> bool:
|
|
111
|
+
"""Check if an event should be processed based on @mentions.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
event: Parsed webhook event
|
|
115
|
+
mentioned_user: Username to look for in @mentions
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
True if the event contains @mentions of the target user
|
|
119
|
+
"""
|
|
120
|
+
import structlog
|
|
121
|
+
logger = structlog.get_logger()
|
|
122
|
+
|
|
123
|
+
logger.info("Checking if event should be processed",
|
|
124
|
+
event_type=type(event).__name__,
|
|
125
|
+
action=event.action,
|
|
126
|
+
mentioned_user=mentioned_user)
|
|
127
|
+
|
|
128
|
+
# Process different types of relevant actions
|
|
129
|
+
relevant_actions = ["opened", "created", "edited"]
|
|
130
|
+
|
|
131
|
+
# For issues and PRs, also process assignments
|
|
132
|
+
if isinstance(event, (IssueEvent, PullRequestEvent)):
|
|
133
|
+
relevant_actions.append("assigned")
|
|
134
|
+
|
|
135
|
+
logger.info("Relevant actions determined",
|
|
136
|
+
relevant_actions=relevant_actions,
|
|
137
|
+
event_action=event.action,
|
|
138
|
+
action_in_relevant=event.action in relevant_actions)
|
|
139
|
+
|
|
140
|
+
if event.action not in relevant_actions:
|
|
141
|
+
logger.info("Event action not in relevant actions, skipping",
|
|
142
|
+
action=event.action,
|
|
143
|
+
relevant_actions=relevant_actions)
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
# Prevent feedback loops: Don't process events created by the bot user
|
|
147
|
+
if event.sender.login == mentioned_user:
|
|
148
|
+
logger.info("Event created by bot user, skipping to prevent feedback loop")
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
# For comment events, also check if the comment author is the bot
|
|
152
|
+
if isinstance(event, CommentEvent) and event.comment.user.login == mentioned_user:
|
|
153
|
+
logger.info("Comment created by bot user, skipping to prevent feedback loop")
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
# Special handling for assignment events, opened events with assignees, AND comment events on assigned/authored issues/PRs
|
|
157
|
+
if (event.action == "assigned" or
|
|
158
|
+
(event.action == "opened" and isinstance(event, (IssueEvent, PullRequestEvent))) or
|
|
159
|
+
(event.action in ["created", "edited"] and isinstance(event, CommentEvent))):
|
|
160
|
+
|
|
161
|
+
logger.info("Checking for assignee",
|
|
162
|
+
event_type=type(event).__name__,
|
|
163
|
+
action=event.action)
|
|
164
|
+
|
|
165
|
+
# Check if the bot user is assigned
|
|
166
|
+
assignee = None
|
|
167
|
+
if isinstance(event, IssueEvent):
|
|
168
|
+
logger.info("Checking issue assignee",
|
|
169
|
+
has_issue=hasattr(event, 'issue'),
|
|
170
|
+
has_assignee=hasattr(event.issue, 'assignee') if hasattr(event, 'issue') else False)
|
|
171
|
+
|
|
172
|
+
if hasattr(event.issue, 'assignee'):
|
|
173
|
+
assignee = event.issue.assignee
|
|
174
|
+
logger.info("Issue assignee found",
|
|
175
|
+
assignee_login=assignee.login if assignee else None,
|
|
176
|
+
assignee_is_none=assignee is None)
|
|
177
|
+
|
|
178
|
+
elif isinstance(event, PullRequestEvent):
|
|
179
|
+
logger.info("Checking PR assignee",
|
|
180
|
+
has_pr=hasattr(event, 'pull_request'),
|
|
181
|
+
has_assignee=hasattr(event.pull_request, 'assignee') if hasattr(event, 'pull_request') else False)
|
|
182
|
+
|
|
183
|
+
if hasattr(event.pull_request, 'assignee'):
|
|
184
|
+
assignee = event.pull_request.assignee
|
|
185
|
+
logger.info("PR assignee found",
|
|
186
|
+
assignee_login=assignee.login if assignee else None,
|
|
187
|
+
assignee_is_none=assignee is None)
|
|
188
|
+
|
|
189
|
+
elif isinstance(event, CommentEvent):
|
|
190
|
+
# For comment events, check if the parent issue/PR is assigned to the bot
|
|
191
|
+
# For PRs, also check if the bot created it (comments are likely feedback)
|
|
192
|
+
if event.issue:
|
|
193
|
+
# Check if this "issue" is actually a PR (has pull_request field)
|
|
194
|
+
is_pr = hasattr(event.issue, 'pull_request') and event.issue.pull_request is not None
|
|
195
|
+
|
|
196
|
+
if is_pr:
|
|
197
|
+
logger.info("Checking comment's PR assignee and author",
|
|
198
|
+
has_assignee=hasattr(event.issue, 'assignee'),
|
|
199
|
+
pr_author=event.issue.user.login,
|
|
200
|
+
is_pr=True)
|
|
201
|
+
|
|
202
|
+
if hasattr(event.issue, 'assignee'):
|
|
203
|
+
assignee = event.issue.assignee
|
|
204
|
+
logger.info("Comment's PR assignee found",
|
|
205
|
+
assignee_login=assignee.login if assignee else None,
|
|
206
|
+
assignee_is_none=assignee is None)
|
|
207
|
+
|
|
208
|
+
# Also check if bot is the PR author (comments are likely feedback/reviews)
|
|
209
|
+
if event.issue.user.login == mentioned_user:
|
|
210
|
+
logger.info("Comment on bot-created PR, processing",
|
|
211
|
+
pr_author=event.issue.user.login,
|
|
212
|
+
mentioned_user=mentioned_user)
|
|
213
|
+
assignee = event.issue.user # Treat author as assignee for processing
|
|
214
|
+
else:
|
|
215
|
+
# True issue comment
|
|
216
|
+
logger.info("Checking comment's issue assignee",
|
|
217
|
+
has_assignee=hasattr(event.issue, 'assignee'),
|
|
218
|
+
is_pr=False)
|
|
219
|
+
|
|
220
|
+
if hasattr(event.issue, 'assignee'):
|
|
221
|
+
assignee = event.issue.assignee
|
|
222
|
+
logger.info("Comment's issue assignee found",
|
|
223
|
+
assignee_login=assignee.login if assignee else None,
|
|
224
|
+
assignee_is_none=assignee is None)
|
|
225
|
+
|
|
226
|
+
elif event.pull_request:
|
|
227
|
+
logger.info("Checking comment's PR assignee and author",
|
|
228
|
+
has_assignee=hasattr(event.pull_request, 'assignee'),
|
|
229
|
+
pr_author=event.pull_request.user.login)
|
|
230
|
+
|
|
231
|
+
if hasattr(event.pull_request, 'assignee'):
|
|
232
|
+
assignee = event.pull_request.assignee
|
|
233
|
+
logger.info("Comment's PR assignee found",
|
|
234
|
+
assignee_login=assignee.login if assignee else None,
|
|
235
|
+
assignee_is_none=assignee is None)
|
|
236
|
+
|
|
237
|
+
# Also check if bot is the PR author (comments are likely feedback/reviews)
|
|
238
|
+
if event.pull_request.user.login == mentioned_user:
|
|
239
|
+
logger.info("Comment on bot-created PR, processing",
|
|
240
|
+
pr_author=event.pull_request.user.login,
|
|
241
|
+
mentioned_user=mentioned_user)
|
|
242
|
+
assignee = event.pull_request.user # Treat author as assignee for processing
|
|
243
|
+
|
|
244
|
+
assignee_matches = assignee and assignee.login == mentioned_user
|
|
245
|
+
logger.info("Assignment check result",
|
|
246
|
+
assignee_login=assignee.login if assignee else None,
|
|
247
|
+
mentioned_user=mentioned_user,
|
|
248
|
+
assignee_matches=assignee_matches)
|
|
249
|
+
|
|
250
|
+
if assignee_matches:
|
|
251
|
+
logger.info("Bot is assigned, processing event")
|
|
252
|
+
return True # Bot is assigned, process it
|
|
253
|
+
elif event.action == "assigned":
|
|
254
|
+
# For "assigned" action, only process if bot was assigned
|
|
255
|
+
logger.info("Bot was not assigned in 'assigned' event, skipping")
|
|
256
|
+
return False
|
|
257
|
+
# For "opened" and "created" actions, continue to check for mentions
|
|
258
|
+
|
|
259
|
+
# Check for @mentions
|
|
260
|
+
mentions = event.extract_mentions(mentioned_user)
|
|
261
|
+
logger.info("Checking for mentions",
|
|
262
|
+
mentions_found=len(mentions),
|
|
263
|
+
should_process=len(mentions) > 0)
|
|
264
|
+
|
|
265
|
+
return len(mentions) > 0
|
|
266
|
+
|
|
267
|
+
@staticmethod
|
|
268
|
+
def should_process_event_for_ci(event: WebhookEvent, devs_options) -> bool:
|
|
269
|
+
"""Check if an event should be processed for CI mode.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
event: Parsed webhook event
|
|
273
|
+
devs_options: DevsOptions configuration for the repository
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
True if the event should trigger CI processing
|
|
277
|
+
"""
|
|
278
|
+
import structlog
|
|
279
|
+
logger = structlog.get_logger()
|
|
280
|
+
|
|
281
|
+
# CI must be enabled in repository configuration
|
|
282
|
+
if not devs_options or not devs_options.ci_enabled:
|
|
283
|
+
logger.info("CI not enabled for repository", repo=event.repository.full_name)
|
|
284
|
+
return False
|
|
285
|
+
|
|
286
|
+
logger.info("Checking if event should trigger CI",
|
|
287
|
+
event_type=type(event).__name__,
|
|
288
|
+
action=event.action,
|
|
289
|
+
repo=event.repository.full_name)
|
|
290
|
+
|
|
291
|
+
# Handle pull request events for CI
|
|
292
|
+
if isinstance(event, PullRequestEvent):
|
|
293
|
+
# Process PR opened, synchronize (new commits), reopened
|
|
294
|
+
ci_pr_actions = ["opened", "synchronize", "reopened"]
|
|
295
|
+
should_process = event.action in ci_pr_actions
|
|
296
|
+
|
|
297
|
+
logger.info("PR event CI check",
|
|
298
|
+
action=event.action,
|
|
299
|
+
ci_pr_actions=ci_pr_actions,
|
|
300
|
+
should_process=should_process)
|
|
301
|
+
|
|
302
|
+
return should_process
|
|
303
|
+
|
|
304
|
+
# Handle push events for CI
|
|
305
|
+
elif isinstance(event, PushEvent):
|
|
306
|
+
# Only process pushes to configured branches
|
|
307
|
+
# Extract branch name from ref (refs/heads/main -> main)
|
|
308
|
+
branch = event.ref.replace('refs/heads/', '') if event.ref.startswith('refs/heads/') else event.ref
|
|
309
|
+
|
|
310
|
+
# Check if branch is in CI configuration
|
|
311
|
+
ci_branches = devs_options.ci_branches or ["main", "master"]
|
|
312
|
+
should_process = branch in ci_branches
|
|
313
|
+
|
|
314
|
+
logger.info("Push event CI check",
|
|
315
|
+
branch=branch,
|
|
316
|
+
ci_branches=ci_branches,
|
|
317
|
+
should_process=should_process)
|
|
318
|
+
|
|
319
|
+
return should_process
|
|
320
|
+
|
|
321
|
+
# Other event types don't trigger CI
|
|
322
|
+
logger.info("Event type does not trigger CI",
|
|
323
|
+
event_type=type(event).__name__)
|
|
324
|
+
return False
|
|
325
|
+
|