1+ import os
12import re
23
34from base import GitHubBot
45from utils import (
56 extract_linked_issues ,
7+ find_open_pr_for_issue ,
8+ get_assignee_logins ,
69 get_valid_linked_issues ,
710 unassign_linked_issues_helper ,
11+ user_in_logins ,
12+ verify_assignment ,
813)
914
1015
1116class IssueAssignmentBot (GitHubBot ):
17+ def __init__ (self ):
18+ super ().__init__ ()
19+ self .bot_username = os .environ .get ("BOT_USERNAME" , "openwisp-companion" )
20+
21+ def is_bot_assign_command (self , comment_body ):
22+ if not comment_body :
23+ return False
24+ pattern = rf"(?<![\w-])@{ re .escape (self .bot_username )} \s+assign\b"
25+ return bool (re .search (pattern , comment_body , re .IGNORECASE ))
26+
1227 def is_assignment_request (self , comment_body ):
1328 if not comment_body :
1429 return False
@@ -194,6 +209,90 @@ def respond_to_assignment_request(self, issue_number, commenter):
194209 print (f"Error responding to assignment request: { e } " )
195210 return False
196211
212+ def _cannot_auto_assign_message (self , pr_author , pr_number ):
213+ return (
214+ f"Hi @{ pr_author } 👋,\n \n "
215+ f"Thank you for opening PR #{ pr_number } to address this issue!\n \n "
216+ "GitHub did not allow the bot to assign this issue to you"
217+ " automatically, because you are not yet an organization"
218+ " member and have not previously commented on this issue.\n \n "
219+ f"To get assigned, please comment `@{ self .bot_username } assign`"
220+ " on this issue. Your comment satisfies GitHub's requirement"
221+ " and the bot will then assign the issue to you."
222+ )
223+
224+ def handle_bot_assign_request (self , issue_number , commenter ):
225+ if not self .repo :
226+ print ("GitHub client not initialized" )
227+ return False
228+ try :
229+ issue = self .repo .get_issue (issue_number )
230+ if (
231+ hasattr (issue , "repository" )
232+ and issue .repository .full_name != self .repository_name
233+ ):
234+ print (
235+ f"Issue #{ issue_number } is from a different"
236+ " repository, ignoring bot command"
237+ )
238+ return True
239+ if issue .pull_request :
240+ print (f"#{ issue_number } is a PR, ignoring bot command" )
241+ return True
242+ if getattr (issue , "state" , "open" ) == "closed" :
243+ print (f"#{ issue_number } is closed, ignoring bot command" )
244+ return True
245+ if user_in_logins (commenter , get_assignee_logins (issue )):
246+ print (f"{ commenter } is already assigned to #{ issue_number } " )
247+ return True
248+ try :
249+ pr = find_open_pr_for_issue (
250+ self .github , self .repository_name , commenter , issue_number
251+ )
252+ except Exception as e :
253+ # Don't post "no PR found" on a search error — that's
254+ # not the same as a verified miss.
255+ print (f"Error searching open PRs by { commenter } : { e } " )
256+ return True
257+ if pr is None :
258+ issue .create_comment (
259+ f"Hi @{ commenter } 👋,\n \n "
260+ "I could not find an open PR by you that references"
261+ f" this issue (#{ issue_number } ). Please open a PR"
262+ f" linking to this issue (e.g. `Fixes #{ issue_number } `)"
263+ f" and then comment `@{ self .bot_username } assign` again."
264+ )
265+ return True
266+ issue .add_to_assignees (commenter )
267+ verified = verify_assignment (self .repo , issue_number , commenter )
268+ if verified is True :
269+ issue .create_comment (
270+ f"This issue has been assigned to @{ commenter } "
271+ f" who opened PR #{ pr .number } to address it. 🎯"
272+ )
273+ print (f"Assigned #{ issue_number } to { commenter } via bot command" )
274+ elif verified is False :
275+ # Commenter has commented but the assignment still
276+ # failed (perm block, outage, etc.).
277+ issue .create_comment (
278+ f"Sorry @{ commenter } , GitHub still did not allow"
279+ " this assignment. A maintainer will need to assign"
280+ " this issue manually."
281+ )
282+ print (
283+ f"Bot-command assignment of #{ issue_number } to"
284+ f" { commenter } was silently rejected."
285+ )
286+ else :
287+ print (
288+ f"Skipping comment for #{ issue_number } :"
289+ " assignment state could not be verified."
290+ )
291+ return True
292+ except Exception as e :
293+ print (f"Error handling bot assign command: { e } " )
294+ return False
295+
197296 def auto_assign_issues_from_pr (self , pr_number , pr_author , pr_body , max_issues = 10 ):
198297 if not self .repo :
199298 print ("GitHub client not initialized" )
@@ -211,18 +310,20 @@ def auto_assign_issues_from_pr(self, pr_number, pr_author, pr_body, max_issues=1
211310 )
212311 assigned_issues = []
213312 for issue_number , issue in get_valid_linked_issues (
214- self .repo , self .repository_name , pr_body
313+ self .repo , self .repository_name , linked_issues
215314 ):
216315 if len (assigned_issues ) >= max_issues :
217316 break
218317 try :
219- current_assignees = [
220- assignee .login
221- for assignee in issue .assignees
222- if hasattr (assignee , "login" )
223- ]
318+ if getattr (issue , "state" , "open" ) == "closed" :
319+ print (
320+ f"Issue #{ issue_number } is closed, skipping"
321+ " auto-assignment"
322+ )
323+ continue
324+ current_assignees = get_assignee_logins (issue )
224325 if current_assignees :
225- if pr_author in current_assignees :
326+ if user_in_logins ( pr_author , current_assignees ) :
226327 print (
227328 f"Issue #{ issue_number } already"
228329 f" assigned to { pr_author } "
@@ -235,17 +336,32 @@ def auto_assign_issues_from_pr(self, pr_number, pr_author, pr_body, max_issues=1
235336 )
236337 continue
237338 issue .add_to_assignees (pr_author )
238- assigned_issues .append (issue_number )
239- print (f"Assigned issue #{ issue_number } " f" to { pr_author } " )
240- comment_message = (
241- "This issue has been automatically"
242- f" assigned to @{ pr_author } "
243- f" who opened PR #{ pr_number } "
244- " to address it. 🎯"
245- )
246- issue .create_comment (comment_message )
339+ verified = verify_assignment (self .repo , issue_number , pr_author )
340+ if verified is True :
341+ assigned_issues .append (issue_number )
342+ print (f"Assigned issue #{ issue_number } to { pr_author } " )
343+ comment_message = (
344+ "This issue has been automatically"
345+ f" assigned to @{ pr_author } "
346+ f" who opened PR #{ pr_number } "
347+ " to address it. 🎯"
348+ )
349+ issue .create_comment (comment_message )
350+ elif verified is False :
351+ print (
352+ f"Assignment of #{ issue_number } to { pr_author } "
353+ " was silently rejected by GitHub."
354+ )
355+ issue .create_comment (
356+ self ._cannot_auto_assign_message (pr_author , pr_number )
357+ )
358+ else :
359+ print (
360+ f"Skipping comment for #{ issue_number } :"
361+ " assignment state could not be verified."
362+ )
247363 except Exception as e :
248- print (f"Error processing issue" f" #{ issue_number } : { e } " )
364+ print (f"Error processing issue #{ issue_number } : { e } " )
249365 return assigned_issues
250366 except Exception as e :
251367 print (f"Error in auto_assign_issues_from_pr: { e } " )
@@ -266,11 +382,27 @@ def unassign_issues_from_pr(self, pr_body, pr_author):
266382 print (f"Error in unassign_issues_from_pr: { e } " )
267383 return []
268384
385+ def _is_bot_comment (self , comment ):
386+ user = comment .get ("user" ) or {}
387+ if (user .get ("type" ) or "" ).lower () == "bot" :
388+ return True
389+ if comment .get ("performed_via_github_app" ):
390+ return True
391+ login = (user .get ("login" ) or "" ).lower ()
392+ return login in {
393+ self .bot_username .lower (),
394+ f"{ self .bot_username } [bot]" .lower (),
395+ }
396+
269397 def handle_issue_comment (self ):
270398 if not self .event_payload :
271399 print ("No event payload available" )
272400 return False
273401 try :
402+ action = self .event_payload .get ("action" )
403+ if action and action != "created" :
404+ print (f"Ignoring issue_comment action '{ action } '" )
405+ return True
274406 if self .event_payload .get ("issue" , {}).get ("pull_request" ):
275407 print ("Comment is on a PR, not an issue - skipping" )
276408 return True
@@ -282,9 +414,14 @@ def handle_issue_comment(self):
282414 if not all ([comment_body , commenter , issue_number ]):
283415 print ("Missing required comment data" )
284416 return False
417+ if self ._is_bot_comment (comment ):
418+ print ("Ignoring comment posted by a bot" )
419+ return True
420+ if self .is_bot_assign_command (comment_body ):
421+ return self .handle_bot_assign_request (issue_number , commenter )
285422 if self .is_assignment_request (comment_body ):
286423 return self .respond_to_assignment_request (issue_number , commenter )
287- print ("Comment does not contain assignment request" )
424+ print ("Comment does not contain an assignment request or bot command " )
288425 return True
289426 except Exception as e :
290427 print (f"Error handling issue comment: { e } " )
0 commit comments