Skip to content

Commit 1670e2c

Browse files
committed
fix: close reviewer cleanup gaps
1 parent bb68dcf commit 1670e2c

12 files changed

Lines changed: 295 additions & 30 deletions

scripts/reviewer_bot_lib/app.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ def execute_run(bot: AppExecutionRuntime, context: EventContext) -> ExecutionRes
150150
sync_changes: list[str] = []
151151
restored: list[str] = []
152152
loaded_active_reviews_count = 0
153+
loaded_active_review_numbers: set[int] = set()
153154
touched_items: list[int] = []
154155
projection_failure: RuntimeError | None = None
155156
loaded_epoch: str | None = None
@@ -186,6 +187,11 @@ def execute_run(bot: AppExecutionRuntime, context: EventContext) -> ExecutionRes
186187
active_reviews = state.get("active_reviews")
187188
if isinstance(active_reviews, dict):
188189
loaded_active_reviews_count = len(active_reviews)
190+
loaded_active_review_numbers = {
191+
int(issue_key)
192+
for issue_key in active_reviews
193+
if isinstance(issue_key, str) and issue_key.isdigit()
194+
}
189195
loaded_epoch = state.get("freshness_runtime_epoch") if isinstance(state.get("freshness_runtime_epoch"), str) else None
190196

191197
if lock_required:
@@ -297,10 +303,16 @@ def execute_run(bot: AppExecutionRuntime, context: EventContext) -> ExecutionRes
297303
allow_empty_override = (
298304
bot.get_config_value("ALLOW_EMPTY_ACTIVE_REVIEWS_WRITE").strip().lower() == "true"
299305
)
306+
allow_closed_cleanup_empty = (
307+
loaded_active_reviews_count == len(loaded_active_review_numbers)
308+
and bool(loaded_active_review_numbers)
309+
and set(touched_items) == loaded_active_review_numbers
310+
)
300311
if (
301312
loaded_active_reviews_count > 0
302313
and current_active_reviews_count == 0
303314
and not allow_empty_override
315+
and not allow_closed_cleanup_empty
304316
):
305317
raise RuntimeError(
306318
"STATE_GUARD_BLOCKED_EMPTY_ACTIVE_REVIEWS: refusing to persist schedule "

scripts/reviewer_bot_lib/comment_routing.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,6 @@ def handle_comment_event(
182182
issue_number = comment_request.issue_number
183183
if not issue_number:
184184
return False
185-
bot.collect_touched_item(issue_number)
186185
route = _route_issue_comment_trust(
187186
bot,
188187
comment_request,
@@ -200,11 +199,13 @@ def handle_comment_event(
200199
issue_state=comment_request.issue_state,
201200
)
202201
return False
202+
bot.collect_touched_item(issue_number)
203203
return _process_comment_event(bot, state, comment_request)
204204
if route == PrCommentRouterOutcome.TRUSTED_DIRECT:
205205
if comment_request.issue_state != "open":
206206
return False
207207
if not _require_v18_for_pr(bot, state, comment_request, "pr_trusted_direct_comment"):
208208
return False
209+
bot.collect_touched_item(issue_number)
209210
return _process_comment_event(bot, state, comment_request)
210211
raise RuntimeError("Deferred PR comment events must not mutate directly in trusted workflows")

scripts/reviewer_bot_lib/deferred_gap_bookkeeping.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,20 @@ def get_observer_discovery_watermarks(review_data: dict) -> dict:
5353
return _observer_discovery_watermarks(review_data)
5454

5555

56+
def _deferred_gap_keys(review_data: dict) -> list[str]:
57+
return list(_deferred_gaps(review_data))
58+
59+
60+
def _get_deferred_gap(review_data: dict, source_event_key: str) -> dict:
61+
gap = _deferred_gaps(review_data).get(source_event_key)
62+
return gap if isinstance(gap, dict) else {}
63+
64+
65+
def _deferred_gap_reason(review_data: dict, source_event_key: str) -> str | None:
66+
reason = _get_deferred_gap(review_data, source_event_key).get("reason")
67+
return reason if isinstance(reason, str) else None
68+
69+
5670
def _now_iso(bot) -> str:
5771
return bot.clock.now().isoformat()
5872

@@ -77,6 +91,16 @@ def _clear_source_event_key(review_data: dict, source_event_key: str) -> bool:
7791
return False
7892

7993

94+
def _update_deferred_gap_fields(review_data: dict, source_event_key: str, fields: dict) -> bool:
95+
deferred_gaps = _deferred_gaps(review_data)
96+
existing = deferred_gaps.get(source_event_key)
97+
if not isinstance(existing, dict):
98+
return False
99+
previous = deepcopy(existing)
100+
existing.update(fields)
101+
return previous != existing
102+
103+
80104
def _mark_reconciled_source_event(
81105
review_data: dict,
82106
source_event_key: str,
@@ -100,7 +124,13 @@ def _mark_reconciled_source_event(
100124

101125

102126
def _was_reconciled_source_event(review_data: dict, source_event_key: str) -> bool:
103-
return source_event_key in _reconciled_source_events(review_data)
127+
existing = _reconciled_source_events(review_data).get(source_event_key)
128+
if not isinstance(existing, dict):
129+
return False
130+
if existing.get("source_event_key") != source_event_key:
131+
return False
132+
reconciled_at = existing.get("reconciled_at")
133+
return isinstance(reconciled_at, str) and bool(reconciled_at.strip())
104134

105135

106136
def _payload_or_existing(payload: dict, existing: dict, key: str):

scripts/reviewer_bot_lib/lifecycle.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,8 +558,20 @@ def handle_closed_event(bot, state: dict) -> bool:
558558
if not issue_number:
559559
return False
560560
bot.collect_touched_item(issue_number)
561+
return remove_closed_review_entry(bot, state, issue_number, reason="closed_event")
562+
563+
564+
def remove_closed_review_entry(bot, state: dict, issue_number: int, *, reason: str) -> bool:
561565
issue_key = str(issue_number)
562566
if isinstance(state.get("active_reviews"), dict) and issue_key in state["active_reviews"]:
563567
del state["active_reviews"][issue_key]
568+
_log(
569+
bot,
570+
"info",
571+
f"Removed active review row for closed item #{issue_number}",
572+
issue_number=issue_number,
573+
reason=reason,
574+
)
575+
bot.collect_touched_item(issue_number)
564576
return True
565577
return False

scripts/reviewer_bot_lib/maintenance_schedule.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
44

55
from dataclasses import dataclass
66

7-
from .lifecycle import handle_transition_notice, maybe_record_head_observation_repair
7+
from .lifecycle import (
8+
handle_transition_notice,
9+
maybe_record_head_observation_repair,
10+
remove_closed_review_entry,
11+
)
812
from .overdue import (
913
backfill_transition_notice_if_present,
1014
check_overdue_reviews,
@@ -108,13 +112,16 @@ def _run_tracked_pr_repairs(bot, state: dict) -> bool:
108112
active_reviews = state.get("active_reviews")
109113
if not isinstance(active_reviews, dict):
110114
return False
111-
for issue_key, review_data in active_reviews.items():
115+
for issue_key, review_data in list(active_reviews.items()):
112116
if not isinstance(review_data, dict) or not review_data.get("current_reviewer"):
113117
continue
114118
issue_number = int(issue_key)
115119
issue_snapshot = bot.github.get_issue_or_pr_snapshot(issue_number)
116120
if not isinstance(issue_snapshot, dict) or not isinstance(issue_snapshot.get("pull_request"), dict):
117121
continue
122+
if str(issue_snapshot.get("state", "")).lower() == "closed":
123+
changed = remove_closed_review_entry(bot, state, issue_number, reason="scheduled_closed_snapshot") or changed
124+
continue
118125
changed = _run_tracked_pr_repair(bot, issue_number, review_data) or changed
119126
return changed
120127

scripts/reviewer_bot_lib/sweeper.py

Lines changed: 16 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ def _diagnose_deferred_event(
121121
source_event_kind: str,
122122
workflow_runs: list[dict] | None,
123123
) -> None:
124-
existing_gap = gap_bookkeeping._deferred_gaps(review_data).get(source_event_key, {})
124+
existing_gap = gap_bookkeeping._get_deferred_gap(review_data, source_event_key)
125125
run_correlation = deferred_gap_diagnosis.correlate_candidate_observer_runs(
126126
source_event_key,
127127
source_event_kind=source_event_kind,
@@ -275,11 +275,9 @@ def _purge_bot_authored_comment_gap(bot, review_data: dict, source_event_key: st
275275
live_comment = _fetch_live_issue_comment(bot, comment_id)
276276
if not isinstance(live_comment, dict) or not _is_automation_comment(live_comment):
277277
return False
278-
deferred_gaps = gap_bookkeeping._deferred_gaps(review_data)
279-
if source_event_key not in deferred_gaps:
278+
if source_event_key not in gap_bookkeeping._deferred_gap_keys(review_data):
280279
return False
281-
deferred_gaps.pop(source_event_key, None)
282-
return True
280+
return gap_bookkeeping._clear_source_event_key(review_data, source_event_key)
283281

284282

285283
def _maybe_fetch_single_candidate_run_detail(bot, run_correlation: dict, artifact_correlation: dict | None) -> dict | None:
@@ -488,28 +486,28 @@ def _record_gap_diagnostics(
488486
reason,
489487
f"Trusted sweeper diagnostics for {source_event_key}: {diagnostic_reason}. See {bot.REVIEW_FRESHNESS_RUNBOOK_PATH}.",
490488
)
491-
gap = gap_bookkeeping._deferred_gaps(review_data)[source_event_key]
492-
gap["full_scan_complete"] = bool(run_correlation.get("full_scan_complete"))
493-
gap["later_recheck_complete"] = bool(run_correlation.get("later_recheck_complete"))
494-
gap["correlated_run_found"] = bool(run_correlation.get("correlated_run"))
489+
gap_fields = {
490+
"full_scan_complete": bool(run_correlation.get("full_scan_complete")),
491+
"later_recheck_complete": bool(run_correlation.get("later_recheck_complete")),
492+
"correlated_run_found": bool(run_correlation.get("correlated_run")),
493+
}
495494
raw_candidate_run_ids = run_correlation.get("candidate_run_ids")
496495
if isinstance(raw_candidate_run_ids, list):
497-
gap["candidate_run_ids"] = raw_candidate_run_ids
496+
gap_fields["candidate_run_ids"] = raw_candidate_run_ids
498497
if isinstance(run_detail, dict):
499-
gap["run_created_at"] = run_detail.get("created_at")
498+
gap_fields["run_created_at"] = run_detail.get("created_at")
500499
if isinstance(artifact_correlation, dict):
501500
prior_visibility = artifact_correlation.get("prior_visibility", {}).get(run_correlation.get("correlated_run"), {})
502501
if isinstance(prior_visibility, dict):
503-
gap.update(prior_visibility)
502+
gap_fields.update(prior_visibility)
503+
gap_bookkeeping._update_deferred_gap_fields(review_data, source_event_key, gap_fields)
504504

505505

506506
def _should_skip_discovered_key(bot, review_data: dict, source_event_key: str, channels: tuple[str, ...]) -> bool:
507507
if gap_bookkeeping._was_reconciled_source_event(review_data, source_event_key):
508508
return True
509-
deferred_gaps = gap_bookkeeping._deferred_gaps(review_data)
510-
if source_event_key in deferred_gaps:
511-
existing_gap = deferred_gaps.get(source_event_key)
512-
if isinstance(existing_gap, dict) and existing_gap.get("reason") in {
509+
if source_event_key in gap_bookkeeping._deferred_gap_keys(review_data):
510+
if gap_bookkeeping._deferred_gap_reason(review_data, source_event_key) in {
513511
"awaiting_observer_run",
514512
"awaiting_observer_approval",
515513
"observer_in_progress",
@@ -538,8 +536,7 @@ def sweep_deferred_gaps(bot, state: dict) -> bool:
538536
pull_request, _ = _read_api_payload(bot, f"pulls/{issue_number}")
539537
if not isinstance(pull_request, dict) or str(pull_request.get("state", "")).lower() != "open":
540538
continue
541-
deferred_gaps = gap_bookkeeping._deferred_gaps(review_data)
542-
for source_event_key in list(deferred_gaps):
539+
for source_event_key in gap_bookkeeping._deferred_gap_keys(review_data):
543540
if _purge_bot_authored_comment_gap(bot, review_data, source_event_key):
544541
changed = True
545542
discovered_comments, comments_complete = _discover_visible_comment_events(bot, issue_number, review_data)
@@ -572,7 +569,7 @@ def sweep_deferred_gaps(bot, state: dict) -> bool:
572569
submitted_at = discovered["source_created_at"]
573570
if _should_skip_discovered_key(bot, review_data, source_event_key, ("reviewer_review",)):
574571
continue
575-
existing_gap = gap_bookkeeping._deferred_gaps(review_data).get(source_event_key, {})
572+
existing_gap = gap_bookkeeping._get_deferred_gap(review_data, source_event_key)
576573
workflow_file = ".github/workflows/reviewer-bot-pr-review-submitted-observer.yml"
577574
workflow_runs = _fetch_workflow_runs_for_file(bot, workflow_file, "pull_request_review")
578575
run_correlation = deferred_gap_diagnosis.correlate_candidate_observer_runs(

tests/contract/reviewer_bot/test_workflow_files.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,15 @@ def test_pr_metadata_workflow_exports_raw_timestamp_boundary_fields():
9999
assert "EVENT_CREATED_AT: ${{ github.event.pull_request.updated_at }}" not in workflow_text
100100

101101

102+
def test_reconcile_workflow_permissions_cover_live_replay_reads():
103+
workflow = yaml.safe_load(Path(".github/workflows/reviewer-bot-reconcile.yml").read_text(encoding="utf-8"))
104+
permissions = workflow["jobs"]["reconcile"]["permissions"]
105+
106+
assert permissions["actions"] == "read"
107+
assert permissions["issues"] in {"read", "write"}
108+
assert permissions["pull-requests"] in {"read", "write"}
109+
110+
102111
@pytest.mark.parametrize(
103112
"workflow_path",
104113
[

tests/integration/reviewer_bot/test_app_closed_issue_cleanup.py

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,8 @@ def fake_load_state(*, fail_on_unavailable=False):
5353

5454
assert result.exit_code == 0
5555
assert save_calls == []
56-
assert len(sync_calls) == 1
57-
assert sync_calls[0][0] is initial_state
58-
assert sync_calls[0][1] == [42]
56+
assert sync_calls == []
57+
5958

6059
def test_execute_run_closed_issue_comment_without_entry_skips_save(monkeypatch):
6160
harness = AppHarness(monkeypatch)
@@ -91,7 +90,56 @@ def test_execute_run_closed_issue_comment_without_entry_skips_save(monkeypatch):
9190

9291
assert result.exit_code == 0
9392
assert save_called["value"] is False
94-
assert sync_calls == [[42]]
93+
assert sync_calls == []
94+
95+
96+
def test_execute_run_closed_pr_comment_safe_noop_does_not_save_or_project(monkeypatch):
97+
harness = AppHarness(monkeypatch)
98+
harness.set_event(
99+
EVENT_NAME="issue_comment",
100+
EVENT_ACTION="created",
101+
ISSUE_NUMBER=42,
102+
IS_PULL_REQUEST="true",
103+
ISSUE_STATE="closed",
104+
ISSUE_AUTHOR="dana",
105+
COMMENT_USER_TYPE="User",
106+
COMMENT_SENDER_TYPE="User",
107+
COMMENT_AUTHOR="alice",
108+
COMMENT_AUTHOR_ID=101,
109+
COMMENT_AUTHOR_ASSOCIATION="MEMBER",
110+
COMMENT_ID=100,
111+
COMMENT_CREATED_AT="2026-03-17T10:00:00Z",
112+
COMMENT_BODY="@guidelines-bot /queue",
113+
COMMENT_PERFORMED_VIA_GITHUB_APP="false",
114+
REVIEWER_BOT_ROUTE_OUTCOME="trusted_direct",
115+
REVIEWER_BOT_TRUST_CLASS="pr_trusted_direct",
116+
GITHUB_REPOSITORY="rustfoundation/safety-critical-rust-coding-guidelines",
117+
PR_HEAD_FULL_NAME="rustfoundation/safety-critical-rust-coding-guidelines",
118+
PR_AUTHOR="dana",
119+
GITHUB_RUN_ID="123",
120+
GITHUB_RUN_ATTEMPT="1",
121+
)
122+
123+
state = make_state()
124+
review = review_state.ensure_review_entry(state, 42, create=True)
125+
assert review is not None
126+
review["current_reviewer"] = "alice"
127+
save_called = {"value": False}
128+
sync_calls = []
129+
130+
harness.stub_lock(acquire=lambda: None, release=lambda: True)
131+
harness.stub_load_state(lambda *, fail_on_unavailable=False: state)
132+
harness.stub_pass_until(lambda current: (current, []))
133+
harness.stub_sync_members(lambda current: (current, []))
134+
harness.stub_save_state(lambda current: save_called.__setitem__("value", True) or True)
135+
harness.stub_sync_status_labels(lambda current, issue_numbers: sync_calls.append(list(issue_numbers)) or False)
136+
137+
result = harness.run_execute()
138+
139+
assert result.exit_code == 0
140+
assert state["active_reviews"]["42"] is review
141+
assert save_called["value"] is False
142+
assert sync_calls == []
95143

96144

97145
def test_execute_run_late_workflow_run_reconcile_missing_row_safe_noop(monkeypatch):

tests/integration/reviewer_bot/test_app_schedule_bookkeeping.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,3 +219,35 @@ def test_execute_run_schedule_warning_diagnostic_mutation_projects_touched_item(
219219
assert result.state_changed is True
220220
assert saved_states
221221
assert synced == [[42]]
222+
223+
224+
def test_execute_run_schedule_removes_closed_pr_rows_through_lifecycle_owner(monkeypatch):
225+
harness = AppHarness(monkeypatch)
226+
harness.set_event(EVENT_NAME="schedule", EVENT_ACTION="")
227+
state = make_state()
228+
review = review_state.ensure_review_entry(state, 42, create=True)
229+
assert review is not None
230+
review["current_reviewer"] = "alice"
231+
saved_active_reviews = []
232+
synced = []
233+
234+
harness.stub_lock(acquire=lambda: None, release=lambda: True)
235+
harness.stub_load_state(lambda *, fail_on_unavailable=False: state)
236+
harness.stub_pass_until(lambda current: (current, []))
237+
harness.stub_sync_members(lambda current: (current, []))
238+
harness.runtime.github.get_issue_or_pr_snapshot = lambda issue_number: {
239+
"number": issue_number,
240+
"state": "closed",
241+
"pull_request": {},
242+
"labels": [],
243+
}
244+
monkeypatch.setattr(maintenance_schedule, "sweep_deferred_gaps", lambda bot, current: False)
245+
monkeypatch.setattr(maintenance_schedule, "check_overdue_reviews", lambda bot, current: [])
246+
harness.stub_save_state(lambda current: saved_active_reviews.append(dict(current["active_reviews"])) or True)
247+
harness.stub_sync_status_labels(lambda current, issue_numbers: synced.append(list(issue_numbers)) or True)
248+
249+
result = harness.run_execute()
250+
251+
assert result.exit_code == 0
252+
assert saved_active_reviews == [{}]
253+
assert synced == [[42]]

0 commit comments

Comments
 (0)