Skip to content

Commit 01b6e63

Browse files
committed
fix: close reviewer command authority gaps
1 parent d0fd889 commit 01b6e63

18 files changed

Lines changed: 400 additions & 140 deletions

REVIEWING.md

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -74,19 +74,6 @@ 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-
9077
### Rectify Review State
9178

9279
```
@@ -99,7 +86,6 @@ permission to persist reviewer-bot state.
9986

10087
Who can run it:
10188
- The currently assigned reviewer
102-
- A maintainer with triage+ permission
10389

10490
What it does (for the current PR only):
10591
- If the latest review by the assigned reviewer is `APPROVED`, it marks the review complete.
@@ -213,7 +199,6 @@ Life happens! Any of these actions will reset the 14-day clock:
213199
- **Post a review comment** - Any substantive feedback counts
214200
- **Use `/pass [reason]`** - Pass the review to the next person if you can't review it
215201
- **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
217202
- **Use `/rectify`** - Reconcile PR review state when review activity happened but bot state is stale
218203

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

scripts/reviewer_bot_core/review_state_types.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,17 @@ class ReviewChannelState:
4545
seen_keys: list[str] = field(default_factory=list)
4646

4747

48+
@dataclass
49+
class CurrentCycleReviewerHandoff:
50+
"""Maps to the exact persisted `/feedback` reviewer handoff shape."""
51+
52+
source_event_key: str
53+
timestamp: str
54+
actor: str
55+
command_name: str
56+
reviewed_head_sha: str | None = None
57+
58+
4859
@dataclass
4960
class ReviewEntryState:
5061
"""Maps to the persisted review entry fields used by the future C1 cutover.
@@ -79,4 +90,4 @@ class ReviewEntryState:
7990
review_dismissal: ReviewChannelState = field(default_factory=ReviewChannelState)
8091
current_cycle_completion: dict[str, Any] = field(default_factory=dict)
8192
current_cycle_write_approval: dict[str, Any] = field(default_factory=dict)
82-
current_cycle_reviewer_handoff: dict[str, Any] | None = None
93+
current_cycle_reviewer_handoff: CurrentCycleReviewerHandoff | None = None

scripts/reviewer_bot_core/reviewer_response_policy.py

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,12 @@ def derive_reviewer_response_state(
333333
None,
334334
issue_is_pull_request=False,
335335
)
336+
if _compare_cross_channel_conversation(
337+
contributor_comment,
338+
reviewer_handoff,
339+
parse_timestamp=live_review_support.parse_github_timestamp,
340+
) > 0:
341+
reviewer_handoff = None
336342
if reviewer_review_helpers.compare_records(
337343
reviewer_handoff,
338344
latest_reviewer_response,
@@ -446,6 +452,25 @@ def derive_reviewer_response_state(
446452
issue_is_pull_request=True,
447453
)
448454

455+
contributor_handoff = contributor_comment
456+
contributor_revision = _contributor_revision_handoff_record(
457+
review_data,
458+
current_head,
459+
reviewer_review if isinstance(reviewer_review, dict) else None,
460+
)
461+
if reviewer_review_helpers.compare_records(
462+
contributor_revision,
463+
contributor_handoff,
464+
parse_timestamp=live_review_support.parse_github_timestamp,
465+
) > 0:
466+
contributor_handoff = contributor_revision
467+
if _compare_cross_channel_conversation(
468+
contributor_handoff,
469+
reviewer_handoff,
470+
parse_timestamp=live_review_support.parse_github_timestamp,
471+
) > 0:
472+
reviewer_handoff = None
473+
449474
if not reviewer_comment and not reviewer_review and not reviewer_handoff:
450475
if not had_reviewer_review:
451476
return _decorate_response(
@@ -474,19 +499,6 @@ def derive_reviewer_response_state(
474499
) > 0:
475500
latest_reviewer_response = reviewer_handoff
476501

477-
contributor_handoff = contributor_comment
478-
contributor_revision = _contributor_revision_handoff_record(
479-
review_data,
480-
current_head,
481-
reviewer_review if isinstance(reviewer_review, dict) else None,
482-
)
483-
if reviewer_review_helpers.compare_records(
484-
contributor_revision,
485-
contributor_handoff,
486-
parse_timestamp=live_review_support.parse_github_timestamp,
487-
) > 0:
488-
contributor_handoff = contributor_revision
489-
490502
if _compare_cross_channel_conversation(
491503
contributor_handoff,
492504
latest_reviewer_response,

scripts/reviewer_bot_core/state_adapters.py

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from .review_state_types import (
1414
AcceptedChannelRecord,
15+
CurrentCycleReviewerHandoff,
1516
DismissalAcceptedRecord,
1617
ReviewChannelState,
1718
ReviewEntryState,
@@ -38,6 +39,14 @@
3839
"source_artifact_name",
3940
}
4041

42+
_REVIEWER_HANDOFF_KEYS = {
43+
"source_event_key",
44+
"timestamp",
45+
"actor",
46+
"command_name",
47+
"reviewed_head_sha",
48+
}
49+
4150

4251
def _migrate_repair_marker(marker: Any) -> dict[str, Any] | None:
4352
if not isinstance(marker, dict):
@@ -213,6 +222,45 @@ def _channel_to_persisted(channel: ReviewChannelState) -> dict[str, Any]:
213222
}
214223

215224

225+
def _reviewer_handoff_from_persisted(value: Any) -> CurrentCycleReviewerHandoff | None:
226+
if not isinstance(value, dict) or set(value) != _REVIEWER_HANDOFF_KEYS:
227+
return None
228+
source_event_key = value.get("source_event_key")
229+
timestamp = value.get("timestamp")
230+
actor = value.get("actor")
231+
command_name = value.get("command_name")
232+
reviewed_head_sha = value.get("reviewed_head_sha")
233+
if not isinstance(source_event_key, str) or not source_event_key.strip():
234+
return None
235+
if not isinstance(timestamp, str) or not timestamp.strip():
236+
return None
237+
if not isinstance(actor, str) or not actor.strip():
238+
return None
239+
if command_name != "feedback":
240+
return None
241+
if reviewed_head_sha is not None and (not isinstance(reviewed_head_sha, str) or not reviewed_head_sha.strip()):
242+
return None
243+
return CurrentCycleReviewerHandoff(
244+
source_event_key=source_event_key,
245+
timestamp=timestamp,
246+
actor=actor,
247+
command_name=command_name,
248+
reviewed_head_sha=reviewed_head_sha,
249+
)
250+
251+
252+
def _reviewer_handoff_to_persisted(handoff: CurrentCycleReviewerHandoff | None) -> dict[str, Any] | None:
253+
if handoff is None:
254+
return None
255+
return {
256+
"source_event_key": handoff.source_event_key,
257+
"timestamp": handoff.timestamp,
258+
"actor": handoff.actor,
259+
"command_name": handoff.command_name,
260+
"reviewed_head_sha": handoff.reviewed_head_sha,
261+
}
262+
263+
216264
def review_entry_from_persisted(review_entry: dict[str, Any] | list[Any] | None) -> ReviewEntryState | None:
217265
if review_entry is None:
218266
return None
@@ -297,9 +345,9 @@ def review_entry_from_persisted(review_entry: dict[str, Any] | list[Any] | None)
297345
current_cycle_write_approval=deepcopy(review_entry.get("current_cycle_write_approval") or {})
298346
if isinstance(review_entry.get("current_cycle_write_approval"), dict)
299347
else {},
300-
current_cycle_reviewer_handoff=deepcopy(review_entry.get("current_cycle_reviewer_handoff"))
301-
if isinstance(review_entry.get("current_cycle_reviewer_handoff"), dict)
302-
else None,
348+
current_cycle_reviewer_handoff=_reviewer_handoff_from_persisted(
349+
review_entry.get("current_cycle_reviewer_handoff")
350+
),
303351
)
304352

305353

@@ -331,7 +379,9 @@ def review_entry_to_persisted(review_entry: ReviewEntryState) -> dict[str, Any]:
331379
"review_dismissal": _channel_to_persisted(review_entry.review_dismissal),
332380
"current_cycle_completion": deepcopy(review_entry.current_cycle_completion),
333381
"current_cycle_write_approval": deepcopy(review_entry.current_cycle_write_approval),
334-
"current_cycle_reviewer_handoff": deepcopy(review_entry.current_cycle_reviewer_handoff),
382+
"current_cycle_reviewer_handoff": _reviewer_handoff_to_persisted(
383+
review_entry.current_cycle_reviewer_handoff
384+
),
335385
}
336386

337387

scripts/reviewer_bot_lib/assignment_flow.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -220,12 +220,20 @@ def resolve_reviewer_command_authority(
220220
}
221221
if not resolution["authorized"] or actor is None:
222222
return resolution
223+
return require_reviewer_command_actor(resolution, actor)
224+
225+
226+
def require_reviewer_command_actor(resolution: dict[str, object], actor: str) -> dict[str, object]:
227+
if not resolution.get("authorized"):
228+
return resolution
223229
tracked_reviewer = resolution.get("tracked_reviewer")
224-
if not isinstance(tracked_reviewer, str) or tracked_reviewer.lower() != actor.lower():
225-
resolution["authorized"] = False
226-
resolution["authorization_status"] = "actor_not_current_reviewer"
227-
resolution["reason"] = "actor_not_current_reviewer"
228-
return resolution
230+
if isinstance(tracked_reviewer, str) and tracked_reviewer.lower() == actor.lower():
231+
return resolution
232+
denied = dict(resolution)
233+
denied["authorized"] = False
234+
denied["authorization_status"] = "actor_not_current_reviewer"
235+
denied["reason"] = "actor_not_current_reviewer"
236+
return denied
229237

230238

231239
def reviewer_command_authority_failure_message(command_name: str, resolution: dict[str, object]) -> str:

scripts/reviewer_bot_lib/commands.py

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ def handle_pass_command(
176176
assignment_request,
177177
actor=comment_author,
178178
)
179+
authority = assignment_flow.require_reviewer_command_actor(authority, comment_author)
179180
if not authority.get("authorized"):
180181
return _reviewer_command_authority_error("pass", authority), False
181182
issue_data = authority.get("review_data")
@@ -426,7 +427,7 @@ def handle_queue_command(
426427

427428

428429
def handle_commands_command(bot) -> tuple[str, bool]:
429-
return (f"ℹ️ **Available Commands**\n\n**Pass or step away:**\n- `{bot.BOT_MENTION} /pass [reason]` - Pass this review to next in queue (current reviewer only)\n- `{bot.BOT_MENTION} /away YYYY-MM-DD [reason]` - Step away from queue until a date\n- `{bot.BOT_MENTION} /feedback` - Mark reviewer feedback ready for contributor follow-up\n- `{bot.BOT_MENTION} /release [@username] [reason]` - Release assignment (yours or someone else's with triage+ permission)\n\n**Assign reviewers:**\n- `{bot.BOT_MENTION} /r? @username` - Assign a specific reviewer\n- `{bot.BOT_MENTION} /r? producers` - Request the next reviewer from the queue\n- `{bot.BOT_MENTION} /claim` - Claim this review for yourself\n\n**Other:**\n- `{bot.BOT_MENTION} /done` - Mark a tracked non-PR issue review complete\n- `{bot.BOT_MENTION} /label +label-name` - Add a label\n- `{bot.BOT_MENTION} /label -label-name` - Remove a label\n- `{bot.BOT_MENTION} /rectify` - Reconcile this issue/PR review state from GitHub\n- `{bot.BOT_MENTION} /accept-no-fls-changes` - Update spec.lock and open a PR when no guidelines are impacted\n- `{bot.BOT_MENTION} /queue` - Show current queue status\n- `{bot.BOT_MENTION} /sync-members` - Sync queue with members.md"), True
430+
return (f"ℹ️ **Available Commands**\n\n**Pass or step away:**\n- `{bot.BOT_MENTION} /pass [reason]` - Pass this review to next in queue (current reviewer only)\n- `{bot.BOT_MENTION} /away YYYY-MM-DD [reason]` - Step away from queue until a date\n- `{bot.BOT_MENTION} /feedback` - Mark reviewer feedback ready for contributor follow-up\n- `{bot.BOT_MENTION} /release [reason]` - Release your current reviewer assignment\n\n**Assign reviewers:**\n- `{bot.BOT_MENTION} /r? @username` - Assign a specific reviewer\n- `{bot.BOT_MENTION} /r? producers` - Request the next reviewer from the queue\n- `{bot.BOT_MENTION} /claim` - Claim this review for yourself\n\n**Other:**\n- `{bot.BOT_MENTION} /done` - Mark a tracked non-PR issue review complete\n- `{bot.BOT_MENTION} /label +label-name` - Add a label\n- `{bot.BOT_MENTION} /label -label-name` - Remove a label\n- `{bot.BOT_MENTION} /rectify` - Reconcile this issue/PR review state from GitHub (current reviewer only)\n- `{bot.BOT_MENTION} /accept-no-fls-changes` - Update spec.lock and open a PR when no guidelines are impacted\n- `{bot.BOT_MENTION} /queue` - Show current queue status\n- `{bot.BOT_MENTION} /sync-members` - Sync queue with members.md"), True
430431

431432

432433
def handle_claim_command(
@@ -475,11 +476,9 @@ def handle_release_command(
475476
request = request or build_assignment_request(bot, issue_number=issue_number)
476477
target_username = None
477478
reason = None
478-
releasing_other = False
479479
if args and args[0].startswith("@"):
480480
target_username = args[0].lstrip("@")
481481
reason = " ".join(args[1:]) if len(args) > 1 else None
482-
releasing_other = target_username.lower() != comment_author.lower()
483482
else:
484483
target_username = comment_author
485484
reason = " ".join(args) if args else None
@@ -489,22 +488,18 @@ def handle_release_command(
489488
issue_data = state["active_reviews"][issue_key]
490489
if isinstance(issue_data, dict):
491490
assignment_method = issue_data.get("assignment_method")
492-
authority = reviewer_authority or assignment_flow.resolve_reviewer_command_authority(bot, state, request)
491+
authority = reviewer_authority or assignment_flow.resolve_reviewer_command_authority(
492+
bot,
493+
state,
494+
request,
495+
actor=comment_author,
496+
)
497+
authority = assignment_flow.require_reviewer_command_actor(authority, comment_author)
493498
if not authority.get("authorized"):
494499
return _reviewer_command_authority_error("release", authority), False
495500
tracked_reviewer = str(authority["tracked_reviewer"])
496501
if target_username.lower() != tracked_reviewer.lower():
497502
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
504-
if not releasing_other and comment_author.lower() != tracked_reviewer.lower():
505-
return (
506-
f"❌ Only the current reviewer (@{tracked_reviewer}) can use `/release` without triage+ permission."
507-
), False
508503
result = assignment_flow.confirm_reviewer_release(
509504
bot,
510505
state,
@@ -517,8 +512,6 @@ def handle_release_command(
517512
result.get("diagnostic_changed") or result.get("cleared_current_reviewer")
518513
)
519514
reason_text = f" Reason: {reason}" if reason else ""
520-
if releasing_other:
521-
return (f"✅ @{comment_author} has released @{target_username} from this review.{reason_text}\n\n_This issue/PR is now unassigned. Use `{bot.BOT_MENTION} /r? producers` to assign the next reviewer from the queue, or `{bot.BOT_MENTION} /claim` to claim it._"), True
522515
return (f"✅ @{target_username} has released this review.{reason_text}\n\n_This issue/PR is now unassigned. Use `{bot.BOT_MENTION} /r? producers` to assign the next reviewer from the queue, or `{bot.BOT_MENTION} /claim` to claim it._"), True
523516

524517

scripts/reviewer_bot_lib/comment_application.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ def _execute_release(bot, state: dict, decision, assignment_request: AssignmentR
226226
bot,
227227
state,
228228
assignment_request,
229-
actor=None,
229+
actor=decision.actor,
230230
)
231231
return _build_execution_result(
232232
decision.command_id,

scripts/reviewer_bot_lib/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@
186186
"pass": "Pass this review to next in queue",
187187
"away": "Step away from queue until date (YYYY-MM-DD)",
188188
"feedback": "Record that reviewer feedback is ready for contributor follow-up",
189-
"release": "Release assignment (yours, or @username with triage+ permission)",
189+
"release": "Release your current reviewer assignment",
190190
"rectify": "Reconcile this issue/PR's review state from GitHub",
191191
"claim": "Claim this review for yourself",
192192
"r?": "Assign a reviewer (@username or 'producers')",

scripts/reviewer_bot_lib/guidance.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ def get_issue_guidance(reviewer: str, issue_author: str) -> str:
4848
- `{BOT_MENTION} /pass [reason]` - Pass just this issue to the next reviewer
4949
- `{BOT_MENTION} /away YYYY-MM-DD [reason]` - Step away from the queue until a date
5050
- `{BOT_MENTION} /feedback` - Mark reviewer feedback ready for contributor follow-up
51-
- `{BOT_MENTION} /release [@username] [reason]` - Release assignment (yours or someone else's with triage+ permission)
51+
- `{BOT_MENTION} /release [reason]` - Release your current reviewer assignment
5252
5353
To assign someone else:
5454
- `{BOT_MENTION} /r? @username` - Assign a specific reviewer
@@ -81,7 +81,7 @@ def get_generic_issue_guidance(reviewer: str, issue_author: str) -> str:
8181
- `{BOT_MENTION} /pass [reason]` - Pass just this issue to the next reviewer
8282
- `{BOT_MENTION} /away YYYY-MM-DD [reason]` - Step away from the queue until a date
8383
- `{BOT_MENTION} /feedback` - Mark reviewer feedback ready for contributor follow-up
84-
- `{BOT_MENTION} /release [@username] [reason]` - Release assignment (yours or someone else's with triage+ permission)
84+
- `{BOT_MENTION} /release [reason]` - Release your current reviewer assignment
8585
8686
To assign someone else:
8787
- `{BOT_MENTION} /r? @username` - Assign a specific reviewer
@@ -119,7 +119,7 @@ def get_fls_audit_guidance(reviewer: str, issue_author: str) -> str:
119119
- `{BOT_MENTION} /pass [reason]` - Pass just this issue to the next reviewer
120120
- `{BOT_MENTION} /away YYYY-MM-DD [reason]` - Step away from the queue until a date
121121
- `{BOT_MENTION} /feedback` - Mark reviewer feedback ready for contributor follow-up
122-
- `{BOT_MENTION} /release [@username] [reason]` - Release assignment (yours or someone else's with triage+ permission)
122+
- `{BOT_MENTION} /release [reason]` - Release your current reviewer assignment
123123
124124
To assign someone else:
125125
- `{BOT_MENTION} /r? @username` - Assign a specific reviewer
@@ -165,7 +165,7 @@ def get_pr_guidance(reviewer: str, pr_author: str) -> str:
165165
- `{BOT_MENTION} /pass [reason]` - Pass just this PR to the next reviewer
166166
- `{BOT_MENTION} /away YYYY-MM-DD [reason]` - Step away from the queue until a date
167167
- `{BOT_MENTION} /feedback` - Mark reviewer feedback ready for contributor follow-up
168-
- `{BOT_MENTION} /release [@username] [reason]` - Release assignment (yours or someone else's with triage+ permission)
168+
- `{BOT_MENTION} /release [reason]` - Release your current reviewer assignment
169169
170170
To assign someone else:
171171
- `{BOT_MENTION} /r? @username` - Assign a specific reviewer

0 commit comments

Comments
 (0)