@@ -492,12 +492,57 @@ def sync_members_with_queue(state: dict) -> tuple[dict, list[str]]:
492492 return state , changes
493493
494494
495+ def reposition_member_as_next (state : dict , username : str ) -> bool :
496+ """
497+ Move a queue member to current_index so they're next up.
498+
499+ Used when undoing or passing an assignment to maintain fairness -
500+ the person who didn't complete their assignment should be next,
501+ but people behind them in the queue shouldn't be pushed further back.
502+
503+ Algorithm:
504+ 1. Remove the user from their current position
505+ 2. Adjust current_index if we removed before it
506+ 3. Insert at current_index (so they're next up)
507+
508+ Returns True if successful, False if user not in queue (e.g., went /away).
509+ """
510+ # Find the user
511+ user_index = None
512+ user_entry = None
513+ for i , member in enumerate (state ["queue" ]):
514+ if member ["github" ].lower () == username .lower ():
515+ user_index = i
516+ user_entry = member
517+ break
518+
519+ if user_entry is None :
520+ return False
521+
522+ # Remove from current position
523+ state ["queue" ].pop (user_index )
524+
525+ # Adjust current_index if we removed before it
526+ if user_index < state ["current_index" ]:
527+ state ["current_index" ] -= 1
528+
529+ # Ensure current_index is valid after removal
530+ if state ["queue" ]:
531+ state ["current_index" ] = state ["current_index" ] % len (state ["queue" ])
532+ else :
533+ state ["current_index" ] = 0
534+
535+ # Insert at current_index (so they're next up)
536+ state ["queue" ].insert (state ["current_index" ], user_entry )
537+
538+ return True
539+
540+
495541def process_pass_until_expirations (state : dict ) -> tuple [dict , list [str ]]:
496542 """
497543 Check for expired pass-until entries and restore them to the queue.
498544
499- Returning members are inserted right after the current index,
500- so they're next up in rotation.
545+ Returning members are repositioned to be next up in the queue.
501546
502547 Returns the updated state and a list of users restored.
503548 """
@@ -517,23 +562,15 @@ def process_pass_until_expirations(state: dict) -> tuple[dict, list[str]]:
517562 return_date = return_date .date ()
518563
519564 if return_date <= now :
520- # Restore to queue - insert right after current index
565+ # Restore to queue
521566 restored_member = {
522567 "github" : entry ["github" ],
523568 "name" : entry .get ("name" , entry ["github" ]),
524569 }
525570
526- if state ["queue" ]:
527- # Insert right after current index
528- insert_position = (state ["current_index" ] + 1 ) % (len (state ["queue" ]) + 1 )
529- state ["queue" ].insert (insert_position , restored_member )
530-
531- # Adjust current_index if we inserted before or at it
532- if insert_position <= state ["current_index" ]:
533- state ["current_index" ] += 1
534- else :
535- # Queue is empty, just add them
536- state ["queue" ].append (restored_member )
571+ # Add to end of queue, then reposition as next
572+ state ["queue" ].append (restored_member )
573+ reposition_member_as_next (state , entry ["github" ])
537574
538575 restored .append (entry ["github" ])
539576 else :
@@ -776,9 +813,7 @@ def handle_pass_command(state: dict, issue_number: int, comment_author: str,
776813 Handle the pass! command - skip current reviewer for this issue only.
777814
778815 Tracks who has passed on each issue to prevent re-assignment.
779- Only the first passer gets moved to "next in queue" position.
780- Subsequent passers just pass without queue reordering, and the
781- original passer remains "next" for future issues.
816+ The passer is repositioned to be next up for future issues.
782817
783818 Returns (response_message, success).
784819 """
@@ -809,22 +844,13 @@ def handle_pass_command(state: dict, issue_number: int, comment_author: str,
809844 if not passed_reviewer :
810845 return "❌ No reviewer is currently assigned to pass." , False
811846
812- # Check if this is the first pass on this issue
847+ # Check if this is the first pass on this issue (for messaging only)
813848 is_first_pass = len (issue_data ["skipped" ]) == 0
814849
815850 # Record this reviewer as having passed on this issue
816851 if passed_reviewer not in issue_data ["skipped" ]:
817852 issue_data ["skipped" ].append (passed_reviewer )
818853
819- # Find the passed reviewer's entry and position in the queue (for first pass reordering)
820- passed_entry = None
821- passed_index = None
822- for i , member in enumerate (state ["queue" ]):
823- if member ["github" ].lower () == passed_reviewer .lower ():
824- passed_entry = member
825- passed_index = i
826- break
827-
828854 # Get the issue author to skip them
829855 issue_author = os .environ .get ("ISSUE_AUTHOR" , "" )
830856
@@ -833,44 +859,17 @@ def handle_pass_command(state: dict, issue_number: int, comment_author: str,
833859 if issue_author :
834860 skip_set .add (issue_author )
835861
836- # Save current index - we'll restore it for non-first passes
837- saved_index = state ["current_index" ]
838-
839862 # Find next reviewer, skipping all who have passed
863+ # This advances current_index past the substitute
840864 next_reviewer = get_next_reviewer (state , skip_usernames = skip_set )
841865
842866 if not next_reviewer :
843- # Restore index since we're failing
844- state ["current_index" ] = saved_index
845867 return ("❌ No other reviewers available. Everyone in the queue has either "
846868 "passed on this issue or is the author." ), False
847869
848- # Only reorder queue on the FIRST pass for this issue
849- if is_first_pass and passed_entry is not None :
850- # Find the substitute's current position
851- substitute_index = None
852- for i , member in enumerate (state ["queue" ]):
853- if member ["github" ].lower () == next_reviewer .lower ():
854- substitute_index = i
855- break
856-
857- if substitute_index is not None :
858- # Reorder: move passed reviewer to right after substitute
859- state ["queue" ].pop (passed_index )
860-
861- # Adjust substitute_index if it was after the removed position
862- if substitute_index > passed_index :
863- substitute_index -= 1
864-
865- # Insert passed reviewer right after substitute
866- insert_position = substitute_index + 1
867- state ["queue" ].insert (insert_position , passed_entry )
868-
869- # Set index to point at passed reviewer (they're next for future issues)
870- state ["current_index" ] = insert_position
871- else :
872- # NOT first pass - restore the index so original passer stays "next"
873- state ["current_index" ] = saved_index
870+ # Reposition the passer to be next up for future issues
871+ # (get_next_reviewer already advanced the index past the substitute)
872+ reposition_member_as_next (state , passed_reviewer )
874873
875874 # Unassign the passed reviewer first (best effort - may fail if no permissions)
876875 unassign_reviewer (issue_number , passed_reviewer )
@@ -1180,7 +1179,7 @@ def handle_claim_command(state: dict, issue_number: int,
11801179 assign_reviewer (issue_number , comment_author )
11811180
11821181 # Track the reviewer in our state
1183- set_current_reviewer (state , issue_number , comment_author )
1182+ set_current_reviewer (state , issue_number , comment_author , assignment_method = "claim" )
11841183
11851184 # Record the assignment
11861185 record_assignment (state , comment_author , issue_number , "pr" if is_pr else "issue" )
@@ -1234,10 +1233,12 @@ def handle_release_command(state: dict, issue_number: int,
12341233 # Check who the current reviewer is (from our state first, then GitHub)
12351234 issue_key = str (issue_number )
12361235 tracked_reviewer = None
1236+ assignment_method = None
12371237 if "active_reviews" in state and issue_key in state ["active_reviews" ]:
12381238 issue_data = state ["active_reviews" ][issue_key ]
12391239 if isinstance (issue_data , dict ):
12401240 tracked_reviewer = issue_data .get ("current_reviewer" )
1241+ assignment_method = issue_data .get ("assignment_method" )
12411242
12421243 # Also check GitHub assignees
12431244 current_assignees = get_issue_assignees (issue_number )
@@ -1276,6 +1277,13 @@ def handle_release_command(state: dict, issue_number: int,
12761277 if isinstance (state ["active_reviews" ][issue_key ], dict ):
12771278 state ["active_reviews" ][issue_key ]["current_reviewer" ] = None
12781279
1280+ # If this was a round-robin assignment, reposition the released user in the queue
1281+ # so they're next up (since they didn't complete the review they were assigned)
1282+ # For 'claim' or 'manual' assignments, don't touch the queue - they volunteered
1283+ # or were specifically requested
1284+ if assignment_method == "round-robin" :
1285+ reposition_member_as_next (state , target_username )
1286+
12791287 reason_text = f" Reason: { reason } " if reason else ""
12801288
12811289 if releasing_other :
@@ -1337,7 +1345,7 @@ def handle_assign_command(state: dict, issue_number: int,
13371345 assign_reviewer (issue_number , username )
13381346
13391347 # Track the reviewer in our state
1340- set_current_reviewer (state , issue_number , username )
1348+ set_current_reviewer (state , issue_number , username , assignment_method = "manual" )
13411349
13421350 # Record the assignment (but don't advance queue - this is manual assignment)
13431351 record_assignment (state , username , issue_number , "pr" if is_pr else "issue" )
@@ -1405,8 +1413,16 @@ def handle_assign_from_queue_command(state: dict, issue_number: int) -> tuple[st
14051413# ==============================================================================
14061414
14071415
1408- def set_current_reviewer (state : dict , issue_number : int , reviewer : str ) -> None :
1409- """Track the designated reviewer for an issue/PR in our state."""
1416+ def set_current_reviewer (state : dict , issue_number : int , reviewer : str ,
1417+ assignment_method : str = "round-robin" ) -> None :
1418+ """Track the designated reviewer for an issue/PR in our state.
1419+
1420+ Args:
1421+ state: Bot state dict
1422+ issue_number: Issue/PR number
1423+ reviewer: GitHub username of the reviewer
1424+ assignment_method: How they were assigned - 'round-robin', 'claim', or 'manual'
1425+ """
14101426 issue_key = str (issue_number )
14111427 now = datetime .now (timezone .utc ).isoformat ()
14121428
@@ -1419,6 +1435,7 @@ def set_current_reviewer(state: dict, issue_number: int, reviewer: str) -> None:
14191435 "assigned_at" : None ,
14201436 "last_reviewer_activity" : None ,
14211437 "transition_warning_sent" : None ,
1438+ "assignment_method" : None ,
14221439 }
14231440 elif isinstance (state ["active_reviews" ][issue_key ], list ):
14241441 # Migrate old format
@@ -1428,6 +1445,7 @@ def set_current_reviewer(state: dict, issue_number: int, reviewer: str) -> None:
14281445 "assigned_at" : None ,
14291446 "last_reviewer_activity" : None ,
14301447 "transition_warning_sent" : None ,
1448+ "assignment_method" : None ,
14311449 }
14321450
14331451 # Ensure new fields exist (migration for existing entries)
@@ -1438,12 +1456,15 @@ def set_current_reviewer(state: dict, issue_number: int, reviewer: str) -> None:
14381456 review_data ["last_reviewer_activity" ] = None
14391457 if "transition_warning_sent" not in review_data :
14401458 review_data ["transition_warning_sent" ] = None
1459+ if "assignment_method" not in review_data :
1460+ review_data ["assignment_method" ] = None
14411461
14421462 # Set the reviewer and timestamps
14431463 review_data ["current_reviewer" ] = reviewer
14441464 review_data ["assigned_at" ] = now
14451465 review_data ["last_reviewer_activity" ] = now
14461466 review_data ["transition_warning_sent" ] = None # Clear any previous warning
1467+ review_data ["assignment_method" ] = assignment_method
14471468
14481469
14491470def update_reviewer_activity (state : dict , issue_number : int , reviewer : str ) -> bool :
0 commit comments