Skip to content

Commit 6ae57a0

Browse files
committed
[fix] Refine stale-PR bot semantics per review feedback
1 parent cf6f686 commit 6ae57a0

3 files changed

Lines changed: 160 additions & 79 deletions

File tree

.github/actions/bot-autoassign/stale_pr_bot.py

Lines changed: 83 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1+
import os
12
import time
23
from datetime import datetime, timezone
34

45
from 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.
813
MAINTAINER_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

Comments
 (0)