Skip to content

Commit cd47f05

Browse files
committed
fix: commonize logic for /pass, /release, and /away to return person to reviewer queue
1 parent 63aa871 commit cd47f05

File tree

1 file changed

+83
-62
lines changed

1 file changed

+83
-62
lines changed

scripts/reviewer_bot.py

Lines changed: 83 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
495541
def 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

14491470
def update_reviewer_activity(state: dict, issue_number: int, reviewer: str) -> bool:

0 commit comments

Comments
 (0)