Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,20 +41,20 @@

github_bot.sync_existing_prs()

def reminder_scheduler():
"""Background thread to periodically check and send reminders."""
def reviewer_assignment_scheduler():
"""Background thread to periodically check automatic reviewer assignment."""
with app.app_context():
while True:
try:
github_bot.check_and_send_reminders()
github_bot.check_and_auto_assign_reviewers()
except Exception as e:
logger.exception(f"Error in reminder scheduler: {str(e)}")
logger.exception(f"Error in reviewer assignment scheduler: {str(e)}")
# Sleep for 1 minute before next check
time.sleep(60)

# Start the reminder scheduler thread
reminder_thread = threading.Thread(target=reminder_scheduler, daemon=True)
reminder_thread.start()
# Start the reviewer assignment scheduler thread
reviewer_assignment_thread = threading.Thread(target=reviewer_assignment_scheduler, daemon=True)
reviewer_assignment_thread.start()


@app.route('/')
Expand Down
66 changes: 3 additions & 63 deletions github_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -560,13 +560,12 @@ def auto_assign_reviewers(self, pr_record):
except Exception as e:
self.logger.exception(f"Error auto-assigning reviewers: {str(e)}")

def check_and_send_reminders(self):
"""Check for PRs needing review reminders and auto-assign reviewers."""
self.logger.info("Checking for PRs needing review reminders...")
def check_and_auto_assign_reviewers(self):
"""Check for PRs needing automatic reviewer assignment."""
self.logger.info("Checking for PRs needing automatic reviewer assignment...")

current_time = datetime.utcnow()
reviewer_threshold = current_time - timedelta(minutes=10)
reminder_threshold = current_time - timedelta(days=2)

# Force a new connection from the pool
self.db.session.remove()
Expand All @@ -580,65 +579,6 @@ def check_and_send_reminders(self):
for pr in prs_needing_assignment:
self.auto_assign_reviewers(pr)

# Nag reviewers, but only on weekdays
now = datetime.utcnow()
if now.weekday() < 4 or (now.weekday() == 5 and now.hour < 17):
reviews_needing_reminders = Review.query.filter(
Review.completed_at.is_(None),
((Review.last_reminder_sent.is_(None) &
(Review.requested_at <= reminder_threshold)) |
(Review.last_reminder_sent <= reminder_threshold))
).all()

for review in reviews_needing_reminders:
self._send_review_reminder(review)

def _send_review_reminder(self, review):
"""Send a reminder comment on a PR."""
try:
# Get assigned reviewers for the PR
repo_url = f"https://api.github.com/repos/{review.repo_name}"
pr_url = f"{repo_url}/pulls/{review.pr_number}"

response = requests.get(pr_url, headers=self.headers)
response.raise_for_status()

pr_data = response.json()
reviewers = [
user['login']
for user in pr_data.get('requested_reviewers', [])
]

if not reviewers:
self.logger.info(f"No reviewers to remind for PR #{review.pr_number}")
return

# Create reminder message tagging all reviewers
reviewer_tags = ' '.join(
[f'@{reviewer}' for reviewer in reviewers])
reminder_count = review.reminder_count + 1
ordinal = lambda n: "%d%s" % (n, "tsnrhtdd"[
(n // 10 % 10 != 1) * (n % 10 < 4) * n % 10::4])

message = (
f"🔔 {ordinal(reminder_count)} Reminder\n\n"
f"Hey {reviewer_tags}! This PR has been waiting for your review.\n"
"Please take a look when you have a chance. If you're unable to review, "
"please let us know so we can find another reviewer.")

# Post the reminder comment
self._create_comment(repo_url, review.pr_number, message)

# Update reminder tracking
review.last_reminder_sent = datetime.utcnow()
review.reminder_count = reminder_count
self.db.session.commit()

self.logger.info(f"Sent review reminder for PR #{review.pr_number}")

except Exception as e:
self.logger.exception(f"Error sending reminder for PR #{review.pr_number}: {str(e)}")

def _has_bot_comment_about_second_reviewer(self, repo_url, pr_number):
"""Check if bot has already asked about second reviewer."""
# Get all comments on the PR
Expand Down
2 changes: 0 additions & 2 deletions models.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,6 @@ class Review(db.Model):
pr_number = db.Column(db.Integer, nullable=False)
reviewer = db.Column(db.String(100), nullable=False)
requested_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
last_reminder_sent = db.Column(db.DateTime, nullable=True)
reminder_count = db.Column(db.Integer, default=0)
completed_at = db.Column(db.DateTime, nullable=True)
review_url = db.Column(db.String(200), nullable=True)
__table_args__ = (db.ForeignKeyConstraint([repo_name, pr_number],
Expand Down
1 change: 1 addition & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Test package for unittest discovery.
85 changes: 85 additions & 0 deletions tests/test_review_reminders.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import unittest
from datetime import datetime, timedelta
from unittest.mock import Mock, patch

from flask import Flask

from db import db
from github_bot import GitHubBot
from models import PRStatus, PullRequest, Review


class ReviewReminderTests(unittest.TestCase):
def setUp(self):
self.app = Flask(__name__)
self.app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
self.app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
db.init_app(self.app)
self.app_context = self.app.app_context()
self.app_context.push()
db.create_all()

def tearDown(self):
db.session.remove()
db.drop_all()
self.app_context.pop()

def test_overdue_review_does_not_post_reminder_comment(self):
now = datetime(2026, 6, 23, 12, 0, 0)
pr = PullRequest(
repo_name="lightningdevkit/ldk-node",
pr_number=9999,
pr_title="Drop reminder comments",
status=PRStatus.PENDING_REVIEW,
created_at=now - timedelta(days=3),
)
review = Review(
repo_name=pr.repo_name,
pr_number=pr.pr_number,
reviewer="tnull",
requested_at=now - timedelta(days=3),
)
db.session.add_all([pr, review])
db.session.commit()

bot = GitHubBot("token", "secret", db)
pr_response = Mock()
pr_response.raise_for_status.return_value = None
pr_response.json.return_value = {"requested_reviewers": [{"login": review.reviewer}]}

with patch("github_bot.datetime") as datetime_mock, \
patch("github_bot.requests.get", return_value=pr_response), \
patch.object(bot, "_create_comment") as create_comment:
datetime_mock.utcnow.return_value = now

bot.check_and_auto_assign_reviewers()

create_comment.assert_not_called()

def test_unassigned_prs_are_still_auto_assigned_after_grace_period(self):
now = datetime(2026, 6, 23, 12, 0, 0)
pr_number = 10000
pr = PullRequest(
repo_name="lightningdevkit/ldk-node",
pr_number=pr_number,
pr_title="Needs a reviewer",
status=PRStatus.PENDING_REVIEWER_CHOICE,
created_at=now - timedelta(minutes=11),
)
db.session.add(pr)
db.session.commit()

bot = GitHubBot("token", "secret", db)

with patch("github_bot.datetime") as datetime_mock, \
patch.object(bot, "auto_assign_reviewers") as auto_assign_reviewers:
datetime_mock.utcnow.return_value = now

bot.check_and_auto_assign_reviewers()

auto_assign_reviewers.assert_called_once()
self.assertEqual(auto_assign_reviewers.call_args.args[0].pr_number, pr_number)


if __name__ == "__main__":
unittest.main()