diff --git a/pgcommitfest/commitfest/models.py b/pgcommitfest/commitfest/models.py index 4583cd79..779d74ef 100644 --- a/pgcommitfest/commitfest/models.py +++ b/pgcommitfest/commitfest/models.py @@ -6,6 +6,7 @@ from datetime import datetime, timedelta, timezone +from pgcommitfest.mailqueue.util import send_template_mail from pgcommitfest.userprofile.models import UserProfile from .util import DiffableModel @@ -109,6 +110,169 @@ def to_json(self): "enddate": self.enddate.isoformat(), } + def _should_auto_move_patch(self, patch, current_date): + """Determine if a patch should be automatically moved to the next commitfest. + + A patch qualifies for auto-move if: + 1. It has had email activity within the configured number of days + 2. It hasn't been failing CI for longer than the configured threshold + """ + activity_cutoff = current_date - timedelta( + days=settings.AUTO_MOVE_EMAIL_ACTIVITY_DAYS + ) + failing_cutoff = current_date - timedelta( + days=settings.AUTO_MOVE_MAX_FAILING_DAYS + ) + + # Check for recent email activity + if not patch.lastmail or patch.lastmail < activity_cutoff: + return False + + # Check if CI has been failing too long + try: + cfbot_branch = patch.cfbot_branch + if ( + cfbot_branch.failing_since + and cfbot_branch.failing_since < failing_cutoff + ): + return False + except CfbotBranch.DoesNotExist: + pass + + return True + + def auto_move_active_patches(self): + """Automatically move active patches to the next commitfest. + + A patch is moved if it has recent email activity and hasn't been + failing CI for too long. + + Returns a set of patch IDs that were moved. + """ + current_date = datetime.now() + + # Get the next open commitfest + # For draft CFs, find the next draft CF + # For regular CFs, find the next regular CF by start date + if self.draft: + next_cf = ( + CommitFest.objects.filter( + status=CommitFest.STATUS_OPEN, + draft=True, + startdate__gt=self.enddate, + ) + .order_by("startdate") + .first() + ) + else: + next_cf = ( + CommitFest.objects.filter( + status=CommitFest.STATUS_OPEN, + draft=False, + startdate__gt=self.enddate, + ) + .order_by("startdate") + .first() + ) + + if not next_cf: + return set() + + # Get all patches with open status in this commitfest + open_pocs = self.patchoncommitfest_set.filter( + status__in=[ + PatchOnCommitFest.STATUS_REVIEW, + PatchOnCommitFest.STATUS_AUTHOR, + PatchOnCommitFest.STATUS_COMMITTER, + ] + ).select_related("patch") + + moved_patch_ids = set() + for poc in open_pocs: + if self._should_auto_move_patch(poc.patch, current_date): + poc.patch.move(self, next_cf, by_user=None, by_cfbot=True) + moved_patch_ids.add(poc.patch.id) + + return moved_patch_ids + + def send_closure_notifications(self, moved_patch_ids=None): + """Send email notifications to authors of patches that weren't auto-moved. + + Args: + moved_patch_ids: Set of patch IDs that were auto-moved to the next commitfest. + These patches are excluded since the move triggers its own notification. + """ + if moved_patch_ids is None: + moved_patch_ids = set() + + # Get patches that still need action (not moved, not closed) + open_pocs = list( + self.patchoncommitfest_set.filter( + status__in=[ + PatchOnCommitFest.STATUS_REVIEW, + PatchOnCommitFest.STATUS_AUTHOR, + PatchOnCommitFest.STATUS_COMMITTER, + ] + ) + .exclude(patch_id__in=moved_patch_ids) + .select_related("patch") + .prefetch_related("patch__authors") + ) + + if not open_pocs: + return + + # Get the next open commitfest if available + next_cf = ( + CommitFest.objects.filter( + status=CommitFest.STATUS_OPEN, + draft=self.draft, + startdate__gt=self.enddate, + ) + .order_by("startdate") + .first() + ) + + if next_cf: + next_cf_url = f"https://commitfest.postgresql.org/{next_cf.id}/" + else: + next_cf_url = "https://commitfest.postgresql.org/" + + # Collect unique authors and their patches + authors_patches = {} + for poc in open_pocs: + for author in poc.patch.authors.all(): + if not author.email: + continue + if author not in authors_patches: + authors_patches[author] = [] + authors_patches[author].append(poc) + + # Send email to each author + for author, patches in authors_patches.items(): + # Get user's notification email preference + email = author.email + try: + if author.userprofile and author.userprofile.notifyemail: + email = author.userprofile.notifyemail.email + except UserProfile.DoesNotExist: + pass + + send_template_mail( + settings.NOTIFICATION_FROM, + None, + email, + f"Commitfest {self.name} has closed", + "mail/commitfest_closure.txt", + { + "user": author, + "commitfest": self, + "patches": patches, + "next_cf": next_cf, + "next_cf_url": next_cf_url, + }, + ) + @staticmethod def _are_relevant_commitfests_up_to_date(cfs, current_date): inprogress_cf = cfs["in_progress"] @@ -141,17 +305,22 @@ def _refresh_relevant_commitfests(cls, for_update): inprogress_cf = cfs["in_progress"] if inprogress_cf and inprogress_cf.enddate < current_date: + moved_patch_ids = inprogress_cf.auto_move_active_patches() inprogress_cf.status = CommitFest.STATUS_CLOSED inprogress_cf.save() + inprogress_cf.send_closure_notifications(moved_patch_ids) open_cf = cfs["open"] if open_cf.startdate <= current_date: if open_cf.enddate < current_date: + moved_patch_ids = open_cf.auto_move_active_patches() open_cf.status = CommitFest.STATUS_CLOSED + open_cf.save() + open_cf.send_closure_notifications(moved_patch_ids) else: open_cf.status = CommitFest.STATUS_INPROGRESS - open_cf.save() + open_cf.save() cls.next_open_cf(current_date).save() @@ -160,8 +329,10 @@ def _refresh_relevant_commitfests(cls, for_update): cls.next_draft_cf(current_date).save() elif draft_cf.enddate < current_date: # If the draft commitfest has started, we need to update it + moved_patch_ids = draft_cf.auto_move_active_patches() draft_cf.status = CommitFest.STATUS_CLOSED draft_cf.save() + draft_cf.send_closure_notifications(moved_patch_ids) cls.next_draft_cf(current_date).save() return cls.relevant_commitfests(for_update=for_update) @@ -454,7 +625,9 @@ def update_lastmail(self): else: self.lastmail = max(threads, key=lambda t: t.latestmessage).latestmessage - def move(self, from_cf, to_cf, by_user, allow_move_to_in_progress=False): + def move( + self, from_cf, to_cf, by_user, allow_move_to_in_progress=False, by_cfbot=False + ): """Returns the new PatchOnCommitFest object, or raises UserInputError""" current_poc = self.current_patch_on_commitfest() @@ -499,6 +672,7 @@ def move(self, from_cf, to_cf, by_user, allow_move_to_in_progress=False): PatchHistory( patch=self, by=by_user, + by_cfbot=by_cfbot, what=f"Moved from CF {from_cf} to CF {to_cf}", ).save_and_notify() diff --git a/pgcommitfest/commitfest/templates/mail/commitfest_closure.txt b/pgcommitfest/commitfest/templates/mail/commitfest_closure.txt new file mode 100644 index 00000000..1f6acfe1 --- /dev/null +++ b/pgcommitfest/commitfest/templates/mail/commitfest_closure.txt @@ -0,0 +1,23 @@ +Hello {{user.first_name|default:user.username}}, + +Commitfest {{commitfest.name}} has now closed. + +You have {{patches|length}} open patch{{patches|length|pluralize:"es"}} that need{{patches|length|pluralize:"s,"}} attention: + +{% for poc in patches %} + - {{poc.patch.name}} + https://commitfest.postgresql.org/{{commitfest.id}}/{{poc.patch.id}}/ +{% endfor %} + +Please take action on {{patches|length|pluralize:"these patches,this patch"}}: + +1. If you want to continue working on {{patches|length|pluralize:"them,it"}}, move {{patches|length|pluralize:"them,it"}} to the next commitfest{% if next_cf %}: {{next_cf_url}}{% endif %} + +2. If you no longer wish to pursue {{patches|length|pluralize:"these patches,this patch"}}, please close {{patches|length|pluralize:"them,it"}} with an appropriate status (Withdrawn, Returned with feedback, etc.) + +{% if next_cf %}The next commitfest is {{next_cf.name}}, which runs from {{next_cf.startdate}} to {{next_cf.enddate}}.{% else %}Please check https://commitfest.postgresql.org/ for upcoming commitfests.{% endif %} + +Thank you for your contributions to PostgreSQL! + +-- +This is an automated message from the PostgreSQL Commitfest application. diff --git a/pgcommitfest/commitfest/tests/conftest.py b/pgcommitfest/commitfest/tests/conftest.py index 9ce147e6..54ee47a8 100644 --- a/pgcommitfest/commitfest/tests/conftest.py +++ b/pgcommitfest/commitfest/tests/conftest.py @@ -7,17 +7,20 @@ import pytest from pgcommitfest.commitfest.models import CommitFest +from pgcommitfest.userprofile.models import UserProfile @pytest.fixture def alice(): - """Create test user Alice.""" - return User.objects.create_user( + """Create test user Alice with notify_all_author enabled.""" + user = User.objects.create_user( username="alice", first_name="Alice", last_name="Anderson", email="alice@example.com", ) + UserProfile.objects.create(user=user, notify_all_author=True) + return user @pytest.fixture diff --git a/pgcommitfest/commitfest/tests/test_closure_notifications.py b/pgcommitfest/commitfest/tests/test_closure_notifications.py new file mode 100644 index 00000000..6c56d111 --- /dev/null +++ b/pgcommitfest/commitfest/tests/test_closure_notifications.py @@ -0,0 +1,514 @@ +from django.conf import settings + +import base64 +from datetime import date, datetime, timedelta +from email import message_from_string + +import pytest + +from pgcommitfest.commitfest.models import ( + CfbotBranch, + CommitFest, + Patch, + PatchHistory, + PatchOnCommitFest, + PendingNotification, + Topic, +) +from pgcommitfest.mailqueue.models import QueuedMail + +pytestmark = pytest.mark.django_db + + +def get_email_body(queued_mail): + """Extract and decode the email body from a QueuedMail object.""" + msg = message_from_string(queued_mail.fullmsg) + for part in msg.walk(): + if part.get_content_type() == "text/plain": + payload = part.get_payload() + return base64.b64decode(payload).decode("utf-8") + return "" + + +@pytest.fixture +def topic(): + """Create a test topic.""" + return Topic.objects.create(topic="General") + + +def test_send_closure_notifications_to_authors_of_open_patches( + alice, in_progress_cf, topic +): + """Authors of patches with open status should receive closure notifications.""" + patch = Patch.objects.create(name="Test Patch", topic=topic) + patch.authors.add(alice) + PatchOnCommitFest.objects.create( + patch=patch, + commitfest=in_progress_cf, + enterdate=datetime.now(), + status=PatchOnCommitFest.STATUS_REVIEW, + ) + + in_progress_cf.send_closure_notifications() + + assert QueuedMail.objects.count() == 1 + mail = QueuedMail.objects.first() + assert mail.receiver == alice.email + assert f"Commitfest {in_progress_cf.name} has closed" in mail.fullmsg + body = get_email_body(mail) + assert "Test Patch" in body + + +def test_no_notification_for_committed_patches(alice, in_progress_cf, topic): + """Authors of committed patches should not receive notifications.""" + patch = Patch.objects.create(name="Committed Patch", topic=topic) + patch.authors.add(alice) + PatchOnCommitFest.objects.create( + patch=patch, + commitfest=in_progress_cf, + enterdate=datetime.now(), + leavedate=datetime.now(), + status=PatchOnCommitFest.STATUS_COMMITTED, + ) + + in_progress_cf.send_closure_notifications() + + assert QueuedMail.objects.count() == 0 + + +def test_no_notification_for_withdrawn_patches(alice, in_progress_cf, open_cf, topic): + """Withdrawn patches should not receive notifications or be auto-moved.""" + patch = Patch.objects.create( + name="Withdrawn Patch", + topic=topic, + lastmail=datetime.now() - timedelta(days=5), + ) + patch.authors.add(alice) + PatchOnCommitFest.objects.create( + patch=patch, + commitfest=in_progress_cf, + enterdate=datetime.now(), + leavedate=datetime.now(), + status=PatchOnCommitFest.STATUS_WITHDRAWN, + ) + + moved_patch_ids = in_progress_cf.auto_move_active_patches() + in_progress_cf.send_closure_notifications(moved_patch_ids) + + assert patch.id not in moved_patch_ids + assert QueuedMail.objects.count() == 0 + + +def test_one_email_per_author_with_multiple_patches(alice, in_progress_cf, topic): + """An author with multiple open patches should receive one email listing all patches.""" + patch1 = Patch.objects.create(name="Patch One", topic=topic) + patch1.authors.add(alice) + PatchOnCommitFest.objects.create( + patch=patch1, + commitfest=in_progress_cf, + enterdate=datetime.now(), + status=PatchOnCommitFest.STATUS_REVIEW, + ) + + patch2 = Patch.objects.create(name="Patch Two", topic=topic) + patch2.authors.add(alice) + PatchOnCommitFest.objects.create( + patch=patch2, + commitfest=in_progress_cf, + enterdate=datetime.now(), + status=PatchOnCommitFest.STATUS_AUTHOR, + ) + + in_progress_cf.send_closure_notifications() + + assert QueuedMail.objects.count() == 1 + mail = QueuedMail.objects.first() + body = get_email_body(mail) + assert "Patch One" in body + assert "Patch Two" in body + + +def test_multiple_authors_receive_separate_emails(alice, bob, in_progress_cf, topic): + """Each author of open patches should receive their own notification.""" + patch1 = Patch.objects.create(name="Alice Patch", topic=topic) + patch1.authors.add(alice) + PatchOnCommitFest.objects.create( + patch=patch1, + commitfest=in_progress_cf, + enterdate=datetime.now(), + status=PatchOnCommitFest.STATUS_REVIEW, + ) + + patch2 = Patch.objects.create(name="Bob Patch", topic=topic) + patch2.authors.add(bob) + PatchOnCommitFest.objects.create( + patch=patch2, + commitfest=in_progress_cf, + enterdate=datetime.now(), + status=PatchOnCommitFest.STATUS_COMMITTER, + ) + + in_progress_cf.send_closure_notifications() + + assert QueuedMail.objects.count() == 2 + receivers = set(QueuedMail.objects.values_list("receiver", flat=True)) + assert receivers == {alice.email, bob.email} + + +def test_notification_includes_next_commitfest_info( + alice, in_progress_cf, open_cf, topic +): + """Notification should include information about the next open commitfest.""" + patch = Patch.objects.create(name="Test Patch", topic=topic) + patch.authors.add(alice) + PatchOnCommitFest.objects.create( + patch=patch, + commitfest=in_progress_cf, + enterdate=datetime.now(), + status=PatchOnCommitFest.STATUS_REVIEW, + ) + + in_progress_cf.send_closure_notifications() + + mail = QueuedMail.objects.first() + body = get_email_body(mail) + assert open_cf.name in body + + +def test_coauthors_both_receive_notification(alice, bob, in_progress_cf, topic): + """Both co-authors of a patch should receive notifications.""" + patch = Patch.objects.create(name="Coauthored Patch", topic=topic) + patch.authors.add(alice) + patch.authors.add(bob) + PatchOnCommitFest.objects.create( + patch=patch, + commitfest=in_progress_cf, + enterdate=datetime.now(), + status=PatchOnCommitFest.STATUS_REVIEW, + ) + + in_progress_cf.send_closure_notifications() + + assert QueuedMail.objects.count() == 2 + receivers = set(QueuedMail.objects.values_list("receiver", flat=True)) + assert receivers == {alice.email, bob.email} + + +def test_no_notification_for_author_without_email(bob, in_progress_cf, topic): + """Authors without email addresses should be skipped.""" + bob.email = "" + bob.save() + + patch = Patch.objects.create(name="Test Patch", topic=topic) + patch.authors.add(bob) + PatchOnCommitFest.objects.create( + patch=patch, + commitfest=in_progress_cf, + enterdate=datetime.now(), + status=PatchOnCommitFest.STATUS_REVIEW, + ) + + in_progress_cf.send_closure_notifications() + + assert QueuedMail.objects.count() == 0 + + +# Auto-move tests + + +def test_auto_move_patch_with_recent_email_activity( + alice, bob, in_progress_cf, open_cf, topic +): + """Patches with recent email activity should be auto-moved to the next commitfest.""" + patch = Patch.objects.create( + name="Active Patch", + topic=topic, + lastmail=datetime.now() - timedelta(days=5), + ) + patch.authors.add(alice) + patch.subscribers.add(bob) # Bob subscribes to get notifications + PatchOnCommitFest.objects.create( + patch=patch, + commitfest=in_progress_cf, + enterdate=datetime.now(), + status=PatchOnCommitFest.STATUS_REVIEW, + ) + + moved_patch_ids = in_progress_cf.auto_move_active_patches() + in_progress_cf.send_closure_notifications(moved_patch_ids) + + # Patch should be moved + patch.refresh_from_db() + assert patch.current_commitfest().id == open_cf.id + + # Move should create a history entry with by_cfbot=True + history = PatchHistory.objects.filter(patch=patch).first() + assert history is not None + assert history.by_cfbot is True + assert "Moved from CF" in history.what + + # PendingNotification should be created for author and subscriber + assert PendingNotification.objects.filter(history=history, user=alice).exists() + assert PendingNotification.objects.filter(history=history, user=bob).exists() + + # No closure email for moved patches (move triggers its own notification) + assert QueuedMail.objects.count() == 0 + + +def test_no_auto_move_without_email_activity(alice, in_progress_cf, open_cf, topic): + """Patches without recent email activity should NOT be auto-moved.""" + patch = Patch.objects.create( + name="Inactive Patch", + topic=topic, + lastmail=datetime.now() + - timedelta(days=settings.AUTO_MOVE_EMAIL_ACTIVITY_DAYS + 10), + ) + patch.authors.add(alice) + PatchOnCommitFest.objects.create( + patch=patch, + commitfest=in_progress_cf, + enterdate=datetime.now(), + status=PatchOnCommitFest.STATUS_REVIEW, + ) + + moved_patch_ids = in_progress_cf.auto_move_active_patches() + in_progress_cf.send_closure_notifications(moved_patch_ids) + + # Patch should NOT be moved + patch.refresh_from_db() + assert patch.current_commitfest().id == in_progress_cf.id + + # Closure email should be sent for non-moved patches + assert QueuedMail.objects.count() == 1 + mail = QueuedMail.objects.first() + body = get_email_body(mail) + assert "Inactive Patch" in body + assert "need" in body # "needs attention" + + +def test_no_auto_move_when_failing_too_long(alice, in_progress_cf, open_cf, topic): + """Patches failing CI for too long should NOT be auto-moved even with recent activity.""" + patch = Patch.objects.create( + name="Failing Patch", + topic=topic, + lastmail=datetime.now() - timedelta(days=5), + ) + patch.authors.add(alice) + PatchOnCommitFest.objects.create( + patch=patch, + commitfest=in_progress_cf, + enterdate=datetime.now(), + status=PatchOnCommitFest.STATUS_REVIEW, + ) + + # Add CfbotBranch with long-standing failure + CfbotBranch.objects.create( + patch=patch, + branch_id=1, + branch_name="test-branch", + apply_url="https://example.com", + status="failed", + failing_since=datetime.now() + - timedelta(days=settings.AUTO_MOVE_MAX_FAILING_DAYS + 10), + ) + + moved_patch_ids = in_progress_cf.auto_move_active_patches() + in_progress_cf.send_closure_notifications(moved_patch_ids) + + # Patch should NOT be moved + patch.refresh_from_db() + assert patch.current_commitfest().id == in_progress_cf.id + + +def test_auto_move_when_failing_within_threshold(alice, in_progress_cf, open_cf, topic): + """Patches failing CI within the threshold should still be auto-moved.""" + patch = Patch.objects.create( + name="Recently Failing Patch", + topic=topic, + lastmail=datetime.now() - timedelta(days=5), + ) + patch.authors.add(alice) + PatchOnCommitFest.objects.create( + patch=patch, + commitfest=in_progress_cf, + enterdate=datetime.now(), + status=PatchOnCommitFest.STATUS_REVIEW, + ) + + # Add CfbotBranch with recent failure (within threshold) + CfbotBranch.objects.create( + patch=patch, + branch_id=2, + branch_name="test-branch-2", + apply_url="https://example.com", + status="failed", + failing_since=datetime.now() + - timedelta(days=settings.AUTO_MOVE_MAX_FAILING_DAYS - 5), + ) + + moved_patch_ids = in_progress_cf.auto_move_active_patches() + in_progress_cf.send_closure_notifications(moved_patch_ids) + + # Patch should be moved (failure is recent enough) + patch.refresh_from_db() + assert patch.current_commitfest().id == open_cf.id + + # No closure email for moved patches + assert QueuedMail.objects.count() == 0 + + +def test_no_auto_move_without_next_commitfest(alice, in_progress_cf, topic): + """Patches should not be auto-moved if there's no next commitfest.""" + patch = Patch.objects.create( + name="Active Patch No Next CF", + topic=topic, + lastmail=datetime.now() - timedelta(days=5), + ) + patch.authors.add(alice) + PatchOnCommitFest.objects.create( + patch=patch, + commitfest=in_progress_cf, + enterdate=datetime.now(), + status=PatchOnCommitFest.STATUS_REVIEW, + ) + + moved_patch_ids = in_progress_cf.auto_move_active_patches() + in_progress_cf.send_closure_notifications(moved_patch_ids) + + # Patch should NOT be moved (no next CF) + patch.refresh_from_db() + assert patch.current_commitfest().id == in_progress_cf.id + + +def test_no_auto_move_with_null_lastmail(alice, in_progress_cf, open_cf, topic): + """Patches with no email activity (null lastmail) should NOT be auto-moved.""" + patch = Patch.objects.create( + name="No Activity Patch", + topic=topic, + lastmail=None, + ) + patch.authors.add(alice) + PatchOnCommitFest.objects.create( + patch=patch, + commitfest=in_progress_cf, + enterdate=datetime.now(), + status=PatchOnCommitFest.STATUS_REVIEW, + ) + + moved_patch_ids = in_progress_cf.auto_move_active_patches() + + assert patch.id not in moved_patch_ids + patch.refresh_from_db() + assert patch.current_commitfest().id == in_progress_cf.id + + +def test_auto_move_patch_without_cfbot_branch(alice, in_progress_cf, open_cf, topic): + """Patches with recent activity but no CI branch should be auto-moved.""" + patch = Patch.objects.create( + name="No CI Patch", + topic=topic, + lastmail=datetime.now() - timedelta(days=5), + ) + patch.authors.add(alice) + PatchOnCommitFest.objects.create( + patch=patch, + commitfest=in_progress_cf, + enterdate=datetime.now(), + status=PatchOnCommitFest.STATUS_REVIEW, + ) + + # No CfbotBranch created - CI never ran + + moved_patch_ids = in_progress_cf.auto_move_active_patches() + in_progress_cf.send_closure_notifications(moved_patch_ids) + + assert patch.id in moved_patch_ids + patch.refresh_from_db() + assert patch.current_commitfest().id == open_cf.id + + # No closure email for moved patches + assert QueuedMail.objects.count() == 0 + + +def test_regular_cf_does_not_move_to_draft_cf(alice, in_progress_cf, topic): + """Regular commitfest should not move patches to a draft commitfest.""" + # Create only a draft CF as the "next" option (should be ignored) + CommitFest.objects.create( + name="2025-05-draft", + status=CommitFest.STATUS_OPEN, + startdate=date(2025, 5, 1), + enddate=date(2025, 5, 31), + draft=True, + ) + + patch = Patch.objects.create( + name="Regular Patch", + topic=topic, + lastmail=datetime.now() - timedelta(days=5), + ) + patch.authors.add(alice) + PatchOnCommitFest.objects.create( + patch=patch, + commitfest=in_progress_cf, + enterdate=datetime.now(), + status=PatchOnCommitFest.STATUS_REVIEW, + ) + + moved_patch_ids = in_progress_cf.auto_move_active_patches() + + # Should not be moved since only draft CF is available + assert patch.id not in moved_patch_ids + patch.refresh_from_db() + assert patch.current_commitfest().id == in_progress_cf.id + + +def test_draft_cf_moves_active_patches_to_next_draft(alice, bob, topic): + """Active patches in a draft commitfest should be auto-moved to the next draft CF.""" + # Create two draft CFs - one closing and one to receive patches + closing_draft_cf = CommitFest.objects.create( + name="2025-03-draft", + status=CommitFest.STATUS_INPROGRESS, + startdate=date(2025, 3, 1), + enddate=date(2025, 3, 31), + draft=True, + ) + next_draft_cf = CommitFest.objects.create( + name="2026-03-draft", + status=CommitFest.STATUS_OPEN, + startdate=date(2026, 3, 1), + enddate=date(2026, 3, 31), + draft=True, + ) + + patch = Patch.objects.create( + name="Draft Patch", + topic=topic, + lastmail=datetime.now() - timedelta(days=5), + ) + patch.authors.add(alice) + patch.subscribers.add(bob) # Bob subscribes to get notifications + PatchOnCommitFest.objects.create( + patch=patch, + commitfest=closing_draft_cf, + enterdate=datetime.now(), + status=PatchOnCommitFest.STATUS_REVIEW, + ) + + moved_patch_ids = closing_draft_cf.auto_move_active_patches() + closing_draft_cf.send_closure_notifications(moved_patch_ids) + + # Patch should be moved to the next draft CF + patch.refresh_from_db() + assert patch.current_commitfest().id == next_draft_cf.id + + # Move should create a history entry with by_cfbot=True + history = PatchHistory.objects.filter(patch=patch).first() + assert history is not None + assert history.by_cfbot is True + + # PendingNotification should be created for author and subscriber + assert PendingNotification.objects.filter(history=history, user=alice).exists() + assert PendingNotification.objects.filter(history=history, user=bob).exists() + + # No closure email for moved patches + assert QueuedMail.objects.count() == 0 diff --git a/pgcommitfest/settings.py b/pgcommitfest/settings.py index e48cf09e..07e4adaf 100644 --- a/pgcommitfest/settings.py +++ b/pgcommitfest/settings.py @@ -168,6 +168,12 @@ CFBOT_API_URL = "https://cfbot.cputube.org/api" +# Auto-move settings for commitfest closure +# Patches with email activity within this many days are considered active +AUTO_MOVE_EMAIL_ACTIVITY_DAYS = 30 +# Patches failing CI for longer than this many days will NOT be auto-moved +AUTO_MOVE_MAX_FAILING_DAYS = 21 + # Load local settings overrides try: from .local_settings import * # noqa: F403