11import time
2- from collections import deque
32from datetime import datetime , timezone
43
54from base import GitHubBot
65from utils import unassign_linked_issues_helper
76
7+ # GitHub author_association values that represent project maintainers.
8+ MAINTAINER_ROLES = frozenset ({"OWNER" , "MEMBER" , "COLLABORATOR" })
9+
810
911class StalePRBot (GitHubBot ):
1012 def __init__ (self ):
@@ -13,6 +15,54 @@ def __init__(self):
1315 self .DAYS_BEFORE_UNASSIGN = 14
1416 self .DAYS_BEFORE_CLOSE = 60
1517
18+ def _get_last_author_activity (
19+ self ,
20+ pr ,
21+ after_date ,
22+ issue_comments = None ,
23+ all_reviews = None ,
24+ review_comments = None ,
25+ ):
26+ """Return the datetime of the PR author's latest activity after *after_date*.
27+
28+ Returns ``None`` when the author has not acted since *after_date*.
29+ """
30+ pr_author = pr .user .login if pr .user else None
31+ if not pr_author :
32+ return None
33+ last_activity = None
34+ for commit in pr .get_commits ():
35+ commit_date = commit .commit .author .date
36+ if commit_date > after_date :
37+ if commit .author and commit .author .login == pr_author :
38+ if not last_activity or commit_date > last_activity :
39+ last_activity = commit_date
40+ if issue_comments is None :
41+ issue_comments = list (pr .get_issue_comments ())
42+ for comment in issue_comments :
43+ if comment .user and comment .user .login == pr_author :
44+ comment_date = comment .created_at
45+ if comment_date > after_date :
46+ if not last_activity or comment_date > last_activity :
47+ last_activity = comment_date
48+ if review_comments is None :
49+ review_comments = list (pr .get_review_comments ())
50+ for comment in review_comments :
51+ if comment .user and comment .user .login == pr_author :
52+ comment_date = comment .created_at
53+ if comment_date > after_date :
54+ if not last_activity or comment_date > last_activity :
55+ last_activity = comment_date
56+ if all_reviews is None :
57+ all_reviews = list (pr .get_reviews ())
58+ for review in all_reviews :
59+ if review .user and review .user .login == pr_author :
60+ review_date = review .submitted_at
61+ if review_date and review_date > after_date :
62+ if not last_activity or review_date > last_activity :
63+ last_activity = review_date
64+ return last_activity
65+
1666 def get_days_since_activity (
1767 self ,
1868 pr ,
@@ -23,78 +73,95 @@ def get_days_since_activity(
2373 ):
2474 if not last_changes_requested :
2575 return 0
76+ try :
77+ last_author_activity = self ._get_last_author_activity (
78+ pr ,
79+ last_changes_requested ,
80+ issue_comments ,
81+ all_reviews ,
82+ review_comments ,
83+ )
84+ reference_date = last_author_activity or last_changes_requested
85+ now = datetime .now (timezone .utc )
86+ return (now - reference_date ).days
87+ except Exception as e :
88+ print ("Error calculating activity" f" for PR #{ pr .number } : { e } " )
89+ return 0
90+
91+ def is_waiting_for_maintainer (
92+ self ,
93+ pr ,
94+ last_changes_requested ,
95+ issue_comments = None ,
96+ all_reviews = None ,
97+ review_comments = None ,
98+ ):
99+ """Return True when the contributor has responded but no maintainer has acted since.
100+
101+ The bot should not warn, mark stale, or close a PR when the ball
102+ is in the maintainers' court.
103+ """
26104 try :
27105 pr_author = pr .user .login if pr .user else None
28106 if not pr_author :
29- return 0
30- last_author_activity = None
31- commits = deque (pr .get_commits (), maxlen = 50 )
32- for commit in commits :
33- commit_date = commit .commit .author .date
34- if commit_date > last_changes_requested :
35- if commit .author and commit .author .login == pr_author :
36- if (
37- not last_author_activity
38- or commit_date > last_author_activity
39- ):
40- last_author_activity = commit_date
107+ return False
108+ last_author_activity = self ._get_last_author_activity (
109+ pr ,
110+ last_changes_requested ,
111+ issue_comments ,
112+ all_reviews ,
113+ review_comments ,
114+ )
115+ if not last_author_activity :
116+ return False
117+ # Check for maintainer activity after the contributor's last action.
118+ # Only OWNER / MEMBER / COLLABORATOR responses count; random
119+ # community comments and bot messages do not.
41120 if issue_comments is None :
42121 issue_comments = list (pr .get_issue_comments ())
43- comments = (
44- issue_comments [- 20 :] if len (issue_comments ) > 20 else issue_comments
45- )
46- for comment in comments :
47- if comment .user and comment .user .login == pr_author :
48- comment_date = comment .created_at
49- if comment_date > last_changes_requested :
50- if (
51- not last_author_activity
52- or comment_date > last_author_activity
53- ):
54- last_author_activity = comment_date
122+ for comment in issue_comments :
123+ if (
124+ comment .user
125+ and comment .user .login != pr_author
126+ and comment .user .type != "Bot"
127+ and getattr (comment , "author_association" , None ) in MAINTAINER_ROLES
128+ and comment .created_at > last_author_activity
129+ ):
130+ return False
55131 if review_comments is None :
56132 review_comments = list (pr .get_review_comments ())
57- all_review_comments = review_comments
58- review_comments = (
59- all_review_comments [- 20 :]
60- if len (all_review_comments ) > 20
61- else all_review_comments
62- )
63133 for comment in review_comments :
64- if comment . user and comment . user . login == pr_author :
65- comment_date = comment .created_at
66- if comment_date > last_changes_requested :
67- if (
68- not last_author_activity
69- or comment_date > last_author_activity
70- ):
71- last_author_activity = comment_date
134+ if (
135+ comment .user
136+ and comment . user . login != pr_author
137+ and comment . user . type != "Bot"
138+ and getattr ( comment , "author_association" , None ) in MAINTAINER_ROLES
139+ and comment . created_at > last_author_activity
140+ ):
141+ return False
72142 if all_reviews is None :
73143 all_reviews = list (pr .get_reviews ())
74- reviews = all_reviews [- 20 :] if len (all_reviews ) > 20 else all_reviews
75- for review in reviews :
76- if review .user and review .user .login == pr_author :
77- review_date = review .submitted_at
78- if review_date and review_date > last_changes_requested :
79- if (
80- not last_author_activity
81- or review_date > last_author_activity
82- ):
83- last_author_activity = review_date
84- reference_date = last_author_activity or last_changes_requested
85- now = datetime .now (timezone .utc )
86- return (now - reference_date ).days
144+ for review in all_reviews :
145+ if (
146+ review .user
147+ and review .user .login != pr_author
148+ and review .user .type != "Bot"
149+ and getattr (review , "author_association" , None ) in MAINTAINER_ROLES
150+ and review .submitted_at
151+ and review .submitted_at > last_author_activity
152+ ):
153+ return False
154+ return True
87155 except Exception as e :
88- print ("Error calculating activity" f" for PR #{ pr .number } : { e } " )
89- return 0
156+ print ("Error checking maintainer activity" f" for PR #{ pr .number } : { e } " )
157+ return False
90158
91159 def get_last_changes_requested (self , pr , all_reviews = None ):
92160 try :
93161 if all_reviews is None :
94162 all_reviews = list (pr .get_reviews ())
95- reviews = all_reviews [- 50 :] if len (all_reviews ) > 50 else all_reviews
96163 changes_requested_reviews = [
97- r for r in reviews if r .state == "CHANGES_REQUESTED"
164+ r for r in all_reviews if r .state == "CHANGES_REQUESTED"
98165 ]
99166 if not changes_requested_reviews :
100167 return None
@@ -348,6 +415,18 @@ def process_stale_prs(self):
348415 f"PR #{ pr .number } : { days_inactive } "
349416 " days since contributor activity"
350417 )
418+ if self .is_waiting_for_maintainer (
419+ pr ,
420+ last_changes_requested ,
421+ issue_comments ,
422+ all_reviews ,
423+ review_comments ,
424+ ):
425+ print (
426+ f"PR #{ pr .number } : waiting for"
427+ " maintainer review, skipping"
428+ )
429+ continue
351430 if days_inactive >= self .DAYS_BEFORE_CLOSE :
352431 if self .close_stale_pr (pr , days_inactive ):
353432 processed_count += 1
0 commit comments