Skip to content

Commit d0fd889

Browse files
committed
fix: finalize reviewer feedback handoff ordering
1 parent 8c2a4d8 commit d0fd889

6 files changed

Lines changed: 287 additions & 11 deletions

File tree

scripts/reviewer_bot_core/review_state_machine.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,18 @@ def _compare_records(left: dict | None, right: dict | None) -> int:
9797
return 0
9898

9999

100+
def _clear_handoff_after_newer_contributor_event(review_data: dict, timestamp: str) -> bool:
101+
handoff = review_data.get("current_cycle_reviewer_handoff")
102+
if not isinstance(handoff, dict):
103+
return False
104+
contributor_time = parse_github_timestamp(timestamp)
105+
handoff_time = parse_github_timestamp(handoff.get("timestamp"))
106+
if contributor_time is None or handoff_time is None or contributor_time <= handoff_time:
107+
return False
108+
review_data["current_cycle_reviewer_handoff"] = None
109+
return True
110+
111+
100112
def semantic_key_seen(review_data: dict, channel_name: str, semantic_key: str) -> bool:
101113
channel = _ensure_channel_map(review_data, channel_name)
102114
return semantic_key in channel["seen_keys"]
@@ -135,6 +147,8 @@ def accept_channel_event(
135147
current = channel.get("accepted")
136148
if _compare_records(candidate, current) >= 0:
137149
channel["accepted"] = candidate
150+
if channel_name in {"contributor_comment", "contributor_revision"}:
151+
_clear_handoff_after_newer_contributor_event(review_data, timestamp)
138152
return True
139153

140154

scripts/reviewer_bot_lib/commands.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -480,11 +480,6 @@ def handle_release_command(
480480
target_username = args[0].lstrip("@")
481481
reason = " ".join(args[1:]) if len(args) > 1 else None
482482
releasing_other = target_username.lower() != comment_author.lower()
483-
permission_status = bot.github.get_user_permission_status(comment_author, "triage")
484-
if permission_status == "unavailable":
485-
return "❌ Unable to verify triage permissions right now; refusing to continue.", False
486-
if releasing_other and permission_status != "granted":
487-
return (f"❌ @{comment_author} does not have permission to release other reviewers. Triage access or higher is required."), False
488483
else:
489484
target_username = comment_author
490485
reason = " ".join(args) if args else None
@@ -500,6 +495,12 @@ def handle_release_command(
500495
tracked_reviewer = str(authority["tracked_reviewer"])
501496
if target_username.lower() != tracked_reviewer.lower():
502497
return (f"❌ @{target_username} is not the current reviewer. Current reviewer: @{tracked_reviewer}"), False
498+
if releasing_other:
499+
permission_status = bot.github.get_user_permission_status(comment_author, "triage")
500+
if permission_status == "unavailable":
501+
return "❌ Unable to verify triage permissions right now; refusing to continue.", False
502+
if permission_status != "granted":
503+
return (f"❌ @{comment_author} does not have permission to release other reviewers. Triage access or higher is required."), False
503504
if not releasing_other and comment_author.lower() != tracked_reviewer.lower():
504505
return (
505506
f"❌ Only the current reviewer (@{tracked_reviewer}) can use `/release` without triage+ permission."

scripts/reviewer_bot_lib/comment_application.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,13 @@ def _feedback_handoff_should_replace(existing: object, candidate: dict) -> bool:
280280
return existing_key is None
281281
if existing_key is None:
282282
return True
283-
return candidate_key >= existing_key
283+
candidate_timestamp, candidate_source = candidate_key
284+
existing_timestamp, existing_source = existing_key
285+
if candidate_timestamp > existing_timestamp:
286+
return True
287+
if candidate_timestamp < existing_timestamp:
288+
return False
289+
return candidate_source == existing_source
284290

285291

286292
def handle_feedback_command(bot, state: dict, request: CommentEventRequest, decision) -> CommandExecutionResult:
@@ -325,7 +331,7 @@ def handle_feedback_command(bot, state: dict, request: CommentEventRequest, deci
325331
before = review_data.get("current_cycle_reviewer_handoff")
326332
if not _feedback_handoff_should_replace(before, handoff):
327333
return CommandExecutionResult(
328-
response="ℹ️ Ignored stale `/feedback` handoff because a newer reviewer handoff is already recorded.",
334+
response="ℹ️ Ignored stale `/feedback` handoff because a newer or same-time reviewer handoff is already recorded.",
329335
success=True,
330336
state_changed=False,
331337
)

tests/contract/reviewer_bot/test_review_state_contract.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,80 @@ def test_current_cycle_reviewer_handoff_is_cleared_by_reviewer_replacement_and_r
252252
assert review["current_cycle_reviewer_handoff"] is None
253253

254254

255+
def test_current_cycle_reviewer_handoff_is_cleared_by_newer_contributor_followup():
256+
state = make_state()
257+
review = review_state.ensure_review_entry(state, 42, create=True)
258+
assert review is not None
259+
review["current_cycle_reviewer_handoff"] = {
260+
"source_event_key": "issue_comment:100",
261+
"timestamp": "2026-03-17T10:00:00Z",
262+
"actor": "alice",
263+
"command_name": "feedback",
264+
"reviewed_head_sha": None,
265+
}
266+
267+
changed = review_state.accept_channel_event(
268+
review,
269+
"contributor_comment",
270+
semantic_key="issue_comment:101",
271+
timestamp="2026-03-17T11:00:00Z",
272+
actor="dana",
273+
)
274+
275+
assert changed is True
276+
assert review["current_cycle_reviewer_handoff"] is None
277+
278+
279+
def test_current_cycle_reviewer_handoff_survives_older_contributor_followup():
280+
state = make_state()
281+
review = review_state.ensure_review_entry(state, 42, create=True)
282+
assert review is not None
283+
handoff = {
284+
"source_event_key": "issue_comment:100",
285+
"timestamp": "2026-03-17T10:00:00Z",
286+
"actor": "alice",
287+
"command_name": "feedback",
288+
"reviewed_head_sha": None,
289+
}
290+
review["current_cycle_reviewer_handoff"] = dict(handoff)
291+
292+
changed = review_state.accept_channel_event(
293+
review,
294+
"contributor_comment",
295+
semantic_key="issue_comment:99",
296+
timestamp="2026-03-17T09:00:00Z",
297+
actor="dana",
298+
)
299+
300+
assert changed is True
301+
assert review["current_cycle_reviewer_handoff"] == handoff
302+
303+
304+
def test_current_cycle_reviewer_handoff_is_cleared_by_newer_contributor_revision():
305+
state = make_state()
306+
review = review_state.ensure_review_entry(state, 42, create=True)
307+
assert review is not None
308+
review["current_cycle_reviewer_handoff"] = {
309+
"source_event_key": "issue_comment:100",
310+
"timestamp": "2026-03-17T10:00:00Z",
311+
"actor": "alice",
312+
"command_name": "feedback",
313+
"reviewed_head_sha": "head-1",
314+
}
315+
316+
changed = review_state.accept_channel_event(
317+
review,
318+
"contributor_revision",
319+
semantic_key="pull_request_sync:42:head-2",
320+
timestamp="2026-03-17T11:00:00Z",
321+
reviewed_head_sha="head-2",
322+
source_precedence=1,
323+
)
324+
325+
assert changed is True
326+
assert review["current_cycle_reviewer_handoff"] is None
327+
328+
255329
def test_list_open_tracked_review_items_returns_only_assigned_entries():
256330
state = make_state()
257331
review_state.ensure_review_entry(state, 42, create=True)

tests/unit/reviewer_bot/test_commands.py

Lines changed: 181 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,30 @@ def test_release_command_accepts_confirmed_pr_reviewer_when_live_reviewers_are_e
258258
assert review["current_reviewer"] is None
259259

260260

261+
def test_release_command_resolves_reviewer_authority_before_triage_fallback(monkeypatch):
262+
harness = CommandHarness(monkeypatch)
263+
state = make_state()
264+
review = review_state.ensure_review_entry(state, 42, create=True)
265+
assert review is not None
266+
review["current_reviewer"] = "bob"
267+
harness.stub_assignees(["bob"])
268+
calls = []
269+
original_resolver = commands.assignment_flow.resolve_reviewer_command_authority
270+
271+
def resolve_with_order(*args, **kwargs):
272+
calls.append("resolver")
273+
return original_resolver(*args, **kwargs)
274+
275+
monkeypatch.setattr(commands.assignment_flow, "resolve_reviewer_command_authority", resolve_with_order)
276+
harness.runtime.github.get_user_permission_status = lambda username, required_permission="triage": calls.append("permission") or "granted"
277+
278+
response, success = harness.handle_release(state, 42, "alice", ["@bob"])
279+
280+
assert success is True
281+
assert "@alice has released @bob" in response
282+
assert calls[:2] == ["resolver", "permission"]
283+
284+
261285
def test_rectify_command_accepts_confirmed_pr_reviewer_when_live_reviewers_are_empty(monkeypatch):
262286
harness = CommandHarness(monkeypatch)
263287
state = make_state()
@@ -279,6 +303,31 @@ def test_rectify_command_accepts_confirmed_pr_reviewer_when_live_reviewers_are_e
279303
assert calls == [(harness.runtime, state, 42)]
280304

281305

306+
def test_rectify_command_resolves_reviewer_authority_before_triage_fallback(monkeypatch):
307+
harness = CommandHarness(monkeypatch)
308+
state = make_state()
309+
harness.stub_assignees([])
310+
calls = []
311+
original_resolver = reconcile.assignment_flow.resolve_reviewer_command_authority
312+
313+
def resolve_with_order(*args, **kwargs):
314+
calls.append("resolver")
315+
return original_resolver(*args, **kwargs)
316+
317+
monkeypatch.setattr(reconcile.assignment_flow, "resolve_reviewer_command_authority", resolve_with_order)
318+
harness.runtime.github.get_user_permission_status = lambda username, required_permission="triage": calls.append("permission") or "granted"
319+
monkeypatch.setattr(
320+
reconcile,
321+
"reconcile_active_review_entry",
322+
lambda bot, current_state, issue_number: calls.append("reconcile") or ("rectified", True, False),
323+
)
324+
325+
message, success, changed = harness.handle_rectify(state, 42, "maintainer")
326+
327+
assert (message, success, changed) == ("rectified", True, False)
328+
assert calls == ["resolver", "permission", "reconcile"]
329+
330+
282331
def test_assign_from_queue_posts_guidance_only_once(monkeypatch):
283332
harness = CommandHarness(monkeypatch)
284333
state = make_state()
@@ -493,6 +542,10 @@ def test_claim_command_fails_closed_when_assignees_unavailable(monkeypatch):
493542
def test_release_command_fails_closed_when_permission_unavailable(monkeypatch):
494543
harness = CommandHarness(monkeypatch)
495544
state = make_state()
545+
review = review_state.ensure_review_entry(state, 42, create=True)
546+
assert review is not None
547+
review["current_reviewer"] = "bob"
548+
harness.stub_assignees(["bob"])
496549
harness.runtime.github.get_user_permission_status = lambda username, required_permission="triage": "unavailable"
497550

498551
response, success = harness.handle_release(state, 42, "alice", ["@bob"])
@@ -894,11 +947,138 @@ def test_feedback_command_does_not_overwrite_newer_handoff(monkeypatch):
894947
"reviewed_head_sha": None,
895948
}
896949
assert side_effects.comments == [
897-
(42, "ℹ️ Ignored stale `/feedback` handoff because a newer reviewer handoff is already recorded.")
950+
(42, "ℹ️ Ignored stale `/feedback` handoff because a newer or same-time reviewer handoff is already recorded.")
898951
]
899952
assert side_effects.reactions == [(100, "eyes"), (100, "+1")]
900953

901954

955+
def test_feedback_command_does_not_overwrite_same_timestamp_different_event(monkeypatch):
956+
harness = CommandHarness(monkeypatch)
957+
state = make_state()
958+
review = review_state.ensure_review_entry(state, 42, create=True)
959+
assert review is not None
960+
review["current_reviewer"] = "alice"
961+
review["current_cycle_reviewer_handoff"] = {
962+
"source_event_key": "issue_comment:100",
963+
"timestamp": "2026-03-17T10:00:00Z",
964+
"actor": "alice",
965+
"command_name": "feedback",
966+
"reviewed_head_sha": None,
967+
}
968+
harness.stub_assignees(["alice"])
969+
side_effects = harness.capture_comment_side_effects()
970+
request = harness.typed_comment_request(
971+
issue_number=42,
972+
actor="alice",
973+
body="@guidelines-bot /feedback",
974+
issue_author="dana",
975+
is_pull_request=False,
976+
comment_id=101,
977+
created_at="2026-03-17T10:00:00Z",
978+
)
979+
980+
changed = comment_application.apply_comment_command(
981+
harness.runtime,
982+
state,
983+
request,
984+
{"command": "feedback", "args": [], "command_count": 1},
985+
classify_issue_comment_actor=lambda current_request: "repo_user_principal",
986+
)
987+
988+
assert changed is False
989+
assert review["current_cycle_reviewer_handoff"]["source_event_key"] == "issue_comment:100"
990+
assert side_effects.comments == [
991+
(42, "ℹ️ Ignored stale `/feedback` handoff because a newer or same-time reviewer handoff is already recorded.")
992+
]
993+
994+
995+
def test_feedback_command_replays_same_event_idempotently(monkeypatch):
996+
harness = CommandHarness(monkeypatch)
997+
state = make_state()
998+
review = review_state.ensure_review_entry(state, 42, create=True)
999+
assert review is not None
1000+
review["current_reviewer"] = "alice"
1001+
review["last_reviewer_activity"] = "2026-03-17T10:00:00Z"
1002+
review["current_cycle_reviewer_handoff"] = {
1003+
"source_event_key": "issue_comment:100",
1004+
"timestamp": "2026-03-17T10:00:00Z",
1005+
"actor": "alice",
1006+
"command_name": "feedback",
1007+
"reviewed_head_sha": None,
1008+
}
1009+
harness.stub_assignees(["alice"])
1010+
harness.capture_comment_side_effects()
1011+
request = harness.typed_comment_request(
1012+
issue_number=42,
1013+
actor="alice",
1014+
body="@guidelines-bot /feedback",
1015+
issue_author="dana",
1016+
is_pull_request=False,
1017+
comment_id=100,
1018+
created_at="2026-03-17T10:00:00Z",
1019+
)
1020+
1021+
changed = comment_application.apply_comment_command(
1022+
harness.runtime,
1023+
state,
1024+
request,
1025+
{"command": "feedback", "args": [], "command_count": 1},
1026+
classify_issue_comment_actor=lambda current_request: "repo_user_principal",
1027+
)
1028+
1029+
assert changed is False
1030+
assert review["current_cycle_reviewer_handoff"] == {
1031+
"source_event_key": "issue_comment:100",
1032+
"timestamp": "2026-03-17T10:00:00Z",
1033+
"actor": "alice",
1034+
"command_name": "feedback",
1035+
"reviewed_head_sha": None,
1036+
}
1037+
1038+
1039+
def test_feedback_command_overwrites_older_handoff(monkeypatch):
1040+
harness = CommandHarness(monkeypatch)
1041+
state = make_state()
1042+
review = review_state.ensure_review_entry(state, 42, create=True)
1043+
assert review is not None
1044+
review["current_reviewer"] = "alice"
1045+
review["current_cycle_reviewer_handoff"] = {
1046+
"source_event_key": "issue_comment:100",
1047+
"timestamp": "2026-03-17T09:00:00Z",
1048+
"actor": "alice",
1049+
"command_name": "feedback",
1050+
"reviewed_head_sha": None,
1051+
}
1052+
harness.stub_assignees(["alice"])
1053+
harness.capture_comment_side_effects()
1054+
request = harness.typed_comment_request(
1055+
issue_number=42,
1056+
actor="alice",
1057+
body="@guidelines-bot /feedback",
1058+
issue_author="dana",
1059+
is_pull_request=False,
1060+
comment_id=101,
1061+
created_at="2026-03-17T10:00:00Z",
1062+
)
1063+
1064+
changed = comment_application.apply_comment_command(
1065+
harness.runtime,
1066+
state,
1067+
request,
1068+
{"command": "feedback", "args": [], "command_count": 1},
1069+
classify_issue_comment_actor=lambda current_request: "repo_user_principal",
1070+
)
1071+
1072+
assert changed is True
1073+
assert review["current_cycle_reviewer_handoff"] == {
1074+
"source_event_key": "issue_comment:101",
1075+
"timestamp": "2026-03-17T10:00:00Z",
1076+
"actor": "alice",
1077+
"command_name": "feedback",
1078+
"reviewed_head_sha": None,
1079+
}
1080+
1081+
9021082
def test_feedback_command_plus_text_does_not_record_reviewer_comment_channel(monkeypatch):
9031083
harness = CommentRoutingHarness(monkeypatch)
9041084
state = make_state()

tests/unit/reviewer_bot/test_reviewer_response_equivalence.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -490,7 +490,7 @@ def test_issue_feedback_handoff_before_cycle_boundary_is_not_consumed():
490490
assert result["current_cycle_reviewer_handoff"] is None
491491

492492

493-
def test_issue_feedback_handoff_is_consumed_as_stale_after_newer_contributor_followup():
493+
def test_issue_feedback_handoff_is_cleared_after_newer_contributor_followup():
494494
state = make_state()
495495
review = make_tracked_review_state(
496496
state,
@@ -516,8 +516,9 @@ def test_issue_feedback_handoff_is_consumed_as_stale_after_newer_contributor_fol
516516
result = reviewer_response_policy.derive_reviewer_response_state(review, issue_is_pull_request=False)
517517

518518
assert result["state"] == "awaiting_reviewer_response"
519-
assert result["reason"] == "contributor_comment_newer"
520-
assert result["anchor_timestamp"] == "2026-03-17T11:00:00Z"
519+
assert result["reason"] == "no_reviewer_activity"
520+
assert result["current_cycle_reviewer_handoff"] is None
521+
assert review["current_cycle_reviewer_handoff"] is None
521522

522523

523524
def test_pr_feedback_handoff_requires_current_tracked_head():

0 commit comments

Comments
 (0)