Skip to content

Commit 6849cd6

Browse files
committed
fix(reviewer-bot): finish reminder authority remediation
Align reminder idempotence, reviewer authority, freshness, and repair diagnostics with confirmed live GitHub assignment truth so stale stored reviewer state cannot drive reminders, reviewer-only actions, or partial-failure recovery.
1 parent e5afa3d commit 6849cd6

24 files changed

Lines changed: 811 additions & 102 deletions

scripts/reviewer_bot_core/state_adapters.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,14 @@
2121
"review_repair",
2222
"head_observation_repair",
2323
"status_label_projection",
24+
"issue_snapshot_read",
25+
"warning_dedupe_read",
26+
"warning_post",
27+
"transition_dedupe_read",
28+
"transition_post",
29+
"assignment_add_write",
30+
"assignment_remove_write",
31+
"assignment_confirm_read",
2432
)
2533

2634
_DEFERRED_GAP_MIGRATION_DROP_KEYS = {
@@ -36,9 +44,13 @@ def _migrate_repair_marker(marker: Any) -> dict[str, Any] | None:
3644
return None
3745
return {
3846
"kind": marker.get("kind"),
47+
"phase": marker.get("phase"),
48+
"status_code": marker.get("status_code"),
3949
"reason": marker.get("reason"),
4050
"failure_kind": marker.get("failure_kind"),
51+
"retry_attempts": marker.get("retry_attempts"),
4152
"recorded_at": marker.get("recorded_at"),
53+
"live_assignees": deepcopy(marker.get("live_assignees")),
4254
}
4355

4456

scripts/reviewer_bot_lib/assignment_flow.py

Lines changed: 212 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
get_issue_guidance,
1111
get_pr_guidance,
1212
)
13+
from .repair_records import clear_repair_marker, store_repair_marker
1314
from .review_state import (
1415
clear_current_reviewer,
1516
ensure_review_entry,
@@ -39,10 +40,75 @@ def _coerce_attempt(bot, result, *, success_status: int) -> object:
3940
if isinstance(result, bool):
4041
if result:
4142
return _success_attempt(bot, success_status)
42-
return bot.AssignmentAttempt(success=False, status_code=None)
43+
return bot.AssignmentAttempt(success=False, status_code=None, failure_kind="transport_error")
4344
return result
4445

4546

47+
def _store_assignment_marker(bot, review_data: dict, issue_number: int, *, phase: str, marker: dict) -> bool:
48+
changed = store_repair_marker(review_data, phase, marker)
49+
if changed:
50+
bot.collect_touched_item(issue_number)
51+
return changed
52+
53+
54+
def _clear_assignment_marker(bot, review_data: dict, issue_number: int, *, phase: str) -> bool:
55+
changed = clear_repair_marker(review_data, phase)
56+
if changed:
57+
bot.collect_touched_item(issue_number)
58+
return changed
59+
60+
61+
def _assignment_attempt_marker(bot, *, phase: str, attempt) -> dict:
62+
return {
63+
"kind": "reminder_transport_failure",
64+
"phase": phase,
65+
"status_code": attempt.status_code,
66+
"failure_kind": attempt.failure_kind or "transport_error",
67+
"retry_attempts": attempt.retry_attempts,
68+
"recorded_at": bot.clock.now().isoformat(),
69+
}
70+
71+
72+
def _assignment_authority_mismatch_marker(bot, *, live_assignees: list[str], reason: str) -> dict:
73+
return {
74+
"kind": "reviewer_authority_mismatch",
75+
"phase": "assignment_confirm_read",
76+
"status_code": None,
77+
"failure_kind": "reviewer_authority_mismatch",
78+
"retry_attempts": 0,
79+
"recorded_at": bot.clock.now().isoformat(),
80+
"reason": reason,
81+
"live_assignees": list(live_assignees),
82+
}
83+
84+
85+
def _hard_fail_if_permission_denied(result, *, action: str, issue_number: int) -> None:
86+
if result.failure_kind in {"unauthorized", "forbidden"}:
87+
raise RuntimeError(
88+
f"Permission denied during {action} for #{issue_number} (status {result.status_code})."
89+
)
90+
91+
92+
def _read_live_assignees(bot, state: dict, issue_number: int, *, is_pull_request: bool | None = None):
93+
review_data = ensure_review_entry(state, issue_number, create=True)
94+
result = bot.github.get_issue_assignees_result(issue_number, is_pull_request=is_pull_request)
95+
diagnostic_changed = False
96+
_hard_fail_if_permission_denied(result, action="assignee confirmation read", issue_number=issue_number)
97+
if not result.ok or not isinstance(result.payload, list):
98+
if isinstance(review_data, dict):
99+
diagnostic_changed = _store_assignment_marker(
100+
bot,
101+
review_data,
102+
issue_number,
103+
phase="assignment_confirm_read",
104+
marker=_assignment_attempt_marker(bot, phase="assignment_confirm_read", attempt=result),
105+
)
106+
return review_data, None, result, diagnostic_changed
107+
if isinstance(review_data, dict):
108+
diagnostic_changed = _clear_assignment_marker(bot, review_data, issue_number, phase="assignment_confirm_read")
109+
return review_data, result.payload, result, diagnostic_changed
110+
111+
46112
def _post_assignment_guidance(bot, request, reviewer: str) -> None:
47113
if request.is_pull_request:
48114
bot.github.post_comment(request.issue_number, get_pr_guidance(reviewer, request.issue_author))
@@ -85,16 +151,42 @@ def confirm_reviewer_assignment(
85151
pr_head_sha: str | None = None,
86152
) -> dict[str, object]:
87153
issue_number = request.issue_number
154+
review_data = ensure_review_entry(state, issue_number, create=True)
155+
stored_reviewer = review_data.get("current_reviewer") if isinstance(review_data, dict) else None
156+
diagnostic_changed = False
88157
live_before = current_assignees
89158
if live_before is None:
90-
live_before = bot.github.get_issue_assignees(issue_number)
159+
review_data, live_before, _, marker_changed = _read_live_assignees(
160+
bot,
161+
state,
162+
issue_number,
163+
is_pull_request=request.is_pull_request,
164+
)
165+
diagnostic_changed = marker_changed or diagnostic_changed
91166
if live_before is None:
92-
return {"confirmed": False, "reason": "assignees_unavailable"}
167+
return {
168+
"confirmed": False,
169+
"reason": "assignees_unavailable",
170+
"diagnostic_changed": diagnostic_changed,
171+
}
93172
if request.issue_author and reviewer.lower() == request.issue_author.lower():
173+
if isinstance(review_data, dict):
174+
diagnostic_changed = _store_assignment_marker(
175+
bot,
176+
review_data,
177+
issue_number,
178+
phase="assignment_confirm_read",
179+
marker=_assignment_authority_mismatch_marker(
180+
bot,
181+
live_assignees=live_before,
182+
reason="self_review_not_allowed",
183+
),
184+
) or diagnostic_changed
94185
return {
95186
"confirmed": False,
96187
"reason": "self_review_not_allowed",
97188
"current_assignees": live_before,
189+
"diagnostic_changed": diagnostic_changed,
98190
}
99191
removal_attempts = {}
100192
live_before_normalized = _normalize_logins(live_before)
@@ -104,25 +196,55 @@ def confirm_reviewer_assignment(
104196
attempt = _remove_live_assignee(bot, request, issue_number, assignee)
105197
removal_attempts[assignee] = attempt
106198
if not attempt.success:
107-
final_assignees = bot.github.get_issue_assignees(issue_number)
199+
if isinstance(review_data, dict):
200+
diagnostic_changed = _store_assignment_marker(
201+
bot,
202+
review_data,
203+
issue_number,
204+
phase="assignment_remove_write",
205+
marker=_assignment_attempt_marker(bot, phase="assignment_remove_write", attempt=attempt),
206+
) or diagnostic_changed
207+
_, final_assignees, _, marker_changed = _read_live_assignees(
208+
bot,
209+
state,
210+
issue_number,
211+
is_pull_request=request.is_pull_request,
212+
)
213+
diagnostic_changed = marker_changed or diagnostic_changed
108214
return {
109215
"confirmed": False,
110216
"reason": "remove_failed",
111217
"current_assignees": live_before,
112218
"final_assignees": final_assignees,
113219
"removal_attempts": removal_attempts,
220+
"diagnostic_changed": diagnostic_changed,
114221
}
115222
assignment_attempt = None
116223
if reviewer.lower() not in live_before_normalized:
117224
assignment_attempt = _add_live_assignee(bot, request, issue_number, reviewer)
118-
final_assignees = bot.github.get_issue_assignees(issue_number)
225+
if not assignment_attempt.success and isinstance(review_data, dict):
226+
diagnostic_changed = _store_assignment_marker(
227+
bot,
228+
review_data,
229+
issue_number,
230+
phase="assignment_add_write",
231+
marker=_assignment_attempt_marker(bot, phase="assignment_add_write", attempt=assignment_attempt),
232+
) or diagnostic_changed
233+
review_data, final_assignees, _, marker_changed = _read_live_assignees(
234+
bot,
235+
state,
236+
issue_number,
237+
is_pull_request=request.is_pull_request,
238+
)
239+
diagnostic_changed = marker_changed or diagnostic_changed
119240
if final_assignees is None:
120241
return {
121242
"confirmed": False,
122243
"reason": "final_assignees_unknown",
123244
"current_assignees": live_before,
124245
"assignment_attempt": assignment_attempt,
125246
"removal_attempts": removal_attempts,
247+
"diagnostic_changed": diagnostic_changed,
126248
}
127249
final_normalized = _normalize_logins(final_assignees)
128250
if len(final_assignees) == 1 and final_normalized[0] == reviewer.lower():
@@ -145,17 +267,41 @@ def confirm_reviewer_assignment(
145267
)
146268
if emit_guidance:
147269
_post_assignment_guidance(bot, request, reviewer)
270+
bot.collect_touched_item(issue_number)
271+
if isinstance(review_data, dict):
272+
diagnostic_changed = _clear_assignment_marker(bot, review_data, issue_number, phase="assignment_add_write") or diagnostic_changed
273+
diagnostic_changed = _clear_assignment_marker(bot, review_data, issue_number, phase="assignment_remove_write") or diagnostic_changed
274+
diagnostic_changed = _clear_assignment_marker(bot, review_data, issue_number, phase="assignment_confirm_read") or diagnostic_changed
148275
return {
149276
"confirmed": True,
150277
"reviewer": reviewer,
151278
"current_assignees": live_before,
152279
"final_assignees": final_assignees,
153280
"assignment_attempt": assignment_attempt or _success_attempt(bot),
154281
"removal_attempts": removal_attempts,
282+
"diagnostic_changed": diagnostic_changed,
155283
}
156284
cleared = False
157-
if len(final_assignees) != 1:
285+
if len(final_assignees) != 1 or (
286+
len(final_assignees) == 1
287+
and isinstance(stored_reviewer, str)
288+
and final_normalized[0] != stored_reviewer.lower()
289+
):
158290
cleared = clear_current_reviewer(state, issue_number)
291+
if cleared:
292+
bot.collect_touched_item(issue_number)
293+
if isinstance(review_data, dict):
294+
diagnostic_changed = _store_assignment_marker(
295+
bot,
296+
review_data,
297+
issue_number,
298+
phase="assignment_confirm_read",
299+
marker=_assignment_authority_mismatch_marker(
300+
bot,
301+
live_assignees=final_assignees,
302+
reason="final_assignee_mismatch",
303+
),
304+
) or diagnostic_changed
159305
failure_comment = None
160306
if assignment_attempt is not None and not assignment_attempt.success:
161307
failure_comment = get_assignment_failure_comment(
@@ -174,6 +320,7 @@ def confirm_reviewer_assignment(
174320
"removal_attempts": removal_attempts,
175321
"failure_comment": failure_comment,
176322
"cleared_current_reviewer": cleared,
323+
"diagnostic_changed": diagnostic_changed,
177324
}
178325

179326

@@ -186,44 +333,100 @@ def confirm_reviewer_release(
186333
reposition_reviewer: bool = False,
187334
) -> dict[str, object]:
188335
issue_number = request.issue_number
189-
live_before = bot.github.get_issue_assignees(issue_number)
336+
review_data = ensure_review_entry(state, issue_number, create=True)
337+
stored_reviewer = review_data.get("current_reviewer") if isinstance(review_data, dict) else None
338+
review_data, live_before, _, diagnostic_changed = _read_live_assignees(
339+
bot,
340+
state,
341+
issue_number,
342+
is_pull_request=request.is_pull_request,
343+
)
190344
if live_before is None:
191-
return {"confirmed": False, "reason": "assignees_unavailable"}
345+
return {
346+
"confirmed": False,
347+
"reason": "assignees_unavailable",
348+
"diagnostic_changed": diagnostic_changed,
349+
}
192350
removal_attempt = None
193351
if reviewer.lower() in _normalize_logins(live_before):
194352
removal_attempt = _remove_live_assignee(bot, request, issue_number, reviewer)
195353
if not removal_attempt.success:
354+
if isinstance(review_data, dict):
355+
diagnostic_changed = _store_assignment_marker(
356+
bot,
357+
review_data,
358+
issue_number,
359+
phase="assignment_remove_write",
360+
marker=_assignment_attempt_marker(bot, phase="assignment_remove_write", attempt=removal_attempt),
361+
) or diagnostic_changed
196362
return {
197363
"confirmed": False,
198364
"reason": "remove_failed",
199365
"current_assignees": live_before,
200366
"removal_attempt": removal_attempt,
367+
"diagnostic_changed": diagnostic_changed,
201368
}
202-
final_assignees = bot.github.get_issue_assignees(issue_number)
369+
review_data, final_assignees, _, marker_changed = _read_live_assignees(
370+
bot,
371+
state,
372+
issue_number,
373+
is_pull_request=request.is_pull_request,
374+
)
375+
diagnostic_changed = marker_changed or diagnostic_changed
203376
if final_assignees is None:
204377
return {
205378
"confirmed": False,
206379
"reason": "final_assignees_unknown",
207380
"current_assignees": live_before,
208381
"removal_attempt": removal_attempt,
382+
"diagnostic_changed": diagnostic_changed,
209383
}
210384
if final_assignees:
385+
cleared = False
386+
if len(final_assignees) != 1 or (
387+
len(final_assignees) == 1
388+
and isinstance(stored_reviewer, str)
389+
and final_assignees[0].lower() != stored_reviewer.lower()
390+
):
391+
cleared = clear_current_reviewer(state, issue_number)
392+
if cleared:
393+
bot.collect_touched_item(issue_number)
394+
if isinstance(review_data, dict):
395+
diagnostic_changed = _store_assignment_marker(
396+
bot,
397+
review_data,
398+
issue_number,
399+
phase="assignment_confirm_read",
400+
marker=_assignment_authority_mismatch_marker(
401+
bot,
402+
live_assignees=final_assignees,
403+
reason="final_assignee_mismatch",
404+
),
405+
) or diagnostic_changed
211406
return {
212407
"confirmed": False,
213408
"reason": "final_assignee_mismatch",
214409
"current_assignees": live_before,
215410
"final_assignees": final_assignees,
216411
"removal_attempt": removal_attempt,
412+
"cleared_current_reviewer": cleared,
413+
"diagnostic_changed": diagnostic_changed,
217414
}
218415
cleared = clear_current_reviewer(state, issue_number)
416+
if cleared:
417+
bot.collect_touched_item(issue_number)
219418
if reposition_reviewer:
220419
bot.adapters.queue.reposition_member_as_next(state, reviewer)
420+
if isinstance(review_data, dict):
421+
diagnostic_changed = _clear_assignment_marker(bot, review_data, issue_number, phase="assignment_remove_write") or diagnostic_changed
422+
diagnostic_changed = _clear_assignment_marker(bot, review_data, issue_number, phase="assignment_confirm_read") or diagnostic_changed
221423
return {
222424
"confirmed": True,
223425
"current_assignees": live_before,
224426
"final_assignees": final_assignees,
225427
"removal_attempt": removal_attempt or _success_attempt(bot, status_code=204),
226428
"cleared_current_reviewer": cleared,
429+
"diagnostic_changed": diagnostic_changed,
227430
}
228431

229432

scripts/reviewer_bot_lib/bootstrap_runtime.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,19 @@ def remove_label(self, issue_number, label):
8383
def ensure_label_exists(self, label, *, color=None, description=None):
8484
return github_api.ensure_label_exists(self._runtime_getter(), label, color=color, description=description)
8585

86-
def get_issue_assignees(self, issue_number):
87-
return github_api.get_issue_assignees(self._runtime_getter(), issue_number)
86+
def get_issue_assignees(self, issue_number, *, is_pull_request=None):
87+
return github_api.get_issue_assignees(
88+
self._runtime_getter(),
89+
issue_number,
90+
is_pull_request=is_pull_request,
91+
)
92+
93+
def get_issue_assignees_result(self, issue_number, *, is_pull_request=None):
94+
return github_api.get_issue_assignees_result(
95+
self._runtime_getter(),
96+
issue_number,
97+
is_pull_request=is_pull_request,
98+
)
8899

89100
def request_pr_reviewer_assignment(self, issue_number, username):
90101
return github_api.request_pr_reviewer_assignment(self._runtime_getter(), issue_number, username)

0 commit comments

Comments
 (0)