|
6 | 6 |
|
7 | 7 | from datetime import datetime, timedelta, timezone |
8 | 8 |
|
| 9 | +from pgcommitfest.mailqueue.util import send_template_mail |
9 | 10 | from pgcommitfest.userprofile.models import UserProfile |
10 | 11 |
|
11 | 12 | from .util import DiffableModel |
@@ -109,6 +110,119 @@ def to_json(self): |
109 | 110 | "enddate": self.enddate.isoformat(), |
110 | 111 | } |
111 | 112 |
|
| 113 | + def _should_auto_move_patch(self, patch, current_date): |
| 114 | + """Determine if a patch should be automatically moved to the next commitfest. |
| 115 | +
|
| 116 | + A patch qualifies for auto-move if it both: |
| 117 | + 1. Has had email activity within the configured number of days |
| 118 | + 2. Hasn't been failing CI for longer than the configured threshold |
| 119 | + """ |
| 120 | + activity_cutoff = current_date - timedelta( |
| 121 | + days=settings.AUTO_MOVE_EMAIL_ACTIVITY_DAYS |
| 122 | + ) |
| 123 | + failing_cutoff = current_date - timedelta( |
| 124 | + days=settings.AUTO_MOVE_MAX_FAILING_DAYS |
| 125 | + ) |
| 126 | + |
| 127 | + # Check for recent email activity |
| 128 | + if not patch.lastmail or patch.lastmail < activity_cutoff: |
| 129 | + return False |
| 130 | + |
| 131 | + # Check if CI has been failing too long |
| 132 | + try: |
| 133 | + cfbot_branch = patch.cfbot_branch |
| 134 | + if ( |
| 135 | + cfbot_branch.failing_since |
| 136 | + and cfbot_branch.failing_since < failing_cutoff |
| 137 | + ): |
| 138 | + return False |
| 139 | + except CfbotBranch.DoesNotExist: |
| 140 | + # IF no CFBot data exists, the patch is probably very new (i.e. no |
| 141 | + # CI run has ever taken place for it yet). So we auto-move it in |
| 142 | + # that case. |
| 143 | + pass |
| 144 | + |
| 145 | + return True |
| 146 | + |
| 147 | + def auto_move_active_patches(self): |
| 148 | + """Automatically move active patches to the next commitfest. |
| 149 | +
|
| 150 | + A patch is moved if it has recent email activity and hasn't been |
| 151 | + failing CI for too long. |
| 152 | + """ |
| 153 | + current_date = datetime.now() |
| 154 | + |
| 155 | + # Get the next open commitfest (must exist, raises IndexError otherwise) |
| 156 | + # For draft CFs, find the next draft CF |
| 157 | + # For regular CFs, find the next regular CF by start date |
| 158 | + if self.draft: |
| 159 | + next_cf = CommitFest.objects.filter( |
| 160 | + status=CommitFest.STATUS_OPEN, |
| 161 | + draft=True, |
| 162 | + startdate__gt=self.enddate, |
| 163 | + ).order_by("startdate")[0] |
| 164 | + else: |
| 165 | + next_cf = CommitFest.objects.filter( |
| 166 | + status=CommitFest.STATUS_OPEN, |
| 167 | + draft=False, |
| 168 | + startdate__gt=self.enddate, |
| 169 | + ).order_by("startdate")[0] |
| 170 | + |
| 171 | + # Get all patches with open status in this commitfest |
| 172 | + open_pocs = self.patchoncommitfest_set.filter( |
| 173 | + status__in=PatchOnCommitFest.OPEN_STATUSES |
| 174 | + ).select_related("patch") |
| 175 | + |
| 176 | + for poc in open_pocs: |
| 177 | + if self._should_auto_move_patch(poc.patch, current_date): |
| 178 | + poc.patch.move(self, next_cf, by_user=None, by_cfbot=True) |
| 179 | + |
| 180 | + def send_closure_notifications(self): |
| 181 | + """Send email notifications to authors of patches that are still open.""" |
| 182 | + # Get patches that still need action (not moved, not closed) |
| 183 | + open_pocs = list( |
| 184 | + self.patchoncommitfest_set.filter( |
| 185 | + status__in=PatchOnCommitFest.OPEN_STATUSES |
| 186 | + ) |
| 187 | + .select_related("patch") |
| 188 | + .prefetch_related("patch__authors") |
| 189 | + ) |
| 190 | + |
| 191 | + if not open_pocs: |
| 192 | + return |
| 193 | + |
| 194 | + # Collect unique authors and their patches |
| 195 | + authors_patches = {} |
| 196 | + for poc in open_pocs: |
| 197 | + for author in poc.patch.authors.all(): |
| 198 | + if author not in authors_patches: |
| 199 | + authors_patches[author] = [] |
| 200 | + authors_patches[author].append(poc) |
| 201 | + |
| 202 | + # Send email to each author who has notifications enabled |
| 203 | + for author, patches in authors_patches.items(): |
| 204 | + try: |
| 205 | + if not author.userprofile.notify_all_author: |
| 206 | + continue |
| 207 | + notifyemail = author.userprofile.notifyemail |
| 208 | + except UserProfile.DoesNotExist: |
| 209 | + continue |
| 210 | + |
| 211 | + email = notifyemail.email if notifyemail else author.email |
| 212 | + |
| 213 | + send_template_mail( |
| 214 | + settings.NOTIFICATION_FROM, |
| 215 | + None, |
| 216 | + email, |
| 217 | + f"Commitfest {self.name} has closed and you have unmoved patches", |
| 218 | + "mail/commitfest_closure.txt", |
| 219 | + { |
| 220 | + "user": author, |
| 221 | + "commitfest": self, |
| 222 | + "patches": patches, |
| 223 | + }, |
| 224 | + ) |
| 225 | + |
112 | 226 | @staticmethod |
113 | 227 | def _are_relevant_commitfests_up_to_date(cfs, current_date): |
114 | 228 | inprogress_cf = cfs["in_progress"] |
@@ -143,26 +257,33 @@ def _refresh_relevant_commitfests(cls, for_update): |
143 | 257 | if inprogress_cf and inprogress_cf.enddate < current_date: |
144 | 258 | inprogress_cf.status = CommitFest.STATUS_CLOSED |
145 | 259 | inprogress_cf.save() |
| 260 | + inprogress_cf.auto_move_active_patches() |
| 261 | + inprogress_cf.send_closure_notifications() |
146 | 262 |
|
147 | 263 | open_cf = cfs["open"] |
148 | 264 |
|
149 | 265 | if open_cf.startdate <= current_date: |
150 | 266 | if open_cf.enddate < current_date: |
151 | 267 | open_cf.status = CommitFest.STATUS_CLOSED |
| 268 | + open_cf.save() |
| 269 | + cls.next_open_cf(current_date).save() |
| 270 | + open_cf.auto_move_active_patches() |
| 271 | + open_cf.send_closure_notifications() |
152 | 272 | else: |
153 | 273 | open_cf.status = CommitFest.STATUS_INPROGRESS |
154 | | - open_cf.save() |
155 | | - |
156 | | - cls.next_open_cf(current_date).save() |
| 274 | + open_cf.save() |
| 275 | + cls.next_open_cf(current_date).save() |
157 | 276 |
|
158 | 277 | draft_cf = cfs["draft"] |
159 | 278 | if not draft_cf: |
160 | 279 | cls.next_draft_cf(current_date).save() |
161 | 280 | elif draft_cf.enddate < current_date: |
162 | | - # If the draft commitfest has started, we need to update it |
| 281 | + # Create next CF first so auto_move has somewhere to move patches |
163 | 282 | draft_cf.status = CommitFest.STATUS_CLOSED |
164 | 283 | draft_cf.save() |
165 | 284 | cls.next_draft_cf(current_date).save() |
| 285 | + draft_cf.auto_move_active_patches() |
| 286 | + draft_cf.send_closure_notifications() |
166 | 287 |
|
167 | 288 | return cls.relevant_commitfests(for_update=for_update) |
168 | 289 |
|
@@ -456,7 +577,9 @@ def update_lastmail(self): |
456 | 577 | else: |
457 | 578 | self.lastmail = max(threads, key=lambda t: t.latestmessage).latestmessage |
458 | 579 |
|
459 | | - def move(self, from_cf, to_cf, by_user, allow_move_to_in_progress=False): |
| 580 | + def move( |
| 581 | + self, from_cf, to_cf, by_user, allow_move_to_in_progress=False, by_cfbot=False |
| 582 | + ): |
460 | 583 | """Returns the new PatchOnCommitFest object, or raises UserInputError""" |
461 | 584 |
|
462 | 585 | current_poc = self.current_patch_on_commitfest() |
@@ -501,6 +624,7 @@ def move(self, from_cf, to_cf, by_user, allow_move_to_in_progress=False): |
501 | 624 | PatchHistory( |
502 | 625 | patch=self, |
503 | 626 | by=by_user, |
| 627 | + by_cfbot=by_cfbot, |
504 | 628 | what=f"Moved from CF {from_cf} to CF {to_cf}", |
505 | 629 | ).save_and_notify() |
506 | 630 |
|
|
0 commit comments