@@ -110,34 +110,123 @@ def to_json(self):
110110 "enddate" : self .enddate .isoformat (),
111111 }
112112
113- def send_closure_notifications (self ):
114- """Send email notifications to authors of open patches when this commitfest is closed."""
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+ pass
141+
142+ return True
143+
144+ def auto_move_active_patches (self ):
145+ """Automatically move active patches to the next commitfest.
146+
147+ A patch is moved if it has recent email activity and hasn't been
148+ failing CI for too long.
149+
150+ Returns a set of patch IDs that were moved.
151+ """
152+ current_date = datetime .now ()
153+
154+ # Get the next open commitfest
155+ # For draft CFs, find the next draft CF
156+ # For regular CFs, find the next regular CF by start date
157+ if self .draft :
158+ next_cf = (
159+ CommitFest .objects .filter (
160+ status = CommitFest .STATUS_OPEN ,
161+ draft = True ,
162+ startdate__gt = self .enddate ,
163+ )
164+ .order_by ("startdate" )
165+ .first ()
166+ )
167+ else :
168+ next_cf = (
169+ CommitFest .objects .filter (
170+ status = CommitFest .STATUS_OPEN ,
171+ draft = False ,
172+ startdate__gt = self .enddate ,
173+ )
174+ .order_by ("startdate" )
175+ .first ()
176+ )
177+
178+ if not next_cf :
179+ return set ()
115180
116181 # Get all patches with open status in this commitfest
117- open_patches = (
182+ open_pocs = self .patchoncommitfest_set .filter (
183+ status__in = [
184+ PatchOnCommitFest .STATUS_REVIEW ,
185+ PatchOnCommitFest .STATUS_AUTHOR ,
186+ PatchOnCommitFest .STATUS_COMMITTER ,
187+ ]
188+ ).select_related ("patch" )
189+
190+ moved_patch_ids = set ()
191+ for poc in open_pocs :
192+ if self ._should_auto_move_patch (poc .patch , current_date ):
193+ poc .patch .move (self , next_cf , by_user = None , by_cfbot = True )
194+ moved_patch_ids .add (poc .patch .id )
195+
196+ return moved_patch_ids
197+
198+ def send_closure_notifications (self , moved_patch_ids = None ):
199+ """Send email notifications to authors of patches that weren't auto-moved.
200+
201+ Args:
202+ moved_patch_ids: Set of patch IDs that were auto-moved to the next commitfest.
203+ These patches are excluded since the move triggers its own notification.
204+ """
205+ if moved_patch_ids is None :
206+ moved_patch_ids = set ()
207+
208+ # Get patches that still need action (not moved, not closed)
209+ open_pocs = list (
118210 self .patchoncommitfest_set .filter (
119211 status__in = [
120212 PatchOnCommitFest .STATUS_REVIEW ,
121213 PatchOnCommitFest .STATUS_AUTHOR ,
122214 PatchOnCommitFest .STATUS_COMMITTER ,
123215 ]
124216 )
217+ .exclude (patch_id__in = moved_patch_ids )
125218 .select_related ("patch" )
126219 .prefetch_related ("patch__authors" )
127220 )
128221
129- # Collect unique authors across all open patches
130- authors_to_notify = set ()
131- for poc in open_patches :
132- for author in poc .patch .authors .all ():
133- if author .email :
134- authors_to_notify .add (author )
222+ if not open_pocs :
223+ return
135224
136225 # Get the next open commitfest if available
137226 next_cf = (
138227 CommitFest .objects .filter (
139228 status = CommitFest .STATUS_OPEN ,
140- draft = False ,
229+ draft = self . draft ,
141230 startdate__gt = self .enddate ,
142231 )
143232 .order_by ("startdate" )
@@ -149,8 +238,18 @@ def send_closure_notifications(self):
149238 else :
150239 next_cf_url = "https://commitfest.postgresql.org/"
151240
241+ # Collect unique authors and their patches
242+ authors_patches = {}
243+ for poc in open_pocs :
244+ for author in poc .patch .authors .all ():
245+ if not author .email :
246+ continue
247+ if author not in authors_patches :
248+ authors_patches [author ] = []
249+ authors_patches [author ].append (poc )
250+
152251 # Send email to each author
153- for author in authors_to_notify :
252+ for author , patches in authors_patches . items () :
154253 # Get user's notification email preference
155254 email = author .email
156255 try :
@@ -159,11 +258,6 @@ def send_closure_notifications(self):
159258 except UserProfile .DoesNotExist :
160259 pass
161260
162- # Get user's open patches in this commitfest
163- user_patches = [
164- poc for poc in open_patches if author in poc .patch .authors .all ()
165- ]
166-
167261 send_template_mail (
168262 settings .NOTIFICATION_FROM ,
169263 None ,
@@ -173,7 +267,7 @@ def send_closure_notifications(self):
173267 {
174268 "user" : author ,
175269 "commitfest" : self ,
176- "patches" : user_patches ,
270+ "patches" : patches ,
177271 "next_cf" : next_cf ,
178272 "next_cf_url" : next_cf_url ,
179273 },
@@ -211,17 +305,19 @@ def _refresh_relevant_commitfests(cls, for_update):
211305
212306 inprogress_cf = cfs ["in_progress" ]
213307 if inprogress_cf and inprogress_cf .enddate < current_date :
308+ moved_patch_ids = inprogress_cf .auto_move_active_patches ()
214309 inprogress_cf .status = CommitFest .STATUS_CLOSED
215310 inprogress_cf .save ()
216- inprogress_cf .send_closure_notifications ()
311+ inprogress_cf .send_closure_notifications (moved_patch_ids )
217312
218313 open_cf = cfs ["open" ]
219314
220315 if open_cf .startdate <= current_date :
221316 if open_cf .enddate < current_date :
317+ moved_patch_ids = open_cf .auto_move_active_patches ()
222318 open_cf .status = CommitFest .STATUS_CLOSED
223319 open_cf .save ()
224- open_cf .send_closure_notifications ()
320+ open_cf .send_closure_notifications (moved_patch_ids )
225321 else :
226322 open_cf .status = CommitFest .STATUS_INPROGRESS
227323 open_cf .save ()
@@ -233,9 +329,10 @@ def _refresh_relevant_commitfests(cls, for_update):
233329 cls .next_draft_cf (current_date ).save ()
234330 elif draft_cf .enddate < current_date :
235331 # If the draft commitfest has started, we need to update it
332+ moved_patch_ids = draft_cf .auto_move_active_patches ()
236333 draft_cf .status = CommitFest .STATUS_CLOSED
237334 draft_cf .save ()
238- draft_cf .send_closure_notifications ()
335+ draft_cf .send_closure_notifications (moved_patch_ids )
239336 cls .next_draft_cf (current_date ).save ()
240337
241338 return cls .relevant_commitfests (for_update = for_update )
@@ -530,7 +627,9 @@ def update_lastmail(self):
530627 else :
531628 self .lastmail = max (threads , key = lambda t : t .latestmessage ).latestmessage
532629
533- def move (self , from_cf , to_cf , by_user , allow_move_to_in_progress = False ):
630+ def move (
631+ self , from_cf , to_cf , by_user , allow_move_to_in_progress = False , by_cfbot = False
632+ ):
534633 """Returns the new PatchOnCommitFest object, or raises UserInputError"""
535634
536635 current_poc = self .current_patch_on_commitfest ()
@@ -575,6 +674,7 @@ def move(self, from_cf, to_cf, by_user, allow_move_to_in_progress=False):
575674 PatchHistory (
576675 patch = self ,
577676 by = by_user ,
677+ by_cfbot = by_cfbot ,
578678 what = f"Moved from CF { from_cf } to CF { to_cf } " ,
579679 ).save_and_notify ()
580680
0 commit comments