Skip to content

Commit 7e21db1

Browse files
committed
fix: guard manual reviewer cleanup
1 parent d6d224c commit 7e21db1

10 files changed

Lines changed: 153 additions & 51 deletions

File tree

scripts/reviewer_bot_lib/app.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,11 @@ def execute_run(bot: AppExecutionRuntime, context: EventContext) -> ExecutionRes
247247
state_changed = bot.handlers.handle_comment_event(state)
248248

249249
elif event_name == "workflow_dispatch":
250-
state_changed = bot.handlers.handle_manual_dispatch(state)
250+
if context.manual_action == "check-overdue":
251+
schedule_result = bot.handlers.handle_scheduled_check_result(state)
252+
state_changed = schedule_result.state_changed
253+
else:
254+
state_changed = bot.handlers.handle_manual_dispatch(state)
251255

252256
elif event_name == "schedule":
253257
schedule_result = bot.handlers.handle_scheduled_check_result(state)
@@ -295,7 +299,7 @@ def execute_run(bot: AppExecutionRuntime, context: EventContext) -> ExecutionRes
295299
"Acquire lock before mutating state."
296300
)
297301

298-
if event_name == "schedule":
302+
if schedule_result is not None:
299303
current_active_reviews = state.get("active_reviews")
300304
current_active_reviews_count = (
301305
len(current_active_reviews) if isinstance(current_active_reviews, dict) else 0
@@ -316,7 +320,7 @@ def execute_run(bot: AppExecutionRuntime, context: EventContext) -> ExecutionRes
316320
and not allow_closed_cleanup_empty
317321
):
318322
raise RuntimeError(
319-
"STATE_GUARD_BLOCKED_EMPTY_ACTIVE_REVIEWS: refusing to persist schedule "
323+
"STATE_GUARD_BLOCKED_EMPTY_ACTIVE_REVIEWS: refusing to persist maintenance "
320324
f"state update that drops active_reviews from {loaded_active_reviews_count} "
321325
"to 0. Set ALLOW_EMPTY_ACTIVE_REVIEWS_WRITE=true to override."
322326
)

scripts/reviewer_bot_lib/deferred_gap_bookkeeping.py

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,14 +122,18 @@ def _ensure_source_event_key(review_data: dict, source_event_key: str, payload:
122122
deferred_gaps[source_event_key] = payload
123123

124124

125-
def _clear_source_event_key(review_data: dict, source_event_key: str) -> bool:
125+
def clear_deferred_gap(review_data: dict, source_event_key: str) -> bool:
126126
deferred_gaps = _deferred_gaps(review_data)
127127
if source_event_key in deferred_gaps:
128128
deferred_gaps.pop(source_event_key, None)
129129
return True
130130
return False
131131

132132

133+
def _clear_source_event_key(review_data: dict, source_event_key: str) -> bool:
134+
return clear_deferred_gap(review_data, source_event_key)
135+
136+
133137
def update_deferred_gap_fields(review_data: dict, source_event_key: str, fields: dict) -> bool:
134138
deferred_gaps = _deferred_gaps(review_data)
135139
existing = deferred_gaps.get(source_event_key)
@@ -144,7 +148,7 @@ def _update_deferred_gap_fields(review_data: dict, source_event_key: str, fields
144148
return update_deferred_gap_fields(review_data, source_event_key, fields)
145149

146150

147-
def _mark_reconciled_source_event(
151+
def mark_reconciled_source_event(
148152
review_data: dict,
149153
source_event_key: str,
150154
*,
@@ -166,6 +170,19 @@ def _mark_reconciled_source_event(
166170
return True
167171

168172

173+
def _mark_reconciled_source_event(
174+
review_data: dict,
175+
source_event_key: str,
176+
*,
177+
reconciled_at: str | None = None,
178+
) -> bool:
179+
return mark_reconciled_source_event(
180+
review_data,
181+
source_event_key,
182+
reconciled_at=reconciled_at,
183+
)
184+
185+
169186
def was_reconciled_source_event(review_data: dict, source_event_key: str) -> bool:
170187
return _is_valid_reconciled_source_event(
171188
_reconciled_source_events(review_data).get(source_event_key),
@@ -182,7 +199,7 @@ def _payload_or_existing(payload: dict, existing: dict, key: str):
182199
return existing.get(key) if value is None else value
183200

184201

185-
def _update_deferred_gap(
202+
def record_deferred_gap_diagnostic(
186203
bot,
187204
review_data: dict,
188205
payload: dict,
@@ -220,3 +237,22 @@ def _update_deferred_gap(
220237
changed = previous != existing
221238
deferred_gaps[source_event_key] = existing
222239
return changed
240+
241+
242+
def _update_deferred_gap(
243+
bot,
244+
review_data: dict,
245+
payload: dict,
246+
reason: str,
247+
diagnostic_summary: str,
248+
*,
249+
failure_kind: str | None = None,
250+
) -> bool:
251+
return record_deferred_gap_diagnostic(
252+
bot,
253+
review_data,
254+
payload,
255+
reason,
256+
diagnostic_summary,
257+
failure_kind=failure_kind,
258+
)

scripts/reviewer_bot_lib/reconcile.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,7 @@ def replay_request(comment_context: LiveCommentReplayContext | None = None, *, c
306306
)
307307

308308
def record_artifact_invalid(problem: InvalidEventInput) -> bool:
309-
return gap_bookkeeping._update_deferred_gap(
309+
return gap_bookkeeping.record_deferred_gap_diagnostic(
310310
bot,
311311
review_data,
312312
payload,
@@ -334,7 +334,7 @@ def record_artifact_invalid(problem: InvalidEventInput) -> bool:
334334
changed = record_conversation_freshness(bot, state, replay_request())
335335
except InvalidEventInput as exc:
336336
return record_artifact_invalid(exc)
337-
gap_changed = gap_bookkeeping._update_deferred_gap(
337+
gap_changed = gap_bookkeeping.record_deferred_gap_diagnostic(
338338
bot,
339339
review_data,
340340
payload,
@@ -370,7 +370,7 @@ def record_artifact_invalid(problem: InvalidEventInput) -> bool:
370370
changed = record_conversation_freshness(bot, state, replay_request(comment_context, comment_body=live_body))
371371
except InvalidEventInput as exc:
372372
return record_artifact_invalid(exc)
373-
gap_changed = gap_bookkeeping._update_deferred_gap(
373+
gap_changed = gap_bookkeeping.record_deferred_gap_diagnostic(
374374
bot,
375375
review_data,
376376
payload,
@@ -396,7 +396,7 @@ def record_artifact_invalid(problem: InvalidEventInput) -> bool:
396396
except InvalidEventInput as exc:
397397
return record_artifact_invalid(exc)
398398
if decision.failed_closed_reason is not None:
399-
gap_changed = gap_bookkeeping._update_deferred_gap(
399+
gap_changed = gap_bookkeeping.record_deferred_gap_diagnostic(
400400
bot,
401401
review_data,
402402
payload,
@@ -418,14 +418,14 @@ def record_artifact_invalid(problem: InvalidEventInput) -> bool:
418418
return record_artifact_invalid(exc)
419419
reconciled_changed = False
420420
if decision.mark_reconciled:
421-
reconciled_changed = gap_bookkeeping._mark_reconciled_source_event(
421+
reconciled_changed = gap_bookkeeping.mark_reconciled_source_event(
422422
review_data,
423423
str(payload.get("source_event_key", "")),
424424
reconciled_at=_now_iso(bot),
425425
)
426426
gap_cleared_changed = False
427427
if decision.clear_gap:
428-
gap_cleared_changed = gap_bookkeeping._clear_source_event_key(review_data, str(payload.get("source_event_key", "")))
428+
gap_cleared_changed = gap_bookkeeping.clear_deferred_gap(review_data, str(payload.get("source_event_key", "")))
429429
return changed or reconciled_changed or gap_cleared_changed
430430

431431

@@ -507,12 +507,12 @@ def _handle_review_submitted_workflow_run(
507507
state_changed = True
508508
if _record_review_rebuild(bot, state, pr_number, review_data):
509509
state_changed = True
510-
reconciled_changed = gap_bookkeeping._mark_reconciled_source_event(
510+
reconciled_changed = gap_bookkeeping.mark_reconciled_source_event(
511511
review_data,
512512
source_event_key,
513513
reconciled_at=_now_iso(bot),
514514
)
515-
gap_cleared_changed = gap_bookkeeping._clear_source_event_key(review_data, source_event_key)
515+
gap_cleared_changed = gap_bookkeeping.clear_deferred_gap(review_data, source_event_key)
516516
return state_changed or reconciled_changed or gap_cleared_changed
517517

518518

@@ -530,7 +530,7 @@ def _handle_review_dismissed_workflow_run(
530530
source_event_key = context.source_event_key
531531
dismissal_time = _resolve_review_dismissal_time(bot, context, parsed_payload.raw_payload)
532532
if not dismissal_time.exact:
533-
return gap_bookkeeping._update_deferred_gap(
533+
return gap_bookkeeping.record_deferred_gap_diagnostic(
534534
bot,
535535
review_data,
536536
parsed_payload.raw_payload,
@@ -559,14 +559,14 @@ def _handle_review_dismissed_workflow_run(
559559
state_changed = _record_review_rebuild(bot, state, context.pr_number, review_data) or state_changed
560560
reconciled_changed = False
561561
if decision.mark_reconciled:
562-
reconciled_changed = gap_bookkeeping._mark_reconciled_source_event(
562+
reconciled_changed = gap_bookkeeping.mark_reconciled_source_event(
563563
review_data,
564564
source_event_key,
565565
reconciled_at=_now_iso(bot),
566566
)
567567
gap_cleared_changed = False
568568
if decision.clear_gap:
569-
gap_cleared_changed = gap_bookkeeping._clear_source_event_key(review_data, source_event_key)
569+
gap_cleared_changed = gap_bookkeeping.clear_deferred_gap(review_data, source_event_key)
570570
return state_changed or reconciled_changed or gap_cleared_changed
571571

572572

@@ -750,7 +750,7 @@ def _build_result(state_changed: bool, pr_number: int) -> WorkflowRunHandlerResu
750750
except RuntimeError as exc:
751751
bot.collect_touched_item(pr_number)
752752
failure_kind = exc.failure_kind if isinstance(exc, ReconcileReadError) else None
753-
gap_changed = gap_bookkeeping._update_deferred_gap(
753+
gap_changed = gap_bookkeeping.record_deferred_gap_diagnostic(
754754
bot,
755755
review_data,
756756
payload,

scripts/reviewer_bot_lib/sweeper.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ def _purge_bot_authored_comment_gap(bot, review_data: dict, source_event_key: st
263263
return False
264264
if source_event_key not in gap_bookkeeping.list_deferred_gap_keys(review_data):
265265
return False
266-
return gap_bookkeeping._clear_source_event_key(review_data, source_event_key)
266+
return gap_bookkeeping.clear_deferred_gap(review_data, source_event_key)
267267

268268

269269
def _maybe_fetch_single_candidate_run_detail(bot, run_correlation: dict, artifact_correlation: dict | None) -> dict | None:
@@ -306,8 +306,8 @@ def _repair_visible_review_gap(bot, review_data: dict, issue_number: int, source
306306
)[0] or changed
307307
record_reviewer_activity(review_data, submitted_at)
308308
completion, _ = rebuild_pr_approval_state(bot, issue_number, review_data)
309-
reconciled_changed = gap_bookkeeping._mark_reconciled_source_event(review_data, source_event_key)
310-
gap_cleared_changed = gap_bookkeeping._clear_source_event_key(review_data, source_event_key)
309+
reconciled_changed = gap_bookkeeping.mark_reconciled_source_event(review_data, source_event_key)
310+
gap_cleared_changed = gap_bookkeeping.clear_deferred_gap(review_data, source_event_key)
311311
return changed or completion is not None or reconciled_changed or gap_cleared_changed
312312

313313

@@ -456,7 +456,7 @@ def _record_gap_diagnostics(
456456
reason: str,
457457
diagnostic_reason: str,
458458
) -> None:
459-
gap_bookkeeping._update_deferred_gap(
459+
gap_bookkeeping.record_deferred_gap_diagnostic(
460460
bot,
461461
review_data,
462462
{

tests/contract/reviewer_bot/test_support_layer_ownership.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -113,10 +113,10 @@ def test_f1a_support_layer_inventory_fixture_records_candidate_classifications_a
113113
assert symbols["scripts.reviewer_bot_core.live_review_support.normalize_reviews_with_parsed_timestamps"]["classification"] == "retained support owner"
114114
assert symbols["scripts.reviewer_bot_core.live_review_support.filter_current_head_reviews_for_cycle"]["classification"] == "retained support owner"
115115
assert symbols["scripts.reviewer_bot_core.live_review_support.collect_permission_statuses"]["classification"] == "retained support owner"
116-
assert symbols["scripts.reviewer_bot_lib.deferred_gap_bookkeeping._clear_source_event_key"]["classification"] == "retained support owner"
117-
assert symbols["scripts.reviewer_bot_lib.deferred_gap_bookkeeping._mark_reconciled_source_event"]["classification"] == "retained support owner"
118-
assert symbols["scripts.reviewer_bot_lib.deferred_gap_bookkeeping._update_deferred_gap"]["classification"] == "retained support owner"
119-
assert symbols["scripts.reviewer_bot_lib.deferred_gap_bookkeeping._was_reconciled_source_event"]["classification"] == "retained support owner"
116+
assert symbols["scripts.reviewer_bot_lib.deferred_gap_bookkeeping.clear_deferred_gap"]["classification"] == "retained support owner"
117+
assert symbols["scripts.reviewer_bot_lib.deferred_gap_bookkeeping.mark_reconciled_source_event"]["classification"] == "retained support owner"
118+
assert symbols["scripts.reviewer_bot_lib.deferred_gap_bookkeeping.record_deferred_gap_diagnostic"]["classification"] == "retained support owner"
119+
assert symbols["scripts.reviewer_bot_lib.deferred_gap_bookkeeping.was_reconciled_source_event"]["classification"] == "retained support owner"
120120
assert symbols["scripts.reviewer_bot_lib.reviews.list_open_items_with_status_labels"]["classification"] == "retained support behavior"
121121
assert symbols["scripts.reviewer_bot_lib.reviews.rebuild_pr_approval_state"]["classification"] == "retained final surface"
122122
assert symbols["scripts.reviewer_bot_lib.sweeper.sweep_deferred_gaps"]["classification"] == "retained final surface"
@@ -135,10 +135,10 @@ def test_f1a_support_layer_inventory_records_transitional_importer_examples_with
135135
"scripts.reviewer_bot_core.live_review_support.normalize_reviews_with_parsed_timestamps",
136136
"scripts.reviewer_bot_core.live_review_support.filter_current_head_reviews_for_cycle",
137137
"scripts.reviewer_bot_core.live_review_support.collect_permission_statuses",
138-
"scripts.reviewer_bot_lib.deferred_gap_bookkeeping._clear_source_event_key",
139-
"scripts.reviewer_bot_lib.deferred_gap_bookkeeping._mark_reconciled_source_event",
140-
"scripts.reviewer_bot_lib.deferred_gap_bookkeeping._update_deferred_gap",
141-
"scripts.reviewer_bot_lib.deferred_gap_bookkeeping._was_reconciled_source_event",
138+
"scripts.reviewer_bot_lib.deferred_gap_bookkeeping.clear_deferred_gap",
139+
"scripts.reviewer_bot_lib.deferred_gap_bookkeeping.mark_reconciled_source_event",
140+
"scripts.reviewer_bot_lib.deferred_gap_bookkeeping.record_deferred_gap_diagnostic",
141+
"scripts.reviewer_bot_lib.deferred_gap_bookkeeping.was_reconciled_source_event",
142142
"scripts.reviewer_bot_lib.reviews.list_open_items_with_status_labels",
143143
"scripts.reviewer_bot_lib.reviews.rebuild_pr_approval_state",
144144
"scripts.reviewer_bot_lib.reviews.rebuild_pr_approval_state_result",

tests/fixtures/equivalence/support_layer/symbol_inventory.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@
155155
]
156156
},
157157
{
158-
"symbol": "scripts.reviewer_bot_lib.deferred_gap_bookkeeping._clear_source_event_key",
158+
"symbol": "scripts.reviewer_bot_lib.deferred_gap_bookkeeping.clear_deferred_gap",
159159
"classification": "retained support owner",
160160
"production_importers": [
161161
"scripts/reviewer_bot_lib/reconcile.py",
@@ -166,7 +166,7 @@
166166
]
167167
},
168168
{
169-
"symbol": "scripts.reviewer_bot_lib.deferred_gap_bookkeeping._mark_reconciled_source_event",
169+
"symbol": "scripts.reviewer_bot_lib.deferred_gap_bookkeeping.mark_reconciled_source_event",
170170
"classification": "retained support owner",
171171
"production_importers": [
172172
"scripts/reviewer_bot_lib/reconcile.py",
@@ -177,7 +177,7 @@
177177
]
178178
},
179179
{
180-
"symbol": "scripts.reviewer_bot_lib.deferred_gap_bookkeeping._update_deferred_gap",
180+
"symbol": "scripts.reviewer_bot_lib.deferred_gap_bookkeeping.record_deferred_gap_diagnostic",
181181
"classification": "retained support owner",
182182
"production_importers": [
183183
"scripts/reviewer_bot_lib/reconcile.py",
@@ -188,7 +188,7 @@
188188
]
189189
},
190190
{
191-
"symbol": "scripts.reviewer_bot_lib.deferred_gap_bookkeeping._was_reconciled_source_event",
191+
"symbol": "scripts.reviewer_bot_lib.deferred_gap_bookkeeping.was_reconciled_source_event",
192192
"classification": "retained support owner",
193193
"production_importers": [
194194
"scripts/reviewer_bot_lib/sweeper.py"

tests/integration/reviewer_bot/test_app_schedule_bookkeeping.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def test_execute_run_schedule_sweeper_bookkeeping_only_mutation_still_saves_stat
2525
save_calls = []
2626

2727
def fake_sweep(bot, current):
28-
deferred_gap_bookkeeping._mark_reconciled_source_event(
28+
deferred_gap_bookkeeping.mark_reconciled_source_event(
2929
current["active_reviews"]["42"],
3030
"pull_request_review:500",
3131
reconciled_at="2026-03-18T00:00:00+00:00",
@@ -381,3 +381,65 @@ def fake_schedule_result(bot, current):
381381

382382
assert result.exit_code == 1
383383
assert save_called["value"] is False
384+
385+
386+
def test_execute_run_manual_check_overdue_uses_closed_cleanup_rows_for_empty_guard(monkeypatch):
387+
harness = AppHarness(monkeypatch)
388+
harness.set_event(EVENT_NAME="workflow_dispatch", EVENT_ACTION="", MANUAL_ACTION="check-overdue")
389+
state = make_state()
390+
state["status_projection_epoch"] = None
391+
review = review_state.ensure_review_entry(state, 42, create=True)
392+
assert review is not None
393+
review["current_reviewer"] = "alice"
394+
saved_active_reviews = []
395+
synced = []
396+
397+
def fake_schedule_result(current):
398+
current["active_reviews"].clear()
399+
return maintenance.ScheduleHandlerResult(
400+
True,
401+
[42],
402+
closed_cleanup_removed_items=(42,),
403+
)
404+
405+
harness.stub_lock(acquire=lambda: None, release=lambda: True)
406+
harness.stub_load_state(lambda *, fail_on_unavailable=False: state)
407+
harness.stub_pass_until(lambda current: (current, []))
408+
harness.stub_sync_members(lambda current: (current, []))
409+
harness.stub_handler("handle_scheduled_check_result", fake_schedule_result)
410+
monkeypatch.setattr(app, "collect_status_projection_repair_items", lambda bot, current: [99])
411+
harness.stub_save_state(lambda current: saved_active_reviews.append(dict(current["active_reviews"])) or True)
412+
harness.stub_sync_status_labels(lambda current, issue_numbers: synced.append(list(issue_numbers)) or True)
413+
414+
result = harness.run_execute()
415+
416+
assert result.exit_code == 0
417+
assert saved_active_reviews == [{}, {}]
418+
assert synced == [[42, 99]]
419+
420+
421+
def test_execute_run_manual_check_overdue_empty_guard_blocks_unowned_full_drop(monkeypatch):
422+
harness = AppHarness(monkeypatch)
423+
harness.set_event(EVENT_NAME="workflow_dispatch", EVENT_ACTION="", MANUAL_ACTION="check-overdue")
424+
state = make_state()
425+
review = review_state.ensure_review_entry(state, 42, create=True)
426+
assert review is not None
427+
review["current_reviewer"] = "alice"
428+
save_called = {"value": False}
429+
430+
def fake_schedule_result(current):
431+
current["active_reviews"].clear()
432+
return maintenance.ScheduleHandlerResult(True, [42])
433+
434+
harness.stub_lock(acquire=lambda: None, release=lambda: True)
435+
harness.stub_load_state(lambda *, fail_on_unavailable=False: state)
436+
harness.stub_pass_until(lambda current: (current, []))
437+
harness.stub_sync_members(lambda current: (current, []))
438+
harness.stub_handler("handle_scheduled_check_result", fake_schedule_result)
439+
harness.stub_save_state(lambda current: save_called.__setitem__("value", True) or True)
440+
harness.stub_sync_status_labels(lambda current, issue_numbers: True)
441+
442+
result = harness.run_execute()
443+
444+
assert result.exit_code == 1
445+
assert save_called["value"] is False

0 commit comments

Comments
 (0)