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.
@@ -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
+