Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions .github/reviewer-bot-tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions scripts/reviewer_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@
STATUS_AWAITING_WRITE_APPROVAL_LABEL,
STATUS_LABEL_CONFIG,
STATUS_LABELS,
STATUS_PROJECTION_EPOCH,
TRANSITION_PERIOD_DAYS,
AssignmentAttempt,
GitHubApiResult,
Expand Down
21 changes: 21 additions & 0 deletions scripts/reviewer_bot_lib/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions scripts/reviewer_bot_lib/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions scripts/reviewer_bot_lib/maintenance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
20 changes: 20 additions & 0 deletions scripts/reviewer_bot_lib/reviews.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions scripts/reviewer_bot_lib/state_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": [],
Expand All @@ -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):
Expand Down
Loading