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