Skip to content

Commit 98ba0a6

Browse files
committed
[fix] Address review feedback on stale-PR bot fix
1 parent 2468f85 commit 98ba0a6

3 files changed

Lines changed: 92 additions & 30 deletions

File tree

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

Lines changed: 44 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,25 @@ def __init__(self):
1515
self.DAYS_BEFORE_UNASSIGN = 14
1616
self.DAYS_BEFORE_CLOSE = 60
1717

18+
@staticmethod
19+
def _commit_activity_date_for_author(commit, pr_author):
20+
dates = []
21+
if (
22+
commit.author
23+
and commit.author.login == pr_author
24+
and commit.commit.author
25+
and commit.commit.author.date
26+
):
27+
dates.append(commit.commit.author.date)
28+
if (
29+
commit.committer
30+
and commit.committer.login == pr_author
31+
and commit.commit.committer
32+
and commit.commit.committer.date
33+
):
34+
dates.append(commit.commit.committer.date)
35+
return max(dates, default=None)
36+
1837
def _get_last_author_activity(
1938
self,
2039
pr,
@@ -32,21 +51,11 @@ def _get_last_author_activity(
3251
return None
3352
last_activity = None
3453
for commit in pr.get_commits():
35-
author_date = commit.commit.author.date if commit.commit.author else None
36-
committer_date = (
37-
commit.commit.committer.date if commit.commit.committer else None
38-
)
39-
if author_date and committer_date:
40-
commit_date = max(author_date, committer_date)
41-
else:
42-
commit_date = author_date or committer_date
54+
commit_date = self._commit_activity_date_for_author(commit, pr_author)
4355
if not commit_date or commit_date <= after_date:
4456
continue
45-
author_login = commit.author.login if commit.author else None
46-
committer_login = commit.committer.login if commit.committer else None
47-
if author_login == pr_author or committer_login == pr_author:
48-
if not last_activity or commit_date > last_activity:
49-
last_activity = commit_date
57+
if not last_activity or commit_date > last_activity:
58+
last_activity = commit_date
5059
if issue_comments is None:
5160
issue_comments = list(pr.get_issue_comments())
5261
for comment in issue_comments:
@@ -167,28 +176,33 @@ def is_waiting_for_maintainer(
167176
return False
168177

169178
def get_last_changes_requested(self, pr, all_reviews=None):
179+
"""Timestamp of the latest CHANGES_REQUESTED that still represents
180+
a human reviewer's current stance, or ``None``.
181+
"""
170182
try:
171183
if all_reviews is None:
172184
all_reviews = list(pr.get_reviews())
185+
# Bot reviews are advisory; COMMENTED does not change stance.
173186
latest_per_reviewer = {}
174-
for r in all_reviews:
175-
if not r.user or not r.submitted_at:
176-
continue
177-
if r.user.type == "Bot":
178-
continue
179-
if r.state == "COMMENTED":
187+
for review in all_reviews:
188+
if (
189+
not review.user
190+
or not review.submitted_at
191+
or review.user.type == "Bot"
192+
or review.state == "COMMENTED"
193+
):
180194
continue
181-
current = latest_per_reviewer.get(r.user.login)
182-
if current is None or r.submitted_at > current.submitted_at:
183-
latest_per_reviewer[r.user.login] = r
184-
blocking = [
185-
r
186-
for r in latest_per_reviewer.values()
187-
if r.state == "CHANGES_REQUESTED"
188-
]
189-
if not blocking:
190-
return None
191-
return max(blocking, key=lambda r: r.submitted_at).submitted_at
195+
current = latest_per_reviewer.get(review.user.login)
196+
if current is None or review.submitted_at > current.submitted_at:
197+
latest_per_reviewer[review.user.login] = review
198+
return max(
199+
(
200+
review.submitted_at
201+
for review in latest_per_reviewer.values()
202+
if review.state == "CHANGES_REQUESTED"
203+
),
204+
default=None,
205+
)
192206
except Exception as e:
193207
print("Error getting reviews" f" for PR #{pr.number}: {e}")
194208
return None

.github/actions/bot-autoassign/tests/test_stale_pr_bot.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,27 @@ def test_both_unlinked_commit_skipped(self, mock_datetime, bot_env):
300300
last_cr = datetime(2024, 1, 1, tzinfo=timezone.utc)
301301
assert bot.get_days_since_activity(mock_pr, last_cr) == 9
302302

303+
@patch("stale_pr_bot.datetime")
304+
def test_maintainer_rebase_does_not_count_as_author_activity(
305+
self, mock_datetime, bot_env
306+
):
307+
mock_datetime.now.return_value = datetime(2024, 3, 1, tzinfo=timezone.utc)
308+
mock_datetime.side_effect = lambda *a, **kw: datetime(*a, **kw)
309+
bot = StalePRBot()
310+
mock_pr = Mock()
311+
mock_pr.user.login = "contributor"
312+
mock_pr.get_issue_comments.return_value = []
313+
mock_pr.get_review_comments.return_value = []
314+
mock_pr.get_reviews.return_value = []
315+
mock_commit = Mock()
316+
mock_commit.commit.author.date = datetime(2023, 12, 1, tzinfo=timezone.utc)
317+
mock_commit.commit.committer.date = datetime(2024, 2, 25, tzinfo=timezone.utc)
318+
mock_commit.author.login = "contributor"
319+
mock_commit.committer.login = "maintainer"
320+
mock_pr.get_commits.return_value = [mock_commit]
321+
last_cr = datetime(2024, 1, 1, tzinfo=timezone.utc)
322+
assert bot.get_days_since_activity(mock_pr, last_cr) == 60
323+
303324

304325
class TestIsWaitingForMaintainer:
305326
def _make_pr(self, author="contributor"):

docs/developer/reusable-github-utils.rst

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,33 @@ OpenWISP repositories. The bot provides the following features:
7373
- **PR reopen reassignment**: When a stale PR is reopened, linked issues
7474
are reassigned back to the author.
7575

76+
**How Stale PR Detection Works**
77+
78+
The Stale PR job runs daily. For each open PR:
79+
80+
1. **Trigger condition.** The PR is processed only when at least one human
81+
reviewer's latest non-COMMENTED review is ``CHANGES_REQUESTED``. Bot
82+
reviews and reviews later superseded by ``APPROVED`` or ``DISMISSED``
83+
from the same reviewer do not block.
84+
2. **Inactivity** is the time since the more recent of: the PR author's
85+
latest commit, issue comment, review comment, or review after the
86+
blocking review; or the blocking review's timestamp if the author has
87+
not acted since. For commits the date is taken from whichever identity
88+
(author or committer) matches the PR author, so a maintainer rebasing
89+
the contributor's commits does not reset the clock.
90+
3. **Maintainer-court skip.** If a maintainer (``OWNER``, ``MEMBER`` or
91+
``COLLABORATOR``) has commented or reviewed after the contributor's
92+
last action, the PR is skipped — the ball is in the maintainer's court.
93+
4. **Action by days inactive:**
94+
95+
- **≥ 7 days:** posts a stale-warning comment.
96+
- **≥ 14 days:** adds the ``stale`` label and unassigns the contributor
97+
from linked issues. Pushing commits or replying revives the PR.
98+
- **≥ 60 days:** closes the PR. Linked issues remain unassigned;
99+
reopening the PR reassigns them and removes the ``stale`` label.
100+
101+
Each stage posts at most once per blocking review cycle.
102+
76103
**Secrets**
77104

78105
These secrets are used by the workflow to generate a ``GITHUB_TOKEN`` via

0 commit comments

Comments
 (0)