1+ import os
12import time
23from datetime import datetime , timezone
34
45from base import GitHubBot
5- from utils import unassign_linked_issues_helper
6+ from utils import (
7+ extract_linked_issues ,
8+ get_valid_linked_issues ,
9+ unassign_linked_issues_helper ,
10+ )
611
712# GitHub author_association values that represent project maintainers.
813MAINTAINER_ROLES = frozenset ({"OWNER" , "MEMBER" , "COLLABORATOR" })
@@ -14,6 +19,7 @@ def __init__(self):
1419 self .DAYS_BEFORE_STALE_WARNING = 7
1520 self .DAYS_BEFORE_UNASSIGN = 14
1621 self .DAYS_BEFORE_FINAL_FOLLOWUP = 60
22+ self .bot_login = os .environ .get ("BOT_USERNAME" , "openwisp-companion" ) + "[bot]"
1723
1824 @staticmethod
1925 def _commit_activity_date_for_author (commit , pr_author ):
@@ -38,6 +44,7 @@ def _get_last_author_activity(
3844 self ,
3945 pr ,
4046 after_date ,
47+ commits = None ,
4148 issue_comments = None ,
4249 all_reviews = None ,
4350 review_comments = None ,
@@ -50,7 +57,9 @@ def _get_last_author_activity(
5057 if not pr_author :
5158 return None
5259 last_activity = None
53- for commit in pr .get_commits ():
60+ if commits is None :
61+ commits = pr .get_commits ()
62+ for commit in commits :
5463 commit_date = self ._commit_activity_date_for_author (commit , pr_author )
5564 if not commit_date or commit_date <= after_date :
5665 continue
@@ -86,6 +95,7 @@ def get_days_since_activity(
8695 self ,
8796 pr ,
8897 last_changes_requested ,
98+ commits = None ,
8999 issue_comments = None ,
90100 all_reviews = None ,
91101 review_comments = None ,
@@ -96,6 +106,7 @@ def get_days_since_activity(
96106 last_author_activity = self ._get_last_author_activity (
97107 pr ,
98108 last_changes_requested ,
109+ commits ,
99110 issue_comments ,
100111 all_reviews ,
101112 review_comments ,
@@ -104,60 +115,35 @@ def get_days_since_activity(
104115 now = datetime .now (timezone .utc )
105116 return (now - reference_date ).days
106117 except Exception as e :
107- print ("Error calculating activity" f" for PR #{ pr .number } : { e } " )
118+ print (f "Error calculating activity for PR #{ pr .number } : { e } " )
108119 return 0
109120
110121 def is_waiting_for_maintainer (
111122 self ,
112123 pr ,
113124 last_changes_requested ,
125+ commits = None ,
114126 issue_comments = None ,
115127 all_reviews = None ,
116128 review_comments = None ,
117129 ):
118- """Return True when the contributor has responded but no maintainer has acted since.
119-
120- The bot should not warn, mark stale, or close a PR when the ball
121- is in the maintainers' court.
130+ """True when the contributor has responded but no maintainer review
131+ has followed. Comments don't count; errors fail closed (skip).
122132 """
123133 try :
124134 pr_author = pr .user .login if pr .user else None
125135 if not pr_author :
126- return False
136+ return True
127137 last_author_activity = self ._get_last_author_activity (
128138 pr ,
129139 last_changes_requested ,
140+ commits ,
130141 issue_comments ,
131142 all_reviews ,
132143 review_comments ,
133144 )
134145 if not last_author_activity :
135146 return False
136- # Check for maintainer activity after the contributor's last action.
137- # Only OWNER / MEMBER / COLLABORATOR responses count; random
138- # community comments and bot messages do not.
139- if issue_comments is None :
140- issue_comments = list (pr .get_issue_comments ())
141- for comment in issue_comments :
142- if (
143- comment .user
144- and comment .user .login != pr_author
145- and comment .user .type != "Bot"
146- and getattr (comment , "author_association" , None ) in MAINTAINER_ROLES
147- and comment .created_at > last_author_activity
148- ):
149- return False
150- if review_comments is None :
151- review_comments = list (pr .get_review_comments ())
152- for comment in review_comments :
153- if (
154- comment .user
155- and comment .user .login != pr_author
156- and comment .user .type != "Bot"
157- and getattr (comment , "author_association" , None ) in MAINTAINER_ROLES
158- and comment .created_at > last_author_activity
159- ):
160- return False
161147 if all_reviews is None :
162148 all_reviews = list (pr .get_reviews ())
163149 for review in all_reviews :
@@ -172,8 +158,8 @@ def is_waiting_for_maintainer(
172158 return False
173159 return True
174160 except Exception as e :
175- print ("Error checking maintainer activity" f" for PR #{ pr .number } : { e } " )
176- return False
161+ print (f "Error checking maintainer activity for PR #{ pr .number } : { e } " )
162+ return True
177163
178164 def get_last_changes_requested (self , pr , all_reviews = None ):
179165 """Timestamp of the latest CHANGES_REQUESTED that still represents
@@ -204,14 +190,12 @@ def get_last_changes_requested(self, pr, all_reviews=None):
204190 default = None ,
205191 )
206192 except Exception as e :
207- print ("Error getting reviews" f" for PR #{ pr .number } : { e } " )
193+ print (f "Error getting reviews for PR #{ pr .number } : { e } " )
208194 return None
209195
210196 def has_bot_comment (self , pr , comment_type , after_date = None , issue_comments = None ):
211- """Check if PR already has a specific type of bot comment.
212-
213- Uses HTML markers. If ``after_date`` is provided,
214- only considers comments posted after that date.
197+ """Check if this bot has already posted a comment with the given
198+ marker. If ``after_date`` is set, only later comments count.
215199 """
216200 try :
217201 if issue_comments is None :
@@ -220,29 +204,53 @@ def has_bot_comment(self, pr, comment_type, after_date=None, issue_comments=None
220204 for comment in issue_comments :
221205 if (
222206 comment .user
223- and comment .user .type == "Bot"
207+ and comment .user .login == self . bot_login
224208 and marker in comment .body
225209 ):
226210 if after_date and comment .created_at <= after_date :
227211 continue
228212 return True
229213 return False
230214 except Exception as e :
231- print ("Error checking bot comments" f" for PR #{ pr .number } : { e } " )
215+ print (f "Error checking bot comments for PR #{ pr .number } : { e } " )
232216 return False
233217
234218 def unassign_linked_issues (self , pr ):
235- try :
236- pr_author = pr . user . login if pr . user else None
237- if not pr_author :
238- return False
239- unassigned_issues = unassign_linked_issues_helper (
219+ pr_author = pr . user . login if pr . user else None
220+ if not pr_author :
221+ return 0
222+ return len (
223+ unassign_linked_issues_helper (
240224 self .repo , self .repository_name , pr .body or "" , pr_author
241225 )
242- return len (unassigned_issues )
226+ )
227+
228+ def _clear_stale_label (self , pr ):
229+ try :
230+ if "stale" not in {label .name for label in pr .get_labels ()}:
231+ return False
232+ pr .remove_from_labels ("stale" )
233+ return True
243234 except Exception as e :
244- print (f"Error processing linked issues for PR #{ pr .number } : { e } " )
245- return 0
235+ print (f"Could not clear stale label from PR #{ pr .number } : { e } " )
236+ return False
237+
238+ def _reassign_unassigned_linked_issues (self , pr ):
239+ pr_author = pr .user .login if pr .user else None
240+ if not pr_author :
241+ return
242+ try :
243+ linked = extract_linked_issues (pr .body or "" )
244+ for _ , issue in get_valid_linked_issues (
245+ self .repo , self .repository_name , linked
246+ ):
247+ try :
248+ if not issue .assignees :
249+ issue .add_to_assignees (pr_author )
250+ except Exception as e :
251+ print (f"Error reassigning issue #{ issue .number } : { e } " )
252+ except Exception as e :
253+ print (f"Error iterating linked issues for PR #{ pr .number } : { e } " )
246254
247255 def send_final_followup (self , pr , days_inactive ):
248256 try :
@@ -251,7 +259,7 @@ def send_final_followup(self, pr, days_inactive):
251259 return False
252260 followup_lines = [
253261 "<!-- bot:final_followup -->" ,
254- f"Hi @{ pr_author } ," ,
262+ f"Hi @{ pr_author } 👋 ," ,
255263 "" ,
256264 (
257265 f"This PR has been inactive for **{ days_inactive } days**"
@@ -310,20 +318,19 @@ def mark_pr_stale(self, pr, days_inactive):
310318 " We're happy to help! 🤝"
311319 ),
312320 ]
313- pr .create_issue_comment ("\n " .join (unassign_lines ))
314321 unassigned_count = self .unassign_linked_issues (pr )
322+ pr .create_issue_comment ("\n " .join (unassign_lines ))
315323 try :
316324 pr .add_to_labels ("stale" )
317325 except Exception as e :
318326 print (f"Could not add stale label: { e } " )
319327 print (
320- f"Marked PR #{ pr .number } as stale after"
321- f" { days_inactive } days,"
328+ f"Marked PR #{ pr .number } as stale after { days_inactive } days,"
322329 f" unassigned { unassigned_count } issues"
323330 )
324331 return True
325332 except Exception as e :
326- print (f"Error marking PR #{ pr .number } " f" as stale: { e } " )
333+ print (f"Error marking PR #{ pr .number } as stale: { e } " )
327334 return False
328335
329336 def send_stale_warning (self , pr , days_inactive ):
@@ -372,7 +379,7 @@ def send_stale_warning(self, pr, days_inactive):
372379 print (f"Sent stale warning for PR #{ pr .number } " )
373380 return True
374381 except Exception as e :
375- print ("Error sending warning" f" for PR #{ pr .number } : { e } " )
382+ print (f "Error sending warning for PR #{ pr .number } : { e } " )
376383 return False
377384
378385 def process_stale_prs (self ):
@@ -394,27 +401,34 @@ def process_stale_prs(self):
394401 continue
395402 issue_comments = list (pr .get_issue_comments ())
396403 review_comments = list (pr .get_review_comments ())
404+ commits = list (pr .get_commits ())
397405 days_inactive = self .get_days_since_activity (
398406 pr ,
399407 last_changes_requested ,
408+ commits ,
400409 issue_comments ,
401410 all_reviews ,
402411 review_comments ,
403412 )
404413 print (
405- f"PR #{ pr .number } : { days_inactive } "
406- " days since contributor activity"
414+ f"PR #{ pr .number } : { days_inactive } days since "
415+ " contributor activity"
407416 )
408417 if self .is_waiting_for_maintainer (
409418 pr ,
410419 last_changes_requested ,
420+ commits ,
411421 issue_comments ,
412422 all_reviews ,
413423 review_comments ,
414424 ):
425+ # If we previously marked the PR stale, unwind
426+ # that state now that the contributor has acted.
427+ if self ._clear_stale_label (pr ):
428+ self ._reassign_unassigned_linked_issues (pr )
415429 print (
416- f"PR #{ pr .number } : waiting for"
417- " maintainer review, skipping"
430+ f"PR #{ pr .number } : waiting for maintainer review, "
431+ " skipping"
418432 )
419433 continue
420434 stages = (
@@ -437,6 +451,7 @@ def process_stale_prs(self):
437451 self .send_final_followup ,
438452 ),
439453 )
454+ posted_stale_this_run = False
440455 for low , high , marker , action in stages :
441456 if days_inactive < low :
442457 continue
@@ -449,25 +464,27 @@ def process_stale_prs(self):
449464 issue_comments = issue_comments ,
450465 ):
451466 continue
467+ # Don't post final-followup in the same run as stale.
468+ if marker == "final_followup" and posted_stale_this_run :
469+ continue
452470 if action (pr , days_inactive ):
453471 processed_count += 1
472+ if marker == "stale" :
473+ posted_stale_this_run = True
454474 except Exception as e :
455- print (f"Error processing" f" PR #{ pr .number } : { e } " )
475+ print (f"Error processing PR #{ pr .number } : { e } " )
456476 continue
457477 finally :
458478 time .sleep (0.5 )
459- print (
460- f"Checked { pr_count } open PRs,"
461- f" processed { processed_count } stale PRs"
462- )
479+ print (f"Checked { pr_count } open PRs, processed { processed_count } stale PRs" )
463480 return True
464481 except Exception as e :
465482 print (f"Error in process_stale_prs: { e } " )
466483 return False
467484
468485 def run (self ):
469486 if not self .github or not self .repo :
470- print ("GitHub client not properly initialized," " cannot proceed" )
487+ print ("GitHub client not properly initialized, cannot proceed" )
471488 return False
472489 print ("Stale PR Management Bot starting..." )
473490 try :
0 commit comments