-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathgithub_client.py
More file actions
335 lines (287 loc) · 14.9 KB
/
Copy pathgithub_client.py
File metadata and controls
335 lines (287 loc) · 14.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
import requests
import logging
from github import Github
from typing import List, Dict, Optional, Set
logger = logging.getLogger(__name__)
class GithubClient:
def __init__(self, token: str):
self.token = token
self.github = Github(token)
self.headers = {
'Authorization': f'token {token}',
'Accept': 'application/vnd.github.v3+json'
}
logger.info(f"GitHub client initialized with token: {'***' + token[-4:] if token else 'None'}")
def get_user_reputation(self, repo_name: str, username: str, core_team: List[str]) -> Dict:
"""Get reputation data for a user in a specific repository."""
logger.info(f"Getting reputation for user @{username} in {repo_name}")
logger.info(f"Core team members: {core_team}")
try:
repo = self.github.get_repo(repo_name)
except Exception as e:
logger.error(f"Failed to get repo {repo_name}: {str(e)}")
raise
merged_prs = 0
open_prs = 0
closed_prs = 0
issues_created = 0
assigned_issues = 0
comments_count = 0
thumbs_up_from_core = 0
thumbs_down_from_core = 0
# Try GitHub Search API first (faster)
logger.info(f"Searching for PRs by @{username} using search API")
pr_fetch_success = False
try:
# Search for all PRs by this user in this repo
pr_query = f"repo:{repo_name} author:{username} is:pr"
pr_results = self.github.search_issues(query=pr_query)
pr_count = 0
# Limit to first 100 PRs for performance
for pr_data in pr_results[:100]:
pr_count += 1
pr = repo.get_pull(pr_data.number)
if pr.merged:
merged_prs += 1
elif pr.state == 'open':
open_prs += 1
elif pr.state == 'closed':
closed_prs += 1
logger.info(f"Found {pr_count} PRs for @{username}: {merged_prs} merged, {open_prs} open, {closed_prs} closed")
pr_fetch_success = True
except Exception as e:
logger.warning(f"Search API failed for @{username}: {str(e)}")
logger.info(f"Falling back to direct PR fetch for @{username}")
# Fallback to direct PR fetching if search fails
if not pr_fetch_success:
try:
# Fetch recent PRs and filter by username
pr_count = 0
checked_prs = 0
max_to_check = 300 # Limit to prevent timeout
# PyGithub uses automatic pagination
prs = repo.get_pulls(state='all')
for pr in prs:
checked_prs += 1
if checked_prs > max_to_check:
logger.info(f"Reached PR check limit of {max_to_check}")
break
if pr.user.login == username:
pr_count += 1
if pr.merged:
merged_prs += 1
elif pr.state == 'open':
open_prs += 1
elif pr.state == 'closed':
closed_prs += 1
logger.info(f"Fallback: Checked {checked_prs} PRs, found {pr_count} for @{username}: {merged_prs} merged, {open_prs} open, {closed_prs} closed")
except Exception as e:
logger.error(f"Fallback PR fetch also failed: {str(e)}")
# Leave counts at 0 instead of using fake data
# Try GitHub Search API for issues
logger.info(f"Searching for issues by @{username} using search API")
issue_fetch_success = False
try:
# Search for issues (not PRs) created by this user
issue_query = f"repo:{repo_name} author:{username} is:issue"
issue_results = self.github.search_issues(query=issue_query)
# Count up to 100 issues
issues_created = min(issue_results.totalCount, 100)
logger.info(f"Found {issues_created} issues created by @{username}")
issue_fetch_success = True
except Exception as e:
logger.warning(f"Search API failed for issues: {str(e)}")
logger.info(f"Falling back to direct issue fetch for @{username}")
# Fallback to direct issue fetching if search fails
if not issue_fetch_success:
try:
# Use creator parameter in get_issues
issues = repo.get_issues(state='all', creator=username)
for issue in issues[:100]: # Limit to 100 issues
if not issue.pull_request:
issues_created += 1
logger.info(f"Fallback: Found {issues_created} issues created by @{username}")
except Exception as e:
logger.error(f"Fallback issue fetch also failed: {str(e)}")
# Leave count at 0
# Try to fetch assigned open issues
logger.info(f"Searching for open issues assigned to @{username}")
try:
# Search for open issues assigned to this user
assigned_query = f"repo:{repo_name} is:issue is:open assignee:{username}"
assigned_results = self.github.search_issues(query=assigned_query)
# Count up to 100 assigned issues
assigned_issues = min(assigned_results.totalCount, 100)
logger.info(f"Found {assigned_issues} open issues assigned to @{username}")
except Exception as e:
logger.warning(f"Search API failed for assigned issues: {str(e)}")
# Try fallback if search fails
try:
issues = repo.get_issues(state='open', assignee=username)
for issue in issues[:100]: # Limit to 100 issues
if not issue.pull_request:
assigned_issues += 1
logger.info(f"Fallback: Found {assigned_issues} open issues assigned to @{username}")
except Exception as e2:
logger.error(f"Fallback assigned issues fetch also failed: {str(e2)}")
# Leave count at 0
# Try to count comments
logger.info(f"Counting comments by @{username}")
try:
query = f"repo:{repo_name} commenter:{username}"
search_results = self.github.search_issues(query=query)
# Just use the count from search results
comments_count = min(search_results.totalCount, 50) # Cap at 50 for performance
logger.info(f"Found approximately {comments_count} issues/PRs with comments from @{username}")
# For reactions, just check a few recent items
logger.info(f"Checking reactions on recent items (limited for performance)")
for item in list(search_results)[:5]: # Only check 5 items
try:
issue = repo.get_issue(item.number)
# Check reactions on the issue itself if author matches
if issue.user.login == username:
reactions = issue.get_reactions()
for reaction in reactions:
if reaction.user.login in core_team:
if reaction.content == '+1':
thumbs_up_from_core += 1
elif reaction.content == '-1':
thumbs_down_from_core += 1
except Exception as e:
logger.warning(f"Error checking reactions on item {item.number}: {str(e)}")
continue
except Exception as e:
logger.warning(f"Error searching comments: {str(e)}")
# Comments count stays at 0, which is more accurate than fake data
logger.info(f"Comments count will remain at 0 for @{username}")
# Skip additional reaction checking for performance
logger.info(f"Skipping additional reaction checks for performance")
result = {
'merged_prs': merged_prs,
'open_prs': open_prs,
'closed_prs': closed_prs,
'issues': issues_created,
'assigned_issues': assigned_issues,
'comments': comments_count,
'core_thumbs_up': thumbs_up_from_core,
'core_thumbs_down': thumbs_down_from_core
}
logger.info(f"Reputation data for @{username}: {result}")
return result
def get_issue_participants(self, repo_name: str, issue_number: int) -> Set[str]:
"""Get all participants (author + commenters) in an issue."""
logger.info(f"Getting participants for issue #{issue_number} in {repo_name}")
try:
repo = self.github.get_repo(repo_name)
issue = repo.get_issue(issue_number)
except Exception as e:
logger.error(f"Failed to get issue #{issue_number}: {str(e)}")
raise
participants = {issue.user.login}
logger.info(f"Issue author: @{issue.user.login}")
comment_count = 0
# Limit to first 30 comments for performance
for comment in list(issue.get_comments())[:30]:
# Skip bot's own comments (London-Cat is our bot account)
if comment.user.login == 'London-Cat':
logger.info(f"Skipping comment from London-Cat (our bot)")
continue
participants.add(comment.user.login)
comment_count += 1
if len(participants) >= 10: # Limit to 10 participants max
logger.info(f"Reached participant limit of 10, stopping")
break
logger.info(f"Processed {comment_count} comments")
# Filter out bot accounts (but not London-Cat from issue author position)
before_filter = len(participants)
participants = {p for p in participants if not p.endswith('[bot]')}
logger.info(f"Participants after filtering bots: {len(participants)} (filtered {before_filter - len(participants)} bots)")
logger.info(f"Participants: {list(participants)}")
return participants
def post_comment(self, repo_name: str, issue_number: int, body: str):
"""Post a comment to an issue or PR."""
logger.info(f"Posting comment to issue #{issue_number} in {repo_name}")
logger.debug(f"Comment body length: {len(body)} chars")
try:
repo = self.github.get_repo(repo_name)
issue = repo.get_issue(issue_number)
comment = issue.create_comment(body)
logger.info(f"Comment posted successfully with ID: {comment.id}")
except Exception as e:
logger.error(f"Failed to post comment: {str(e)}")
raise
def close_pull_request(self, repo_name: str, pr_number: int):
"""Close a pull request."""
logger.info(f"Attempting to close PR #{pr_number} in {repo_name}")
try:
repo = self.github.get_repo(repo_name)
logger.info(f"Got repo: {repo.full_name}")
# Get the PR
pr = repo.get_pull(pr_number)
logger.info(f"Got PR #{pr.number}, current state: {pr.state}, URL: {pr.html_url}")
# Close the PR using edit
pr.edit(state='closed')
logger.info(f"PR #{pr_number} closed successfully")
return True
except Exception as e:
logger.error(f"Failed to close PR #{pr_number}: {str(e)}")
logger.error(f"Error type: {type(e).__name__}")
if hasattr(e, 'status'):
logger.error(f"Error status: {e.status}")
if hasattr(e, 'data'):
logger.error(f"Error data: {e.data}")
# Don't re-raise to avoid breaking the webhook processing
# The comment will still be posted even if closing fails
return False
def extract_usernames_from_comment(self, comment_body: str) -> Set[str]:
"""Extract usernames from a bot comment body."""
import re
usernames = set()
# Look for usernames in the table format: | **@username** |
pattern = r'\| \*\*@([a-zA-Z0-9][\w-]*)\*\* \|'
matches = re.findall(pattern, comment_body)
usernames.update(matches)
logger.info(f"Extracted {len(usernames)} usernames from comment: {usernames}")
return usernames
def find_bot_comment(self, repo_name: str, issue_number: int) -> Optional[Dict]:
"""Find an existing bot comment on an issue."""
logger.info(f"Searching for existing bot comment on issue #{issue_number}")
try:
repo = self.github.get_repo(repo_name)
issue = repo.get_issue(issue_number)
# Get the authenticated user (the bot itself)
bot_user = self.github.get_user()
bot_username = bot_user.login
logger.info(f"Bot username: {bot_username}")
for comment in issue.get_comments():
# Check if comment is from our bot AND contains our signature
# Check multiple possible signatures for robustness
is_bot_comment = (
comment.user.login == bot_username or
'Generated by Reputation Bot' in comment.body or
'Generated by [Reputation Bot]' in comment.body or
'📊 Reputation Summary' in comment.body
)
if is_bot_comment:
logger.info(f"Found existing bot comment with ID: {comment.id} from user: {comment.user.login}")
return {
'id': comment.id,
'body': comment.body
}
logger.info(f"No existing bot comment found (checked comments from users)")
return None
except Exception as e:
logger.error(f"Error searching for bot comment: {str(e)}")
raise
def update_comment(self, repo_name: str, comment_id: int, body: str):
"""Update an existing comment."""
logger.info(f"Updating comment {comment_id} in {repo_name}")
logger.debug(f"New comment body length: {len(body)} chars")
url = f"https://api.github.com/repos/{repo_name}/issues/comments/{comment_id}"
try:
response = requests.patch(url, headers=self.headers, json={'body': body})
response.raise_for_status()
logger.info(f"Comment {comment_id} updated successfully")
except Exception as e:
logger.error(f"Failed to update comment: {str(e)}")
raise