Skip to content

Commit 8c2a4d8

Browse files
committed
fix: close reviewer feedback handoff gaps
1 parent 2051e13 commit 8c2a4d8

13 files changed

Lines changed: 171 additions & 13 deletions

File tree

REVIEWING.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,19 @@ Use this to release your assignment from an issue/PR without automatically assig
7474
@guidelines-bot /release Need to focus on other priorities
7575
```
7676

77+
### Record Feedback for Contributor Follow-Up
78+
79+
```
80+
@guidelines-bot /feedback
81+
```
82+
83+
Use this after you have provided reviewer feedback and are waiting for the contributor to respond. This keeps reviewer-bot's review state aligned without marking the review complete or changing the assigned reviewer.
84+
85+
**Example:**
86+
```
87+
@guidelines-bot /feedback
88+
```
89+
7790
### Rectify Review State
7891

7992
```
@@ -200,6 +213,7 @@ Life happens! Any of these actions will reset the 14-day clock:
200213
- **Post a review comment** - Any substantive feedback counts
201214
- **Use `/pass [reason]`** - Pass the review to the next person if you can't review it
202215
- **Use `/away YYYY-MM-DD [reason]`** - Step away temporarily (e.g., "On vacation until 2025-02-15")
216+
- **Use `/feedback`** - Record that reviewer feedback is ready for contributor follow-up
203217
- **Use `/rectify`** - Reconcile PR review state when review activity happened but bot state is stale
204218

205219
#### Before You Pass: Consider the Learning Opportunity

scripts/reviewer_bot_core/review_state_machine.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,13 @@ def _reset_cycle_state(review_data: dict) -> None:
165165
review_data["overdue_anchor"] = None
166166

167167

168+
def clear_current_cycle_reviewer_handoff(review_data: dict) -> bool:
169+
if review_data.get("current_cycle_reviewer_handoff") is None:
170+
return False
171+
review_data["current_cycle_reviewer_handoff"] = None
172+
return True
173+
174+
168175
def set_current_reviewer(
169176
state: dict,
170177
issue_number: int,
@@ -205,9 +212,7 @@ def clear_current_reviewer(state: dict, issue_number: int) -> bool:
205212
if review_data.get("assignment_method") is not None:
206213
review_data["assignment_method"] = None
207214
changed = True
208-
if review_data.get("current_cycle_reviewer_handoff") is not None:
209-
review_data["current_cycle_reviewer_handoff"] = None
210-
changed = True
215+
changed = clear_current_cycle_reviewer_handoff(review_data) or changed
211216
clear_transition_timers(review_data)
212217
return changed
213218

scripts/reviewer_bot_lib/commands.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,9 +170,6 @@ def handle_pass_command(
170170
reviewer_authority: dict[str, object] | None = None,
171171
) -> tuple[str, bool]:
172172
assignment_request = request or build_assignment_request(bot, issue_number=issue_number)
173-
issue_data = review_state.ensure_review_entry(state, issue_number, create=True)
174-
if issue_data is None:
175-
return "❌ Unable to load review state.", False
176173
authority = reviewer_authority or assignment_flow.resolve_reviewer_command_authority(
177174
bot,
178175
state,
@@ -181,6 +178,9 @@ def handle_pass_command(
181178
)
182179
if not authority.get("authorized"):
183180
return _reviewer_command_authority_error("pass", authority), False
181+
issue_data = authority.get("review_data")
182+
if not isinstance(issue_data, dict):
183+
return "❌ Unable to load review state.", False
184184
passed_reviewer = str(authority["tracked_reviewer"])
185185
current_assignees = list(authority.get("live_control_plane_reviewers") or [])
186186
skipped = list(issue_data["skipped"])

scripts/reviewer_bot_lib/comment_application.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from scripts.reviewer_bot_core import (
99
comment_command_policy,
1010
comment_freshness_policy,
11+
live_review_support,
1112
privileged_command_policy,
1213
)
1314

@@ -262,6 +263,26 @@ def _execute_rectify(bot, state: dict, request: CommentEventRequest, decision) -
262263
)
263264

264265

266+
def _handoff_order_key(handoff: dict) -> tuple[object, str] | None:
267+
timestamp = live_review_support.parse_github_timestamp(handoff.get("timestamp"))
268+
if timestamp is None:
269+
return None
270+
source_event_key = handoff.get("source_event_key")
271+
return timestamp, source_event_key if isinstance(source_event_key, str) else ""
272+
273+
274+
def _feedback_handoff_should_replace(existing: object, candidate: dict) -> bool:
275+
if not isinstance(existing, dict):
276+
return True
277+
candidate_key = _handoff_order_key(candidate)
278+
existing_key = _handoff_order_key(existing)
279+
if candidate_key is None:
280+
return existing_key is None
281+
if existing_key is None:
282+
return True
283+
return candidate_key >= existing_key
284+
285+
265286
def handle_feedback_command(bot, state: dict, request: CommentEventRequest, decision) -> CommandExecutionResult:
266287
if request.issue_state.lower() != "open":
267288
return CommandExecutionResult(
@@ -302,6 +323,12 @@ def handle_feedback_command(bot, state: dict, request: CommentEventRequest, deci
302323
"reviewed_head_sha": reviewed_head_sha,
303324
}
304325
before = review_data.get("current_cycle_reviewer_handoff")
326+
if not _feedback_handoff_should_replace(before, handoff):
327+
return CommandExecutionResult(
328+
response="ℹ️ Ignored stale `/feedback` handoff because a newer reviewer handoff is already recorded.",
329+
success=True,
330+
state_changed=False,
331+
)
305332
review_data["current_cycle_reviewer_handoff"] = handoff
306333
activity_changed = record_reviewer_activity(review_data, request.comment_created_at)
307334
state_changed = before != handoff or activity_changed
@@ -370,11 +397,11 @@ def apply_comment_command(
370397
return False
371398

372399
issue_number = request.issue_number
373-
review_data = ensure_review_entry(state, issue_number, create=True)
374-
if review_data is None:
375-
return False
376400
source_event_key = request.comment_source_event_key or f"issue_comment:{request.comment_id}"
377401
if isinstance(decision, comment_command_policy.DeferPrivilegedHandoffDecision):
402+
review_data = ensure_review_entry(state, issue_number, create=True)
403+
if review_data is None:
404+
return False
378405
permission_status = bot.github.get_user_permission_status(request.comment_author, "triage")
379406
handoff = privileged_command_policy.validate_accept_no_fls_changes_handoff(
380407
request,

scripts/reviewer_bot_lib/lifecycle.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from .config import CODING_GUIDELINE_LABEL, TRANSITION_NOTICE_MARKER_PREFIX
1414
from .review_state import (
1515
accept_channel_event,
16+
clear_current_cycle_reviewer_handoff,
1617
ensure_review_entry,
1718
mark_review_complete,
1819
record_transition_notice_sent,
@@ -439,6 +440,9 @@ def handle_pull_request_target_synchronize(bot, state: dict) -> bool:
439440
previous_review_completed_by = review_data.get("review_completed_by")
440441
previous_review_completion_source = review_data.get("review_completion_source")
441442
review_data["active_head_sha"] = head_sha
443+
handoff_changed = False
444+
if previous_head_sha != head_sha:
445+
handoff_changed = clear_current_cycle_reviewer_handoff(review_data)
442446
timestamp = request.event_created_at
443447
changed = accept_channel_event(
444448
review_data,
@@ -456,7 +460,7 @@ def handle_pull_request_target_synchronize(bot, state: dict) -> bool:
456460
or previous_review_completed_by != review_data.get("review_completed_by")
457461
or previous_review_completion_source != review_data.get("review_completion_source")
458462
)
459-
return changed or previous_head_sha != review_data.get("active_head_sha") or approval_changed
463+
return changed or previous_head_sha != review_data.get("active_head_sha") or approval_changed or handoff_changed
460464

461465

462466
def maybe_record_head_observation_repair(bot, issue_number: int, review_data: dict) -> HeadObservationRepairResult:
@@ -521,6 +525,7 @@ def maybe_record_head_observation_repair(bot, issue_number: int, review_data: di
521525
contributor_revision = review_data.get("contributor_revision", {}).get("accepted")
522526
if isinstance(contributor_revision, dict) and contributor_revision.get("reviewed_head_sha") == head_sha:
523527
review_data["active_head_sha"] = head_sha
528+
clear_current_cycle_reviewer_handoff(review_data)
524529
return HeadObservationRepairResult(changed=True, outcome="changed")
525530
changed = accept_channel_event(
526531
review_data,
@@ -531,6 +536,7 @@ def maybe_record_head_observation_repair(bot, issue_number: int, review_data: di
531536
source_precedence=0,
532537
)
533538
review_data["active_head_sha"] = head_sha
539+
clear_current_cycle_reviewer_handoff(review_data)
534540
review_data["current_cycle_completion"] = {}
535541
review_data["current_cycle_write_approval"] = {}
536542
review_data["review_completed_at"] = None

scripts/reviewer_bot_lib/review_state.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ def clear_current_reviewer(state: dict, issue_number: int) -> bool:
5151
return review_state_machine.clear_current_reviewer(state, issue_number)
5252

5353

54+
def clear_current_cycle_reviewer_handoff(review_data: dict) -> bool:
55+
return review_state_machine.clear_current_cycle_reviewer_handoff(review_data)
56+
57+
5458
def semantic_key_seen(review_data: dict, channel_name: str, semantic_key: str) -> bool:
5559
return review_state_machine.semantic_key_seen(review_data, channel_name, semantic_key)
5660

scripts/reviewer_bot_lib/reviews.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,10 @@ def apply_pr_approval_state(
117117
write_approval: dict,
118118
current_head_sha: str,
119119
) -> None:
120+
previous_head_sha = review_data.get("active_head_sha")
120121
review_data["active_head_sha"] = current_head_sha
122+
if previous_head_sha != current_head_sha:
123+
review_state.clear_current_cycle_reviewer_handoff(review_data)
121124
review_data["current_cycle_completion"] = completion
122125
review_data["current_cycle_write_approval"] = write_approval
123126
if completion.get("completed"):

tests/contract/reviewer_bot/test_review_state_contract.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ def test_review_state_mutation_inventory_freezes_overlap_classification_and_live
175175
"- set_current_reviewer: local-state-only mutation",
176176
"- update_reviewer_activity: local-state-only mutation",
177177
"- mark_review_complete: local-state-only mutation",
178+
"- clear_current_cycle_reviewer_handoff: local-state-only mutation",
178179
"- get_current_cycle_boundary: read-only helper",
179180
"- clear_transition_timers: local-state-only mutation",
180181
"- semantic_key_seen: read-only helper",
@@ -269,6 +270,7 @@ def test_review_state_module_exposes_named_mutation_surface():
269270
"set_current_reviewer",
270271
"update_reviewer_activity",
271272
"mark_review_complete",
273+
"clear_current_cycle_reviewer_handoff",
272274
"get_current_cycle_boundary",
273275
]:
274276
assert hasattr(review_state, name)

tests/fixtures/equivalence/review_state/api_inventory.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Local-State-Only Mutation APIs
77
- update_reviewer_activity: local-state-only mutation
88
- mark_review_complete: local-state-only mutation
99
- clear_transition_timers: local-state-only mutation
10+
- clear_current_cycle_reviewer_handoff: local-state-only mutation
1011

1112
Live-Read-Assisted Mutation APIs
1213
- accept_reviewer_review_from_live_review: live-read-assisted mutation; C1c in-scope

tests/unit/reviewer_bot/test_commands.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -825,6 +825,80 @@ def test_feedback_command_rejects_triage_actor_who_is_not_current_reviewer(monke
825825
assert side_effects.reactions == [(100, "eyes")]
826826

827827

828+
def test_feedback_command_does_not_materialize_review_entry_when_no_active_review(monkeypatch):
829+
harness = CommandHarness(monkeypatch)
830+
state = make_state()
831+
harness.stub_assignees([])
832+
side_effects = harness.capture_comment_side_effects()
833+
request = harness.typed_comment_request(
834+
issue_number=42,
835+
actor="alice",
836+
body="@guidelines-bot /feedback",
837+
issue_author="dana",
838+
is_pull_request=False,
839+
)
840+
841+
changed = comment_application.apply_comment_command(
842+
harness.runtime,
843+
state,
844+
request,
845+
{"command": "feedback", "args": [], "command_count": 1},
846+
classify_issue_comment_actor=lambda current_request: "repo_user_principal",
847+
)
848+
849+
assert changed is False
850+
assert "42" not in state["active_reviews"]
851+
assert side_effects.comments == [(42, "❌ No active tracked review exists for this issue/PR.")]
852+
assert side_effects.reactions == [(100, "eyes")]
853+
854+
855+
def test_feedback_command_does_not_overwrite_newer_handoff(monkeypatch):
856+
harness = CommandHarness(monkeypatch)
857+
state = make_state()
858+
review = review_state.ensure_review_entry(state, 42, create=True)
859+
assert review is not None
860+
review["current_reviewer"] = "alice"
861+
review["current_cycle_reviewer_handoff"] = {
862+
"source_event_key": "issue_comment:101",
863+
"timestamp": "2026-03-17T11:00:00Z",
864+
"actor": "alice",
865+
"command_name": "feedback",
866+
"reviewed_head_sha": None,
867+
}
868+
harness.stub_assignees(["alice"])
869+
side_effects = harness.capture_comment_side_effects()
870+
request = harness.typed_comment_request(
871+
issue_number=42,
872+
actor="alice",
873+
body="@guidelines-bot /feedback",
874+
issue_author="dana",
875+
is_pull_request=False,
876+
comment_id=100,
877+
created_at="2026-03-17T10:00:00Z",
878+
)
879+
880+
changed = comment_application.apply_comment_command(
881+
harness.runtime,
882+
state,
883+
request,
884+
{"command": "feedback", "args": [], "command_count": 1},
885+
classify_issue_comment_actor=lambda current_request: "repo_user_principal",
886+
)
887+
888+
assert changed is False
889+
assert review["current_cycle_reviewer_handoff"] == {
890+
"source_event_key": "issue_comment:101",
891+
"timestamp": "2026-03-17T11:00:00Z",
892+
"actor": "alice",
893+
"command_name": "feedback",
894+
"reviewed_head_sha": None,
895+
}
896+
assert side_effects.comments == [
897+
(42, "ℹ️ Ignored stale `/feedback` handoff because a newer reviewer handoff is already recorded.")
898+
]
899+
assert side_effects.reactions == [(100, "eyes"), (100, "+1")]
900+
901+
828902
def test_feedback_command_plus_text_does_not_record_reviewer_comment_channel(monkeypatch):
829903
harness = CommentRoutingHarness(monkeypatch)
830904
state = make_state()

0 commit comments

Comments
 (0)