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,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
+ ]