diff --git a/.github/reviewer-bot-tests/test_main.py b/.github/reviewer-bot-tests/test_main.py index f4359c3e7..3e697437c 100644 --- a/.github/reviewer-bot-tests/test_main.py +++ b/.github/reviewer-bot-tests/test_main.py @@ -13,6 +13,7 @@ def make_state(): return { "schema_version": reviewer_bot.STATE_SCHEMA_VERSION, "freshness_runtime_epoch": reviewer_bot.FRESHNESS_RUNTIME_EPOCH_V18, + "status_projection_epoch": reviewer_bot.STATUS_PROJECTION_EPOCH, "last_updated": None, "current_index": 0, "queue": [ @@ -360,6 +361,111 @@ def test_main_schedule_reviewer_review_repair_marks_item_for_label_sync(monkeypa assert synced_issue_numbers == [42] +def test_main_schedule_status_projection_epoch_mismatch_triggers_label_repair_sweep(monkeypatch): + monkeypatch.setenv("EVENT_NAME", "schedule") + monkeypatch.setenv("EVENT_ACTION", "") + state = make_state() + state["status_projection_epoch"] = "status_projection_v1" + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + synced_issue_numbers = [] + saved_epochs = [] + + monkeypatch.setattr(reviewer_bot, "acquire_state_issue_lease_lock", lambda: None) + monkeypatch.setattr(reviewer_bot, "release_state_issue_lease_lock", lambda: True) + monkeypatch.setattr(reviewer_bot, "load_state", lambda *args, **kwargs: state) + monkeypatch.setattr(reviewer_bot, "process_pass_until_expirations", lambda current: (current, [])) + monkeypatch.setattr(reviewer_bot, "sync_members_with_queue", lambda current: (current, [])) + monkeypatch.setattr(reviewer_bot, "handle_scheduled_check", lambda current: False) + monkeypatch.setattr(reviewer_bot, "list_open_items_with_status_labels", lambda: [99]) + monkeypatch.setattr( + reviewer_bot, + "sync_status_labels_for_items", + lambda current, issue_numbers: synced_issue_numbers.extend(issue_numbers) or True, + ) + monkeypatch.setattr( + reviewer_bot, + "save_state", + lambda current: saved_epochs.append(current.get("status_projection_epoch")) or True, + ) + + reviewer_bot.main() + + assert synced_issue_numbers == [42, 99] + assert saved_epochs[-1] == reviewer_bot.STATUS_PROJECTION_EPOCH + + +def test_main_schedule_status_projection_epoch_not_advanced_on_label_sync_failure(monkeypatch): + monkeypatch.setenv("EVENT_NAME", "schedule") + monkeypatch.setenv("EVENT_ACTION", "") + state = make_state() + state["status_projection_epoch"] = "status_projection_v1" + review = reviewer_bot.ensure_review_entry(state, 42, create=True) + assert review is not None + review["current_reviewer"] = "alice" + saved_epochs = [] + + monkeypatch.setattr(reviewer_bot, "acquire_state_issue_lease_lock", lambda: None) + monkeypatch.setattr(reviewer_bot, "release_state_issue_lease_lock", lambda: True) + monkeypatch.setattr(reviewer_bot, "load_state", lambda *args, **kwargs: state) + monkeypatch.setattr(reviewer_bot, "process_pass_until_expirations", lambda current: (current, [])) + monkeypatch.setattr(reviewer_bot, "sync_members_with_queue", lambda current: (current, [])) + monkeypatch.setattr(reviewer_bot, "handle_scheduled_check", lambda current: False) + monkeypatch.setattr(reviewer_bot, "list_open_items_with_status_labels", lambda: [42]) + monkeypatch.setattr( + reviewer_bot, + "sync_status_labels_for_items", + lambda current, issue_numbers: (_ for _ in ()).throw(RuntimeError("projection exploded")), + ) + monkeypatch.setattr( + reviewer_bot, + "save_state", + lambda current: saved_epochs.append(current.get("status_projection_epoch")) or True, + ) + + reviewer_bot.main() + assert all(epoch != reviewer_bot.STATUS_PROJECTION_EPOCH for epoch in saved_epochs) + + +def test_main_schedule_projection_epoch_repair_relabels_previously_repaired_pr(monkeypatch): + monkeypatch.setenv("EVENT_NAME", "schedule") + monkeypatch.setenv("EVENT_ACTION", "") + state = make_state() + state["status_projection_epoch"] = "status_projection_v1" + review = reviewer_bot.ensure_review_entry(state, 256, create=True) + assert review is not None + review["current_reviewer"] = "vccjgust" + review["reviewer_review"]["accepted"] = { + "semantic_key": "pull_request_review:3821749029", + "timestamp": "2026-02-18T20:28:12Z", + "actor": "vccjgust", + "reviewed_head_sha": "head-1", + "source_precedence": 1, + "payload": {}, + } + review["active_head_sha"] = "head-1" + synced_issue_numbers = [] + + monkeypatch.setattr(reviewer_bot, "acquire_state_issue_lease_lock", lambda: None) + monkeypatch.setattr(reviewer_bot, "release_state_issue_lease_lock", lambda: True) + monkeypatch.setattr(reviewer_bot, "load_state", lambda *args, **kwargs: state) + monkeypatch.setattr(reviewer_bot, "process_pass_until_expirations", lambda current: (current, [])) + monkeypatch.setattr(reviewer_bot, "sync_members_with_queue", lambda current: (current, [])) + monkeypatch.setattr(reviewer_bot, "handle_scheduled_check", lambda current: False) + monkeypatch.setattr(reviewer_bot, "list_open_items_with_status_labels", lambda: [256]) + monkeypatch.setattr( + reviewer_bot, + "sync_status_labels_for_items", + lambda current, issue_numbers: synced_issue_numbers.extend(issue_numbers) or True, + ) + monkeypatch.setattr(reviewer_bot, "save_state", lambda current: True) + + reviewer_bot.main() + + assert synced_issue_numbers == [256] + + def test_main_mutating_event_fails_closed_when_state_unavailable(monkeypatch): monkeypatch.setenv("EVENT_NAME", "issue_comment") monkeypatch.setenv("EVENT_ACTION", "created") diff --git a/scripts/reviewer_bot.py b/scripts/reviewer_bot.py index 2f45eba06..b6b321d48 100644 --- a/scripts/reviewer_bot.py +++ b/scripts/reviewer_bot.py @@ -128,6 +128,7 @@ STATUS_AWAITING_WRITE_APPROVAL_LABEL, STATUS_LABEL_CONFIG, STATUS_LABELS, + STATUS_PROJECTION_EPOCH, TRANSITION_PERIOD_DAYS, AssignmentAttempt, GitHubApiResult, diff --git a/scripts/reviewer_bot_lib/app.py b/scripts/reviewer_bot_lib/app.py index 2d71a2933..e40321aad 100644 --- a/scripts/reviewer_bot_lib/app.py +++ b/scripts/reviewer_bot_lib/app.py @@ -4,6 +4,10 @@ import sys from .context import ReviewerBotContext +from .maintenance import ( + collect_status_projection_repair_items, + status_projection_repair_needed, +) def _revalidate_epoch(bot: ReviewerBotContext, expected_epoch: str | None, phase: str) -> None: @@ -106,6 +110,7 @@ def main(bot: ReviewerBotContext): touched_items: list[int] = [] projection_failure: RuntimeError | None = None loaded_epoch: str | None = None + projection_epoch_repair = False try: if lock_required: @@ -172,6 +177,14 @@ def main(bot: ReviewerBotContext): ) touched_items = bot.drain_touched_items() + if event_name in {"schedule", "workflow_dispatch"} and status_projection_repair_needed(bot, state): + touched_items = sorted( + { + *touched_items, + *collect_status_projection_repair_items(bot, state), + } + ) + projection_epoch_repair = True if state_changed or sync_changes or restored: if not lock_acquired: @@ -231,6 +244,14 @@ def main(bot: ReviewerBotContext): raise RuntimeError( "Projection failed and repair-needed metadata could not be persisted." ) + else: + if projection_epoch_repair: + state["status_projection_epoch"] = bot.STATUS_PROJECTION_EPOCH + _revalidate_epoch(bot, loaded_epoch, "status-projection epoch save") + if not bot.save_state(state): + raise RuntimeError( + "Status projection epoch repair succeeded but could not be persisted." + ) with open(os.environ.get("GITHUB_OUTPUT", "/dev/null"), "a") as output_file: output_file.write( diff --git a/scripts/reviewer_bot_lib/config.py b/scripts/reviewer_bot_lib/config.py index 0cd7b7dfa..b33177148 100644 --- a/scripts/reviewer_bot_lib/config.py +++ b/scripts/reviewer_bot_lib/config.py @@ -44,6 +44,7 @@ STATE_SCHEMA_VERSION = 18 FRESHNESS_RUNTIME_EPOCH_LEGACY = "legacy_v14" FRESHNESS_RUNTIME_EPOCH_V18 = "freshness_v15" +STATUS_PROJECTION_EPOCH = "status_projection_v2" REVIEW_FRESHNESS_RUNBOOK_PATH = "docs/reviewer-bot-review-freshness-operator-runbook.md" AUTHOR_ASSOCIATION_TRUST_ALLOWLIST = {"OWNER", "MEMBER", "COLLABORATOR"} DEFERRED_ARTIFACT_RETENTION_DAYS = 7 diff --git a/scripts/reviewer_bot_lib/maintenance.py b/scripts/reviewer_bot_lib/maintenance.py index af6d018f0..987a6ca99 100644 --- a/scripts/reviewer_bot_lib/maintenance.py +++ b/scripts/reviewer_bot_lib/maintenance.py @@ -20,6 +20,17 @@ def _now_iso(bot) -> str: return bot.datetime.now(bot.timezone.utc).isoformat() +def status_projection_repair_needed(bot, state: dict) -> bool: + current_epoch = state.get("status_projection_epoch") + return current_epoch != bot.STATUS_PROJECTION_EPOCH + + +def collect_status_projection_repair_items(bot, state: dict) -> list[int]: + numbers = set(bot.list_open_items_with_status_labels()) + numbers.update(bot.reviews_module.list_open_tracked_review_items(state)) + return sorted(number for number in numbers if isinstance(number, int) and number > 0) + + def handle_manual_dispatch(bot, state: dict) -> bool: action = os.environ.get("MANUAL_ACTION", "") if action == "show-state": diff --git a/scripts/reviewer_bot_lib/reviews.py b/scripts/reviewer_bot_lib/reviews.py index 0a9c67b99..6df744572 100644 --- a/scripts/reviewer_bot_lib/reviews.py +++ b/scripts/reviewer_bot_lib/reviews.py @@ -882,6 +882,26 @@ def list_open_items_with_status_labels(bot) -> list[int]: return sorted(numbers) +def list_open_tracked_review_items(state: dict) -> list[int]: + numbers: set[int] = set() + active_reviews = state.get("active_reviews") + if not isinstance(active_reviews, dict): + return [] + for issue_key, review_data in active_reviews.items(): + if not isinstance(review_data, dict): + continue + current_reviewer = review_data.get("current_reviewer") + if not isinstance(current_reviewer, str) or not current_reviewer.strip(): + continue + try: + issue_number = int(issue_key) + except (TypeError, ValueError): + continue + if issue_number > 0: + numbers.add(issue_number) + return sorted(numbers) + + def handle_pr_approved_review(bot, state: dict, issue_number: int, review_author: str, completion_source: str) -> bool: review_data = ensure_review_entry(state, issue_number) if review_data is None: diff --git a/scripts/reviewer_bot_lib/state_store.py b/scripts/reviewer_bot_lib/state_store.py index 70feb1493..b65d74b26 100644 --- a/scripts/reviewer_bot_lib/state_store.py +++ b/scripts/reviewer_bot_lib/state_store.py @@ -324,6 +324,7 @@ def load_state(bot: StateStoreContext, *, fail_on_unavailable: bool = False) -> default_state = { "schema_version": STATE_SCHEMA_VERSION, "freshness_runtime_epoch": FRESHNESS_RUNTIME_EPOCH_LEGACY, + "status_projection_epoch": None, "last_updated": None, "current_index": 0, "queue": [], @@ -347,6 +348,8 @@ def load_state(bot: StateStoreContext, *, fail_on_unavailable: bool = False) -> state["schema_version"] = STATE_SCHEMA_VERSION if not isinstance(state.get("freshness_runtime_epoch"), str) or not state.get("freshness_runtime_epoch"): state["freshness_runtime_epoch"] = FRESHNESS_RUNTIME_EPOCH_LEGACY + if not isinstance(state.get("status_projection_epoch"), str) or not state.get("status_projection_epoch"): + state["status_projection_epoch"] = None if state.get("last_updated") is None: state["last_updated"] = None if not isinstance(state.get("current_index"), int):