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,226 @@
1
+ """GitHub App authentication for enhanced API access."""
2
+
3
+ import time
4
+ import jwt
5
+ import requests
6
+ from typing import Optional, Dict, Any
7
+ from datetime import datetime, timedelta, timezone
8
+ import structlog
9
+
10
+ logger = structlog.get_logger()
11
+
12
+
13
+ class GitHubAppAuth:
14
+ """GitHub App authentication handler for generating installation tokens."""
15
+
16
+ def __init__(self, app_id: str, private_key: str, installation_id: Optional[str] = None):
17
+ """Initialize GitHub App authentication.
18
+
19
+ Args:
20
+ app_id: GitHub App ID
21
+ private_key: Private key content in PEM format
22
+ installation_id: Installation ID (optional, can be auto-discovered)
23
+ """
24
+ self.app_id = app_id
25
+ self.private_key = private_key
26
+ self.installation_id = installation_id
27
+ self._installation_token: Optional[str] = None
28
+ self._token_expires_at: Optional[datetime] = None
29
+
30
+ def _generate_jwt_token(self) -> str:
31
+ """Generate a JWT token for GitHub App authentication.
32
+
33
+ Returns:
34
+ JWT token for authenticating as the GitHub App
35
+ """
36
+ now = datetime.now(timezone.utc)
37
+
38
+ payload = {
39
+ 'iat': int(now.timestamp()),
40
+ 'exp': int((now + timedelta(minutes=5)).timestamp()), # 5 minutes max
41
+ 'iss': self.app_id
42
+ }
43
+
44
+ return jwt.encode(payload, self.private_key, algorithm='RS256')
45
+
46
+ async def _get_installation_id(self, repo: str) -> Optional[str]:
47
+ """Auto-discover installation ID for a repository.
48
+
49
+ Args:
50
+ repo: Repository in format "owner/repo"
51
+
52
+ Returns:
53
+ Installation ID if found, None otherwise
54
+ """
55
+ if self.installation_id:
56
+ return self.installation_id
57
+
58
+ try:
59
+ jwt_token = self._generate_jwt_token()
60
+ headers = {
61
+ 'Authorization': f'Bearer {jwt_token}',
62
+ 'Accept': 'application/vnd.github.v3+json'
63
+ }
64
+
65
+ # Get installations for this app
66
+ url = 'https://api.github.com/app/installations'
67
+ response = requests.get(url, headers=headers)
68
+
69
+ if response.status_code != 200:
70
+ logger.error("Failed to get app installations",
71
+ status=response.status_code, error=response.text)
72
+ return None
73
+
74
+ installations = response.json()
75
+
76
+ # Find installation for the repository
77
+ for installation in installations:
78
+ install_id = str(installation['id'])
79
+
80
+ # Check if this installation has access to the repository
81
+ repo_url = f'https://api.github.com/installation/repositories'
82
+ install_headers = await self._get_installation_headers(install_id)
83
+ if install_headers:
84
+ repo_response = requests.get(repo_url, headers=install_headers)
85
+ if repo_response.status_code == 200:
86
+ repos = repo_response.json().get('repositories', [])
87
+ for repository in repos:
88
+ if repository['full_name'] == repo:
89
+ logger.info("Auto-discovered installation ID",
90
+ installation_id=install_id, repo=repo)
91
+ self.installation_id = install_id
92
+ return install_id
93
+
94
+ logger.warning("No installation found for repository", repo=repo)
95
+ return None
96
+
97
+ except Exception as e:
98
+ logger.error("Error auto-discovering installation ID",
99
+ repo=repo, error=str(e))
100
+ return None
101
+
102
+ async def _get_installation_headers(self, installation_id: str) -> Optional[Dict[str, str]]:
103
+ """Get headers with a valid installation access token.
104
+
105
+ Args:
106
+ installation_id: GitHub App installation ID
107
+
108
+ Returns:
109
+ Headers dict with Authorization header, or None if failed
110
+ """
111
+ token = await self._get_installation_token(installation_id)
112
+ if not token:
113
+ return None
114
+
115
+ return {
116
+ 'Authorization': f'token {token}',
117
+ 'Accept': 'application/vnd.github.v3+json'
118
+ }
119
+
120
+ async def _get_installation_token(self, installation_id: str) -> Optional[str]:
121
+ """Get or refresh installation access token.
122
+
123
+ Args:
124
+ installation_id: GitHub App installation ID
125
+
126
+ Returns:
127
+ Installation access token or None if failed
128
+ """
129
+ # Check if we have a valid cached token
130
+ if (self._installation_token and
131
+ self._token_expires_at and
132
+ self._token_expires_at > datetime.now(timezone.utc) + timedelta(minutes=5)):
133
+ return self._installation_token
134
+
135
+ # Generate new installation token
136
+ try:
137
+ jwt_token = self._generate_jwt_token()
138
+ headers = {
139
+ 'Authorization': f'Bearer {jwt_token}',
140
+ 'Accept': 'application/vnd.github.v3+json'
141
+ }
142
+
143
+ url = f'https://api.github.com/app/installations/{installation_id}/access_tokens'
144
+ response = requests.post(url, headers=headers)
145
+
146
+ if response.status_code == 201:
147
+ token_data = response.json()
148
+ self._installation_token = token_data['token']
149
+ expires_at_str = token_data['expires_at']
150
+ self._token_expires_at = datetime.fromisoformat(
151
+ expires_at_str.replace('Z', '+00:00')
152
+ )
153
+
154
+ logger.info("Generated new installation token",
155
+ installation_id=installation_id,
156
+ expires_at=expires_at_str)
157
+ return self._installation_token
158
+ else:
159
+ logger.error("Failed to get installation token",
160
+ installation_id=installation_id,
161
+ status=response.status_code, error=response.text)
162
+ return None
163
+
164
+ except Exception as e:
165
+ logger.error("Error generating installation token",
166
+ installation_id=installation_id, error=str(e))
167
+ return None
168
+
169
+ async def get_auth_headers(self, repo: str) -> Optional[Dict[str, str]]:
170
+ """Get authentication headers for a specific repository.
171
+
172
+ Args:
173
+ repo: Repository in format "owner/repo"
174
+
175
+ Returns:
176
+ Headers dict with Authorization header, or None if authentication failed
177
+ """
178
+ # Get or discover installation ID
179
+ installation_id = await self._get_installation_id(repo)
180
+ if not installation_id:
181
+ return None
182
+
183
+ return await self._get_installation_headers(installation_id)
184
+
185
+ async def get_auth_headers_for_installation(self, installation_id: str) -> Optional[Dict[str, str]]:
186
+ """Get authentication headers for a specific installation ID.
187
+
188
+ Args:
189
+ installation_id: GitHub App installation ID
190
+
191
+ Returns:
192
+ Headers dict with Authorization header, or None if authentication failed
193
+ """
194
+ return await self._get_installation_headers(installation_id)
195
+
196
+ async def test_authentication(self, repo: str) -> bool:
197
+ """Test GitHub App authentication for a repository.
198
+
199
+ Args:
200
+ repo: Repository in format "owner/repo"
201
+
202
+ Returns:
203
+ True if authentication successful
204
+ """
205
+ headers = await self.get_auth_headers(repo)
206
+ if not headers:
207
+ return False
208
+
209
+ try:
210
+ # Test by making a simple API call
211
+ url = f'https://api.github.com/repos/{repo}'
212
+ response = requests.get(url, headers=headers)
213
+
214
+ success = response.status_code == 200
215
+ if success:
216
+ logger.info("GitHub App authentication test successful", repo=repo)
217
+ else:
218
+ logger.error("GitHub App authentication test failed",
219
+ repo=repo, status=response.status_code, error=response.text)
220
+
221
+ return success
222
+
223
+ except Exception as e:
224
+ logger.error("Error testing GitHub App authentication",
225
+ repo=repo, error=str(e))
226
+ return False
@@ -0,0 +1,472 @@
1
+ """GitHub API client using PyGithub."""
2
+
3
+ from github import Github
4
+ from github.GithubException import GithubException
5
+ from typing import Optional, Dict, Any, List
6
+ from pathlib import Path
7
+ from datetime import datetime, timezone
8
+ import requests
9
+ import structlog
10
+
11
+ from .app_auth import GitHubAppAuth
12
+
13
+ logger = structlog.get_logger()
14
+
15
+
16
+ class GitHubClient:
17
+ """GitHub API client using PyGithub."""
18
+
19
+ def __init__(self, config):
20
+ """Initialize GitHub client.
21
+
22
+ Args:
23
+ config: WebhookConfig instance
24
+ """
25
+ self.config = config
26
+ self.token = config.github_token
27
+ self.app_auth = config.create_github_app_auth("github client")
28
+ self.github = Github(self.token)
29
+
30
+ if self.app_auth:
31
+ logger.info("GitHub API client initialized with PyGithub and GitHub App authentication")
32
+ else:
33
+ logger.info("GitHub API client initialized with PyGithub (personal token only)")
34
+
35
+ async def _get_auth_headers(self, repo: str, prefer_app_auth: bool = False, installation_id: Optional[str] = None) -> Dict[str, str]:
36
+ """Get authentication headers, preferring GitHub App auth when available.
37
+
38
+ Args:
39
+ repo: Repository in format "owner/repo"
40
+ prefer_app_auth: Whether to prefer GitHub App auth over personal token
41
+ installation_id: GitHub App installation ID if known from webhook event
42
+
43
+ Returns:
44
+ Headers dict with Authorization header
45
+ """
46
+ # Try GitHub App auth first if available and preferred
47
+ if prefer_app_auth and self.app_auth:
48
+ # If we have an installation ID from a webhook event, use it directly
49
+ if installation_id:
50
+ app_headers = await self.app_auth.get_auth_headers_for_installation(installation_id)
51
+ else:
52
+ app_headers = await self.app_auth.get_auth_headers(repo)
53
+
54
+ if app_headers:
55
+ logger.debug("Using GitHub App authentication", repo=repo, installation_id=installation_id)
56
+ return app_headers
57
+ else:
58
+ logger.warning("GitHub App auth failed, falling back to personal token", repo=repo, installation_id=installation_id)
59
+
60
+ # Fall back to personal token
61
+ logger.debug("Using personal token authentication", repo=repo)
62
+ return {
63
+ 'Authorization': f'token {self.token}',
64
+ 'Accept': 'application/vnd.github.v3+json'
65
+ }
66
+
67
+ async def comment_on_issue(
68
+ self,
69
+ repo: str,
70
+ issue_number: int,
71
+ comment: str
72
+ ) -> bool:
73
+ """Add a comment to a GitHub issue.
74
+
75
+ Args:
76
+ repo: Repository in format "owner/repo"
77
+ issue_number: Issue number
78
+ comment: Comment text
79
+
80
+ Returns:
81
+ True if successful
82
+ """
83
+ try:
84
+ repository = self.github.get_repo(repo)
85
+ issue = repository.get_issue(issue_number)
86
+ issue.create_comment(comment)
87
+
88
+ logger.info("Comment added to issue", repo=repo, issue=issue_number)
89
+ return True
90
+
91
+ except GithubException as e:
92
+ logger.error("Failed to comment on issue",
93
+ repo=repo, issue=issue_number, error=str(e))
94
+ return False
95
+ except Exception as e:
96
+ logger.error("Unexpected error commenting on issue",
97
+ repo=repo, issue=issue_number, error=str(e))
98
+ return False
99
+
100
+ async def comment_on_pr(
101
+ self,
102
+ repo: str,
103
+ pr_number: int,
104
+ comment: str
105
+ ) -> bool:
106
+ """Add a comment to a GitHub pull request.
107
+
108
+ Args:
109
+ repo: Repository in format "owner/repo"
110
+ pr_number: Pull request number
111
+ comment: Comment text
112
+
113
+ Returns:
114
+ True if successful
115
+ """
116
+ try:
117
+ repository = self.github.get_repo(repo)
118
+ pull_request = repository.get_pull(pr_number)
119
+ pull_request.create_issue_comment(comment)
120
+
121
+ logger.info("Comment added to PR", repo=repo, pr=pr_number)
122
+ return True
123
+
124
+ except GithubException as e:
125
+ logger.error("Failed to comment on PR",
126
+ repo=repo, pr=pr_number, error=str(e))
127
+ return False
128
+ except Exception as e:
129
+ logger.error("Unexpected error commenting on PR",
130
+ repo=repo, pr=pr_number, error=str(e))
131
+ return False
132
+
133
+ async def get_repository_info(self, repo: str) -> Optional[Dict[str, Any]]:
134
+ """Get repository information.
135
+
136
+ Args:
137
+ repo: Repository in format "owner/repo"
138
+
139
+ Returns:
140
+ Repository info dict or None if failed
141
+ """
142
+ try:
143
+ repository = self.github.get_repo(repo)
144
+ return {
145
+ "name": repository.name,
146
+ "full_name": repository.full_name,
147
+ "owner": repository.owner.login,
148
+ "url": repository.html_url,
149
+ "clone_url": repository.clone_url,
150
+ "ssh_url": repository.ssh_url,
151
+ "default_branch": repository.default_branch
152
+ }
153
+
154
+ except GithubException as e:
155
+ logger.error("Failed to get repository info", repo=repo, error=str(e))
156
+ return None
157
+ except Exception as e:
158
+ logger.error("Unexpected error getting repository info", repo=repo, error=str(e))
159
+ return None
160
+
161
+ async def add_reaction_to_issue(
162
+ self,
163
+ repo: str,
164
+ issue_number: int,
165
+ reaction: str = "eyes"
166
+ ) -> bool:
167
+ """Add a reaction to a GitHub issue.
168
+
169
+ Args:
170
+ repo: Repository in format "owner/repo"
171
+ issue_number: Issue number
172
+ reaction: Reaction type (eyes, +1, -1, laugh, confused, heart, hooray, rocket)
173
+
174
+ Returns:
175
+ True if successful
176
+ """
177
+ try:
178
+ repository = self.github.get_repo(repo)
179
+ issue = repository.get_issue(issue_number)
180
+ issue.create_reaction(reaction)
181
+
182
+ logger.info("Reaction added to issue",
183
+ repo=repo, issue=issue_number, reaction=reaction)
184
+ return True
185
+
186
+ except GithubException as e:
187
+ logger.error("Failed to add reaction to issue",
188
+ repo=repo, issue=issue_number, reaction=reaction, error=str(e))
189
+ return False
190
+ except Exception as e:
191
+ logger.error("Unexpected error adding reaction to issue",
192
+ repo=repo, issue=issue_number, reaction=reaction, error=str(e))
193
+ return False
194
+
195
+ async def add_reaction_to_pr(
196
+ self,
197
+ repo: str,
198
+ pr_number: int,
199
+ reaction: str = "eyes"
200
+ ) -> bool:
201
+ """Add a reaction to a GitHub pull request.
202
+
203
+ Args:
204
+ repo: Repository in format "owner/repo"
205
+ pr_number: Pull request number
206
+ reaction: Reaction type (eyes, +1, -1, laugh, confused, heart, hooray, rocket)
207
+
208
+ Returns:
209
+ True if successful
210
+ """
211
+ try:
212
+ repository = self.github.get_repo(repo)
213
+ # PRs are issues in GitHub's API, so we can use get_issue
214
+ pr_as_issue = repository.get_issue(pr_number)
215
+ pr_as_issue.create_reaction(reaction)
216
+
217
+ logger.info("Reaction added to PR",
218
+ repo=repo, pr=pr_number, reaction=reaction)
219
+ return True
220
+
221
+ except GithubException as e:
222
+ logger.error("Failed to add reaction to PR",
223
+ repo=repo, pr=pr_number, reaction=reaction, error=str(e))
224
+ return False
225
+ except Exception as e:
226
+ logger.error("Unexpected error adding reaction to PR",
227
+ repo=repo, pr=pr_number, reaction=reaction, error=str(e))
228
+ return False
229
+
230
+ async def add_reaction_to_comment(
231
+ self,
232
+ repo: str,
233
+ comment_id: int,
234
+ reaction: str = "eyes"
235
+ ) -> bool:
236
+ """Add a reaction to a GitHub comment.
237
+
238
+ Args:
239
+ repo: Repository in format "owner/repo"
240
+ comment_id: Comment ID
241
+ reaction: Reaction type (eyes, +1, -1, laugh, confused, heart, hooray, rocket)
242
+
243
+ Returns:
244
+ True if successful
245
+ """
246
+ try:
247
+ # PyGithub's repository.get_comment() gets commit comments, not issue comments.
248
+ # For issue/PR comments, we need to use the REST API directly.
249
+
250
+ headers = await self._get_auth_headers(repo, prefer_app_auth=False)
251
+
252
+ # Add reaction to issue/PR comment via REST API
253
+ reaction_url = f'https://api.github.com/repos/{repo}/issues/comments/{comment_id}/reactions'
254
+ response = requests.post(
255
+ reaction_url,
256
+ json={'content': reaction},
257
+ headers=headers
258
+ )
259
+
260
+ if response.status_code in [200, 201]:
261
+ logger.info("Reaction added to comment",
262
+ repo=repo, comment_id=comment_id, reaction=reaction)
263
+ return True
264
+ else:
265
+ logger.error("Failed to add reaction to comment",
266
+ repo=repo, comment_id=comment_id, reaction=reaction,
267
+ status=response.status_code, error=response.text)
268
+ return False
269
+
270
+ except Exception as e:
271
+ logger.error("Unexpected error adding reaction to comment",
272
+ repo=repo, comment_id=comment_id, reaction=reaction, error=str(e))
273
+ return False
274
+
275
+ # GitHub Checks API methods
276
+
277
+ async def create_check_run(
278
+ self,
279
+ repo: str,
280
+ name: str,
281
+ head_sha: str,
282
+ status: str = "queued",
283
+ details_url: Optional[str] = None,
284
+ external_id: Optional[str] = None,
285
+ installation_id: Optional[str] = None
286
+ ) -> Optional[int]:
287
+ """Create a check run using GitHub Checks API.
288
+
289
+ Args:
290
+ repo: Repository in format "owner/repo"
291
+ name: Name of the check run (e.g., "tests")
292
+ head_sha: SHA of the commit to run checks on
293
+ status: Status of check run (queued, in_progress, completed)
294
+ details_url: URL to external build/test results
295
+ external_id: External identifier for the check run
296
+ installation_id: GitHub App installation ID if known from webhook event
297
+
298
+ Returns:
299
+ Check run ID if successful, None otherwise
300
+ """
301
+ try:
302
+ headers = await self._get_auth_headers(repo, prefer_app_auth=True, installation_id=installation_id)
303
+
304
+ data = {
305
+ 'name': name,
306
+ 'head_sha': head_sha,
307
+ 'status': status,
308
+ 'started_at': datetime.now(timezone.utc).isoformat()
309
+ }
310
+
311
+ if details_url:
312
+ data['details_url'] = details_url
313
+ if external_id:
314
+ data['external_id'] = external_id
315
+
316
+ url = f'https://api.github.com/repos/{repo}/check-runs'
317
+ response = requests.post(url, json=data, headers=headers)
318
+
319
+ if response.status_code == 201:
320
+ check_run = response.json()
321
+ check_run_id = check_run['id']
322
+ logger.info("Check run created",
323
+ repo=repo, name=name, check_run_id=check_run_id, head_sha=head_sha)
324
+ return check_run_id
325
+ else:
326
+ logger.error("Failed to create check run",
327
+ repo=repo, name=name, head_sha=head_sha,
328
+ status=response.status_code, error=response.text)
329
+ return None
330
+
331
+ except Exception as e:
332
+ logger.error("Unexpected error creating check run",
333
+ repo=repo, name=name, head_sha=head_sha, error=str(e))
334
+ return None
335
+
336
+ async def update_check_run(
337
+ self,
338
+ repo: str,
339
+ check_run_id: int,
340
+ status: str,
341
+ conclusion: Optional[str] = None,
342
+ output: Optional[Dict[str, Any]] = None,
343
+ details_url: Optional[str] = None,
344
+ installation_id: Optional[str] = None
345
+ ) -> bool:
346
+ """Update a check run using GitHub Checks API.
347
+
348
+ Args:
349
+ repo: Repository in format "owner/repo"
350
+ check_run_id: ID of the check run to update
351
+ status: Status of check run (queued, in_progress, completed)
352
+ conclusion: Conclusion when status=completed (success, failure, neutral, cancelled, skipped, timed_out, action_required)
353
+ output: Output object with title, summary, text, annotations, images
354
+ details_url: URL to external build/test results
355
+ installation_id: GitHub App installation ID if known from webhook event
356
+
357
+ Returns:
358
+ True if successful
359
+ """
360
+ try:
361
+ headers = await self._get_auth_headers(repo, prefer_app_auth=True, installation_id=installation_id)
362
+
363
+ data = {
364
+ 'status': status
365
+ }
366
+
367
+ if status == 'completed':
368
+ data['completed_at'] = datetime.now(timezone.utc).isoformat()
369
+ if conclusion:
370
+ data['conclusion'] = conclusion
371
+
372
+ if output:
373
+ data['output'] = output
374
+ if details_url:
375
+ data['details_url'] = details_url
376
+
377
+ url = f'https://api.github.com/repos/{repo}/check-runs/{check_run_id}'
378
+ response = requests.patch(url, json=data, headers=headers)
379
+
380
+ if response.status_code == 200:
381
+ logger.info("Check run updated",
382
+ repo=repo, check_run_id=check_run_id, status=status, conclusion=conclusion)
383
+ return True
384
+ else:
385
+ logger.error("Failed to update check run",
386
+ repo=repo, check_run_id=check_run_id, status=status,
387
+ response_status=response.status_code, error=response.text)
388
+ return False
389
+
390
+ except Exception as e:
391
+ logger.error("Unexpected error updating check run",
392
+ repo=repo, check_run_id=check_run_id, status=status, error=str(e))
393
+ return False
394
+
395
+ async def complete_check_run_success(
396
+ self,
397
+ repo: str,
398
+ check_run_id: int,
399
+ title: str = "Tests passed",
400
+ summary: str = "All tests completed successfully",
401
+ details_url: Optional[str] = None,
402
+ installation_id: Optional[str] = None
403
+ ) -> bool:
404
+ """Complete a check run with success status.
405
+
406
+ Args:
407
+ repo: Repository in format "owner/repo"
408
+ check_run_id: ID of the check run to complete
409
+ title: Title for the check run output
410
+ summary: Summary text for the check run
411
+ details_url: URL to external build/test results
412
+ installation_id: GitHub App installation ID if known from webhook event
413
+
414
+ Returns:
415
+ True if successful
416
+ """
417
+ output = {
418
+ 'title': title,
419
+ 'summary': summary
420
+ }
421
+
422
+ return await self.update_check_run(
423
+ repo=repo,
424
+ check_run_id=check_run_id,
425
+ status='completed',
426
+ conclusion='success',
427
+ output=output,
428
+ details_url=details_url,
429
+ installation_id=installation_id
430
+ )
431
+
432
+ async def complete_check_run_failure(
433
+ self,
434
+ repo: str,
435
+ check_run_id: int,
436
+ title: str = "Tests failed",
437
+ summary: str = "Some tests failed",
438
+ text: Optional[str] = None,
439
+ details_url: Optional[str] = None,
440
+ installation_id: Optional[str] = None
441
+ ) -> bool:
442
+ """Complete a check run with failure status.
443
+
444
+ Args:
445
+ repo: Repository in format "owner/repo"
446
+ check_run_id: ID of the check run to complete
447
+ title: Title for the check run output
448
+ summary: Summary text for the check run
449
+ text: Additional detailed text output
450
+ details_url: URL to external build/test results
451
+ installation_id: GitHub App installation ID if known from webhook event
452
+
453
+ Returns:
454
+ True if successful
455
+ """
456
+ output = {
457
+ 'title': title,
458
+ 'summary': summary
459
+ }
460
+
461
+ if text:
462
+ output['text'] = text
463
+
464
+ return await self.update_check_run(
465
+ repo=repo,
466
+ check_run_id=check_run_id,
467
+ status='completed',
468
+ conclusion='failure',
469
+ output=output,
470
+ details_url=details_url,
471
+ installation_id=installation_id
472
+ )